Add 3D cycle ring (Three.js) with flat-ring fallback

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSDgjk1Agjy9Z2UH2n8bc3
This commit is contained in:
Claude committed 2026-10-15 00:22:53 +00:00
1 parent 251b3af5ed
commit b5f5518c1e
4 files changed
+173 -1

No files matched your search

+11 -1
View File
@@ -144,7 +144,11 @@ button{font-family:inherit;}
} }
.hero h1{font-size:clamp(1.9rem,3.4vw,2.7rem);font-weight:600;line-height:1.08;} .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 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;} .hero-art svg{width:100%;max-width:220px;height:auto;}
.stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;} .stat-row{display:flex;gap:12px;flex-wrap:wrap;margin-top:22px;}
@@ -840,6 +844,10 @@ footer.note{
document.getElementById("today-marker").setAttribute("cx", tp[0]); document.getElementById("today-marker").setAttribute("cx", tp[0]);
document.getElementById("today-marker").setAttribute("cy", tp[1]); document.getElementById("today-marker").setAttribute("cy", tp[1]);
document.getElementById("cycle-day-big").textContent = dic+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){ function statTile(num,label){
@@ -1063,4 +1071,6 @@ footer.note{
</script> </script>
<script src="vendor/gsap.min.js"></script> <script src="vendor/gsap.min.js"></script>
<script src="vendor/ScrollTrigger.min.js"></script> <script src="vendor/ScrollTrigger.min.js"></script>
<script src="vendor/three.min.js"></script>
<script src="ring3d.js"></script>
<script src="animations.js"></script> <script src="animations.js"></script>
+134
View File
@@ -0,0 +1,134 @@
/* 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";
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);
}
})();
+21
View File
@@ -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.
+7
View File
@@ -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