Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95826a1f19 | ||
|
|
cd5b21514d | ||
|
|
8c9bf89676 | ||
|
|
b5f5518c1e | ||
|
|
251b3af5ed | ||
|
|
0b42fdeae1 |
No files matched your search
@@ -0,0 +1,102 @@
|
||||
---
|
||||
name: remotion-video
|
||||
description: Create videos from React code with Remotion. Use when the user asks to make, animate, preview, or render a video, motion graphic, intro, caption, or social clip using code.
|
||||
---
|
||||
|
||||
# Remotion Video
|
||||
|
||||
Remotion makes videos out of React components. Each frame is a web page. Render them in order and you get an MP4.
|
||||
|
||||
## Core ideas
|
||||
- **Composition**: one video. Has `width`, `height`, `fps`, `durationInFrames`.
|
||||
- **Frame**: `useCurrentFrame()` returns the current frame number. All animation is a function of the frame.
|
||||
- **`interpolate`**: maps a frame range to a value range (fade, slide).
|
||||
- **`spring`**: natural bouncy motion.
|
||||
- **`Sequence`**: shows children only during a frame range.
|
||||
- **`AbsoluteFill`**: full-screen layer; stack them for backgrounds.
|
||||
- **`Audio` / `OffthreadVideo` / `Img`**: media. Put files in `public/` and load with `staticFile("name.mp3")`.
|
||||
|
||||
## Workflow
|
||||
1. **Plan first.** Ask or decide: length (seconds), size (1920x1080, or 1080x1920 for vertical), fps (30), and the scenes. Keep scenes to a short list.
|
||||
2. **Set up** (skip if the project exists):
|
||||
```
|
||||
npx create-video@latest
|
||||
cd <project> && npm install
|
||||
```
|
||||
3. **Write** compositions in `src/`, register them in `src/Root.tsx`.
|
||||
4. **Preview**: `npm run dev` (opens Remotion Studio).
|
||||
5. **Render**: `npx remotion render <CompositionId> out/video.mp4`
|
||||
Still frame: `npx remotion still <CompositionId> out/frame.png --frame=30`
|
||||
6. **Verify**: render one still first, check it looks right, then render the full video.
|
||||
|
||||
## Starter example
|
||||
`src/Root.tsx`
|
||||
```tsx
|
||||
import { Composition } from "remotion";
|
||||
import { Hello } from "./Hello";
|
||||
|
||||
export const RemotionRoot = () => (
|
||||
<Composition
|
||||
id="Hello"
|
||||
component={Hello}
|
||||
durationInFrames={150}
|
||||
fps={30}
|
||||
width={1920}
|
||||
height={1080}
|
||||
defaultProps={{ title: "Hello, world" }}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
`src/Hello.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const Hello = ({ title }: { title: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const opacity = interpolate(frame, [0, 30], [0, 1], { extrapolateRight: "clamp" });
|
||||
const scale = spring({ frame, fps, config: { damping: 12 } });
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 120, opacity, transform: `scale(${scale})` }}>{title}</h1>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Rules of thumb
|
||||
- Never animate with CSS transitions, `setTimeout`, or CSS animations. They don't render per frame. Drive everything from `useCurrentFrame()`.
|
||||
- Always pass `extrapolateRight: "clamp"` to `interpolate` unless you want values to keep growing.
|
||||
- Seconds to frames: `seconds * fps`.
|
||||
- Use `<Sequence from={..} durationInFrames={..}>` to order scenes; `<Series>` to chain them.
|
||||
- Load fonts with `@remotion/google-fonts` so they are ready before rendering.
|
||||
- Keep props in `defaultProps` so the user can edit text in the Studio.
|
||||
- Rendering needs Chromium and ffmpeg (Remotion installs/handles these); first render can be slow.
|
||||
|
||||
## Fonts
|
||||
Google Fonts: `npm i @remotion/google-fonts`, then
|
||||
```tsx
|
||||
import { loadFont } from "@remotion/google-fonts/Inter";
|
||||
const { fontFamily } = loadFont(); // use as style={{ fontFamily }}
|
||||
```
|
||||
It downloads the font while rendering, so the machine needs internet access to fonts.googleapis.com. If the render fails with "Failed to load font", the network is blocking it: use a local font file in `public/` with `@remotion/fonts` (`loadFont({ family, url: staticFile("x.woff2") })`) or a system font. (Not verified in the sandbox where this skill was tested, which had no access to Google.)
|
||||
|
||||
## Common problems
|
||||
- **Can't download the browser (no internet / locked-down machine)**: point Remotion at an installed Chromium:
|
||||
`npx remotion render <Id> out/video.mp4 --browser-executable=/path/to/chromium --chrome-mode=chrome-for-testing`
|
||||
- **Blank/white frames**: an asset path is wrong; use `staticFile()`.
|
||||
- **Flicker**: an image or font not loaded; use `<Img>` and `@remotion/google-fonts`.
|
||||
- **Slow render**: lower resolution/fps while testing, or use `--concurrency`.
|
||||
|
||||
## Templates
|
||||
Copy-paste starting points. Read the one that fits:
|
||||
- `templates/captions.md` - words highlighted in time with speech
|
||||
- `templates/charts.md` - animated bar chart
|
||||
- `templates/intro-outro.md` - title card + chaining scenes with `Series`
|
||||
- `templates/audio-voiceover.md` - background music, voiceover, auto video length
|
||||
- `templates/vertical.md` - 1080x1920 clips for Reels/TikTok/Shorts
|
||||
|
||||
## Docs
|
||||
https://www.remotion.dev/docs
|
||||
@@ -0,0 +1,62 @@
|
||||
# Template: Audio, music and voiceover
|
||||
|
||||
Put sound files in `public/` (mp3 or wav). Load them with `staticFile()`.
|
||||
|
||||
`src/Narrated.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, Audio, Sequence, interpolate, staticFile, useVideoConfig } from "remotion";
|
||||
import { getAudioDurationInSeconds } from "@remotion/media-utils";
|
||||
import type { CalculateMetadataFunction } from "remotion";
|
||||
|
||||
type Props = { title: string; voice: string; music: string };
|
||||
|
||||
// Make the video exactly as long as the voiceover (+1 second of padding).
|
||||
export const calcNarratedMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
|
||||
const seconds = await getAudioDurationInSeconds(staticFile(props.voice));
|
||||
return { durationInFrames: Math.ceil((seconds + 1) * 30) };
|
||||
};
|
||||
|
||||
export const Narrated = ({ title, voice, music }: Props) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "#0b1020", justifyContent: "center", alignItems: "center" }}>
|
||||
<h1 style={{ color: "white", fontSize: 110 }}>{title}</h1>
|
||||
|
||||
{/* Background music: quiet, fades out over the last second. */}
|
||||
<Audio
|
||||
src={staticFile(music)}
|
||||
volume={(f) => interpolate(f, [durationInFrames - fps, durationInFrames], [0.15, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" })}
|
||||
/>
|
||||
|
||||
{/* Voiceover starts half a second in, full volume. */}
|
||||
<Sequence from={Math.round(fps * 0.5)}>
|
||||
<Audio src={staticFile(voice)} volume={1} />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Register it (note `calculateMetadata`):
|
||||
```tsx
|
||||
<Composition
|
||||
id="Narrated"
|
||||
component={Narrated}
|
||||
calculateMetadata={calcNarratedMetadata}
|
||||
durationInFrames={300} // placeholder, replaced by calculateMetadata
|
||||
fps={30} width={1920} height={1080}
|
||||
defaultProps={{ title: "My story", voice: "voice.mp3", music: "music.mp3" }}
|
||||
/>
|
||||
```
|
||||
|
||||
Install: `npm i @remotion/media-utils`
|
||||
|
||||
Getting a voiceover
|
||||
- Record yourself, or generate speech with a text-to-speech service, and save as `public/voice.mp3`. Never commit API keys; keep them in environment variables.
|
||||
- Pair with `templates/captions.md`: feed it word timings from a transcription of the same file.
|
||||
|
||||
Rules
|
||||
- Music volume 0.1-0.2 so the voice stays clear.
|
||||
- `<Sequence from={...}>` delays when a sound starts.
|
||||
- Audio is only in the final render (mp4). Stills (`remotion still`) have no sound.
|
||||
- Check the sound made it in: `ffprobe out/video.mp4` should list an `Audio:` stream.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Template: Captions
|
||||
|
||||
Words appear in time with speech. Give each word a start time in seconds.
|
||||
|
||||
`src/Captions.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Word = { text: string; start: number; end: number }; // seconds
|
||||
|
||||
export const Captions = ({ words, background = "#111" }: { words: Word[]; background?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const t = frame / fps;
|
||||
|
||||
// Show a window of up to 5 words around the current one.
|
||||
const current = words.findIndex((w) => t >= w.start && t < w.end);
|
||||
const i = current === -1 ? 0 : current;
|
||||
const visible = words.slice(Math.floor(i / 5) * 5, Math.floor(i / 5) * 5 + 5);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: background, justifyContent: "flex-end", alignItems: "center", paddingBottom: 200 }}>
|
||||
<div style={{ fontSize: 72, fontWeight: 800, color: "white", textShadow: "0 4px 12px black", textAlign: "center", maxWidth: "80%" }}>
|
||||
{visible.map((w, k) => (
|
||||
<span key={k} style={{ color: t >= w.start && t < w.end ? "#ffd400" : "white", marginRight: 16 }}>
|
||||
{w.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Get word times from a transcription tool (e.g. `@remotion/captions` or Whisper) and save them as JSON in `public/`.
|
||||
- `background` is the backdrop when there is no video. Pass `"transparent"` when layering over a video.
|
||||
- Layer it over the video: `<AbsoluteFill><OffthreadVideo src={staticFile("clip.mp4")} /><Captions words={words} background="transparent" /></AbsoluteFill>`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Template: Animated bar chart
|
||||
|
||||
Bars grow one after another.
|
||||
|
||||
`src/BarChart.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
type Bar = { label: string; value: number };
|
||||
|
||||
export const BarChart = ({ data, color = "#4f8cff" }: { data: Bar[]; color?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const max = Math.max(...data.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "#0b1020", padding: 120, justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 40, height: 700 }}>
|
||||
{data.map((d, i) => {
|
||||
const grow = spring({ frame: frame - i * 6, fps, config: { damping: 15 } });
|
||||
return (
|
||||
<div key={d.label} style={{ flex: 1, textAlign: "center", color: "white", fontSize: 36 }}>
|
||||
<div style={{ height: (d.value / max) * 600 * grow, background: color, borderRadius: 8 }} />
|
||||
<div style={{ marginTop: 16 }}>{d.label}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Tips
|
||||
- Delay each bar with `frame - i * 6` (6 frames apart).
|
||||
- For lines, animate `strokeDashoffset` on an SVG path using `interpolate`.
|
||||
- Keep numbers readable: show the value as text, rounded.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Template: Title intro / outro card
|
||||
|
||||
A short card (2-3 seconds) with a title and subtitle. Use at the start or end.
|
||||
|
||||
`src/TitleCard.tsx`
|
||||
```tsx
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
|
||||
export const TitleCard = ({ title, subtitle }: { title: string; subtitle?: string }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const rise = spring({ frame, fps, config: { damping: 14 } });
|
||||
const fadeOut = interpolate(frame, [durationInFrames - 15, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ background: "linear-gradient(135deg,#1b2a6b,#0b1020)", justifyContent: "center", alignItems: "center", opacity: fadeOut }}>
|
||||
<h1 style={{ color: "white", fontSize: 130, margin: 0, transform: `translateY(${(1 - rise) * 60}px)`, opacity: rise }}>{title}</h1>
|
||||
{subtitle && <p style={{ color: "#9fb3ff", fontSize: 48, opacity: rise }}>{subtitle}</p>}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Chain with the main video using `<Series>`:
|
||||
```tsx
|
||||
<Series>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="My Video" /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={600}><MainScene /></Series.Sequence>
|
||||
<Series.Sequence durationInFrames={90}><TitleCard title="Thanks for watching" /></Series.Sequence>
|
||||
</Series>
|
||||
```
|
||||
@@ -0,0 +1,23 @@
|
||||
# Template: Vertical clips (Reels / TikTok / Shorts)
|
||||
|
||||
Same code, different size. Register a vertical composition:
|
||||
|
||||
```tsx
|
||||
<Composition
|
||||
id="Vertical"
|
||||
component={MyScene}
|
||||
width={1080}
|
||||
height={1920}
|
||||
fps={30}
|
||||
durationInFrames={30 * 30} // 30 seconds
|
||||
/>
|
||||
```
|
||||
|
||||
Render: `npx remotion render Vertical out/vertical.mp4`
|
||||
|
||||
Rules
|
||||
- Keep text inside the **safe zone**: about 150px from the top, 350px from the bottom (apps cover those parts with buttons and captions), and 60px from the sides.
|
||||
- Use big text (60px+). Phone screens are small.
|
||||
- Keep clips under 60 seconds (Shorts limit); 15-30 seconds does best.
|
||||
- Use `useVideoConfig()` for width/height instead of hard-coding, so the same scene works wide and tall.
|
||||
- Put captions in the lower-middle, above the bottom safe zone (see captions.md).
|
||||
@@ -0,0 +1,45 @@
|
||||
/* CycleWise AU - scroll animations (Step 1).
|
||||
Purely cosmetic: if GSAP fails to load or the visitor prefers reduced
|
||||
motion, this file does nothing and the page looks exactly as before. */
|
||||
(function(){
|
||||
"use strict";
|
||||
if (!window.gsap || !window.ScrollTrigger) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
var done = new WeakSet();
|
||||
|
||||
function reveal(panel){
|
||||
if (!panel || done.has(panel)) return;
|
||||
done.add(panel);
|
||||
|
||||
// Top of the tab: hero text and picture fade up one after another.
|
||||
gsap.from(panel.querySelectorAll(".hero > *"), {
|
||||
opacity: 0, y: 24, duration: 0.7, stagger: 0.12, ease: "power2.out",
|
||||
clearProps: "all"
|
||||
});
|
||||
|
||||
// Everything below: fade up when it scrolls into view.
|
||||
panel.querySelectorAll(
|
||||
".ref-heading, .ref-grid, .timeline, .week-panel, .filmstrip, .cal-card, .detail-card"
|
||||
).forEach(function(el){
|
||||
gsap.from(el, {
|
||||
opacity: 0, y: 32, duration: 0.7, ease: "power2.out",
|
||||
clearProps: "all",
|
||||
scrollTrigger: { trigger: el, start: "top 88%", once: true }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function activePanel(){ return document.querySelector(".mode-panel.active"); }
|
||||
|
||||
reveal(activePanel());
|
||||
|
||||
document.querySelectorAll(".mode-btn").forEach(function(btn){
|
||||
btn.addEventListener("click", function(){
|
||||
// Wait one tick so the page's own code switches the tab first.
|
||||
setTimeout(function(){ reveal(activePanel()); ScrollTrigger.refresh(); }, 0);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,539 @@
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/* ---------------- date helpers ---------------- */
|
||||
function toDate(y,m,d){ return new Date(y,m,d); }
|
||||
function fromInput(str){
|
||||
if(!str) return null;
|
||||
var p = str.split("-");
|
||||
return new Date(+p[0], +p[1]-1, +p[2]);
|
||||
}
|
||||
function toInputVal(d){
|
||||
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
|
||||
}
|
||||
function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
|
||||
function daysBetween(a,b){ // b - a, in whole days, ignoring time
|
||||
var A = new Date(a.getFullYear(),a.getMonth(),a.getDate());
|
||||
var B = new Date(b.getFullYear(),b.getMonth(),b.getDate());
|
||||
return Math.round((B-A)/86400000);
|
||||
}
|
||||
function sameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
|
||||
function mod(n,m){ return ((n % m) + m) % m; }
|
||||
var MONTH_NAMES = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
var today = new Date(); today.setHours(0,0,0,0);
|
||||
|
||||
function loadLS(key, fallback){
|
||||
try{ var v = localStorage.getItem(key); return v===null ? fallback : v; }catch(e){ return fallback; }
|
||||
}
|
||||
function saveLS(key, val){ try{ localStorage.setItem(key,val); }catch(e){} }
|
||||
|
||||
/* ---------------- mode switching ---------------- */
|
||||
var modeBtns = document.querySelectorAll(".mode-btn");
|
||||
var panels = { period: document.getElementById("mode-period"), ovulation: document.getElementById("mode-ovulation"), pregnancy: document.getElementById("mode-pregnancy") };
|
||||
modeBtns.forEach(function(btn){
|
||||
btn.addEventListener("click", function(){
|
||||
modeBtns.forEach(function(b){ b.classList.remove("active"); b.setAttribute("aria-selected","false"); });
|
||||
btn.classList.add("active"); btn.setAttribute("aria-selected","true");
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.remove("active"); });
|
||||
panels[btn.dataset.mode].classList.add("active");
|
||||
saveLS("ca-mode", btn.dataset.mode);
|
||||
});
|
||||
});
|
||||
var savedMode = loadLS("ca-mode", null);
|
||||
if(savedMode && panels[savedMode]){
|
||||
modeBtns.forEach(function(b){ b.classList.toggle("active", b.dataset.mode===savedMode); b.setAttribute("aria-selected", b.dataset.mode===savedMode ? "true":"false"); });
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.toggle("active", k===savedMode); });
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PHASE + OVULATION REFERENCE DATA
|
||||
================================================================ */
|
||||
var PHASES = {
|
||||
menstrual: {
|
||||
title:"Menstrual", color:"var(--period)", soft:"var(--period-soft)",
|
||||
window:"Cycle day 1 through the end of bleeding",
|
||||
desc:"The uterine lining sheds because no pregnancy occurred last cycle. Estrogen and progesterone are both at their lowest point of the month.",
|
||||
hormone:"Estrogen & progesterone: low", energy:"Energy: lowest",
|
||||
symptoms:"Cramping, fatigue, lower back ache, mood dips",
|
||||
tip:"Iron-rich food, gentle movement, and heat on the lower abdomen ease cramping better than rest alone."
|
||||
},
|
||||
follicular: {
|
||||
title:"Follicular", color:"var(--ovulation)", soft:"var(--ovulation-soft)",
|
||||
window:"Cycle day 1 through ovulation (overlaps menstrual)",
|
||||
desc:"The pituitary releases FSH, prompting several ovarian follicles to mature. Estrogen climbs steadily as one dominant follicle takes the lead.",
|
||||
hormone:"Estrogen: rising", energy:"Energy: climbing",
|
||||
symptoms:"Clearer skin, sharper focus, rising motivation",
|
||||
tip:"This is the physiologically best window for starting new training blocks or demanding projects."
|
||||
},
|
||||
ovulatory: {
|
||||
title:"Ovulatory", color:"var(--ovulation-strong)", soft:"var(--ovulation-soft)",
|
||||
window:"Roughly one day, mid-cycle",
|
||||
desc:"A surge in luteinizing hormone (LH) triggers the dominant follicle to release its egg. This is the single most fertile day of the cycle.",
|
||||
hormone:"LH: sharp surge", energy:"Energy & libido: peak",
|
||||
symptoms:"Egg-white cervical mucus, one-sided pelvic twinge (mittelschmerz)",
|
||||
tip:"Ovulation predictor kits detect the LH surge roughly 24–36 hours before the egg is released."
|
||||
},
|
||||
luteal: {
|
||||
title:"Luteal", color:"var(--pregnancy)", soft:"var(--pregnancy-soft)",
|
||||
window:"Ovulation through the day before your next period",
|
||||
desc:"The ruptured follicle becomes the corpus luteum and secretes progesterone to prepare the uterine lining. If no implantation occurs, hormone levels fall and the cycle restarts.",
|
||||
hormone:"Progesterone: rises then falls", energy:"Energy: gradually declining",
|
||||
symptoms:"Bloating, breast tenderness, irritability, food cravings (PMS)",
|
||||
tip:"Basal body temperature stays elevated ~0.3–0.5°C through this phase — a useful way to confirm ovulation already happened."
|
||||
}
|
||||
};
|
||||
|
||||
// offset relative to ovulation day (0 = ovulation)
|
||||
var OVULATION_DAYS = {
|
||||
"-5": {fert:"Low", fertClass:"fert-low", bbt:"Baseline (pre-shift)", mucus:"Dry to sticky", note:"Sperm can survive up to 5 days, so conception is technically possible starting here."},
|
||||
"-4": {fert:"Low–moderate", fertClass:"fert-low", bbt:"Baseline", mucus:"Sticky, cloudy", note:"Fertility begins climbing as estrogen rises toward its pre-ovulation peak."},
|
||||
"-3": {fert:"Moderate", fertClass:"fert-mid", bbt:"Baseline", mucus:"Creamy, increasing in volume", note:"Cervical mucus is becoming more hospitable to sperm."},
|
||||
"-2": {fert:"High", fertClass:"fert-high", bbt:"Baseline", mucus:"Thin, stretchy, egg-white-like", note:"One of the two most fertile days of the entire cycle."},
|
||||
"-1": {fert:"Peak", fertClass:"fert-peak", bbt:"Baseline (often the day of the LH surge)", mucus:"Clear, slippery, maximum stretch", note:"Statistically the single highest-probability day for conception."},
|
||||
"0": {fert:"Peak", fertClass:"fert-peak", bbt:"Still baseline — the rise hasn't happened yet", mucus:"Egg-white, abundant", note:"The egg is released and is viable for roughly 12–24 hours. Some feel a one-sided twinge (mittelschmerz)."},
|
||||
"1": {fert:"Dropping fast", fertClass:"fert-mid", bbt:"Beginning to rise ~0.3–0.5°C", mucus:"Becoming tacky again", note:"The egg's fertile window is closing; BBT starts its post-ovulation climb."},
|
||||
"2": {fert:"Very low", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry or minimal", note:"Progesterone from the corpus luteum keeps temperature elevated through the luteal phase."},
|
||||
"3": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"If fertilization happened, the embryo is traveling toward the uterus."},
|
||||
"4": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Too late in the cycle for a new ovulation; luteal phase symptoms may start appearing."},
|
||||
"5": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Implantation, if it happens this cycle, typically occurs around days 6–10 after ovulation."}
|
||||
};
|
||||
var FERT_COLOR = {"fert-low":"var(--ink-soft)","fert-mid":"var(--fertile)","fert-high":"var(--fertile-strong)","fert-peak":"var(--ovulation-strong)"};
|
||||
|
||||
function phaseForDayInCycle(dic, periodLen, ovulationDic){
|
||||
if(dic < periodLen) return "menstrual";
|
||||
if(dic < ovulationDic) return "follicular";
|
||||
if(dic === ovulationDic) return "ovulatory";
|
||||
return "luteal";
|
||||
}
|
||||
function isFertile(dic, ovulationDic){
|
||||
return dic >= ovulationDic - 5 && dic <= ovulationDic;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY WEEK DATA (1–40)
|
||||
================================================================ */
|
||||
var WEEKS = [
|
||||
{c:"a poppy seed (not yet fertilized)", m:"n/a", dev:"Pregnancy is dated from the first day of this period, before conception happens — your body is shedding last cycle's lining and preparing a fresh egg.", body:"This week looks and feels like an ordinary period.", tip:"If you're trying to conceive, this is a good week to start a prenatal vitamin with folic acid."},
|
||||
{c:"an egg cell", m:"~0.1 mm", dev:"Rising estrogen prompts an egg to mature; ovulation typically occurs near the end of this week, roughly two weeks before a missed period.", body:"Cervical mucus becomes clear and stretchy around ovulation — a natural fertility sign.", tip:"This is the fertile window if you're trying to conceive — see the Ovulation tab for day-by-day detail."},
|
||||
{c:"a grain of sand", m:"~0.1–0.2 mm", dev:"If sperm meets egg this week, fertilization occurs in the fallopian tube and the resulting cell begins dividing as it travels toward the uterus.", body:"No symptoms are detectable yet — even a sensitive test won't turn positive for another week or two.", tip:"Implantation, if it happens, usually occurs 6–10 days after ovulation."},
|
||||
{c:"a poppy seed", m:"~0.1 mm", dev:"The fertilized egg implants in the uterine lining and begins producing hCG, the hormone home pregnancy tests detect.", body:"Most people notice no symptoms yet; a sensitive test may already turn positive near the end of this week.", tip:"A missed period is usually the first sign — testing a few days after that gives the most reliable result."},
|
||||
{c:"a sesame seed", m:"~2 mm", dev:"The neural tube — the earliest structure of the brain and spinal cord — begins to form, along with the first blood vessels.", body:"A missed period is usually the first clear sign; fatigue and tender breasts may begin.", tip:"Avoid alcohol and confirm any medications with a clinician — this window is critical for early development."},
|
||||
{c:"a lentil", m:"~4 mm", dev:"A primitive heart tube starts beating, and the beginnings of the face, arms, and legs appear as small buds.", body:"Morning sickness and smell sensitivity often begin here, though timing varies widely.", tip:"Small, frequent, bland meals tend to sit better than three large ones if nausea has started."},
|
||||
{c:"a blueberry", m:"~1.3 cm", dev:"The brain is dividing into distinct sections, and the arm and leg buds are elongating into recognizable limbs.", body:"Bloating and mild cramping are common as the uterus makes room to grow.", tip:"This is when most people schedule their first prenatal appointment — dating scans are most accurate now."},
|
||||
{c:"a raspberry", m:"~1.6 cm", dev:"Fingers and toes are forming, still webbed; the heart now has four distinct chambers.", body:"Fatigue often peaks around this point as progesterone rises sharply.", tip:"Rest when you can — first-trimester exhaustion is real and temporary, not a sign anything is wrong."},
|
||||
{c:"a grape", m:"~2.3 cm · 4 g", dev:"The embryo is officially called a fetus starting around now; essential organs are all present in early form and the tail has disappeared.", body:"Nausea, food aversions, and a heightened sense of smell are often at their strongest.", tip:"Ginger tea, vitamin B6, and staying hydrated are commonly recommended first-line options for nausea."},
|
||||
{c:"a kumquat", m:"~3 cm · 7 g", dev:"Vital organs — kidneys, liver, brain — are functioning in a basic form, and tiny fingernail beds are forming.", body:"A visible baby bump is still unlikely, though clothes may feel tighter.", tip:"Ask your provider about first-trimester screening options available in this window."},
|
||||
{c:"a fig", m:"~4.1 cm · 10 g", dev:"The fetus can now bend its arms and legs, and tooth buds are forming beneath the gums.", body:"Round ligament twinges may start as the uterus stretches.", tip:"Gentle stretching and changing positions slowly can ease early ligament discomfort."},
|
||||
{c:"a lime", m:"~5.4 cm · 14 g", dev:"Reflexes are developing — fingers can curl, and toes can wiggle, though it can't be felt yet.", body:"The end of the first trimester is near, and miscarriage risk drops meaningfully from here.", tip:"Many people choose to share their pregnancy news around now, though the choice is entirely personal."},
|
||||
{c:"a plum", m:"~6.7 cm · 23 g", dev:"Vocal cords are forming, and unique fingerprints are being etched onto tiny fingertips.", body:"Some early nausea may start easing as the placenta takes over hormone production.", tip:"The nuchal translucency scan, if chosen, is typically done between now and week 13."},
|
||||
{c:"a passion fruit", m:"~10 cm · 45 g", dev:"Baby can open and close fists and may even suck a thumb; genitals are differentiating, though not yet visible on ultrasound.", body:"For many, first-trimester nausea and fatigue begin to lift.", tip:"Second-trimester energy often supports resuming or starting light-to-moderate exercise — check in with your provider first."},
|
||||
{c:"a peach", m:"~11.6 cm · 58 g", dev:"Vocal cords are complete and the intestines, which developed in the umbilical cord, move into the abdomen.", body:"Welcome to the second trimester — often called the most comfortable stretch of pregnancy.", tip:"An anatomy scan is usually scheduled in the coming weeks; ask your clinic when it's booked."},
|
||||
{c:"a lemon", m:"~13 cm · 70 g", dev:"Fine, downy hair called lanugo begins covering the body, and the kidneys are producing urine into the amniotic fluid.", body:"A small bump may finally become visible; appetite often improves noticeably.", tip:"Iron needs rise sharply now — leafy greens, legumes, and lean meat help meet increased demand."},
|
||||
{c:"an apple", m:"~14.2 cm · 95 g", dev:"The skeleton continues hardening and baby can sense light through closed eyelids.", body:"Skin changes like a faint linea nigra or darkened areolas may appear as pigment increases.", tip:"Daily SPF helps limit pregnancy-related skin pigmentation (melasma) from sun exposure."},
|
||||
{c:"an avocado", m:"~16 cm · 135 g", dev:"The placenta is now fully formed and functioning as baby's life-support system.", body:"First flutters of movement ('quickening') may be felt any time from now through week 20.", tip:"Movement timing varies a lot — first pregnancies and anterior placentas often mean feeling it later."},
|
||||
{c:"a pomegranate", m:"~17.7 cm · 160 g", dev:"The skeleton is converting from cartilage to true bone, and fat stores are just beginning to accumulate.", body:"The uterus is now roughly at the level of the belly button.", tip:"A pregnancy support pillow can meaningfully improve sleep as side-sleeping becomes more comfortable."},
|
||||
{c:"a bell pepper", m:"~19 cm · 190 g", dev:"Ears have reached their final position and baby may begin startling at loud external sounds.", body:"Quickening is commonly felt by now — often described as bubbles or light taps.", tip:"Try narrating your day out loud — hearing is developing and familiar voices become recognizable over time."},
|
||||
{c:"a mango", m:"~20.3 cm · 240 g", dev:"A waxy coating called vernix caseosa begins protecting the skin, and taste buds are forming.", body:"Backaches can start as posture shifts to balance the growing bump.", tip:"A supportive, flatter shoe and a pillow between the knees at night both help low back strain."},
|
||||
{c:"a banana", m:"~25.6 cm · 300 g", dev:"The detailed anatomy ultrasound is often done around now if it hasn't happened already, and sex can typically be identified on it.", body:"Movements are stronger and more frequent; the uterus is now level with the belly button.", tip:"If you want to know the sex and haven't had the anatomy scan yet, ask your clinic to schedule it soon — it's usually done weeks 18–22."},
|
||||
{c:"a carrot", m:"~27 cm · 360 g", dev:"Baby is swallowing small amounts of amniotic fluid, giving the digestive system practice.", body:"Stretch marks may start appearing on the belly, hips, or chest as skin stretches.", tip:"Moisturizing won't prevent stretch marks, but it can ease the itchiness that comes with stretching skin."},
|
||||
{c:"a papaya", m:"~28.9 cm · 430 g", dev:"Eyebrows and eyelids are fully formed, and the sense of touch is developing rapidly.", body:"Braxton Hicks — brief, painless practice contractions — may be felt occasionally from here on.", tip:"Braxton Hicks usually ease with a change of position or a glass of water; true labor contractions don't."},
|
||||
{c:"an ear of corn", m:"~32.2 cm · 600 g", dev:"The lungs begin producing surfactant, a substance essential for breathing air after birth — a key viability milestone.", body:"The linea nigra (a dark vertical line on the belly) may become more visible.", tip:"This week is often cited as the threshold of viability with intensive neonatal care, should birth happen very early."},
|
||||
{c:"a cauliflower head", m:"~33.7 cm · 660 g", dev:"Baby can now respond to your voice, and hand grip strength is developing.", body:"The glucose screening test for gestational diabetes is typically scheduled around now.", tip:"If asked to fast beforehand, follow your clinic's instructions closely for an accurate glucose result."},
|
||||
{c:"a head of lettuce", m:"~35.6 cm · 760 g", dev:"Eyes begin to open after being fused shut since early development, and hair may be visibly growing.", body:"Round ligament pain and rib discomfort can increase as space gets tighter.", tip:"A maternity support band can ease rib and ligament pressure during long periods of standing or walking."},
|
||||
{c:"a bunch of scallions", m:"~36.6 cm · 875 g", dev:"Brain tissue is developing rapidly, and baby is developing regular hiccups you may be able to feel.", body:"Third trimester begins soon — appointments typically become more frequent from here.", tip:"Hiccup-like twitches are completely normal and are the diaphragm practicing its rhythm."},
|
||||
{c:"an eggplant", m:"~37.6 cm · 1.1 kg", dev:"Eyes can blink now, and baby may cycle through active REM sleep.", body:"Welcome to the third trimester — the home stretch begins.", tip:"This is a common time to start planning a hospital bag and reviewing birth preferences."},
|
||||
{c:"a butternut squash", m:"~38.6 cm · 1.2 kg", dev:"Bones are fully formed but still soft and flexible, ready to move through the birth canal.", body:"Frequent urination often returns as the head engages lower in the pelvis.", tip:"Kick counts become a useful daily check-in — note baby's typical pattern of movement."},
|
||||
{c:"a large cabbage", m:"~39.9 cm · 1.3 kg", dev:"The brain is developing at its fastest rate yet, adding folds and surface area.", body:"Sleep can become harder to find as the bump limits comfortable positions.", tip:"A full-body pregnancy pillow supports the belly, back, and knees simultaneously."},
|
||||
{c:"a coconut", m:"~41.1 cm · 1.5 kg", dev:"All five senses are now functioning, and baby is gaining roughly half a pound a week.", body:"Colostrum, the first form of breast milk, may begin to leak slightly.", tip:"A few nursing pads in your bag can help if colostrum leaking becomes noticeable."},
|
||||
{c:"a jicama", m:"~42.4 cm · 1.7 kg", dev:"Baby is practicing breathing movements against a closed airway, strengthening the lungs.", body:"Swelling and mild carpal tunnel tingling in the hands can appear from fluid retention.", tip:"Report sudden or severe swelling to your provider promptly — it can occasionally signal preeclampsia."},
|
||||
{c:"a pineapple", m:"~43.7 cm · 1.9 kg", dev:"Bones are hardening overall, though the skull stays deliberately soft and flexible for delivery.", body:"Pelvic pressure increases as baby continues descending.", tip:"Prenatal massage or a warm bath can ease the deep pelvic ache many feel this week."},
|
||||
{c:"a cantaloupe", m:"~45 cm · 2.1 kg", dev:"The central nervous system is maturing quickly, and baby's grip is now quite strong.", body:"Braxton Hicks contractions often become more noticeable and frequent.", tip:"Time any contractions that feel different — regular, intensifying, closer together warrants a call to your provider."},
|
||||
{c:"a honeydew melon", m:"~46.2 cm · 2.4 kg", dev:"The kidneys are fully developed, and the liver is processing waste products on its own.", body:"You may feel baby 'drop' lower as it settles into position for birth.", tip:"Lightening (the baby dropping) can ease heartburn but often increases bathroom trips."},
|
||||
{c:"a head of romaine", m:"~47.4 cm · 2.6 kg", dev:"Most physical development is complete; the focus now is steady weight gain.", body:"You've just reached 'early term' — babies born from here on tend to do very well.", tip:"Pack your hospital bag if you haven't yet — early-term babies can arrive with little warning."},
|
||||
{c:"a leek", m:"~49.8 cm · 3.1 kg", dev:"Baby continues laying down fat for temperature regulation after birth, and the brain is still rapidly developing.", body:"Nesting instinct and a burst of energy are common right before labor.", tip:"Balance nesting energy with rest — labor's timing is unpredictable."},
|
||||
{c:"a mini watermelon", m:"~50.7 cm · 3.3 kg", dev:"Full term. The lungs are completing final maturation in preparation for that first breath.", body:"Increased pelvic pressure and more frequent, intense Braxton Hicks are common.", tip:"Watch for your labor signs: regular contractions, water breaking, or a bloody show."},
|
||||
{c:"a small pumpkin", m:"~51.2 cm · 3.5 kg", dev:"Baby is fully developed and simply continuing to grow, ready to meet you.", body:"If labor hasn't started, your provider will discuss monitoring or induction options.", tip:"Most babies arrive within two weeks of their due date in either direction — both are entirely normal."}
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
SVG helpers
|
||||
================================================================ */
|
||||
function polarToXY(cx,cy,r,angleDeg){
|
||||
var rad = (angleDeg-90) * Math.PI/180;
|
||||
return [cx + r*Math.cos(rad), cy + r*Math.sin(rad)];
|
||||
}
|
||||
function arcPath(cx,cy,r,startDeg,endDeg){
|
||||
if(endDeg <= startDeg) endDeg = startDeg + 0.01;
|
||||
var s = polarToXY(cx,cy,r,startDeg), e = polarToXY(cx,cy,r,endDeg);
|
||||
var large = (endDeg-startDeg) > 180 ? 1 : 0;
|
||||
return "M "+s[0].toFixed(2)+" "+s[1].toFixed(2)+" A "+r+" "+r+" 0 "+large+" 1 "+e[0].toFixed(2)+" "+e[1].toFixed(2);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SHARED STATE: period start / cycle length / period length
|
||||
================================================================ */
|
||||
var exampleStart = addDays(today, -10);
|
||||
var startInput = document.getElementById("p-start");
|
||||
var cycleLenInput = document.getElementById("p-cyclelen");
|
||||
var periodLenInput = document.getElementById("p-periodlen");
|
||||
|
||||
var savedStart = loadLS("ca-start", null);
|
||||
var savedCycleLen = loadLS("ca-cyclelen", null);
|
||||
var savedPeriodLen = loadLS("ca-periodlen", null);
|
||||
var usingExample = !savedStart;
|
||||
|
||||
startInput.value = savedStart || toInputVal(exampleStart);
|
||||
cycleLenInput.value = savedCycleLen || 28;
|
||||
periodLenInput.value = savedPeriodLen || 5;
|
||||
|
||||
function getCycleState(){
|
||||
return {
|
||||
start: fromInput(startInput.value) || exampleStart,
|
||||
cycleLen: Math.max(21, Math.min(35, parseInt(cycleLenInput.value,10) || 28)),
|
||||
periodLen: Math.max(2, Math.min(10, parseInt(periodLenInput.value,10) || 5))
|
||||
};
|
||||
}
|
||||
|
||||
var pCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var oCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var pSelectedDate = new Date(today);
|
||||
var oSelectedDate = new Date(today);
|
||||
|
||||
[startInput,cycleLenInput,periodLenInput].forEach(function(inp){
|
||||
inp.addEventListener("input", function(){
|
||||
saveLS("ca-start", startInput.value);
|
||||
saveLS("ca-cyclelen", cycleLenInput.value);
|
||||
saveLS("ca-periodlen", periodLenInput.value);
|
||||
usingExample = false;
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
renderAll();
|
||||
});
|
||||
});
|
||||
if(!usingExample) document.getElementById("p-example-note").style.display = "none";
|
||||
|
||||
/* ================================================================
|
||||
CALENDAR RENDERING (shared builder)
|
||||
================================================================ */
|
||||
function buildCalendar(gridEl, weekdayHeaderIgnored, monthDate, dayClassifier, onSelect, selectedDate){
|
||||
gridEl.innerHTML = "";
|
||||
var year = monthDate.getFullYear(), month = monthDate.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum < 1 || dayNum > daysInMonth){
|
||||
cell.classList.add("pad");
|
||||
gridEl.appendChild(cell);
|
||||
continue;
|
||||
}
|
||||
var d = new Date(year,month,dayNum);
|
||||
var info = dayClassifier(d);
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>";
|
||||
if(info.period) cell.classList.add("is-period");
|
||||
if(info.fertile) cell.classList.add("is-fertile");
|
||||
if(info.ovulation) cell.classList.add("is-ovulation");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
if(selectedDate && sameDay(d,selectedDate)) cell.classList.add("selected");
|
||||
cell.setAttribute("aria-label", d.toDateString());
|
||||
cell.addEventListener("click", function(dd){ return function(){ onSelect(dd); }; }(d));
|
||||
gridEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyForCycle(d, state){
|
||||
var dic = mod(daysBetween(state.start, d), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
return {
|
||||
period: dic < state.periodLen,
|
||||
fertile: isFertile(dic, ovDic),
|
||||
ovulation: dic === ovDic,
|
||||
dic: dic, ovDic: ovDic
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- PERIOD tab render ---------------- */
|
||||
function renderPeriodCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("p-cal-title").textContent = MONTH_NAMES[pCalMonth.getMonth()] + " " + pCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("p-daygrid"), null, pCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){ pSelectedDate = d; renderPeriodDetail(); renderPeriodCalendar(); },
|
||||
pSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderPeriodDetail(){
|
||||
var state = getCycleState();
|
||||
var info = classifyForCycle(pSelectedDate, state);
|
||||
var phaseKey = phaseForDayInCycle(info.dic, state.periodLen, info.ovDic);
|
||||
var phase = PHASES[phaseKey];
|
||||
var el = document.getElementById("p-detail");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+pSelectedDate.toDateString()+'</span>'+
|
||||
'<span class="pill" style="background:'+phase.soft+';color:'+phase.color+'">'+phase.title+' phase'+(info.fertile?' · fertile window':'')+'</span>'+
|
||||
'<div class="d-phase">Cycle day '+(info.dic+1)+'</div>'+
|
||||
'<p class="d-desc">'+phase.desc+'</p>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Hormones</div><div class="v">'+phase.hormone+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Typical symptoms</div><div class="v" style="font-weight:500;font-size:0.82rem;">'+phase.symptoms+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc"><strong style="color:var(--ink)">Care note — </strong>'+phase.tip+'</p>';
|
||||
}
|
||||
|
||||
function renderPeriodStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToNextPeriod = mod(state.cycleLen - dic, state.cycleLen);
|
||||
if(daysToNextPeriod === 0 && dic !== 0) daysToNextPeriod = state.cycleLen;
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var phaseKey = phaseForDayInCycle(dic, state.periodLen, ovDic);
|
||||
document.getElementById("period-stats").innerHTML =
|
||||
statTile(dic+1, "Cycle day today") +
|
||||
statTile(PHASES[phaseKey].title, "Current phase") +
|
||||
statTile(dic===0 ? "Today" : (daysToNextPeriod+"d"), "Until next period") +
|
||||
statTile(dic===ovDic ? "Today" : (daysToOvulation+"d"), "Until ovulation");
|
||||
|
||||
// hero ring
|
||||
var cx=100, cy=100, r=88;
|
||||
var perStart=0, perEnd = state.periodLen/state.cycleLen*360;
|
||||
var follEnd = ovDic/state.cycleLen*360;
|
||||
var fertStart = Math.max(0,(ovDic-5))/state.cycleLen*360;
|
||||
var lutStart = (ovDic+1)/state.cycleLen*360;
|
||||
document.getElementById("phase-arc-menstrual").setAttribute("d", arcPath(cx,cy,r,perStart,perEnd));
|
||||
document.getElementById("phase-arc-follicular").setAttribute("d", arcPath(cx,cy,r,perEnd,fertStart));
|
||||
document.getElementById("phase-arc-fertile").setAttribute("d", arcPath(cx,cy,r,fertStart,follEnd+ (360/state.cycleLen)));
|
||||
document.getElementById("phase-arc-luteal").setAttribute("d", arcPath(cx,cy,r,lutStart,360));
|
||||
var todayAngle = dic/state.cycleLen*360;
|
||||
var tp = polarToXY(cx,cy,r,todayAngle);
|
||||
document.getElementById("today-marker").setAttribute("cx", tp[0]);
|
||||
document.getElementById("today-marker").setAttribute("cy", tp[1]);
|
||||
document.getElementById("cycle-day-big").textContent = dic+1;
|
||||
// hand the same numbers to the optional 3D ring (ring3d.js)
|
||||
window.cycleRingData = {perEnd:perEnd, fertStart:fertStart, fertEnd:follEnd+(360/state.cycleLen),
|
||||
lutStart:lutStart, todayAngle:todayAngle};
|
||||
window.dispatchEvent(new CustomEvent("cycle:ring", {detail: window.cycleRingData}));
|
||||
}
|
||||
|
||||
function statTile(num,label){
|
||||
return '<div class="stat-tile"><div class="num">'+num+'</div><div class="lbl">'+label+'</div></div>';
|
||||
}
|
||||
|
||||
document.getElementById("p-prev").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()-1); renderPeriodCalendar(); });
|
||||
document.getElementById("p-next").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()+1); renderPeriodCalendar(); });
|
||||
|
||||
function renderPhaseRefGrid(){
|
||||
var order = ["menstrual","follicular","ovulatory","luteal"];
|
||||
document.getElementById("phase-ref-grid").innerHTML = order.map(function(key,i){
|
||||
var p = PHASES[key];
|
||||
return '<div class="ref-card"><span class="r-num">0'+(i+1)+'</span><h4 style="color:'+p.color+'">'+p.title+'</h4>'+
|
||||
'<p>'+p.desc+'</p>'+
|
||||
'<div class="tag-row"><span class="tagchip">'+p.window+'</span></div></div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* ---------------- OVULATION tab render ---------------- */
|
||||
var ovSelectedOffset = 0;
|
||||
function renderOvTimeline(){
|
||||
var wrap = document.getElementById("ov-timeline");
|
||||
wrap.innerHTML = "";
|
||||
for(var off=-5; off<=5; off++){
|
||||
var d = OVULATION_DAYS[String(off)];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "tl-btn" + (off===ovSelectedOffset ? " active":"");
|
||||
btn.innerHTML = '<div class="tl-day">'+(off===0?"OVUL.":(off>0?"+":"")+off)+'</div>'+
|
||||
'<div class="tl-fert" style="background:'+FERT_COLOR[d.fertClass]+'"></div>';
|
||||
btn.addEventListener("click", function(o){ return function(){ ovSelectedOffset=o; renderOvTimeline(); renderOvDetailFromOffset(); }; }(off));
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
}
|
||||
function renderOvDetailFromOffset(){
|
||||
var d = OVULATION_DAYS[String(ovSelectedOffset)];
|
||||
var el = document.getElementById("o-detail");
|
||||
var label = ovSelectedOffset===0 ? "Ovulation day" : (ovSelectedOffset>0 ? ovSelectedOffset+" day"+(ovSelectedOffset>1?"s":"")+" after ovulation" : Math.abs(ovSelectedOffset)+" day"+(ovSelectedOffset<-1?"s":"")+" before ovulation");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+label+'</span>'+
|
||||
'<span class="pill" style="background:var(--ovulation-soft);color:'+FERT_COLOR[d.fertClass]+'">Fertility: '+d.fert+'</span>'+
|
||||
'<div class="d-phase">'+(ovSelectedOffset===0?"Egg release":(ovSelectedOffset<0?"Building fertility":"Fertility fading"))+'</div>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Basal body temp</div><div class="v" style="font-size:0.82rem;">'+d.bbt+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Cervical mucus</div><div class="v" style="font-size:0.82rem;">'+d.mucus+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc">'+d.note+'</p>';
|
||||
}
|
||||
|
||||
function renderOvulationCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("o-cal-title").textContent = MONTH_NAMES[oCalMonth.getMonth()] + " " + oCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("o-daygrid"), null, oCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){
|
||||
oSelectedDate = d;
|
||||
var info = classifyForCycle(d, state);
|
||||
var offset = info.dic - info.ovDic;
|
||||
if(offset < -5) offset = -5; if(offset > 5) offset = 5;
|
||||
ovSelectedOffset = offset;
|
||||
renderOvTimeline(); renderOvDetailFromOffset(); renderOvulationCalendar();
|
||||
},
|
||||
oSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderOvulationStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var fertileStartDic = mod(ovDic-5, state.cycleLen);
|
||||
var inFertile = isFertile(dic, ovDic);
|
||||
var daysToFertile = inFertile ? 0 : mod(fertileStartDic - dic, state.cycleLen);
|
||||
document.getElementById("ovulation-stats").innerHTML =
|
||||
statTile(inFertile ? "Yes" : "No", "In fertile window") +
|
||||
statTile(dic===ovDic ? "Today" : daysToOvulation+"d", "Until ovulation") +
|
||||
statTile(inFertile ? "Now" : daysToFertile+"d", "Fertile window") +
|
||||
statTile("6 days", "Window length");
|
||||
}
|
||||
|
||||
document.getElementById("o-prev").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()-1); renderOvulationCalendar(); });
|
||||
document.getElementById("o-next").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()+1); renderOvulationCalendar(); });
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY tab
|
||||
================================================================ */
|
||||
var lmpInput = document.getElementById("pr-lmp");
|
||||
var exampleLMP = addDays(today, -20*7 - 3);
|
||||
var savedLMP = loadLS("ca-lmp", null);
|
||||
lmpInput.value = savedLMP || toInputVal(exampleLMP);
|
||||
if(savedLMP) document.getElementById("pr-example-note").style.display = "none";
|
||||
|
||||
var slider = document.getElementById("week-slider");
|
||||
var prCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
|
||||
lmpInput.addEventListener("input", function(){
|
||||
saveLS("ca-lmp", lmpInput.value);
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
syncSliderToLMP();
|
||||
renderPregnancy();
|
||||
});
|
||||
|
||||
function getLMP(){ return fromInput(lmpInput.value) || exampleLMP; }
|
||||
function currentGestWeek(){
|
||||
var lmp = getLMP();
|
||||
var w = Math.floor(daysBetween(lmp,today)/7) + 1;
|
||||
return Math.max(1, Math.min(40, w));
|
||||
}
|
||||
function syncSliderToLMP(){ slider.value = currentGestWeek(); }
|
||||
syncSliderToLMP();
|
||||
|
||||
function renderPregnancyStats(){
|
||||
var lmp = getLMP();
|
||||
var due = addDays(lmp, 280);
|
||||
var week = currentGestWeek();
|
||||
var daysLeft = Math.max(0, daysBetween(today, due));
|
||||
document.getElementById("pregnancy-stats").innerHTML =
|
||||
statTile(week, "Weeks along") +
|
||||
statTile(Math.floor((week-1)/13)+1 <= 3 ? ["First","Second","Third"][Math.min(2,Math.floor((week-1)/13))] : "Third", "Trimester") +
|
||||
statTile(daysLeft, "Days to due date") +
|
||||
statTile(due.toLocaleDateString(undefined,{month:"short",day:"numeric"}), "Estimated due date");
|
||||
|
||||
var pct = (week-1)/39;
|
||||
var pathLen = 230;
|
||||
var offset = pathLen - pct*pathLen;
|
||||
var path = document.getElementById("pregnancy-progress-path");
|
||||
path.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
||||
try{
|
||||
var totalLen = path.getTotalLength ? path.getTotalLength() : pathLen;
|
||||
var pt = path.getPointAtLength ? path.getPointAtLength(pct*totalLen) : null;
|
||||
if(pt) { document.getElementById("pregnancy-progress-dot").setAttribute("cx",pt.x); document.getElementById("pregnancy-progress-dot").setAttribute("cy",pt.y); }
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function renderWeekPanel(week){
|
||||
var w = WEEKS[week-1];
|
||||
document.getElementById("wk-badge").textContent = "WEEK " + week;
|
||||
document.getElementById("wk-compare").textContent = "size of " + w.c;
|
||||
document.getElementById("wk-metric").textContent = w.m === "n/a" ? "not yet fertilized" : "≈ " + w.m;
|
||||
document.getElementById("wk-dev").textContent = w.dev;
|
||||
document.getElementById("wk-body").textContent = w.body;
|
||||
document.getElementById("wk-tip").textContent = w.tip;
|
||||
var tri = week<=13?1:(week<=27?2:3);
|
||||
var triColor = "var(--pregnancy)";
|
||||
document.getElementById("wk-blob").innerHTML =
|
||||
'<svg width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="'+(14+week*0.9)+'" fill="'+ (tri===1?"var(--pregnancy-soft)":tri===2?"var(--pregnancy)":"var(--pregnancy-strong)") +'" opacity="0.85"/></svg>';
|
||||
document.querySelectorAll(".film-item").forEach(function(f){ f.classList.toggle("active", +f.dataset.week===week); });
|
||||
var active = document.querySelector('.film-item[data-week="'+week+'"]');
|
||||
if(active) active.scrollIntoView({inline:"center", block:"nearest", behavior:"smooth"});
|
||||
}
|
||||
|
||||
slider.addEventListener("input", function(){ renderWeekPanel(+slider.value); });
|
||||
|
||||
function buildFilmstrip(){
|
||||
var wrap = document.getElementById("week-filmstrip");
|
||||
wrap.innerHTML = "";
|
||||
for(var w=1; w<=40; w++){
|
||||
var item = document.createElement("button");
|
||||
item.className = "film-item";
|
||||
item.dataset.week = w;
|
||||
item.innerHTML = '<div class="fw">WK</div><div class="fn">'+w+'</div>';
|
||||
item.addEventListener("click", function(ww){ return function(){ slider.value=ww; renderWeekPanel(ww); }; }(w));
|
||||
wrap.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPregnancyCalendar(){
|
||||
var lmp = getLMP();
|
||||
document.getElementById("pr-cal-title").textContent = MONTH_NAMES[prCalMonth.getMonth()] + " " + prCalMonth.getFullYear();
|
||||
var grid = document.getElementById("pr-daygrid");
|
||||
grid.innerHTML = "";
|
||||
var year = prCalMonth.getFullYear(), month = prCalMonth.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum<1 || dayNum>daysInMonth){ cell.classList.add("pad"); grid.appendChild(cell); continue; }
|
||||
var d = new Date(year,month,dayNum);
|
||||
var wk = Math.floor(daysBetween(lmp,d)/7)+1;
|
||||
var inRange = wk>=1 && wk<=42;
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>" + (inRange ? '<span class="dot-row" style="position:static;margin-top:2px;font-size:0.55rem;color:var(--pregnancy-strong)">W'+wk+'</span>' : "");
|
||||
if(inRange && wk<=40) cell.classList.add("is-fertile");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
cell.addEventListener("click", function(ww){ return function(){ if(ww>=1&&ww<=40){ slider.value=ww; renderWeekPanel(ww); } }; }(wk));
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
document.getElementById("pr-prev").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()-1); renderPregnancyCalendar(); });
|
||||
document.getElementById("pr-next").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()+1); renderPregnancyCalendar(); });
|
||||
|
||||
function renderPregnancy(){
|
||||
renderPregnancyStats();
|
||||
renderWeekPanel(currentGestWeek());
|
||||
renderPregnancyCalendar();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
INIT
|
||||
================================================================ */
|
||||
function renderAll(){
|
||||
renderPeriodStats();
|
||||
renderPeriodCalendar();
|
||||
renderPeriodDetail();
|
||||
renderOvulationStats();
|
||||
renderOvTimeline();
|
||||
renderOvDetailFromOffset();
|
||||
renderOvulationCalendar();
|
||||
renderPregnancy();
|
||||
}
|
||||
|
||||
renderPhaseRefGrid();
|
||||
buildFilmstrip();
|
||||
slider.value = currentGestWeek();
|
||||
renderAll();
|
||||
})();
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Fraunces Project Authors (github.com/undercasetype/Fraunces) Fraunces-Italic[SOFT,WONK,opsz,wght].ttf: Copyright 2020 The Fraunces Project Authors (github.com/undercasetype/Fraunces)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans) WorkSans-Italic[wght].ttf: Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,15 @@
|
||||
/* Self-hosted fonts (Latin subset), SIL Open Font License 1.1 - see OFL-*.txt */
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:400;font-display:swap;src:url("fraunces-latin-400-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:500;font-display:swap;src:url("fraunces-latin-500-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:600;font-display:swap;src:url("fraunces-latin-600-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:700;font-display:swap;src:url("fraunces-latin-700-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:normal;font-weight:900;font-display:swap;src:url("fraunces-latin-900-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:italic;font-weight:500;font-display:swap;src:url("fraunces-latin-500-italic.woff2") format("woff2");}
|
||||
@font-face{font-family:"Fraunces";font-style:italic;font-weight:600;font-display:swap;src:url("fraunces-latin-600-italic.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:400;font-display:swap;src:url("work-sans-latin-400-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:500;font-display:swap;src:url("work-sans-latin-500-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:600;font-display:swap;src:url("work-sans-latin-600-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"Work Sans";font-style:normal;font-weight:700;font-display:swap;src:url("work-sans-latin-700-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:400;font-display:swap;src:url("ibm-plex-mono-latin-400-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:500;font-display:swap;src:url("ibm-plex-mono-latin-500-normal.woff2") format("woff2");}
|
||||
@font-face{font-family:"IBM Plex Mono";font-style:normal;font-weight:600;font-display:swap;src:url("ibm-plex-mono-latin-600-normal.woff2") format("woff2");}
|
||||
@@ -1,7 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
|
||||
<title>CycleWise AU</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=Fraunces:ital,wght@0,400;0,500;0,600;0,700;0,900;1,500;1,600&family=Work+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="fonts/fonts.css">
|
||||
<style>
|
||||
:root{
|
||||
--paper:#FBF7F2;
|
||||
@@ -131,6 +135,7 @@ button{font-family:inherit;}
|
||||
.mode-panel.active{display:block;animation:rise .4s ease both;}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(6px);}to{opacity:1;transform:none;}}
|
||||
@media (prefers-reduced-motion: reduce){.mode-panel.active{animation:none;}}
|
||||
@media (prefers-color-scheme: dark){.mode-btn[data-mode="pregnancy"].active{color:#1B1417;}}
|
||||
|
||||
.hero{
|
||||
display:grid;grid-template-columns:1.3fr 1fr;gap:40px;
|
||||
@@ -144,7 +149,11 @@ button{font-family:inherit;}
|
||||
}
|
||||
.hero h1{font-size:clamp(1.9rem,3.4vw,2.7rem);font-weight:600;line-height:1.08;}
|
||||
.hero p.lede{color:var(--ink-soft);font-size:1.02rem;line-height:1.6;max-width:52ch;margin-top:14px;}
|
||||
.hero-art{display:flex;justify-content:center;}
|
||||
.hero-art{display:flex;justify-content:center;position:relative;}
|
||||
.hero-art.has-3d{align-items:center;}
|
||||
.hero-art.has-3d .ring3d{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);}
|
||||
.hero-art.has-3d svg{position:relative;max-width:300px;}
|
||||
.hero-art.has-3d svg path,.hero-art.has-3d svg circle{display:none;}
|
||||
.hero-art svg{width:100%;max-width:220px;height:auto;}
|
||||
|
||||
.stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
|
||||
@@ -262,7 +271,7 @@ button{font-family:inherit;}
|
||||
text-transform:uppercase;letter-spacing:0.05em;margin-bottom:8px;font-weight:600;}
|
||||
input[type="range"].week-slider{
|
||||
width:100%;-webkit-appearance:none;appearance:none;height:8px;border-radius:999px;
|
||||
background:linear-gradient(to right, var(--pregnancy-soft) 0%, var(--pregnancy-soft) 100%);
|
||||
background:linear-gradient(to right, var(--pregnancy) 0, var(--pregnancy) calc(11px + (100% - 22px) * var(--f,0)), var(--pregnancy-soft) calc(11px + (100% - 22px) * var(--f,0)), var(--pregnancy-soft) 100%);
|
||||
outline:none;cursor:pointer;
|
||||
}
|
||||
input[type="range"].week-slider::-webkit-slider-thumb{
|
||||
@@ -297,6 +306,7 @@ input[type="range"].week-slider::-moz-range-thumb{
|
||||
|
||||
.filmstrip{display:flex;gap:8px;overflow-x:auto;padding:4px 2px 20px;margin-top:8px;}
|
||||
.film-item{
|
||||
color:var(--ink);
|
||||
flex:none;width:64px;height:64px;border-radius:12px;border:1px solid var(--line);
|
||||
background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
cursor:pointer;font-family:"IBM Plex Mono",monospace;
|
||||
@@ -310,7 +320,8 @@ footer.note{
|
||||
font-size:0.78rem;color:var(--ink-soft);line-height:1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="wordmark">
|
||||
@@ -324,15 +335,15 @@ footer.note{
|
||||
<span class="tag">Menstrual & Pregnancy Charting</span>
|
||||
</div>
|
||||
<nav class="mode-switch" role="tablist" aria-label="Tracking mode">
|
||||
<button class="mode-btn active" data-mode="period" role="tab" aria-selected="true">Period</button>
|
||||
<button class="mode-btn" data-mode="ovulation" role="tab" aria-selected="false">Ovulation</button>
|
||||
<button class="mode-btn" data-mode="pregnancy" role="tab" aria-selected="false">Pregnancy</button>
|
||||
<button class="mode-btn active" id="tab-period" aria-controls="mode-period" data-mode="period" role="tab" aria-selected="true">Period</button>
|
||||
<button class="mode-btn" id="tab-ovulation" aria-controls="mode-ovulation" data-mode="ovulation" role="tab" aria-selected="false">Ovulation</button>
|
||||
<button class="mode-btn" id="tab-pregnancy" aria-controls="mode-pregnancy" data-mode="pregnancy" role="tab" aria-selected="false">Pregnancy</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ================= PERIOD ================= -->
|
||||
<section id="mode-period" class="mode-panel active">
|
||||
<section id="mode-period" class="mode-panel active" role="tabpanel" aria-labelledby="tab-period">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--period)">01 — Menstrual Cycle</span>
|
||||
@@ -398,7 +409,7 @@ footer.note{
|
||||
</section>
|
||||
|
||||
<!-- ================= OVULATION ================= -->
|
||||
<section id="mode-ovulation" class="mode-panel">
|
||||
<section id="mode-ovulation" class="mode-panel" role="tabpanel" aria-labelledby="tab-ovulation">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--ovulation)">02 — Fertile Window</span>
|
||||
@@ -446,7 +457,7 @@ footer.note{
|
||||
</section>
|
||||
|
||||
<!-- ================= PREGNANCY ================= -->
|
||||
<section id="mode-pregnancy" class="mode-panel">
|
||||
<section id="mode-pregnancy" class="mode-panel" role="tabpanel" aria-labelledby="tab-pregnancy">
|
||||
<div class="hero">
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--pregnancy)">03 — Forty Weeks</span>
|
||||
@@ -476,7 +487,7 @@ footer.note{
|
||||
<span style="color:var(--pregnancy)">Trimester 2 · Weeks 14–27</span>
|
||||
<span style="color:var(--pregnancy)">Trimester 3 · Weeks 28–40</span>
|
||||
</div>
|
||||
<input type="range" class="week-slider" id="week-slider" min="1" max="40" value="20">
|
||||
<input type="range" class="week-slider" id="week-slider" aria-label="Pregnancy week, 1 to 40" min="1" max="40" value="20">
|
||||
<div class="week-ticks"><span>Wk 1</span><span>Wk 13</span><span>Wk 27</span><span>Wk 40</span></div>
|
||||
</div>
|
||||
|
||||
@@ -524,540 +535,11 @@ footer.note{
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
|
||||
/* ---------------- date helpers ---------------- */
|
||||
function toDate(y,m,d){ return new Date(y,m,d); }
|
||||
function fromInput(str){
|
||||
if(!str) return null;
|
||||
var p = str.split("-");
|
||||
return new Date(+p[0], +p[1]-1, +p[2]);
|
||||
}
|
||||
function toInputVal(d){
|
||||
return d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
|
||||
}
|
||||
function addDays(d,n){ var r=new Date(d); r.setDate(r.getDate()+n); return r; }
|
||||
function daysBetween(a,b){ // b - a, in whole days, ignoring time
|
||||
var A = new Date(a.getFullYear(),a.getMonth(),a.getDate());
|
||||
var B = new Date(b.getFullYear(),b.getMonth(),b.getDate());
|
||||
return Math.round((B-A)/86400000);
|
||||
}
|
||||
function sameDay(a,b){ return a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate(); }
|
||||
function mod(n,m){ return ((n % m) + m) % m; }
|
||||
var MONTH_NAMES = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
var today = new Date(); today.setHours(0,0,0,0);
|
||||
|
||||
function loadLS(key, fallback){
|
||||
try{ var v = localStorage.getItem(key); return v===null ? fallback : v; }catch(e){ return fallback; }
|
||||
}
|
||||
function saveLS(key, val){ try{ localStorage.setItem(key,val); }catch(e){} }
|
||||
|
||||
/* ---------------- mode switching ---------------- */
|
||||
var modeBtns = document.querySelectorAll(".mode-btn");
|
||||
var panels = { period: document.getElementById("mode-period"), ovulation: document.getElementById("mode-ovulation"), pregnancy: document.getElementById("mode-pregnancy") };
|
||||
modeBtns.forEach(function(btn){
|
||||
btn.addEventListener("click", function(){
|
||||
modeBtns.forEach(function(b){ b.classList.remove("active"); b.setAttribute("aria-selected","false"); });
|
||||
btn.classList.add("active"); btn.setAttribute("aria-selected","true");
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.remove("active"); });
|
||||
panels[btn.dataset.mode].classList.add("active");
|
||||
saveLS("ca-mode", btn.dataset.mode);
|
||||
});
|
||||
});
|
||||
var savedMode = loadLS("ca-mode", null);
|
||||
if(savedMode && panels[savedMode]){
|
||||
modeBtns.forEach(function(b){ b.classList.toggle("active", b.dataset.mode===savedMode); b.setAttribute("aria-selected", b.dataset.mode===savedMode ? "true":"false"); });
|
||||
Object.keys(panels).forEach(function(k){ panels[k].classList.toggle("active", k===savedMode); });
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PHASE + OVULATION REFERENCE DATA
|
||||
================================================================ */
|
||||
var PHASES = {
|
||||
menstrual: {
|
||||
title:"Menstrual", color:"var(--period)", soft:"var(--period-soft)",
|
||||
window:"Cycle day 1 through the end of bleeding",
|
||||
desc:"The uterine lining sheds because no pregnancy occurred last cycle. Estrogen and progesterone are both at their lowest point of the month.",
|
||||
hormone:"Estrogen & progesterone: low", energy:"Energy: lowest",
|
||||
symptoms:"Cramping, fatigue, lower back ache, mood dips",
|
||||
tip:"Iron-rich food, gentle movement, and heat on the lower abdomen ease cramping better than rest alone."
|
||||
},
|
||||
follicular: {
|
||||
title:"Follicular", color:"var(--ovulation)", soft:"var(--ovulation-soft)",
|
||||
window:"Cycle day 1 through ovulation (overlaps menstrual)",
|
||||
desc:"The pituitary releases FSH, prompting several ovarian follicles to mature. Estrogen climbs steadily as one dominant follicle takes the lead.",
|
||||
hormone:"Estrogen: rising", energy:"Energy: climbing",
|
||||
symptoms:"Clearer skin, sharper focus, rising motivation",
|
||||
tip:"This is the physiologically best window for starting new training blocks or demanding projects."
|
||||
},
|
||||
ovulatory: {
|
||||
title:"Ovulatory", color:"var(--ovulation-strong)", soft:"var(--ovulation-soft)",
|
||||
window:"Roughly one day, mid-cycle",
|
||||
desc:"A surge in luteinizing hormone (LH) triggers the dominant follicle to release its egg. This is the single most fertile day of the cycle.",
|
||||
hormone:"LH: sharp surge", energy:"Energy & libido: peak",
|
||||
symptoms:"Egg-white cervical mucus, one-sided pelvic twinge (mittelschmerz)",
|
||||
tip:"Ovulation predictor kits detect the LH surge roughly 24–36 hours before the egg is released."
|
||||
},
|
||||
luteal: {
|
||||
title:"Luteal", color:"var(--pregnancy)", soft:"var(--pregnancy-soft)",
|
||||
window:"Ovulation through the day before your next period",
|
||||
desc:"The ruptured follicle becomes the corpus luteum and secretes progesterone to prepare the uterine lining. If no implantation occurs, hormone levels fall and the cycle restarts.",
|
||||
hormone:"Progesterone: rises then falls", energy:"Energy: gradually declining",
|
||||
symptoms:"Bloating, breast tenderness, irritability, food cravings (PMS)",
|
||||
tip:"Basal body temperature stays elevated ~0.3–0.5°C through this phase — a useful way to confirm ovulation already happened."
|
||||
}
|
||||
};
|
||||
|
||||
// offset relative to ovulation day (0 = ovulation)
|
||||
var OVULATION_DAYS = {
|
||||
"-5": {fert:"Low", fertClass:"fert-low", bbt:"Baseline (pre-shift)", mucus:"Dry to sticky", note:"Sperm can survive up to 5 days, so conception is technically possible starting here."},
|
||||
"-4": {fert:"Low–moderate", fertClass:"fert-low", bbt:"Baseline", mucus:"Sticky, cloudy", note:"Fertility begins climbing as estrogen rises toward its pre-ovulation peak."},
|
||||
"-3": {fert:"Moderate", fertClass:"fert-mid", bbt:"Baseline", mucus:"Creamy, increasing in volume", note:"Cervical mucus is becoming more hospitable to sperm."},
|
||||
"-2": {fert:"High", fertClass:"fert-high", bbt:"Baseline", mucus:"Thin, stretchy, egg-white-like", note:"One of the two most fertile days of the entire cycle."},
|
||||
"-1": {fert:"Peak", fertClass:"fert-peak", bbt:"Baseline (often the day of the LH surge)", mucus:"Clear, slippery, maximum stretch", note:"Statistically the single highest-probability day for conception."},
|
||||
"0": {fert:"Peak", fertClass:"fert-peak", bbt:"Still baseline — the rise hasn't happened yet", mucus:"Egg-white, abundant", note:"The egg is released and is viable for roughly 12–24 hours. Some feel a one-sided twinge (mittelschmerz)."},
|
||||
"1": {fert:"Dropping fast", fertClass:"fert-mid", bbt:"Beginning to rise ~0.3–0.5°C", mucus:"Becoming tacky again", note:"The egg's fertile window is closing; BBT starts its post-ovulation climb."},
|
||||
"2": {fert:"Very low", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry or minimal", note:"Progesterone from the corpus luteum keeps temperature elevated through the luteal phase."},
|
||||
"3": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"If fertilization happened, the embryo is traveling toward the uterus."},
|
||||
"4": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Too late in the cycle for a new ovulation; luteal phase symptoms may start appearing."},
|
||||
"5": {fert:"Essentially none", fertClass:"fert-low", bbt:"Elevated, holding", mucus:"Dry", note:"Implantation, if it happens this cycle, typically occurs around days 6–10 after ovulation."}
|
||||
};
|
||||
var FERT_COLOR = {"fert-low":"var(--ink-soft)","fert-mid":"var(--fertile)","fert-high":"var(--fertile-strong)","fert-peak":"var(--ovulation-strong)"};
|
||||
|
||||
function phaseForDayInCycle(dic, periodLen, ovulationDic){
|
||||
if(dic < periodLen) return "menstrual";
|
||||
if(dic < ovulationDic) return "follicular";
|
||||
if(dic === ovulationDic) return "ovulatory";
|
||||
return "luteal";
|
||||
}
|
||||
function isFertile(dic, ovulationDic){
|
||||
return dic >= ovulationDic - 5 && dic <= ovulationDic;
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY WEEK DATA (1–40)
|
||||
================================================================ */
|
||||
var WEEKS = [
|
||||
{c:"a poppy seed (not yet fertilized)", m:"n/a", dev:"Pregnancy is dated from the first day of this period, before conception happens — your body is shedding last cycle's lining and preparing a fresh egg.", body:"This week looks and feels like an ordinary period.", tip:"If you're trying to conceive, this is a good week to start a prenatal vitamin with folic acid."},
|
||||
{c:"an egg cell", m:"~0.1 mm", dev:"Rising estrogen prompts an egg to mature; ovulation typically occurs near the end of this week, roughly two weeks before a missed period.", body:"Cervical mucus becomes clear and stretchy around ovulation — a natural fertility sign.", tip:"This is the fertile window if you're trying to conceive — see the Ovulation tab for day-by-day detail."},
|
||||
{c:"a grain of sand", m:"~0.1–0.2 mm", dev:"If sperm meets egg this week, fertilization occurs in the fallopian tube and the resulting cell begins dividing as it travels toward the uterus.", body:"No symptoms are detectable yet — even a sensitive test won't turn positive for another week or two.", tip:"Implantation, if it happens, usually occurs 6–10 days after ovulation."},
|
||||
{c:"a poppy seed", m:"~0.1 mm", dev:"The fertilized egg implants in the uterine lining and begins producing hCG, the hormone home pregnancy tests detect.", body:"Most people notice no symptoms yet; a sensitive test may already turn positive near the end of this week.", tip:"A missed period is usually the first sign — testing a few days after that gives the most reliable result."},
|
||||
{c:"a sesame seed", m:"~2 mm", dev:"The neural tube — the earliest structure of the brain and spinal cord — begins to form, along with the first blood vessels.", body:"A missed period is usually the first clear sign; fatigue and tender breasts may begin.", tip:"Avoid alcohol and confirm any medications with a clinician — this window is critical for early development."},
|
||||
{c:"a lentil", m:"~4 mm", dev:"A primitive heart tube starts beating, and the beginnings of the face, arms, and legs appear as small buds.", body:"Morning sickness and smell sensitivity often begin here, though timing varies widely.", tip:"Small, frequent, bland meals tend to sit better than three large ones if nausea has started."},
|
||||
{c:"a blueberry", m:"~1.3 cm", dev:"The brain is dividing into distinct sections, and the arm and leg buds are elongating into recognizable limbs.", body:"Bloating and mild cramping are common as the uterus makes room to grow.", tip:"This is when most people schedule their first prenatal appointment — dating scans are most accurate now."},
|
||||
{c:"a raspberry", m:"~1.6 cm", dev:"Fingers and toes are forming, still webbed; the heart now has four distinct chambers.", body:"Fatigue often peaks around this point as progesterone rises sharply.", tip:"Rest when you can — first-trimester exhaustion is real and temporary, not a sign anything is wrong."},
|
||||
{c:"a grape", m:"~2.3 cm · 4 g", dev:"The embryo is officially called a fetus starting around now; essential organs are all present in early form and the tail has disappeared.", body:"Nausea, food aversions, and a heightened sense of smell are often at their strongest.", tip:"Ginger tea, vitamin B6, and staying hydrated are commonly recommended first-line options for nausea."},
|
||||
{c:"a kumquat", m:"~3 cm · 7 g", dev:"Vital organs — kidneys, liver, brain — are functioning in a basic form, and tiny fingernail beds are forming.", body:"A visible baby bump is still unlikely, though clothes may feel tighter.", tip:"Ask your provider about first-trimester screening options available in this window."},
|
||||
{c:"a fig", m:"~4.1 cm · 10 g", dev:"The fetus can now bend its arms and legs, and tooth buds are forming beneath the gums.", body:"Round ligament twinges may start as the uterus stretches.", tip:"Gentle stretching and changing positions slowly can ease early ligament discomfort."},
|
||||
{c:"a lime", m:"~5.4 cm · 14 g", dev:"Reflexes are developing — fingers can curl, and toes can wiggle, though it can't be felt yet.", body:"The end of the first trimester is near, and miscarriage risk drops meaningfully from here.", tip:"Many people choose to share their pregnancy news around now, though the choice is entirely personal."},
|
||||
{c:"a plum", m:"~6.7 cm · 23 g", dev:"Vocal cords are forming, and unique fingerprints are being etched onto tiny fingertips.", body:"Some early nausea may start easing as the placenta takes over hormone production.", tip:"The nuchal translucency scan, if chosen, is typically done between now and week 13."},
|
||||
{c:"a passion fruit", m:"~10 cm · 45 g", dev:"Baby can open and close fists and may even suck a thumb; genitals are differentiating, though not yet visible on ultrasound.", body:"For many, first-trimester nausea and fatigue begin to lift.", tip:"Second-trimester energy often supports resuming or starting light-to-moderate exercise — check in with your provider first."},
|
||||
{c:"a peach", m:"~11.6 cm · 58 g", dev:"Vocal cords are complete and the intestines, which developed in the umbilical cord, move into the abdomen.", body:"Welcome to the second trimester — often called the most comfortable stretch of pregnancy.", tip:"An anatomy scan is usually scheduled in the coming weeks; ask your clinic when it's booked."},
|
||||
{c:"a lemon", m:"~13 cm · 70 g", dev:"Fine, downy hair called lanugo begins covering the body, and the kidneys are producing urine into the amniotic fluid.", body:"A small bump may finally become visible; appetite often improves noticeably.", tip:"Iron needs rise sharply now — leafy greens, legumes, and lean meat help meet increased demand."},
|
||||
{c:"an apple", m:"~14.2 cm · 95 g", dev:"The skeleton continues hardening and baby can sense light through closed eyelids.", body:"Skin changes like a faint linea nigra or darkened areolas may appear as pigment increases.", tip:"Daily SPF helps limit pregnancy-related skin pigmentation (melasma) from sun exposure."},
|
||||
{c:"an avocado", m:"~16 cm · 135 g", dev:"The placenta is now fully formed and functioning as baby's life-support system.", body:"First flutters of movement ('quickening') may be felt any time from now through week 20.", tip:"Movement timing varies a lot — first pregnancies and anterior placentas often mean feeling it later."},
|
||||
{c:"a pomegranate", m:"~17.7 cm · 160 g", dev:"The skeleton is converting from cartilage to true bone, and fat stores are just beginning to accumulate.", body:"The uterus is now roughly at the level of the belly button.", tip:"A pregnancy support pillow can meaningfully improve sleep as side-sleeping becomes more comfortable."},
|
||||
{c:"a bell pepper", m:"~19 cm · 190 g", dev:"Ears have reached their final position and baby may begin startling at loud external sounds.", body:"Quickening is commonly felt by now — often described as bubbles or light taps.", tip:"Try narrating your day out loud — hearing is developing and familiar voices become recognizable over time."},
|
||||
{c:"a mango", m:"~20.3 cm · 240 g", dev:"A waxy coating called vernix caseosa begins protecting the skin, and taste buds are forming.", body:"Backaches can start as posture shifts to balance the growing bump.", tip:"A supportive, flatter shoe and a pillow between the knees at night both help low back strain."},
|
||||
{c:"a banana", m:"~25.6 cm · 300 g", dev:"The detailed anatomy ultrasound is often done around now if it hasn't happened already, and sex can typically be identified on it.", body:"Movements are stronger and more frequent; the uterus is now level with the belly button.", tip:"If you want to know the sex and haven't had the anatomy scan yet, ask your clinic to schedule it soon — it's usually done weeks 18–22."},
|
||||
{c:"a carrot", m:"~27 cm · 360 g", dev:"Baby is swallowing small amounts of amniotic fluid, giving the digestive system practice.", body:"Stretch marks may start appearing on the belly, hips, or chest as skin stretches.", tip:"Moisturizing won't prevent stretch marks, but it can ease the itchiness that comes with stretching skin."},
|
||||
{c:"a papaya", m:"~28.9 cm · 430 g", dev:"Eyebrows and eyelids are fully formed, and the sense of touch is developing rapidly.", body:"Braxton Hicks — brief, painless practice contractions — may be felt occasionally from here on.", tip:"Braxton Hicks usually ease with a change of position or a glass of water; true labor contractions don't."},
|
||||
{c:"an ear of corn", m:"~32.2 cm · 600 g", dev:"The lungs begin producing surfactant, a substance essential for breathing air after birth — a key viability milestone.", body:"The linea nigra (a dark vertical line on the belly) may become more visible.", tip:"This week is often cited as the threshold of viability with intensive neonatal care, should birth happen very early."},
|
||||
{c:"a cauliflower head", m:"~33.7 cm · 660 g", dev:"Baby can now respond to your voice, and hand grip strength is developing.", body:"The glucose screening test for gestational diabetes is typically scheduled around now.", tip:"If asked to fast beforehand, follow your clinic's instructions closely for an accurate glucose result."},
|
||||
{c:"a head of lettuce", m:"~35.6 cm · 760 g", dev:"Eyes begin to open after being fused shut since early development, and hair may be visibly growing.", body:"Round ligament pain and rib discomfort can increase as space gets tighter.", tip:"A maternity support band can ease rib and ligament pressure during long periods of standing or walking."},
|
||||
{c:"a bunch of scallions", m:"~36.6 cm · 875 g", dev:"Brain tissue is developing rapidly, and baby is developing regular hiccups you may be able to feel.", body:"Third trimester begins soon — appointments typically become more frequent from here.", tip:"Hiccup-like twitches are completely normal and are the diaphragm practicing its rhythm."},
|
||||
{c:"an eggplant", m:"~37.6 cm · 1.1 kg", dev:"Eyes can blink now, and baby may cycle through active REM sleep.", body:"Welcome to the third trimester — the home stretch begins.", tip:"This is a common time to start planning a hospital bag and reviewing birth preferences."},
|
||||
{c:"a butternut squash", m:"~38.6 cm · 1.2 kg", dev:"Bones are fully formed but still soft and flexible, ready to move through the birth canal.", body:"Frequent urination often returns as the head engages lower in the pelvis.", tip:"Kick counts become a useful daily check-in — note baby's typical pattern of movement."},
|
||||
{c:"a large cabbage", m:"~39.9 cm · 1.3 kg", dev:"The brain is developing at its fastest rate yet, adding folds and surface area.", body:"Sleep can become harder to find as the bump limits comfortable positions.", tip:"A full-body pregnancy pillow supports the belly, back, and knees simultaneously."},
|
||||
{c:"a coconut", m:"~41.1 cm · 1.5 kg", dev:"All five senses are now functioning, and baby is gaining roughly half a pound a week.", body:"Colostrum, the first form of breast milk, may begin to leak slightly.", tip:"A few nursing pads in your bag can help if colostrum leaking becomes noticeable."},
|
||||
{c:"a jicama", m:"~42.4 cm · 1.7 kg", dev:"Baby is practicing breathing movements against a closed airway, strengthening the lungs.", body:"Swelling and mild carpal tunnel tingling in the hands can appear from fluid retention.", tip:"Report sudden or severe swelling to your provider promptly — it can occasionally signal preeclampsia."},
|
||||
{c:"a pineapple", m:"~43.7 cm · 1.9 kg", dev:"Bones are hardening overall, though the skull stays deliberately soft and flexible for delivery.", body:"Pelvic pressure increases as baby continues descending.", tip:"Prenatal massage or a warm bath can ease the deep pelvic ache many feel this week."},
|
||||
{c:"a cantaloupe", m:"~45 cm · 2.1 kg", dev:"The central nervous system is maturing quickly, and baby's grip is now quite strong.", body:"Braxton Hicks contractions often become more noticeable and frequent.", tip:"Time any contractions that feel different — regular, intensifying, closer together warrants a call to your provider."},
|
||||
{c:"a honeydew melon", m:"~46.2 cm · 2.4 kg", dev:"The kidneys are fully developed, and the liver is processing waste products on its own.", body:"You may feel baby 'drop' lower as it settles into position for birth.", tip:"Lightening (the baby dropping) can ease heartburn but often increases bathroom trips."},
|
||||
{c:"a head of romaine", m:"~47.4 cm · 2.6 kg", dev:"Most physical development is complete; the focus now is steady weight gain.", body:"You've just reached 'early term' — babies born from here on tend to do very well.", tip:"Pack your hospital bag if you haven't yet — early-term babies can arrive with little warning."},
|
||||
{c:"a leek", m:"~49.8 cm · 3.1 kg", dev:"Baby continues laying down fat for temperature regulation after birth, and the brain is still rapidly developing.", body:"Nesting instinct and a burst of energy are common right before labor.", tip:"Balance nesting energy with rest — labor's timing is unpredictable."},
|
||||
{c:"a mini watermelon", m:"~50.7 cm · 3.3 kg", dev:"Full term. The lungs are completing final maturation in preparation for that first breath.", body:"Increased pelvic pressure and more frequent, intense Braxton Hicks are common.", tip:"Watch for your labor signs: regular contractions, water breaking, or a bloody show."},
|
||||
{c:"a small pumpkin", m:"~51.2 cm · 3.5 kg", dev:"Baby is fully developed and simply continuing to grow, ready to meet you.", body:"If labor hasn't started, your provider will discuss monitoring or induction options.", tip:"Most babies arrive within two weeks of their due date in either direction — both are entirely normal."}
|
||||
];
|
||||
|
||||
/* ================================================================
|
||||
SVG helpers
|
||||
================================================================ */
|
||||
function polarToXY(cx,cy,r,angleDeg){
|
||||
var rad = (angleDeg-90) * Math.PI/180;
|
||||
return [cx + r*Math.cos(rad), cy + r*Math.sin(rad)];
|
||||
}
|
||||
function arcPath(cx,cy,r,startDeg,endDeg){
|
||||
if(endDeg <= startDeg) endDeg = startDeg + 0.01;
|
||||
var s = polarToXY(cx,cy,r,startDeg), e = polarToXY(cx,cy,r,endDeg);
|
||||
var large = (endDeg-startDeg) > 180 ? 1 : 0;
|
||||
return "M "+s[0].toFixed(2)+" "+s[1].toFixed(2)+" A "+r+" "+r+" 0 "+large+" 1 "+e[0].toFixed(2)+" "+e[1].toFixed(2);
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
SHARED STATE: period start / cycle length / period length
|
||||
================================================================ */
|
||||
var exampleStart = addDays(today, -10);
|
||||
var startInput = document.getElementById("p-start");
|
||||
var cycleLenInput = document.getElementById("p-cyclelen");
|
||||
var periodLenInput = document.getElementById("p-periodlen");
|
||||
|
||||
var savedStart = loadLS("ca-start", null);
|
||||
var savedCycleLen = loadLS("ca-cyclelen", null);
|
||||
var savedPeriodLen = loadLS("ca-periodlen", null);
|
||||
var usingExample = !savedStart;
|
||||
|
||||
startInput.value = savedStart || toInputVal(exampleStart);
|
||||
cycleLenInput.value = savedCycleLen || 28;
|
||||
periodLenInput.value = savedPeriodLen || 5;
|
||||
|
||||
function getCycleState(){
|
||||
return {
|
||||
start: fromInput(startInput.value) || exampleStart,
|
||||
cycleLen: Math.max(21, Math.min(35, parseInt(cycleLenInput.value,10) || 28)),
|
||||
periodLen: Math.max(2, Math.min(10, parseInt(periodLenInput.value,10) || 5))
|
||||
};
|
||||
}
|
||||
|
||||
var pCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var oCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
var pSelectedDate = new Date(today);
|
||||
var oSelectedDate = new Date(today);
|
||||
|
||||
[startInput,cycleLenInput,periodLenInput].forEach(function(inp){
|
||||
inp.addEventListener("input", function(){
|
||||
saveLS("ca-start", startInput.value);
|
||||
saveLS("ca-cyclelen", cycleLenInput.value);
|
||||
saveLS("ca-periodlen", periodLenInput.value);
|
||||
usingExample = false;
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
renderAll();
|
||||
});
|
||||
});
|
||||
if(!usingExample) document.getElementById("p-example-note").style.display = "none";
|
||||
|
||||
/* ================================================================
|
||||
CALENDAR RENDERING (shared builder)
|
||||
================================================================ */
|
||||
function buildCalendar(gridEl, weekdayHeaderIgnored, monthDate, dayClassifier, onSelect, selectedDate){
|
||||
gridEl.innerHTML = "";
|
||||
var year = monthDate.getFullYear(), month = monthDate.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum < 1 || dayNum > daysInMonth){
|
||||
cell.classList.add("pad");
|
||||
gridEl.appendChild(cell);
|
||||
continue;
|
||||
}
|
||||
var d = new Date(year,month,dayNum);
|
||||
var info = dayClassifier(d);
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>";
|
||||
if(info.period) cell.classList.add("is-period");
|
||||
if(info.fertile) cell.classList.add("is-fertile");
|
||||
if(info.ovulation) cell.classList.add("is-ovulation");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
if(selectedDate && sameDay(d,selectedDate)) cell.classList.add("selected");
|
||||
cell.setAttribute("aria-label", d.toDateString());
|
||||
cell.addEventListener("click", function(dd){ return function(){ onSelect(dd); }; }(d));
|
||||
gridEl.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyForCycle(d, state){
|
||||
var dic = mod(daysBetween(state.start, d), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
return {
|
||||
period: dic < state.periodLen,
|
||||
fertile: isFertile(dic, ovDic),
|
||||
ovulation: dic === ovDic,
|
||||
dic: dic, ovDic: ovDic
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- PERIOD tab render ---------------- */
|
||||
function renderPeriodCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("p-cal-title").textContent = MONTH_NAMES[pCalMonth.getMonth()] + " " + pCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("p-daygrid"), null, pCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){ pSelectedDate = d; renderPeriodDetail(); renderPeriodCalendar(); },
|
||||
pSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderPeriodDetail(){
|
||||
var state = getCycleState();
|
||||
var info = classifyForCycle(pSelectedDate, state);
|
||||
var phaseKey = phaseForDayInCycle(info.dic, state.periodLen, info.ovDic);
|
||||
var phase = PHASES[phaseKey];
|
||||
var el = document.getElementById("p-detail");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+pSelectedDate.toDateString()+'</span>'+
|
||||
'<span class="pill" style="background:'+phase.soft+';color:'+phase.color+'">'+phase.title+' phase'+(info.fertile?' · fertile window':'')+'</span>'+
|
||||
'<div class="d-phase">Cycle day '+(info.dic+1)+'</div>'+
|
||||
'<p class="d-desc">'+phase.desc+'</p>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Hormones</div><div class="v">'+phase.hormone+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Typical symptoms</div><div class="v" style="font-weight:500;font-size:0.82rem;">'+phase.symptoms+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc"><strong style="color:var(--ink)">Care note — </strong>'+phase.tip+'</p>';
|
||||
}
|
||||
|
||||
function renderPeriodStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToNextPeriod = mod(state.cycleLen - dic, state.cycleLen);
|
||||
if(daysToNextPeriod === 0 && dic !== 0) daysToNextPeriod = state.cycleLen;
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var phaseKey = phaseForDayInCycle(dic, state.periodLen, ovDic);
|
||||
document.getElementById("period-stats").innerHTML =
|
||||
statTile(dic+1, "Cycle day today") +
|
||||
statTile(PHASES[phaseKey].title, "Current phase") +
|
||||
statTile(dic===0 ? "Today" : (daysToNextPeriod+"d"), "Until next period") +
|
||||
statTile(dic===ovDic ? "Today" : (daysToOvulation+"d"), "Until ovulation");
|
||||
|
||||
// hero ring
|
||||
var cx=100, cy=100, r=88;
|
||||
var perStart=0, perEnd = state.periodLen/state.cycleLen*360;
|
||||
var follEnd = ovDic/state.cycleLen*360;
|
||||
var fertStart = Math.max(0,(ovDic-5))/state.cycleLen*360;
|
||||
var lutStart = (ovDic+1)/state.cycleLen*360;
|
||||
document.getElementById("phase-arc-menstrual").setAttribute("d", arcPath(cx,cy,r,perStart,perEnd));
|
||||
document.getElementById("phase-arc-follicular").setAttribute("d", arcPath(cx,cy,r,perEnd,fertStart));
|
||||
document.getElementById("phase-arc-fertile").setAttribute("d", arcPath(cx,cy,r,fertStart,follEnd+ (360/state.cycleLen)));
|
||||
document.getElementById("phase-arc-luteal").setAttribute("d", arcPath(cx,cy,r,lutStart,360));
|
||||
var todayAngle = dic/state.cycleLen*360;
|
||||
var tp = polarToXY(cx,cy,r,todayAngle);
|
||||
document.getElementById("today-marker").setAttribute("cx", tp[0]);
|
||||
document.getElementById("today-marker").setAttribute("cy", tp[1]);
|
||||
document.getElementById("cycle-day-big").textContent = dic+1;
|
||||
}
|
||||
|
||||
function statTile(num,label){
|
||||
return '<div class="stat-tile"><div class="num">'+num+'</div><div class="lbl">'+label+'</div></div>';
|
||||
}
|
||||
|
||||
document.getElementById("p-prev").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()-1); renderPeriodCalendar(); });
|
||||
document.getElementById("p-next").addEventListener("click", function(){ pCalMonth.setMonth(pCalMonth.getMonth()+1); renderPeriodCalendar(); });
|
||||
|
||||
function renderPhaseRefGrid(){
|
||||
var order = ["menstrual","follicular","ovulatory","luteal"];
|
||||
document.getElementById("phase-ref-grid").innerHTML = order.map(function(key,i){
|
||||
var p = PHASES[key];
|
||||
return '<div class="ref-card"><span class="r-num">0'+(i+1)+'</span><h4 style="color:'+p.color+'">'+p.title+'</h4>'+
|
||||
'<p>'+p.desc+'</p>'+
|
||||
'<div class="tag-row"><span class="tagchip">'+p.window+'</span></div></div>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* ---------------- OVULATION tab render ---------------- */
|
||||
var ovSelectedOffset = 0;
|
||||
function renderOvTimeline(){
|
||||
var wrap = document.getElementById("ov-timeline");
|
||||
wrap.innerHTML = "";
|
||||
for(var off=-5; off<=5; off++){
|
||||
var d = OVULATION_DAYS[String(off)];
|
||||
var btn = document.createElement("button");
|
||||
btn.className = "tl-btn" + (off===ovSelectedOffset ? " active":"");
|
||||
btn.innerHTML = '<div class="tl-day">'+(off===0?"OVUL.":(off>0?"+":"")+off)+'</div>'+
|
||||
'<div class="tl-fert" style="background:'+FERT_COLOR[d.fertClass]+'"></div>';
|
||||
btn.addEventListener("click", function(o){ return function(){ ovSelectedOffset=o; renderOvTimeline(); renderOvDetailFromOffset(); }; }(off));
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
}
|
||||
function renderOvDetailFromOffset(){
|
||||
var d = OVULATION_DAYS[String(ovSelectedOffset)];
|
||||
var el = document.getElementById("o-detail");
|
||||
var label = ovSelectedOffset===0 ? "Ovulation day" : (ovSelectedOffset>0 ? ovSelectedOffset+" day"+(ovSelectedOffset>1?"s":"")+" after ovulation" : Math.abs(ovSelectedOffset)+" day"+(ovSelectedOffset<-1?"s":"")+" before ovulation");
|
||||
el.innerHTML =
|
||||
'<span class="d-date">'+label+'</span>'+
|
||||
'<span class="pill" style="background:var(--ovulation-soft);color:'+FERT_COLOR[d.fertClass]+'">Fertility: '+d.fert+'</span>'+
|
||||
'<div class="d-phase">'+(ovSelectedOffset===0?"Egg release":(ovSelectedOffset<0?"Building fertility":"Fertility fading"))+'</div>'+
|
||||
'<div class="d-grid">'+
|
||||
'<div class="d-mini"><div class="k">Basal body temp</div><div class="v" style="font-size:0.82rem;">'+d.bbt+'</div></div>'+
|
||||
'<div class="d-mini"><div class="k">Cervical mucus</div><div class="v" style="font-size:0.82rem;">'+d.mucus+'</div></div>'+
|
||||
'</div>'+
|
||||
'<p class="d-desc">'+d.note+'</p>';
|
||||
}
|
||||
|
||||
function renderOvulationCalendar(){
|
||||
var state = getCycleState();
|
||||
document.getElementById("o-cal-title").textContent = MONTH_NAMES[oCalMonth.getMonth()] + " " + oCalMonth.getFullYear();
|
||||
buildCalendar(document.getElementById("o-daygrid"), null, oCalMonth,
|
||||
function(d){ return classifyForCycle(d, state); },
|
||||
function(d){
|
||||
oSelectedDate = d;
|
||||
var info = classifyForCycle(d, state);
|
||||
var offset = info.dic - info.ovDic;
|
||||
if(offset < -5) offset = -5; if(offset > 5) offset = 5;
|
||||
ovSelectedOffset = offset;
|
||||
renderOvTimeline(); renderOvDetailFromOffset(); renderOvulationCalendar();
|
||||
},
|
||||
oSelectedDate
|
||||
);
|
||||
}
|
||||
|
||||
function renderOvulationStats(){
|
||||
var state = getCycleState();
|
||||
var dic = mod(daysBetween(state.start, today), state.cycleLen);
|
||||
var ovDic = mod(state.cycleLen - 14, state.cycleLen);
|
||||
var daysToOvulation = mod(ovDic - dic, state.cycleLen);
|
||||
var fertileStartDic = mod(ovDic-5, state.cycleLen);
|
||||
var inFertile = isFertile(dic, ovDic);
|
||||
var daysToFertile = inFertile ? 0 : mod(fertileStartDic - dic, state.cycleLen);
|
||||
document.getElementById("ovulation-stats").innerHTML =
|
||||
statTile(inFertile ? "Yes" : "No", "In fertile window") +
|
||||
statTile(dic===ovDic ? "Today" : daysToOvulation+"d", "Until ovulation") +
|
||||
statTile(inFertile ? "Now" : daysToFertile+"d", "Fertile window") +
|
||||
statTile("6 days", "Window length");
|
||||
}
|
||||
|
||||
document.getElementById("o-prev").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()-1); renderOvulationCalendar(); });
|
||||
document.getElementById("o-next").addEventListener("click", function(){ oCalMonth.setMonth(oCalMonth.getMonth()+1); renderOvulationCalendar(); });
|
||||
|
||||
/* ================================================================
|
||||
PREGNANCY tab
|
||||
================================================================ */
|
||||
var lmpInput = document.getElementById("pr-lmp");
|
||||
var exampleLMP = addDays(today, -20*7 - 3);
|
||||
var savedLMP = loadLS("ca-lmp", null);
|
||||
lmpInput.value = savedLMP || toInputVal(exampleLMP);
|
||||
if(savedLMP) document.getElementById("pr-example-note").style.display = "none";
|
||||
|
||||
var slider = document.getElementById("week-slider");
|
||||
var prCalMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
|
||||
lmpInput.addEventListener("input", function(){
|
||||
saveLS("ca-lmp", lmpInput.value);
|
||||
document.getElementById("pr-example-note").style.display = "none";
|
||||
document.getElementById("p-example-note").style.display = "none";
|
||||
syncSliderToLMP();
|
||||
renderPregnancy();
|
||||
});
|
||||
|
||||
function getLMP(){ return fromInput(lmpInput.value) || exampleLMP; }
|
||||
function currentGestWeek(){
|
||||
var lmp = getLMP();
|
||||
var w = Math.floor(daysBetween(lmp,today)/7) + 1;
|
||||
return Math.max(1, Math.min(40, w));
|
||||
}
|
||||
function syncSliderToLMP(){ slider.value = currentGestWeek(); }
|
||||
syncSliderToLMP();
|
||||
|
||||
function renderPregnancyStats(){
|
||||
var lmp = getLMP();
|
||||
var due = addDays(lmp, 280);
|
||||
var week = currentGestWeek();
|
||||
var daysLeft = Math.max(0, daysBetween(today, due));
|
||||
document.getElementById("pregnancy-stats").innerHTML =
|
||||
statTile(week, "Weeks along") +
|
||||
statTile(Math.floor((week-1)/13)+1 <= 3 ? ["First","Second","Third"][Math.min(2,Math.floor((week-1)/13))] : "Third", "Trimester") +
|
||||
statTile(daysLeft, "Days to due date") +
|
||||
statTile(due.toLocaleDateString(undefined,{month:"short",day:"numeric"}), "Estimated due date");
|
||||
|
||||
var pct = (week-1)/39;
|
||||
var pathLen = 230;
|
||||
var offset = pathLen - pct*pathLen;
|
||||
var path = document.getElementById("pregnancy-progress-path");
|
||||
path.setAttribute("stroke-dashoffset", offset.toFixed(1));
|
||||
try{
|
||||
var totalLen = path.getTotalLength ? path.getTotalLength() : pathLen;
|
||||
var pt = path.getPointAtLength ? path.getPointAtLength(pct*totalLen) : null;
|
||||
if(pt) { document.getElementById("pregnancy-progress-dot").setAttribute("cx",pt.x); document.getElementById("pregnancy-progress-dot").setAttribute("cy",pt.y); }
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
function renderWeekPanel(week){
|
||||
var w = WEEKS[week-1];
|
||||
document.getElementById("wk-badge").textContent = "WEEK " + week;
|
||||
document.getElementById("wk-compare").textContent = "size of " + w.c;
|
||||
document.getElementById("wk-metric").textContent = w.m === "n/a" ? "not yet fertilized" : "≈ " + w.m;
|
||||
document.getElementById("wk-dev").textContent = w.dev;
|
||||
document.getElementById("wk-body").textContent = w.body;
|
||||
document.getElementById("wk-tip").textContent = w.tip;
|
||||
var tri = week<=13?1:(week<=27?2:3);
|
||||
var triColor = "var(--pregnancy)";
|
||||
document.getElementById("wk-blob").innerHTML =
|
||||
'<svg width="96" height="96" viewBox="0 0 96 96"><circle cx="48" cy="48" r="'+(14+week*0.9)+'" fill="'+ (tri===1?"var(--pregnancy-soft)":tri===2?"var(--pregnancy)":"var(--pregnancy-strong)") +'" opacity="0.85"/></svg>';
|
||||
document.querySelectorAll(".film-item").forEach(function(f){ f.classList.toggle("active", +f.dataset.week===week); });
|
||||
var active = document.querySelector('.film-item[data-week="'+week+'"]');
|
||||
if(active) active.scrollIntoView({inline:"center", block:"nearest", behavior:"smooth"});
|
||||
}
|
||||
|
||||
slider.addEventListener("input", function(){ renderWeekPanel(+slider.value); });
|
||||
|
||||
function buildFilmstrip(){
|
||||
var wrap = document.getElementById("week-filmstrip");
|
||||
wrap.innerHTML = "";
|
||||
for(var w=1; w<=40; w++){
|
||||
var item = document.createElement("button");
|
||||
item.className = "film-item";
|
||||
item.dataset.week = w;
|
||||
item.innerHTML = '<div class="fw">WK</div><div class="fn">'+w+'</div>';
|
||||
item.addEventListener("click", function(ww){ return function(){ slider.value=ww; renderWeekPanel(ww); }; }(w));
|
||||
wrap.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPregnancyCalendar(){
|
||||
var lmp = getLMP();
|
||||
document.getElementById("pr-cal-title").textContent = MONTH_NAMES[prCalMonth.getMonth()] + " " + prCalMonth.getFullYear();
|
||||
var grid = document.getElementById("pr-daygrid");
|
||||
grid.innerHTML = "";
|
||||
var year = prCalMonth.getFullYear(), month = prCalMonth.getMonth();
|
||||
var firstDow = new Date(year,month,1).getDay();
|
||||
var daysInMonth = new Date(year,month+1,0).getDate();
|
||||
var totalCells = Math.ceil((firstDow+daysInMonth)/7)*7;
|
||||
for(var i=0;i<totalCells;i++){
|
||||
var dayNum = i - firstDow + 1;
|
||||
var cell = document.createElement("button");
|
||||
cell.className = "day-cell";
|
||||
if(dayNum<1 || dayNum>daysInMonth){ cell.classList.add("pad"); grid.appendChild(cell); continue; }
|
||||
var d = new Date(year,month,dayNum);
|
||||
var wk = Math.floor(daysBetween(lmp,d)/7)+1;
|
||||
var inRange = wk>=1 && wk<=42;
|
||||
cell.innerHTML = "<span>"+dayNum+"</span>" + (inRange ? '<span class="dot-row" style="position:static;margin-top:2px;font-size:0.55rem;color:var(--pregnancy-strong)">W'+wk+'</span>' : "");
|
||||
if(inRange && wk<=40) cell.classList.add("is-fertile");
|
||||
if(sameDay(d,today)) cell.classList.add("today");
|
||||
cell.addEventListener("click", function(ww){ return function(){ if(ww>=1&&ww<=40){ slider.value=ww; renderWeekPanel(ww); } }; }(wk));
|
||||
grid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
document.getElementById("pr-prev").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()-1); renderPregnancyCalendar(); });
|
||||
document.getElementById("pr-next").addEventListener("click", function(){ prCalMonth.setMonth(prCalMonth.getMonth()+1); renderPregnancyCalendar(); });
|
||||
|
||||
function renderPregnancy(){
|
||||
renderPregnancyStats();
|
||||
renderWeekPanel(currentGestWeek());
|
||||
renderPregnancyCalendar();
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
INIT
|
||||
================================================================ */
|
||||
function renderAll(){
|
||||
renderPeriodStats();
|
||||
renderPeriodCalendar();
|
||||
renderPeriodDetail();
|
||||
renderOvulationStats();
|
||||
renderOvTimeline();
|
||||
renderOvDetailFromOffset();
|
||||
renderOvulationCalendar();
|
||||
renderPregnancy();
|
||||
}
|
||||
|
||||
renderPhaseRefGrid();
|
||||
buildFilmstrip();
|
||||
slider.value = currentGestWeek();
|
||||
renderAll();
|
||||
})();
|
||||
</script>
|
||||
<script src="app.js"></script>
|
||||
<script src="vendor/gsap.min.js"></script>
|
||||
<script src="vendor/ScrollTrigger.min.js"></script>
|
||||
<script src="ring3d.js"></script>
|
||||
<script src="animations.js"></script>
|
||||
<script src="timeline.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,2 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,46 +0,0 @@
|
||||
# PRD: Keith Rana Portfolio (built from the "Jack 3D Creator" template)
|
||||
|
||||
## The restaurant analogy
|
||||
| Stage | Restaurant | This project |
|
||||
|---|---|---|
|
||||
| Phase 1 (done) | Food stand: one great dish, open today | The full single-page site with Keith's real content, photos and animations |
|
||||
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
|
||||
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
||||
|
||||
Front of house = what visitors see. Back of house = forms, email, storage, security.
|
||||
|
||||
## Core features
|
||||
1. Hero: giant "Hi, i'm jack" title, nav, magnetic portrait, Contact button
|
||||
2. Marquee: two rows of GIFs sliding left/right as you scroll
|
||||
3. About: floating 3D icons + words that light up as you scroll
|
||||
4. Services: white panel, 5 numbered services
|
||||
5. Projects: 3 cards that stack and shrink as you scroll
|
||||
6. Reusable parts: ContactButton, LiveProjectButton, FadeIn, Magnet, AnimatedText
|
||||
|
||||
## Phased roadmap
|
||||
**Phase 1 — Food stand (today).** Static site, all 5 sections, all animations, responsive.
|
||||
Done when: builds with no errors; page loads with no console errors; every button/link works; all image URLs load.
|
||||
|
||||
**Phase 2 — Small restaurant.** Nav links smooth-scroll to sections, Contact Me opens a mailto, content moved into editable data files, reduced-motion support, alt text, SEO tags, image fallbacks.
|
||||
|
||||
**Phase 3 — Full restaurant.** Contact form with a backend (server-side validation, spam protection, rate limits), project detail pages, CMS, analytics, hosting.
|
||||
|
||||
## Test ladder (each step must pass before the next)
|
||||
1. Page loads with no errors
|
||||
2. Buttons / links / forms work
|
||||
3. Real external APIs/assets work (images load)
|
||||
4. Security check (no secrets in code, safe external links, dependency audit)
|
||||
|
||||
## Security notes
|
||||
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
|
||||
|
||||
## Phase 1 status (done)
|
||||
- Content comes from Keith's existing portfolio: profile, 8 capabilities, 3 roles + 5 earlier roles, 4 projects, 10 certifications, contact details (all in `src/data/content.ts`).
|
||||
- Each project card has its own illustrative diagram (drawn from the deliverables in Keith's portfolio text; swap for real screenshots when available).
|
||||
- Sections: Hero (portrait), scrolling photo/skills rows, About, Capability, Experience, Projects (stacking cards), Credentials, Contact.
|
||||
- Photos and the Kanit font ship inside the site (no outside image hosts needed).
|
||||
- Tested at 360, 390, 768, 1024, 1440 and 1920 px wide: no sideways scroll, no console errors, all images load.
|
||||
- Cloud preview: `node scripts/build-preview.mjs out.html` builds one self-contained page.
|
||||
|
||||
## Not used yet
|
||||
- `it_helpdesk.db` (87 IT help-desk FAQs) was supplied but is not portfolio content. Candidate for a Phase 2 FAQ section if wanted.
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Keith Rana, IT Service Delivery Manager in Melbourne, Australia." />
|
||||
<title>Keith Rana -- IT Service Delivery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "keith-portfolio",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/kanit": "^5.3.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export default {
|
||||
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
// Builds ONE self-contained HTML page for the Claude cloud preview.
|
||||
// Claude's cloud pages block outside images and files, so photos and fonts are baked in.
|
||||
// Usage: node scripts/build-preview.mjs <output.html>
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const out = process.argv[2] ?? 'preview.html';
|
||||
execFileSync('npm', ['run', 'build'], { stdio: 'inherit', env: { ...process.env, INLINE: '1' } });
|
||||
|
||||
const dir = 'dist/assets/';
|
||||
const files = readdirSync(dir);
|
||||
const js = readFileSync(dir + files.find((f) => f.endsWith('.js')), 'utf8').replace(/<\/script/g, '<\\/script');
|
||||
const css = readFileSync(dir + files.find((f) => f.endsWith('.css')), 'utf8');
|
||||
|
||||
const html = `<meta charset="utf-8">
|
||||
<title>Keith Rana Portfolio</title>
|
||||
<style>${css}
|
||||
html,body{background:#0C0C0C}</style>
|
||||
<div id="root"></div>
|
||||
<script type="module">${js}</script>
|
||||
`;
|
||||
writeFileSync(out, html);
|
||||
const mb = Buffer.byteLength(html) / 1048576;
|
||||
console.log(`wrote ${out} (${mb.toFixed(1)} MB)${mb > 16 ? ' WARNING: over the 16 MB page limit' : ''}`);
|
||||
@@ -1,26 +0,0 @@
|
||||
import HeroSection from './sections/HeroSection';
|
||||
import MarqueeSection from './sections/MarqueeSection';
|
||||
import AboutSection from './sections/AboutSection';
|
||||
import ServicesSection from './sections/ServicesSection';
|
||||
import ExperienceSection from './sections/ExperienceSection';
|
||||
import ProjectsSection from './sections/ProjectsSection';
|
||||
import CredentialsSection from './sections/CredentialsSection';
|
||||
import ContactSection from './sections/ContactSection';
|
||||
import { ContactProvider } from './components/ContactDialog';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ContactProvider>
|
||||
<main style={{ background: '#0C0C0C', overflowX: 'clip' }}>
|
||||
<HeroSection />
|
||||
<MarqueeSection />
|
||||
<AboutSection />
|
||||
<ServicesSection />
|
||||
<ExperienceSection />
|
||||
<ProjectsSection />
|
||||
<CredentialsSection />
|
||||
<ContactSection />
|
||||
</main>
|
||||
</ContactProvider>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 641 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 277 KiB |
@@ -1,30 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import type { MotionValue } from 'framer-motion';
|
||||
|
||||
function Char({ char, index, total, progress }: { char: string; index: number; total: number; progress: MotionValue<number> }) {
|
||||
const start = index / total;
|
||||
const end = start + 1 / total;
|
||||
const opacity = useTransform(progress, [start, end], [0.2, 1]);
|
||||
return (
|
||||
<span className="relative">
|
||||
<span className="invisible">{char}</span>
|
||||
<motion.span className="absolute left-0 top-0" style={{ opacity }}>
|
||||
{char}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AnimatedText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.8', 'end 0.2'] });
|
||||
const chars = Array.from(text);
|
||||
return (
|
||||
<p ref={ref} className={className} style={style} aria-label={text}>
|
||||
{chars.map((c, i) =>
|
||||
c === ' ' ? ' ' : <Char key={i} char={c} index={i} total={chars.length} progress={scrollYProgress} />
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useOpenContact } from './ContactDialog';
|
||||
|
||||
export default function ContactButton() {
|
||||
const openContact = useOpenContact();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openContact}
|
||||
className="inline-block whitespace-nowrap rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
|
||||
style={{
|
||||
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
||||
boxShadow: '0px 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721B1 inset',
|
||||
outline: '2px solid white',
|
||||
outlineOffset: '-3px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Contact Me
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, Copy, Mail, X } from 'lucide-react';
|
||||
import { profile } from '../data/content';
|
||||
|
||||
const OpenContext = createContext<() => void>(() => {});
|
||||
export const useOpenContact = () => useContext(OpenContext);
|
||||
|
||||
const subject = encodeURIComponent('Portfolio enquiry');
|
||||
const to = encodeURIComponent(profile.email);
|
||||
const options = [
|
||||
{ label: 'Open my email app', href: `mailto:${profile.email}?subject=${subject}`, newTab: false },
|
||||
{ label: 'Write in Gmail', href: `https://mail.google.com/mail/?view=cm&fs=1&to=${to}&su=${subject}`, newTab: true },
|
||||
{ label: 'Write in Yahoo Mail', href: `https://compose.mail.yahoo.com/?to=${to}&subject=${subject}`, newTab: true },
|
||||
];
|
||||
|
||||
const optionClass =
|
||||
'flex items-center justify-between gap-3 rounded-full border-2 border-[#D7E2EA] px-6 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:text-base';
|
||||
|
||||
export function ContactProvider({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
|
||||
const show = useCallback(() => {
|
||||
opener.current = document.activeElement as HTMLElement | null;
|
||||
setCopied(false);
|
||||
setOpen(true);
|
||||
}, []);
|
||||
const hide = useCallback(() => {
|
||||
setOpen(false);
|
||||
opener.current?.focus?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
closeRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && hide();
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, hide]);
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.email);
|
||||
setCopied(true);
|
||||
} catch {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OpenContext.Provider value={show}>
|
||||
{children}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||
onMouseDown={(e) => e.target === e.currentTarget && hide()}
|
||||
>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="contact-title" className="relative flex w-full max-w-md flex-col gap-5 rounded-[32px] border-2 border-[#D7E2EA] bg-[#0C0C0C] p-6 sm:p-8">
|
||||
<button ref={closeRef} onClick={hide} aria-label="Close" className="absolute right-4 top-4 rounded-full p-2 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<X size={22} />
|
||||
</button>
|
||||
<h2 id="contact-title" className="hero-heading pr-10 text-4xl font-black uppercase leading-none sm:text-5xl">Email Keith</h2>
|
||||
<p className="font-light leading-relaxed text-[#D7E2EA]/80">Pick how you would like to write. Your message goes to the address below.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="contact-email"
|
||||
readOnly
|
||||
value={profile.email}
|
||||
aria-label="Email address"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="min-w-0 flex-1 rounded-full border border-[#D7E2EA]/40 bg-transparent px-5 py-3 text-[#D7E2EA]"
|
||||
/>
|
||||
<button onClick={copy} className="flex shrink-0 items-center gap-2 rounded-full border border-[#D7E2EA]/40 px-4 py-3 text-sm text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{options.map((o) => (
|
||||
<a key={o.label} href={o.href} className={optionClass} {...(o.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}>
|
||||
{o.label}
|
||||
<Mail size={18} />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OpenContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import type { MotionProps } from 'framer-motion';
|
||||
import type { ComponentType, ElementType, ReactNode } from 'react';
|
||||
|
||||
type FadeInProps = {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
duration?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
className?: string;
|
||||
as?: ElementType;
|
||||
};
|
||||
|
||||
const cache = new Map<ElementType, ComponentType<MotionProps & { className?: string }>>();
|
||||
const getMotion = (el: ElementType) => {
|
||||
if (!cache.has(el)) cache.set(el, motion.create(el as 'div') as ComponentType<MotionProps & { className?: string }>);
|
||||
return cache.get(el)!;
|
||||
};
|
||||
|
||||
export default function FadeIn({
|
||||
children,
|
||||
delay = 0,
|
||||
duration = 0.7,
|
||||
x = 0,
|
||||
y = 30,
|
||||
className,
|
||||
as = 'div',
|
||||
}: FadeInProps) {
|
||||
const Component = getMotion(as);
|
||||
return (
|
||||
<Component
|
||||
className={className}
|
||||
initial={{ opacity: 0, x, y }}
|
||||
whileInView={{ opacity: 1, x: 0, y: 0 }}
|
||||
viewport={{ once: true, margin: '50px', amount: 0 }}
|
||||
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type MagnetProps = {
|
||||
children: ReactNode;
|
||||
padding?: number;
|
||||
strength?: number;
|
||||
activeTransition?: string;
|
||||
inactiveTransition?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Magnet({
|
||||
children,
|
||||
padding = 100,
|
||||
strength = 2,
|
||||
activeTransition = 'transform 0.3s ease-out',
|
||||
inactiveTransition = 'transform 0.6s ease-in-out',
|
||||
className,
|
||||
}: MagnetProps) {
|
||||
const [active, setActive] = useState(false);
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!ref.current) return;
|
||||
const { left, top, width, height } = ref.current.getBoundingClientRect();
|
||||
const cx = left + width / 2;
|
||||
const cy = top + height / 2;
|
||||
const dx = Math.abs(cx - e.clientX);
|
||||
const dy = Math.abs(cy - e.clientY);
|
||||
if (dx < width / 2 + padding && dy < height / 2 + padding) {
|
||||
setActive(true);
|
||||
setPos({ x: (e.clientX - cx) / strength, y: (e.clientY - cy) / strength });
|
||||
} else {
|
||||
setActive(false);
|
||||
setPos({ x: 0, y: 0 });
|
||||
}
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
return () => window.removeEventListener('mousemove', onMove);
|
||||
}, [padding, strength]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||
transition: active ? activeTransition : inactiveTransition,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
export type DiagramKind = 'network' | 'server' | 'sccm' | 'voice';
|
||||
|
||||
const INK = '#D7E2EA';
|
||||
const TEAL = '#2BB3A6';
|
||||
const MUTED = 'rgba(215,226,234,0.55)';
|
||||
const FILL = '#14161a';
|
||||
|
||||
function Box({ x, y, w, h, label, sub, accent }: { x: number; y: number; w: number; h: number; label: string; sub?: string; accent?: boolean }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} rx={14} fill={FILL} stroke={accent ? TEAL : INK} strokeWidth={accent ? 2.5 : 1.5} />
|
||||
<text x={x + w / 2} y={y + h / 2 + (sub ? -4 : 8)} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>{label}</text>
|
||||
{sub && <text x={x + w / 2} y={y + h / 2 + 22} textAnchor="middle" fontSize={18} fill={MUTED}>{sub}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Line({ x1, y1, x2, y2, dashed }: { x1: number; y1: number; x2: number; y2: number; dashed?: boolean }) {
|
||||
return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke={MUTED} strokeWidth={2} strokeDasharray={dashed ? '6 6' : undefined} />;
|
||||
}
|
||||
|
||||
function Arrow({ x1, y, x2, label }: { x1: number; y: number; x2: number; label?: string }) {
|
||||
return (
|
||||
<g>
|
||||
<line x1={x1} y1={y} x2={x2 - 10} y2={y} stroke={TEAL} strokeWidth={3} />
|
||||
<path d={`M${x2 - 14} ${y - 8} L${x2} ${y} L${x2 - 14} ${y + 8} Z`} fill={TEAL} />
|
||||
{label && <text x={(x1 + x2) / 2} y={y - 14} textAnchor="middle" fontSize={20} fill={TEAL}>{label}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ cx, cy, r = 14 }: { cx: number; cy: number; r?: number }) {
|
||||
return <circle cx={cx} cy={cy} r={r} fill={FILL} stroke={TEAL} strokeWidth={2.5} />;
|
||||
}
|
||||
|
||||
function Wifi({ cx, cy }: { cx: number; cy: number }) {
|
||||
return (
|
||||
<g fill="none" stroke={TEAL} strokeWidth={2.5} strokeLinecap="round">
|
||||
<path d={`M${cx - 18} ${cy - 4} Q${cx} ${cy - 22} ${cx + 18} ${cy - 4}`} />
|
||||
<path d={`M${cx - 10} ${cy + 4} Q${cx} ${cy - 6} ${cx + 10} ${cy + 4}`} />
|
||||
<circle cx={cx} cy={cy + 12} r={2.5} fill={TEAL} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
const diagrams: Record<DiagramKind, { label: string; body: ReactNode }> = {
|
||||
network: {
|
||||
label: 'Network topology: Fortinet firewall connects to Fortinet 48P switches, which connect to access points.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={190} y={30} w={220} h={64} label="Fortinet firewall" accent />
|
||||
<Line x1={300} y1={94} x2={300} y2={130} />
|
||||
<Line x1={150} y1={130} x2={450} y2={130} />
|
||||
<Line x1={150} y1={130} x2={150} y2={160} />
|
||||
<Line x1={450} y1={130} x2={450} y2={160} />
|
||||
<Box x={50} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
||||
<Box x={350} y={160} w={200} h={64} label="48P switch" sub="Fortinet" />
|
||||
{[90, 210, 390, 510].map((x, i) => (
|
||||
<g key={x}>
|
||||
<Line x1={i < 2 ? 150 : 450} y1={224} x2={x} y2={274} dashed />
|
||||
<Dot cx={x} cy={296} r={22} />
|
||||
<Wifi cx={x} cy={292} />
|
||||
</g>
|
||||
))}
|
||||
<text x={300} y={372} textAnchor="middle" fontSize={20} fill={MUTED}>Access point uplift</text>
|
||||
<text x={300} y={400} textAnchor="middle" fontSize={20} fill={MUTED}>Network topology upgrade</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
server: {
|
||||
label: 'Server upgrade: virtual machines and services move from the existing server to the upgraded server, with updated network settings.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={20} y={70} w={190} h={200} label="" />
|
||||
<text x={115} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Existing server</text>
|
||||
<Box x={40} y={130} w={150} h={44} label="VMs" />
|
||||
<Box x={40} y={192} w={150} h={44} label="Services" />
|
||||
<Arrow x1={222} y={170} x2={378} label="Data migrated" />
|
||||
<Box x={390} y={70} w={190} h={200} label="" accent />
|
||||
<text x={485} y={105} textAnchor="middle" fontSize={22} fontWeight={500} fill={INK}>Upgraded server</text>
|
||||
<Box x={410} y={130} w={150} h={44} label="VMs" />
|
||||
<Box x={410} y={192} w={150} h={44} label="Services" />
|
||||
<Line x1={485} y1={270} x2={485} y2={320} dashed />
|
||||
<Box x={150} y={320} w={320} h={56} label="Updated network settings" />
|
||||
<Line x1={470} y1={348} x2={485} y2={348} dashed />
|
||||
</>
|
||||
),
|
||||
},
|
||||
sccm: {
|
||||
label: 'SCCM implementation: install and configure SCCM, prepare settings, then establish the task-sequence process for endpoints.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={200} y={26} w={200} h={64} label="SCCM" accent />
|
||||
<Line x1={300} y1={90} x2={300} y2={122} />
|
||||
<Box x={40} y={122} w={150} h={92} label="Install" sub="& configure" />
|
||||
<Box x={225} y={122} w={150} h={92} label="Prepare" sub="settings" />
|
||||
<Box x={410} y={122} w={150} h={92} label="Task" sub="sequence" accent />
|
||||
<Arrow x1={190} y={168} x2={225} />
|
||||
<Arrow x1={375} y={168} x2={410} />
|
||||
<Line x1={485} y1={214} x2={485} y2={262} dashed />
|
||||
<Line x1={150} y1={262} x2={450} y2={262} dashed />
|
||||
{[150, 250, 350, 450].map((x) => (
|
||||
<g key={x}>
|
||||
<Line x1={x} y1={262} x2={x} y2={292} dashed />
|
||||
<rect x={x - 30} y={292} width={60} height={40} rx={6} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
<line x1={x - 40} y1={344} x2={x + 40} y2={344} stroke={TEAL} strokeWidth={3} strokeLinecap="round" />
|
||||
</g>
|
||||
))}
|
||||
<text x={300} y={392} textAnchor="middle" fontSize={20} fill={MUTED}>Endpoints built through the task sequence</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
voice: {
|
||||
label: 'Microsoft Teams calling: number porting, dial-plan and call-flow design, then users supported by training, documentation and adoption monitoring.',
|
||||
body: (
|
||||
<>
|
||||
<Box x={20} y={50} w={160} h={70} label="Number" sub="porting" />
|
||||
<Box x={220} y={50} w={160} h={70} label="Dial plan" accent />
|
||||
<Box x={420} y={50} w={160} h={70} label="Call flow" />
|
||||
<Arrow x1={180} y={85} x2={220} />
|
||||
<Arrow x1={380} y={85} x2={420} />
|
||||
<Line x1={300} y1={120} x2={300} y2={168} dashed />
|
||||
<Line x1={100} y1={168} x2={500} y2={168} dashed />
|
||||
{[100, 300, 500].map((x) => (
|
||||
<g key={x}>
|
||||
<Line x1={x} y1={168} x2={x} y2={196} dashed />
|
||||
<circle cx={x} cy={214} r={16} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
<path d={`M${x - 28} ${264} Q${x} ${226} ${x + 28} ${264}`} fill={FILL} stroke={TEAL} strokeWidth={2.5} />
|
||||
</g>
|
||||
))}
|
||||
<text x={100} y={310} textAnchor="middle" fontSize={20} fill={INK}>User training</text>
|
||||
<text x={300} y={310} textAnchor="middle" fontSize={20} fill={INK}>Documentation</text>
|
||||
<text x={500} y={310} textAnchor="middle" fontSize={20} fill={INK}>Adoption monitoring</text>
|
||||
<text x={300} y={380} textAnchor="middle" fontSize={20} fill={MUTED}>Microsoft Teams calling</text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default function ProjectDiagram({ kind, className, style }: { kind: DiagramKind; className?: string; style?: CSSProperties }) {
|
||||
const d = diagrams[kind];
|
||||
return (
|
||||
<svg viewBox="0 0 600 420" role="img" aria-label={`Illustrative diagram. ${d.label}`} className={className} style={{ fontFamily: 'Kanit, sans-serif', ...style }}>
|
||||
{d.body}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import portrait from '../assets/portrait.jpg';
|
||||
import leadership from '../assets/leadership.webp';
|
||||
import systems from '../assets/systems.webp';
|
||||
import signalMark from '../assets/signal-mark.webp';
|
||||
import networkPhoto from '../assets/projects/network.jpg';
|
||||
import serverPhoto from '../assets/projects/server.jpg';
|
||||
import sccmPhoto from '../assets/projects/sccm.jpg';
|
||||
import voicePhoto from '../assets/projects/voice.jpg';
|
||||
|
||||
import type { DiagramKind } from '../components/ProjectDiagram';
|
||||
|
||||
export const images = { portrait, leadership, systems, signalMark };
|
||||
|
||||
export const profile = {
|
||||
name: 'Keith Rana',
|
||||
firstName: 'keith',
|
||||
role: 'IT Service Delivery Manager',
|
||||
location: 'Melbourne, Australia',
|
||||
email: 'keith_rana@yahoo.com',
|
||||
phone: '04 1668 7558',
|
||||
phoneHref: 'tel:+61416687558',
|
||||
tagline: 'an it service delivery manager keeping critical services steady while the work keeps moving',
|
||||
};
|
||||
|
||||
export const aboutText =
|
||||
'A results-driven IT professional with over 16 years of experience across telecommunications, education, and managed services, including more than 8 years leading technical teams. I make technology service delivery visible, stable, and continually better. Let\'s make delivery more dependable.';
|
||||
|
||||
export const stats = [
|
||||
{ value: '16+', label: 'Years in IT' },
|
||||
{ value: '8+', label: 'Years leading' },
|
||||
];
|
||||
|
||||
export const expertise = [
|
||||
{ name: 'IT Service Delivery', description: 'End-to-end accountability for service delivery against agreed SLAs, with reporting to senior stakeholders.' },
|
||||
{ name: 'Enterprise Infrastructure', description: 'Enterprise network, server and endpoint environments, including Fortinet, Zscaler and SCCM.' },
|
||||
{ name: 'ITIL & Change Control', description: 'ITIL process, measured change and service reporting that improve compliance and service quality.' },
|
||||
{ name: 'Incident & Escalation Leadership', description: 'Major incident management, workload prioritisation and escalations led with a calm, evidence-based approach.' },
|
||||
{ name: 'Vendor & Stakeholder Management', description: 'Clear ownership of the vendor relationships that sit behind every service the business relies on.' },
|
||||
{ name: 'Microsoft 365 & Intune', description: 'Hands-on depth in Microsoft 365, Azure and Intune across cloud applications and end-user devices.' },
|
||||
{ name: 'Cybersecurity & Access', description: 'Security, identity and access practices built into daily operations, backed by Microsoft security certifications.' },
|
||||
{ name: 'Team Development', description: 'Coaching, performance conversations, training and mentoring that grow capable, confident support teams.' },
|
||||
];
|
||||
|
||||
export const roles = [
|
||||
{
|
||||
dates: 'Oct 2023 — Present',
|
||||
role: 'IT Service Delivery Manager',
|
||||
organisation: 'AFL Telecommunications',
|
||||
summary: 'Leading the daily delivery of business-critical IT services, balancing prompt resolution, availability, security, and a dependable experience for end users.',
|
||||
points: [
|
||||
'Own service operations across cloud applications, Windows and Linux environments, desktops, virtual apps, networks, and end-user support.',
|
||||
'Lead a support and service-desk team through coaching, performance conversations, workload prioritisation, and escalations.',
|
||||
'Use incident patterns, service-performance reporting, change controls, and feedback loops to improve ITIL compliance and service quality.',
|
||||
],
|
||||
},
|
||||
{
|
||||
dates: 'Dec 2021 — Sep 2023',
|
||||
role: 'Information Technology Manager',
|
||||
organisation: 'Suzanne Cory High School',
|
||||
summary: 'Managed technology services, documentation, knowledge systems, infrastructure capacity, and Microsoft 365 operations in a fast-moving education environment.',
|
||||
points: [
|
||||
'Oversaw Microsoft 365 services, endpoint operations, security, quality, and the administration and maintenance of core IT infrastructure.',
|
||||
'Led a skilled IT specialist team with a focus on strategic direction, mentoring, service effectiveness, and superior customer outcomes.',
|
||||
'Coordinated systems migrations, upgrades, and deployments to defined scope and schedule constraints.',
|
||||
],
|
||||
},
|
||||
{
|
||||
dates: 'May 2018 — Nov 2021',
|
||||
role: 'IT Support Specialist — Team Lead',
|
||||
organisation: 'Wesley College Melbourne',
|
||||
summary: 'Directed a six-person IT support team and helped translate organisational priorities into reliable, well-documented technical service.',
|
||||
points: [
|
||||
'Established project scope, supported IT initiatives, and aligned delivery activity with strategic objectives.',
|
||||
'Worked across Casper Management, AirWatch MDM, Microsoft Intune, and operational processes informed by ITIL principles.',
|
||||
'Identified skills gaps, organised training, coordinated with project managers, and maintained clear project documentation.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const foundations = [
|
||||
{ role: 'IT Support Officer', organisation: 'Mentone Grammar School', dates: 'Apr 2017 — Dec 2017' },
|
||||
{ role: 'IT Project Manager', organisation: 'Archirayan Infotech', dates: 'Aug 2015 — Jul 2016' },
|
||||
{ role: 'IT Technical Support', organisation: 'Has Tech Solutions', dates: 'Aug 2014 — Jul 2015' },
|
||||
{ role: 'IT Technical Engineer', organisation: 'Hindustan Cyber Services', dates: 'Oct 2012 — Aug 2014' },
|
||||
{ role: 'Assistant Systems Analyst', organisation: 'Prutha Technologies', dates: 'Oct 2009 — Sep 2010' },
|
||||
];
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
category: 'Network',
|
||||
name: 'Network project',
|
||||
body: 'Fortinet Firewall upgrade, Fortinet 48P Switches upgrade, Fortinet access point uplift, and network topology upgrade.',
|
||||
delivered: ['Fortinet firewall upgrade', 'Fortinet 48P switches upgrade', 'Fortinet access point uplift', 'Network topology upgrade'],
|
||||
diagram: 'network' as DiagramKind,
|
||||
photo: networkPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Infrastructure',
|
||||
name: 'Server upgrade',
|
||||
body: 'Upgraded the server with existing VMs and services, migrated data, and configured updated network settings.',
|
||||
delivered: ['Server upgraded with existing VMs and services', 'Data migrated', 'Updated network settings configured'],
|
||||
diagram: 'server' as DiagramKind,
|
||||
photo: serverPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Endpoint',
|
||||
name: 'SCCM implementation',
|
||||
body: 'Installed and configured SCCM, prepared the required settings, and established the task-sequence process.',
|
||||
delivered: ['SCCM installed and configured', 'Required settings prepared', 'Task-sequence process established'],
|
||||
diagram: 'sccm' as DiagramKind,
|
||||
photo: sccmPhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
{
|
||||
category: 'Voice',
|
||||
name: 'Microsoft Teams calling',
|
||||
body: 'Delivered number-porting support, call-flow and dial-plan design, user training, documentation, and adoption monitoring.',
|
||||
delivered: ['Number-porting support', 'Call-flow and dial-plan design', 'User training and documentation', 'Adoption monitoring'],
|
||||
diagram: 'voice' as DiagramKind,
|
||||
photo: voicePhoto,
|
||||
photoPosition: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
export const certifications = [
|
||||
'Introduction to Cyber Security',
|
||||
'Windows Client',
|
||||
'Microsoft Teams Voice Engineer',
|
||||
'M365 Teams Administrator Associate',
|
||||
'Microsoft Azure Administrator Associate',
|
||||
'Microsoft Azure Security Engineer Associate',
|
||||
'M365 Security Administrator Associate',
|
||||
'Microsoft Information Protection Administrator Associate',
|
||||
'Project Management Essentials Certified',
|
||||
'Cloud Computing',
|
||||
];
|
||||
|
||||
export const education = { degree: 'Bachelor of Information Technology', school: 'Federation University', year: '2009' };
|
||||
@@ -1,25 +0,0 @@
|
||||
@import '@fontsource/kanit/latin-300.css';
|
||||
@import '@fontsource/kanit/latin-400.css';
|
||||
@import '@fontsource/kanit/latin-500.css';
|
||||
@import '@fontsource/kanit/latin-700.css';
|
||||
@import '@fontsource/kanit/latin-900.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body, #root { background: #0C0C0C; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { font-family: 'Kanit', sans-serif; }
|
||||
|
||||
.hero-heading {
|
||||
background: linear-gradient(180deg, #646973 0%, #BBCCD7 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1,46 +0,0 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import AnimatedText from '../components/AnimatedText';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { aboutText, images, stats } from '../data/content';
|
||||
|
||||
export default function AboutSection() {
|
||||
return (
|
||||
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
|
||||
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[3%] w-[90px] sm:left-[2%] sm:w-[130px] md:left-[4%] md:w-[170px]">
|
||||
<img src={images.signalMark} alt="" className="aspect-square w-full rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[4%] left-[3%] w-[100px] sm:left-[6%] sm:w-[150px] md:left-[10%] md:w-[200px]">
|
||||
<img src={images.systems} alt="" className="aspect-[4/3] w-full -rotate-6 rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[3%] w-[100px] sm:right-[2%] sm:w-[150px] md:right-[4%] md:w-[210px]">
|
||||
<img src={images.leadership} alt="" className="aspect-[3/2] w-full rotate-6 rounded-2xl object-cover" />
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[4%] right-[3%] sm:right-[6%] md:right-[10%]">
|
||||
<div className="flex gap-2 sm:gap-3">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-2xl border border-[#D7E2EA]/30 px-2.5 py-2 text-center sm:px-5 sm:py-3">
|
||||
<div className="hero-heading text-2xl font-black leading-none sm:text-5xl md:text-6xl">{s.value}</div>
|
||||
<div className="mt-1 whitespace-nowrap text-[9px] font-light uppercase tracking-wide text-[#D7E2EA] sm:text-xs">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
||||
<FadeIn delay={0} y={40}>
|
||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
About me
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<AnimatedText
|
||||
text={aboutText}
|
||||
className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]"
|
||||
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-10 mb-24 sm:mb-28 md:mb-0">
|
||||
<ContactButton />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ArrowUpRight, Phone } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { profile } from '../data/content';
|
||||
|
||||
export default function ContactSection() {
|
||||
return (
|
||||
<section id="contact" className="flex flex-col items-center gap-10 px-5 pb-10 pt-20 sm:gap-14 sm:px-8 md:px-10 md:pt-28">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(2.6rem, 10vw, 140px)' }}>
|
||||
Let’s talk
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<p className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]" style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}>
|
||||
Based in {profile.location}. Available for the right conversation.
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<ContactButton />
|
||||
</FadeIn>
|
||||
<FadeIn className="w-full max-w-3xl">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<a href={`mailto:${profile.email}`} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Email</span>
|
||||
<strong className="block break-words font-medium">{profile.email}</strong>
|
||||
</span>
|
||||
<ArrowUpRight size={20} className="shrink-0" />
|
||||
</a>
|
||||
<a href={profile.phoneHref} className="flex min-w-0 items-center justify-between gap-3 rounded-3xl border border-[#D7E2EA]/25 p-5 text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-light uppercase tracking-widest opacity-70">Phone</span>
|
||||
<strong className="block font-medium">{profile.phone}</strong>
|
||||
</span>
|
||||
<Phone size={18} className="shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<footer className="mt-10 flex w-full flex-wrap justify-between gap-2 border-t border-[#D7E2EA]/20 pt-6 text-xs font-light uppercase tracking-wider text-[#D7E2EA]/70">
|
||||
<span>{profile.name}</span>
|
||||
<span>{profile.role}</span>
|
||||
<span>{profile.location}</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { certifications, education } from '../data/content';
|
||||
|
||||
export default function CredentialsSection() {
|
||||
return (
|
||||
<section id="credentials" className="px-5 py-20 sm:px-8 sm:py-24 md:px-10 md:py-32">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading mb-10 text-center font-black uppercase leading-none tracking-tight sm:mb-14" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Credentials
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<FadeIn>
|
||||
<div className="mx-auto mb-12 flex max-w-5xl flex-col items-center gap-1 text-center text-[#D7E2EA] sm:mb-16">
|
||||
<span className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">Education</span>
|
||||
<strong className="font-medium uppercase" style={{ fontSize: 'clamp(1.1rem, 2.2vw, 2.1rem)' }}>{education.degree}</strong>
|
||||
<span className="font-light opacity-70">{education.school}, {education.year}</span>
|
||||
</div>
|
||||
</FadeIn>
|
||||
<div className="mx-auto grid max-w-5xl gap-3 sm:grid-cols-2">
|
||||
{certifications.map((c, i) => (
|
||||
<FadeIn key={c} delay={(i % 2) * 0.08}>
|
||||
<div className="flex h-full items-center gap-4 rounded-3xl border border-[#D7E2EA]/25 p-5 sm:p-6">
|
||||
<span className="hero-heading text-3xl font-black leading-none sm:text-4xl">{String(i + 1).padStart(2, '0')}</span>
|
||||
<p className="min-w-0 font-medium uppercase leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.1rem)' }}>{c}</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { foundations, roles } from '../data/content';
|
||||
|
||||
export default function ExperienceSection() {
|
||||
return (
|
||||
<section id="experience" className="bg-white px-5 pb-28 pt-20 sm:px-8 sm:pb-32 sm:pt-24 md:px-10 md:pb-40 md:pt-32">
|
||||
<FadeIn>
|
||||
<h2 className="mb-16 text-center font-black uppercase leading-none text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Experience
|
||||
</h2>
|
||||
</FadeIn>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{roles.map((r, i) => (
|
||||
<FadeIn key={r.role + r.organisation}>
|
||||
<article className="grid gap-4 py-8 sm:py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||
<div className="flex flex-col gap-1 text-[#0C0C0C]">
|
||||
<span className="text-sm font-medium uppercase tracking-wider opacity-60">{r.dates}</span>
|
||||
<span className="font-black uppercase leading-tight" style={{ fontSize: 'clamp(1.1rem, 1.8vw, 1.5rem)' }}>{r.organisation}</span>
|
||||
<span className="text-sm font-light opacity-60">{String(i + 1).padStart(2, '0')}</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 text-[#0C0C0C]">
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{r.role}</h3>
|
||||
<p className="max-w-2xl font-light leading-relaxed opacity-70" style={{ fontSize: 'clamp(0.9rem, 1.6vw, 1.2rem)' }}>{r.summary}</p>
|
||||
<ul className="flex max-w-2xl flex-col gap-3">
|
||||
{r.points.map((p) => (
|
||||
<li key={p} className="flex gap-3 font-light leading-relaxed" style={{ fontSize: 'clamp(0.85rem, 1.4vw, 1.05rem)' }}>
|
||||
<Check size={18} className="mt-1 shrink-0" />
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</FadeIn>
|
||||
))}
|
||||
|
||||
<FadeIn>
|
||||
<div className="grid gap-6 py-10 md:grid-cols-[220px_1fr] md:gap-12 md:py-12" style={{ borderTop: '1px solid rgba(12, 12, 12, 0.15)' }}>
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-[#0C0C0C] opacity-60">Earlier foundations</h3>
|
||||
<div className="grid gap-x-8 gap-y-6 sm:grid-cols-2">
|
||||
{foundations.map((f) => (
|
||||
<div key={f.role + f.organisation} className="flex min-w-0 flex-col text-[#0C0C0C]">
|
||||
<span className="text-xs font-medium uppercase tracking-wider opacity-60">{f.dates}</span>
|
||||
<strong className="font-medium uppercase">{f.role}</strong>
|
||||
<span className="font-light opacity-70">{f.organisation}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import Magnet from '../components/Magnet';
|
||||
import ContactButton from '../components/ContactButton';
|
||||
import { images, profile } from '../data/content';
|
||||
|
||||
const links = [
|
||||
{ label: 'About', href: '#about' },
|
||||
{ label: 'Skills', href: '#skills' },
|
||||
{ label: 'Projects', href: '#projects' },
|
||||
{ label: 'Contact', href: '#contact' },
|
||||
];
|
||||
|
||||
export default function HeroSection() {
|
||||
return (
|
||||
<section className="relative flex h-screen min-h-[600px] flex-col" style={{ overflowX: 'clip' }}>
|
||||
<FadeIn delay={0} y={-20}>
|
||||
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8" aria-label="Section navigation">
|
||||
{links.map((l) => (
|
||||
<a
|
||||
key={l.label}
|
||||
href={l.href}
|
||||
className="text-sm font-medium uppercase tracking-wider text-[#D7E2EA] transition-opacity duration-200 hover:opacity-70 md:text-lg lg:text-[1.4rem]"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</FadeIn>
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<FadeIn delay={0.15} y={40}>
|
||||
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[16.5vw] font-black uppercase leading-none tracking-tight sm:mt-4 md:-mt-5">
|
||||
Hi, i’m {profile.firstName}
|
||||
</h1>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-1/2 top-1/2 z-10 w-[min(280px,52vh)] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[min(360px,56vh)] sm:translate-y-0 md:w-[min(440px,58vh)] lg:w-[min(520px,58vh)]">
|
||||
<FadeIn delay={0.6} y={30}>
|
||||
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
||||
<img
|
||||
src={images.portrait}
|
||||
alt="Keith Rana at his work desk"
|
||||
className="aspect-square w-full border border-[#D7E2EA]/30 object-cover object-top"
|
||||
style={{ borderRadius: '999px 999px 32px 32px' }}
|
||||
/>
|
||||
</Magnet>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-end justify-between gap-4 px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
|
||||
<FadeIn delay={0.35} y={20}>
|
||||
<p
|
||||
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
|
||||
style={{ fontSize: 'clamp(0.7rem, 1.4vw, 1.5rem)' }}
|
||||
>
|
||||
{profile.tagline}
|
||||
</p>
|
||||
</FadeIn>
|
||||
<FadeIn delay={0.5} y={20} className="relative z-20">
|
||||
<ContactButton />
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { expertise, images } from '../data/content';
|
||||
|
||||
const TILE_W = 420;
|
||||
const GAP = 12;
|
||||
|
||||
const photoTiles: { src: string; alt: string; position: string }[] = [
|
||||
{ src: images.portrait, alt: 'Keith Rana at his work desk', position: 'center 20%' },
|
||||
{ src: images.leadership, alt: 'Hands organising a service-delivery briefing', position: 'center' },
|
||||
{ src: images.systems, alt: 'Abstract composition of connected service pathways', position: 'center' },
|
||||
];
|
||||
|
||||
const photos = photoTiles.map((t) => (
|
||||
<img
|
||||
key={t.alt}
|
||||
src={t.src}
|
||||
alt={t.alt}
|
||||
loading="lazy"
|
||||
className="h-[270px] w-[420px] shrink-0 rounded-2xl object-cover"
|
||||
style={{ objectPosition: t.position }}
|
||||
/>
|
||||
));
|
||||
|
||||
const skills = expertise.map((e, i) => (
|
||||
<div
|
||||
key={e.name}
|
||||
className="flex h-[270px] w-[420px] shrink-0 flex-col justify-between rounded-2xl border border-[#D7E2EA]/25 p-6"
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||
>
|
||||
<span className="hero-heading text-5xl font-black leading-none">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span className="text-2xl font-medium uppercase leading-tight text-[#D7E2EA]">{e.name}</span>
|
||||
</div>
|
||||
));
|
||||
|
||||
function Row({ tiles, setWidth, transform }: { tiles: ReactNode[]; setWidth: number; transform: string }) {
|
||||
return (
|
||||
<div className="flex" style={{ gap: GAP, marginLeft: -setWidth, transform, willChange: 'transform' }} aria-hidden={tiles === skills}>
|
||||
{[0, 1, 2].map((copy) => (
|
||||
<div key={copy} className="flex" style={{ gap: GAP }}>
|
||||
{tiles}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarqueeSection() {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
if (!ref.current) return;
|
||||
const top = ref.current.getBoundingClientRect().top + window.scrollY;
|
||||
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
|
||||
};
|
||||
onScroll();
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const width = (n: number) => n * (TILE_W + GAP) + GAP;
|
||||
|
||||
return (
|
||||
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
|
||||
<Row tiles={photos} setWidth={width(photos.length)} transform={`translateX(${offset - 200}px)`} />
|
||||
<Row tiles={skills} setWidth={width(skills.length)} transform={`translateX(${-(offset - 200)}px)`} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useRef } from 'react';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import ProjectDiagram from '../components/ProjectDiagram';
|
||||
import { projects } from '../data/content';
|
||||
|
||||
type Project = (typeof projects)[number];
|
||||
|
||||
const radius = 'rounded-[40px] sm:rounded-[50px] md:rounded-[60px]';
|
||||
|
||||
function ProjectCard({ project, index, total }: { project: Project; index: number; total: number }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'start start'] });
|
||||
const targetScale = 1 - (total - 1 - index) * 0.03;
|
||||
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="sticky top-24 min-h-[85vh]">
|
||||
<motion.div
|
||||
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
|
||||
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
|
||||
>
|
||||
<div className="mb-4 flex flex-col gap-3 sm:mb-6 md:flex-row md:items-center md:justify-between md:gap-10">
|
||||
<div className="flex items-center gap-4 sm:gap-8">
|
||||
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="min-w-0 text-[#D7E2EA]">
|
||||
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-w-xl px-1 font-light leading-relaxed text-[#D7E2EA]/70" style={{ fontSize: 'clamp(0.85rem, 1.5vw, 1.15rem)' }}>
|
||||
{project.body}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div className="flex flex-col gap-3 sm:w-[40%] sm:gap-4">
|
||||
<img
|
||||
src={project.photo}
|
||||
alt={`${project.name}: illustrative photo`}
|
||||
loading="lazy"
|
||||
className={`w-full object-cover ${radius}`}
|
||||
style={{ height: 'clamp(150px, 16vw, 230px)', objectPosition: project.photoPosition }}
|
||||
/>
|
||||
<div
|
||||
className={`flex flex-1 flex-col justify-center gap-3 border border-[#D7E2EA]/25 p-5 sm:gap-4 sm:p-6 md:p-8 ${radius}`}
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)', minHeight: 'clamp(160px, 22vw, 340px)' }}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-widest text-[#D7E2EA]/60 sm:text-sm">Delivered</span>
|
||||
<ul className="flex flex-col gap-2 sm:gap-3">
|
||||
{project.delivered.map((d) => (
|
||||
<li key={d} className="flex gap-2 font-light leading-snug text-[#D7E2EA]" style={{ fontSize: 'clamp(0.8rem, 1.3vw, 1.1rem)' }}>
|
||||
<Check className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{d}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`order-first flex items-center justify-center border border-[#D7E2EA]/25 p-3 sm:order-none sm:w-[60%] sm:p-5 md:p-8 ${radius}`}
|
||||
style={{ background: 'linear-gradient(135deg, #14161a, #1d2127)' }}
|
||||
>
|
||||
<ProjectDiagram kind={project.diagram} className="h-auto w-full" style={{ maxHeight: "clamp(240px, 30vw, 430px)" }} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectsSection() {
|
||||
return (
|
||||
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
|
||||
<FadeIn>
|
||||
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Projects
|
||||
</h2>
|
||||
</FadeIn>
|
||||
{projects.map((p, i) => (
|
||||
<ProjectCard key={p.name} project={p} index={i} total={projects.length} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import FadeIn from '../components/FadeIn';
|
||||
import { expertise } from '../data/content';
|
||||
|
||||
export default function ServicesSection() {
|
||||
return (
|
||||
<section id="skills" className="rounded-t-[40px] bg-white px-5 pt-20 sm:rounded-t-[50px] sm:px-8 sm:pt-24 md:rounded-t-[60px] md:px-10 md:pt-32">
|
||||
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||
Capability
|
||||
</h2>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
{expertise.map((s, i) => (
|
||||
<FadeIn key={s.name} delay={(i % 4) * 0.1}>
|
||||
<div
|
||||
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
|
||||
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
|
||||
>
|
||||
<span className="min-w-[1.6em] font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-3 text-[#0C0C0C]">
|
||||
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
|
||||
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
||||
{s.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,6 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
|
||||
plugins: [],
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// INLINE=1 bakes every image and font into the JS/CSS (used for the one-file cloud preview).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
build: { assetsInlineLimit: process.env.INLINE ? 100_000_000 : 4096 },
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/* CycleWise AU - 3D cycle ring (Step 2).
|
||||
Draws the same four phase arcs as the flat SVG ring, in 3D.
|
||||
The page code sends the numbers via the "cycle:ring" event.
|
||||
If WebGL is missing or anything fails, the flat ring stays visible. */
|
||||
(function(){
|
||||
"use strict";
|
||||
function start(){
|
||||
try {
|
||||
if (!window.THREE) return;
|
||||
var art = document.getElementById("phase-arc-menstrual");
|
||||
art = art && art.closest(".hero-art");
|
||||
if (!art) return;
|
||||
|
||||
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var svg = art.querySelector("svg");
|
||||
var canvas = document.createElement("canvas");
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
canvas.className = "ring3d";
|
||||
|
||||
var renderer;
|
||||
try { renderer = new THREE.WebGLRenderer({canvas: canvas, antialias: true, alpha: true}); }
|
||||
catch(e){ return; } // no WebGL: keep the flat ring
|
||||
|
||||
var R = 1, TUBE = 0.085;
|
||||
var scene = new THREE.Scene();
|
||||
var camera = new THREE.PerspectiveCamera(30, 1, 0.1, 20);
|
||||
camera.position.set(0, 0, 5.2);
|
||||
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
|
||||
var sun = new THREE.DirectionalLight(0xffffff, 1.1);
|
||||
sun.position.set(2, 3, 4);
|
||||
scene.add(sun);
|
||||
|
||||
var group = new THREE.Group();
|
||||
scene.add(group);
|
||||
|
||||
function cssColor(name){
|
||||
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return new THREE.Color(v || "#888888");
|
||||
}
|
||||
function point(deg, r){
|
||||
var a = deg * Math.PI / 180; // 0 deg = top, clockwise
|
||||
return new THREE.Vector3(Math.sin(a) * r, Math.cos(a) * r, 0);
|
||||
}
|
||||
class ArcCurve extends THREE.Curve {
|
||||
constructor(a0, a1){ super(); this.a0 = a0; this.a1 = a1; }
|
||||
getPoint(t, target){
|
||||
return (target || new THREE.Vector3()).copy(point(this.a0 + (this.a1 - this.a0) * t, R));
|
||||
}
|
||||
}
|
||||
|
||||
var data = window.cycleRingData || null;
|
||||
var meshes = [];
|
||||
|
||||
function clear(){
|
||||
meshes.forEach(function(m){
|
||||
group.remove(m); m.geometry.dispose(); m.material.dispose();
|
||||
});
|
||||
meshes = [];
|
||||
}
|
||||
function add(mesh){ group.add(mesh); meshes.push(mesh); }
|
||||
function mat(c){ return new THREE.MeshStandardMaterial({color: c, roughness: 0.45, metalness: 0.05}); }
|
||||
|
||||
function build(){
|
||||
clear();
|
||||
if (!data) return;
|
||||
var cols = {
|
||||
menstrual: cssColor("--period"),
|
||||
follicular: cssColor("--ovulation-soft"),
|
||||
fertile: cssColor("--fertile"),
|
||||
luteal: cssColor("--pregnancy-soft"),
|
||||
ink: cssColor("--ink")
|
||||
};
|
||||
var arcs = [
|
||||
[0, data.perEnd, cols.menstrual],
|
||||
[data.perEnd, data.fertStart, cols.follicular],
|
||||
[data.fertStart, data.fertEnd, cols.fertile],
|
||||
[data.lutStart, 360, cols.luteal]
|
||||
];
|
||||
arcs.forEach(function(a){
|
||||
var end = Math.max(a[1], a[0] + 0.5);
|
||||
var tube = new THREE.Mesh(new THREE.TubeGeometry(new ArcCurve(a[0], end), 48, TUBE, 16, false), mat(a[2]));
|
||||
add(tube);
|
||||
[a[0], end].forEach(function(deg){ // round end caps
|
||||
var cap = new THREE.Mesh(new THREE.SphereGeometry(TUBE, 16, 12), mat(a[2]));
|
||||
cap.position.copy(point(deg, R));
|
||||
add(cap);
|
||||
});
|
||||
});
|
||||
var marker = new THREE.Mesh(new THREE.SphereGeometry(TUBE * 1.9, 24, 16), mat(cols.ink));
|
||||
marker.position.copy(point(data.todayAngle, R));
|
||||
marker.position.z = TUBE * 0.6;
|
||||
add(marker);
|
||||
}
|
||||
|
||||
function resize(){
|
||||
var w = svg.getBoundingClientRect().width || 220;
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setSize(w, w, false);
|
||||
canvas.style.width = canvas.style.height = w + "px";
|
||||
}
|
||||
|
||||
var visible = false, raf = 0;
|
||||
function frame(t){
|
||||
raf = 0;
|
||||
var s = t / 1000;
|
||||
group.rotation.x = -0.55 + (reduce ? 0 : Math.sin(s * 0.6) * 0.12);
|
||||
group.rotation.y = reduce ? 0.25 : Math.sin(s * 0.4) * 0.45;
|
||||
renderer.render(scene, camera);
|
||||
if (visible && !reduce) raf = requestAnimationFrame(frame);
|
||||
}
|
||||
function kick(){ if (!raf) raf = requestAnimationFrame(frame); }
|
||||
|
||||
// Show the 3D ring; hide the flat arcs but keep the "CYCLE DAY" text.
|
||||
art.classList.add("has-3d");
|
||||
art.insertBefore(canvas, svg);
|
||||
resize();
|
||||
build();
|
||||
kick();
|
||||
|
||||
window.addEventListener("cycle:ring", function(e){ data = e.detail; build(); kick(); });
|
||||
window.addEventListener("resize", function(){ resize(); kick(); });
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function(){ build(); kick(); });
|
||||
|
||||
// Only animate while the ring is actually on screen (saves battery).
|
||||
new IntersectionObserver(function(entries){
|
||||
visible = entries[0].isIntersecting;
|
||||
if (visible) { resize(); kick(); }
|
||||
}).observe(canvas);
|
||||
} catch(err) {
|
||||
// Anything went wrong: leave the flat ring alone.
|
||||
var c = document.querySelector(".ring3d"); if (c) c.remove();
|
||||
var h = document.querySelector(".hero-art.has-3d"); if (h) h.classList.remove("has-3d");
|
||||
if (window.console) console.warn("3D ring disabled:", err && err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// three.js is big: fetch it only after the page is up, so the page shows first.
|
||||
function loadThree(){
|
||||
var tag = document.createElement("script");
|
||||
tag.src = "vendor/three.min.js";
|
||||
tag.onload = start;
|
||||
document.head.appendChild(tag);
|
||||
}
|
||||
function schedule(){
|
||||
if (window.THREE) { start(); }
|
||||
else if (window.requestIdleCallback) { requestIdleCallback(loadThree, {timeout: 2000}); }
|
||||
else { setTimeout(loadThree, 300); }
|
||||
}
|
||||
if (document.readyState === "complete") schedule();
|
||||
else window.addEventListener("load", schedule);
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
/* CycleWise AU - pregnancy timeline motion (Step 3).
|
||||
- The week slider's track fills with colour up to the current week.
|
||||
It grows the first time you scroll to it, then follows every week change.
|
||||
- The baby-size picture pops a little when the week changes.
|
||||
- The 40 week buttons wave in the first time they appear.
|
||||
Cosmetic only: without GSAP the slider simply shows its normal look. */
|
||||
(function(){
|
||||
"use strict";
|
||||
var slider = document.getElementById("week-slider");
|
||||
var badge = document.getElementById("wk-badge");
|
||||
if (!slider || !badge) return;
|
||||
|
||||
var hasGsap = !!window.gsap;
|
||||
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var animate = hasGsap && !reduce;
|
||||
|
||||
var fill = { f: 0 };
|
||||
var target = 0;
|
||||
var introDone = false;
|
||||
|
||||
function paint(){ slider.style.setProperty("--f", fill.f.toFixed(4)); }
|
||||
function currentWeek(){
|
||||
var n = parseInt((badge.textContent || "").replace(/\D/g, ""), 10);
|
||||
return isNaN(n) ? (parseInt(slider.value, 10) || 1) : Math.max(1, Math.min(40, n));
|
||||
}
|
||||
function goTo(f){
|
||||
target = f;
|
||||
if (!introDone) return; // wait until the slider is on screen
|
||||
if (!animate) { fill.f = f; paint(); return; }
|
||||
gsap.to(fill, { f: f, duration: 0.6, ease: "power2.out", overwrite: true, onUpdate: paint });
|
||||
}
|
||||
|
||||
// Follow every week change, whether from the slider, a week button or the calendar.
|
||||
var lastWeek = currentWeek();
|
||||
target = (lastWeek - 1) / 39;
|
||||
new MutationObserver(function(){
|
||||
var w = currentWeek();
|
||||
if (w === lastWeek) return;
|
||||
lastWeek = w;
|
||||
goTo((w - 1) / 39);
|
||||
var blob = document.querySelector("#wk-blob svg");
|
||||
if (animate && blob) {
|
||||
gsap.fromTo(blob, { scale: 0.8, transformOrigin: "50% 50%" },
|
||||
{ scale: 1, duration: 0.5, ease: "back.out(2)", clearProps: "transform" });
|
||||
}
|
||||
}).observe(badge, { childList: true, characterData: true, subtree: true });
|
||||
|
||||
// First time the slider is visible: grow the fill from empty up to this week.
|
||||
new IntersectionObserver(function(entries, obs){
|
||||
if (!entries[0].isIntersecting) return;
|
||||
obs.disconnect();
|
||||
introDone = true;
|
||||
if (!animate) { fill.f = target; paint(); return; }
|
||||
gsap.to(fill, { f: target, duration: 1.2, ease: "power2.out", onUpdate: paint });
|
||||
}).observe(slider);
|
||||
|
||||
// First time the week buttons are visible: wave them in.
|
||||
var strip = document.getElementById("week-filmstrip");
|
||||
if (strip && animate) {
|
||||
new IntersectionObserver(function(entries, obs){
|
||||
if (!entries[0].isIntersecting) return;
|
||||
obs.disconnect();
|
||||
gsap.from(strip.querySelectorAll(".film-item"), {
|
||||
opacity: 0, scale: 0.8, duration: 0.4, stagger: 0.02, ease: "power2.out", clearProps: "all"
|
||||
});
|
||||
}).observe(strip);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,11 @@
|
||||
/*!
|
||||
* ScrollTrigger 3.12.5
|
||||
* https://gsap.com
|
||||
*
|
||||
* @license Copyright 2024, GreenSock. All rights reserved.
|
||||
* Subject to the terms at https://gsap.com/standard-license or for Club GSAP members, the agreement issued with that membership.
|
||||
* @author: Jack Doyle, jack@greensock.com
|
||||
*/
|
||||
|
||||
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e=e||self).window=e.window||{})}(this,function(e){"use strict";function _defineProperties(e,t){for(var r=0;r<t.length;r++){var n=t[r];n.enumerable=n.enumerable||!1,n.configurable=!0,"value"in n&&(n.writable=!0),Object.defineProperty(e,n.key,n)}}function r(){return Ce||"undefined"!=typeof window&&(Ce=window.gsap)&&Ce.registerPlugin&&Ce}function z(e,t){return~Le.indexOf(e)&&Le[Le.indexOf(e)+1][t]}function A(e){return!!~t.indexOf(e)}function B(e,t,r,n,o){return e.addEventListener(t,r,{passive:!1!==n,capture:!!o})}function C(e,t,r,n){return e.removeEventListener(t,r,!!n)}function F(){return De&&De.isPressed||Ie.cache++}function G(r,n){function dd(e){if(e||0===e){o&&(Se.history.scrollRestoration="manual");var t=De&&De.isPressed;e=dd.v=Math.round(e)||(De&&De.iOS?1:0),r(e),dd.cacheID=Ie.cache,t&&i("ss",e)}else(n||Ie.cache!==dd.cacheID||i("ref"))&&(dd.cacheID=Ie.cache,dd.v=r());return dd.v+dd.offset}return dd.offset=0,r&&dd}function J(e,t){return(t&&t._ctx&&t._ctx.selector||Ce.utils.toArray)(e)[0]||("string"==typeof e&&!1!==Ce.config().nullTargetWarn?console.warn("Element not found:",e):null)}function K(t,e){var r=e.s,n=e.sc;A(t)&&(t=ke.scrollingElement||Pe);var o=Ie.indexOf(t),i=n===Fe.sc?1:2;~o||(o=Ie.push(t)-1),Ie[o+i]||B(t,"scroll",F);var a=Ie[o+i],s=a||(Ie[o+i]=G(z(t,r),!0)||(A(t)?n:G(function(e){return arguments.length?t[r]=e:t[r]})));return s.target=t,a||(s.smooth="smooth"===Ce.getProperty(t,"scrollBehavior")),s}function L(e,t,o){function Cd(e,t){var r=ze();t||n<r-s?(a=i,i=e,l=s,s=r):o?i+=e:i=a+(e-a)/(r-l)*(s-l)}var i=e,a=e,s=ze(),l=s,n=t||50,c=Math.max(500,3*n);return{update:Cd,reset:function reset(){a=i=o?0:i,l=s=0},getVelocity:function getVelocity(e){var t=l,r=a,n=ze();return!e&&0!==e||e===i||Cd(e),s===l||c<n-l?0:(i+(o?r:-r))/((o?n:s)-t)*1e3}}}function M(e,t){return t&&!e._gsapAllow&&e.preventDefault(),e.changedTouches?e.changedTouches[0]:e}function N(e){var t=Math.max.apply(Math,e),r=Math.min.apply(Math,e);return Math.abs(t)>=Math.abs(r)?t:r}function O(){(Ae=Ce.core.globals().ScrollTrigger)&&Ae.core&&function _integrate(){var e=Ae.core,r=e.bridge||{},t=e._scrollers,n=e._proxies;t.push.apply(t,Ie),n.push.apply(n,Le),Ie=t,Le=n,i=function _bridge(e,t){return r[e](t)}}()}function P(e){return Ce=e||r(),!Te&&Ce&&"undefined"!=typeof document&&document.body&&(Se=window,Pe=(ke=document).documentElement,Me=ke.body,t=[Se,ke,Pe,Me],Ce.utils.clamp,Be=Ce.core.context||function(){},Oe="onpointerenter"in Me?"pointer":"mouse",Ee=k.isTouch=Se.matchMedia&&Se.matchMedia("(hover: none), (pointer: coarse)").matches?1:"ontouchstart"in Se||0<navigator.maxTouchPoints||0<navigator.msMaxTouchPoints?2:0,Re=k.eventTypes=("ontouchstart"in Pe?"touchstart,touchmove,touchcancel,touchend":"onpointerdown"in Pe?"pointerdown,pointermove,pointercancel,pointerup":"mousedown,mousemove,mouseup,mouseup").split(","),setTimeout(function(){return o=0},500),O(),Te=1),Te}var Ce,Te,Se,ke,Pe,Me,Ee,Oe,Ae,t,De,Re,Be,o=1,qe=[],Ie=[],Le=[],ze=Date.now,i=function _bridge(e,t){return t},n="scrollLeft",a="scrollTop",Ye={s:n,p:"left",p2:"Left",os:"right",os2:"Right",d:"width",d2:"Width",a:"x",sc:G(function(e){return arguments.length?Se.scrollTo(e,Fe.sc()):Se.pageXOffset||ke[n]||Pe[n]||Me[n]||0})},Fe={s:a,p:"top",p2:"Top",os:"bottom",os2:"Bottom",d:"height",d2:"Height",a:"y",op:Ye,sc:G(function(e){return arguments.length?Se.scrollTo(Ye.sc(),e):Se.pageYOffset||ke[a]||Pe[a]||Me[a]||0})};Ye.op=Fe,Ie.cache=0;var k=(Observer.prototype.init=function init(e){Te||P(Ce)||console.warn("Please gsap.registerPlugin(Observer)"),Ae||O();var o=e.tolerance,a=e.dragMinimum,t=e.type,i=e.target,r=e.lineHeight,n=e.debounce,s=e.preventDefault,l=e.onStop,c=e.onStopDelay,u=e.ignore,f=e.wheelSpeed,d=e.event,p=e.onDragStart,g=e.onDragEnd,h=e.onDrag,v=e.onPress,b=e.onRelease,m=e.onRight,y=e.onLeft,x=e.onUp,w=e.onDown,_=e.onChangeX,T=e.onChangeY,S=e.onChange,k=e.onToggleX,E=e.onToggleY,D=e.onHover,R=e.onHoverEnd,q=e.onMove,I=e.ignoreCheck,z=e.isNormalizer,Y=e.onGestureStart,H=e.onGestureEnd,X=e.onWheel,W=e.onEnable,V=e.onDisable,U=e.onClick,j=e.scrollSpeed,G=e.capture,Q=e.allowClicks,Z=e.lockAxis,$=e.onLockAxis;function cf(){return xe=ze()}function df(e,t){return(se.event=e)&&u&&~u.indexOf(e.target)||t&&he&&"touch"!==e.pointerType||I&&I(e,t)}function ff(){var e=se.deltaX=N(me),t=se.deltaY=N(ye),r=Math.abs(e)>=o,n=Math.abs(t)>=o;S&&(r||n)&&S(se,e,t,me,ye),r&&(m&&0<se.deltaX&&m(se),y&&se.deltaX<0&&y(se),_&&_(se),k&&se.deltaX<0!=le<0&&k(se),le=se.deltaX,me[0]=me[1]=me[2]=0),n&&(w&&0<se.deltaY&&w(se),x&&se.deltaY<0&&x(se),T&&T(se),E&&se.deltaY<0!=ce<0&&E(se),ce=se.deltaY,ye[0]=ye[1]=ye[2]=0),(ne||re)&&(q&&q(se),re&&(h(se),re=!1),ne=!1),ie&&!(ie=!1)&&$&&$(se),oe&&(X(se),oe=!1),ee=0}function gf(e,t,r){me[r]+=e,ye[r]+=t,se._vx.update(e),se._vy.update(t),n?ee=ee||requestAnimationFrame(ff):ff()}function hf(e,t){Z&&!ae&&(se.axis=ae=Math.abs(e)>Math.abs(t)?"xLine truncated
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2023 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,11 @@
|
||||
/*!
|
||||
* GSAP 3.12.5
|
||||
* https://gsap.com
|
||||
*
|
||||
* @license Copyright 2024, GreenSock. All rights reserved.
|
||||
* Subject to the terms at https://gsap.com/standard-license or for Club GSAP members, the agreement issued with that membership.
|
||||
* @author: Jack Doyle, jack@greensock.com
|
||||
*/
|
||||
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t=t||self).window=t.window||{})}(this,function(e){"use strict";function _inheritsLoose(t,e){t.prototype=Object.create(e.prototype),(t.prototype.constructor=t).__proto__=e}function _assertThisInitialized(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}function r(t){return"string"==typeof t}function s(t){return"function"==typeof t}function t(t){return"number"==typeof t}function u(t){return void 0===t}function v(t){return"object"==typeof t}function w(t){return!1!==t}function x(){return"undefined"!=typeof window}function y(t){return s(t)||r(t)}function P(t){return(i=yt(t,ot))&&ze}function Q(t,e){return console.warn("Invalid property",t,"set to",e,"Missing plugin? gsap.registerPlugin()")}function R(t,e){return!e&&console.warn(t)}function S(t,e){return t&&(ot[t]=e)&&i&&(i[t]=e)||ot}function T(){return 0}function ea(t){var e,r,i=t[0];if(v(i)||s(i)||(t=[t]),!(e=(i._gsap||{}).harness)){for(r=gt.length;r--&&!gt[r].targetTest(i););e=gt[r]}for(r=t.length;r--;)t[r]&&(t[r]._gsap||(t[r]._gsap=new Vt(t[r],e)))||t.splice(r,1);return t}function fa(t){return t._gsap||ea(Mt(t))[0]._gsap}function ga(t,e,r){return(r=t[e])&&s(r)?t[e]():u(r)&&t.getAttribute&&t.getAttribute(e)||r}function ha(t,e){return(t=t.split(",")).forEach(e)||t}function ia(t){return Math.round(1e5*t)/1e5||0}function ja(t){return Math.round(1e7*t)/1e7||0}function ka(t,e){var r=e.charAt(0),i=parseFloat(e.substr(2));return t=parseFloat(t),"+"===r?t+i:"-"===r?t-i:"*"===r?t*i:t/i}function la(t,e){for(var r=e.length,i=0;t.indexOf(e[i])<0&&++i<r;);return i<r}function ma(){var t,e,r=dt.length,i=dt.slice(0);for(ct={},t=dt.length=0;t<r;t++)(e=i[t])&&e._lazy&&(e.render(e._lazy[0],e._lazy[1],!0)._lazy=0)}function na(t,e,r,i){dt.length&&!L&&ma(),t.render(e,r,i||L&&e<0&&(t._initted||t._startAt)),dt.length&&!L&&ma()}function oa(t){var e=parseFloat(t);return(e||0===e)&&(t+"").match(at).length<2?e:r(t)?t.trim():t}function pa(t){return t}function qa(t,e){for(var r in e)r in t||(t[r]=e[r]);return t}function ta(t,e){for(var r in e)"__proto__"!==r&&"constructor"!==r&&"prototype"!==r&&(t[r]=v(e[r])?ta(t[r]||(t[r]={}),e[r]):e[r]);return t}function ua(t,e){var r,i={};for(r in t)r in e||(i[r]=t[r]);return i}function va(t){var e=t.parent||I,r=t.keyframes?function _setKeyframeDefaults(i){return function(t,e){for(var r in e)r in t||"duration"===r&&i||"ease"===r||(t[r]=e[r])}}(Z(t.keyframes)):qa;if(w(t.inherit))for(;e;)r(t,e.vars.defaults),e=e.parent||e._dp;return t}function xa(t,e,r,i,n){void 0===r&&(r="_first"),void 0===i&&(i="_last");var a,s=t[i];if(n)for(a=e[n];s&&s[n]>a;)s=s._prev;return s?(e._next=s._next,s._next=e):(e._next=t[r],t[r]=e),e._next?e._next._prev=e:t[i]=e,e._prev=s,e.parent=e._dp=t,e}function ya(t,e,r,i){void 0===r&&(r="_first"),void 0===i&&(i="_last");var n=e._prev,a=e._next;n?n._next=a:t[r]===e&&(t[r]=a),a?a._prev=n:t[i]===e&&(t[i]=n),e._next=e._prev=e.parent=null}function za(t,e){t.parent&&(!e||t.parent.autoRemoveChildren)&&t.parent.remove&&t.parent.remove(t),t._act=0}function Aa(t,e){if(t&&(!e||e._end>t._dur||e._start<0))for(var r=t;r;)r._dirty=1,r=r.parent;return t}function Ca(t,e,r,i){return t._startAt&&(L?t._startAt.revert(ht):t.vars.immediateRender&&!t.vars.autoRevert||t._startAt.render(e,!0,i))}function Ea(t){return t._repeat?Tt(t._tTime,t=t.duration()+t._rDelay)*t:0}function Ga(t,e){return(t-e._start)*e._ts+(0<=e._ts?0:e._dirty?e.totalDuration():e._tDur)}function Ha(t){return t._end=ja(t._start+(t._tDur/Math.abs(t._ts||t._rts||X)||0))}function Ia(t,e){var r=t._dp;return r&&r.smoothChildTiming&&t._ts&&(t._start=ja(r._time-(0<t._ts?e/t._ts:((t._dirty?t.totalDuration():t._tDur)-e)/-t._ts)),Ha(t),r._dirty||Aa(r,t)),t}function Ja(t,e){var r;if((e._time||!e._dur&&e._initted||e._start<t._time&&(e._dur||!e.add))&&(r=Ga(t.rawTime(),e),(!e._dur||Ot(0,e.totalDuration(),r)-e._tTime>X)&&e.render(r,!0)),Aa(t,e)._dp&&t._initted&&t._time>=t._dur&&t._ts){if(t._dur<t.duration())for(r=t;r._dp;)0<=r.rawTime()&&r.totalTime(r._tTime),r=r._dp;t._zTime=-X}}function Ka(e,r,i,n){return r.parent&&za(r),r._start=ja((t(i)?i:i||e!==I?xt(e,i,r):e._time)+r._delay),r._end=ja(r._start+(r.totalDuration()/Math.abs(r.timeScale())||0)),xa(e,r,"_first","_last",e._sort?"_start":0),bt(r)||(e._recent=r),n||Ja(e,r),e._ts<0&&Ia(e,e._tTime),e}function La(t,e){return(ot.ScrollTrigger||Q("scrollTrigger",e))&&ot.ScrollTrigger.create(e,t)}function Ma(t,e,r,i,n){return Qt(t,e,n),t._initted?!r&&t._pt&&!L&&(t._dur&&!1!==t.vars.lazy||!t._dur&&t.vars.lazy)&&f!==Rt.frame?(dt.push(t),t._lazy=[n,i],1):void 0:1}function Ra(t,e,r,i){var n=t._repeat,a=ja(e)||0,s=t._tTime/t._tDur;return s&&!i&&(t._time*=a/t._dur),t._dur=a,t._tDur=n?n<0?1e10:ja(a*(n+1)+t._rDelay*n):a,0<s&&!i&&Ia(t,t._tTime=t._tDur*s),t.parent&&Ha(t),r||Aa(t.parent,t),t}function Sa(t){return t instanceof Xt?Aa(t):Ra(t,t._dur)}function Va(e,r,i){var n,aLine truncated
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
console.warn('Scripts "build/three.js" and "build/three.min.js" are deprecated with r150+, and will be removed with r160. Please use ES Modules or alternatives: https://threejs.org/docs/index.html#manual/en/introduction/Installation'),
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2010-2023 Three.js Authors
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).THREE={})}(this,(function(t){"use strict";const e="160",n=1,i=2,r=3,s=0,a=1,o=100,l=204,c=205,h=0,u=1,d=2,p=0,m=1,f=2,g=3,_=4,v=5,x=6,y="attached",M="detached",S=300,b=301,T=302,E=303,w=304,A=306,R=1e3,C=1001,L=1002,P=1003,I=1004,U=1005,D=1006,N=1007,O=1008,F=1009,B=1012,z=1013,G=1014,H=1015,V=1016,k=1017,W=1018,X=1020,j=1023,q=1026,Y=1027,Z=1029,J=1031,K=1033,$=33776,Q=33777,tt=33778,et=33779,nt=35840,it=35841,rt=35842,st=35843,at=36196,ot=37492,lt=37496,ct=37808,ht=37809,ut=37810,dt=37811,pt=37812,mt=37813,ft=37814,gt=37815,_t=37816,vt=37817,xt=37818,yt=37819,Mt=37820,St=37821,bt=36492,Tt=36494,Et=36495,wt=36284,At=36285,Rt=36286,Ct=2300,Lt=2301,Pt=2302,It=2400,Ut=2401,Dt=2402,Nt=2500,Ot=2501,Ft=3e3,Bt=3001,zt="",Gt="srgb",Ht="srgb-linear",Vt="display-p3",kt="display-p3-linear",Wt="linear",Xt="srgb",jt="rec709",qt="p3",Yt=7680,Zt=35044,Jt="300 es",Kt=1035,$t=2e3,Qt=2001;class te{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const ee=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let ne=1234567;const ie=Math.PI/180,re=180/Math.PI;function se(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(ee[255&t]+ee[t>>8&255]+ee[t>>16&255]+ee[t>>24&255]+"-"+ee[255&e]+ee[e>>8&255]+"-"+ee[e>>16&15|64]+ee[e>>24&255]+"-"+ee[63&n|128]+ee[n>>8&255]+"-"+ee[n>>16&255]+ee[n>>24&255]+ee[255&i]+ee[i>>8&255]+ee[i>>16&255]+ee[i>>24&255]).toLowerCase()}function ae(t,e,n){return Math.max(e,Math.min(n,t))}function oe(t,e){return(t%e+e)%e}function le(t,e,n){return(1-n)*t+n*e}function ce(t){return 0==(t&t-1)&&0!==t}function he(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function ue(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function de(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const pe={DEG2RAD:ie,RAD2DEG:re,generateUUID:se,clamp:ae,euclideanModulo:oe,mapLinear:function(t,e,n,i,r){return i+(t-e)*(r-i)/(n-e)},inverseLerp:function(t,e,n){return t!==e?(n-t)/(e-t):0},lerp:le,damp:function(t,e,n,i){return le(t,e,1-Math.exp(-n*i))},pingpong:function(t,e=1){return e-Math.abs(oe(t,2*e)-e)},smoothstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*(3-2*t)},smootherstep:function(t,e,n){return t<=e?0:t>=n?1:(t=(t-e)/(n-e))*t*t*(t*(6*t-15)+10)},randInt:function(t,e){return t+Math.floor(Math.random()*(e-t+1))},randFloat:function(t,e){return t+Math.random()*(e-t)},randFloatLine truncated
|
||||