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:
4 files changed
+173
-1
No files matched your search
+11
-1
@@ -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 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;}
|
||||
@@ -840,6 +844,10 @@ footer.note{
|
||||
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){
|
||||
@@ -1063,4 +1071,6 @@ footer.note{
|
||||
</script>
|
||||
<script src="vendor/gsap.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>
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+7
@@ -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
|
||||
Reference in new issue
Block a user