Created
March 10, 2026 01:43
-
-
Save EncodeTheCode/6aba855452ac133bacbea90e0ad6ac7e to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>PS1 Demo-One — Underwater Menu (Pearl Noise + Curved 3D Ribbons)</title> | |
| <!-- jQuery --> | |
| <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> | |
| <style> | |
| :root{ | |
| --menu-radius: 380px; /* visual radius of the ring */ | |
| --label-size: 48px; /* requested 48px */ | |
| --label-active-scale: 1.22; | |
| --label-inactive-scale: 0.86; | |
| --dot-size: 30px; | |
| --bg-blur: 18px; | |
| --ribbon-thickness: 72px; | |
| } | |
| html,body{ | |
| height:100%; | |
| margin:0; | |
| font-family: "Trebuchet MS", Arial, sans-serif; | |
| background: #00121a; /* dark fallback */ | |
| overflow:hidden; | |
| -webkit-font-smoothing:antialiased; | |
| -moz-osx-font-smoothing:grayscale; | |
| } | |
| /* full viewport canvas background */ | |
| #bgCanvas{ | |
| position:fixed; | |
| inset:0; | |
| width:100%; | |
| height:100%; | |
| display:block; | |
| z-index:0; | |
| filter: blur(0px); | |
| background: linear-gradient(#042833,#082a40 30%, #042a3d 80%); | |
| } | |
| /* stage sits on top */ | |
| .stage{ | |
| position:relative; | |
| z-index:2; | |
| width:100%; | |
| height:100%; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| perspective:1100px; | |
| } | |
| /* UI container */ | |
| .ui{ | |
| width:92%; | |
| max-width:1280px; | |
| height:86%; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| position:relative; | |
| pointer-events:none; | |
| } | |
| /* central area for the ring */ | |
| .menu-wrapper{ | |
| width:860px; | |
| height:460px; | |
| position:relative; | |
| transform-style:preserve-3d; | |
| pointer-events:none; | |
| } | |
| /* NOTE: we no longer rotate a single container; we compute transforms per-item */ | |
| .menu3d{ | |
| position:absolute; | |
| inset:0; | |
| transform-style:preserve-3d; | |
| pointer-events:none; | |
| z-index:40; /* ensure menu sits above star and bg */ | |
| } | |
| /* menu item wrapper (each will contain an SVG "ribbon") */ | |
| .menu-item{ | |
| position:absolute; | |
| left:50%; | |
| top:50%; | |
| transform-origin:center center; | |
| text-align:center; | |
| pointer-events:none; | |
| will-change:transform,opacity,filter; | |
| backface-visibility:visible; | |
| transform-style:preserve-3d; | |
| display:block; | |
| transition: opacity 260ms, transform 360ms; | |
| } | |
| /* SVG ribbon sizing — big enough to avoid clipping */ | |
| svg.ribbon{ | |
| width:720px; | |
| height:240px; | |
| overflow:visible; | |
| display:block; | |
| pointer-events:none; | |
| } | |
| /* text inside the SVG */ | |
| svg.ribbon text{ | |
| font-weight:900; | |
| font-size:var(--label-size); | |
| dominant-baseline:middle; | |
| text-anchor:middle; | |
| pointer-events:none; | |
| } | |
| /* ribbon fill visible and shadow for pop */ | |
| svg.ribbon .ribbon-shape{ | |
| filter: drop-shadow(0 12px 14px rgba(0,0,0,0.55)); | |
| transition: filter 220ms, opacity 220ms; | |
| } | |
| /* dot styling inside SVG */ | |
| svg.ribbon .dot { | |
| transition: transform 260ms, opacity 260ms; | |
| } | |
| /* visual emphasis states */ | |
| .menu-item.front{ opacity:1; z-index:120; } | |
| .menu-item.side{ opacity:0.38; z-index:60; } | |
| .menu-item.back{ opacity:0.16; z-index:20; } | |
| /* center star image (png support) stays behind the ribbons */ | |
| .star-wrap{ | |
| position:absolute; | |
| left:50%; | |
| top:50%; | |
| transform:translate(-50%,-50%); | |
| width:420px; | |
| height:420px; | |
| pointer-events:none; | |
| z-index:12; /* sits between background and menu items visually */ | |
| display:flex; align-items:center; justify-content:center; | |
| } | |
| #starImg{ | |
| max-width:100%; | |
| max-height:100%; | |
| transform-origin:center center; | |
| will-change:transform,opacity; | |
| filter: drop-shadow(0 22px 24px rgba(0,0,0,0.55)); | |
| } | |
| /* small UI controls (speed slider & file input) */ | |
| .controls{ | |
| position:fixed; | |
| right:16px; | |
| top:16px; | |
| z-index:60; | |
| background: rgba(0,0,0,0.28); | |
| padding:10px 12px; | |
| border-radius:10px; | |
| color:#dfffe5; | |
| font-size:13px; | |
| backdrop-filter: blur(6px); | |
| pointer-events:auto; | |
| } | |
| .controls input[type="range"]{ width:160px; display:block; margin-top:6px;} | |
| .controls label{ display:block; font-weight:700; font-size:13px; margin-bottom:6px;} | |
| .detail{ | |
| position:absolute; | |
| bottom:6%; | |
| left:50%; | |
| transform:translateX(-50%); | |
| z-index:60; | |
| font-weight:700; | |
| color:#eaffc8; | |
| text-shadow: 0 2px 8px rgba(0,0,0,0.6); | |
| font-size:20px; | |
| pointer-events:none; | |
| } | |
| .help { | |
| position:fixed; left:16px; bottom:16px; z-index:60; | |
| color:rgba(255,255,255,0.78); | |
| font-size:12px; | |
| pointer-events:auto; | |
| background:rgba(0,0,0,0.24); | |
| padding:8px 10px; border-radius:8px; | |
| } | |
| @media (max-width:900px){ | |
| .menu-wrapper{ width:92%; height:360px;} | |
| .star-wrap{ width:320px; height:320px;} | |
| :root{ --menu-radius: 260px; --label-size:36px; --ribbon-thickness:56px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- background canvas for pearl noise --> | |
| <canvas id="bgCanvas"></canvas> | |
| <div class="stage"> | |
| <div class="ui"> | |
| <div class="menu-wrapper"> | |
| <!-- 3D ring container --> | |
| <div class="menu3d" id="menu3d"></div> | |
| <!-- star (PNG-support) --> | |
| <div class="star-wrap" id="starWrap" aria-hidden="true"> | |
| <img id="starImg" src="" alt="starfish placeholder"/> | |
| </div> | |
| <!-- detail area --> | |
| <div class="detail" id="detail"> | |
| <div id="detailMain">Games</div> | |
| <div id="detailSub" style="margin-left:14px; opacity:0.95; font-weight:800; color:#c6ffd8"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- controls --> | |
| <div class="controls"> | |
| <label>Rotation speed (deg/sec, negative = left): <span id="speedVal">-6</span></label> | |
| <input id="speedRange" type="range" min="-60" max="60" value="-6" step="1"/> | |
| <div style="height:8px;"></div> | |
| <label>Star PNG (optional)</label> | |
| <input id="starFile" type="file" accept="image/*"/> | |
| <div style="height:8px;"></div> | |
| <button id="resetBtn" style="padding:6px 8px; border-radius:6px;">Reset view</button> | |
| </div> | |
| <div class="help"> | |
| Arrow Left / Right — jump previous/next. <br/> | |
| Continuous spin always runs (adjust slider). <br/> | |
| When <strong>Games</strong> is front, the sublabel cycles games on repeated Left/Right. | |
| </div> | |
| <script> | |
| /* ============================ | |
| Pearl-noise animated background | |
| (unchanged) | |
| ============================ */ | |
| (function(){ | |
| const canvas = document.getElementById('bgCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| let W = canvas.width = innerWidth; | |
| let H = canvas.height = innerHeight; | |
| const BLOBS = 36; | |
| const BLOB_SIZE = Math.max(160, Math.min(420, Math.round(Math.max(W,H) * 0.12))); | |
| const PALETTE = [ | |
| [6,128,120],[8,92,168],[24,200,190],[56,168,220],[14,94,110] | |
| ]; | |
| const blobs = []; | |
| for(let i=0;i<BLOBS;i++){ | |
| blobs.push({ | |
| x: Math.random()*W, | |
| y: Math.random()*H, | |
| r: BLOB_SIZE * (0.7 + Math.random()*1.2), | |
| vx: (Math.random()*2-1)*0.06, | |
| vy: (Math.random()*2-1)*0.06, | |
| ci: Math.floor(Math.random()*PALETTE.length), | |
| alpha: 0.24 + Math.random()*0.38 | |
| }); | |
| } | |
| let perf = 0; | |
| function draw(){ | |
| ctx.clearRect(0,0,W,H); | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'lighter'; | |
| ctx.filter = 'blur(24px)'; | |
| for(let b of blobs){ | |
| const c = PALETTE[b.ci]; | |
| ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},${b.alpha})`; | |
| ctx.beginPath(); | |
| ctx.ellipse(b.x, b.y, b.r, b.r*0.9, 0, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| ctx.restore(); | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'overlay'; | |
| ctx.filter = 'blur(14px)'; | |
| for(let i=0;i<6;i++){ | |
| const x = (i/6)*W + (Math.sin(perf*0.0006 + i)*60); | |
| const y = (Math.cos(perf*0.0005 + i)*80) + H*0.33; | |
| const g = ctx.createRadialGradient(x,y,20,x,y,Math.min(W,H)*0.7); | |
| g.addColorStop(0,'rgba(255,255,255,0.03)'); | |
| g.addColorStop(1,'rgba(255,255,255,0.0)'); | |
| ctx.fillStyle = g; | |
| ctx.fillRect(0,0,W,H); | |
| } | |
| ctx.restore(); | |
| ctx.save(); | |
| const og = ctx.createLinearGradient(0,0,0,H); | |
| og.addColorStop(0,'rgba(0,10,18,0.06)'); | |
| og.addColorStop(1,'rgba(0,3,8,0.22)'); | |
| ctx.fillStyle = og; | |
| ctx.fillRect(0,0,W,H); | |
| ctx.restore(); | |
| } | |
| function step(ts){ | |
| perf = ts; | |
| for(let b of blobs){ | |
| b.x += b.vx * 0.6; b.y += b.vy * 0.6; | |
| b.x += Math.sin((perf*0.00015) + b.r) * 0.08; | |
| b.y += Math.cos((perf*0.00012) + b.r) * 0.06; | |
| if(b.x < -b.r) b.x = W + b.r; | |
| if(b.x > W + b.r) b.x = -b.r; | |
| if(b.y < -b.r) b.y = H + b.r; | |
| if(b.y > H + b.r) b.y = -b.r; | |
| } | |
| draw(); | |
| requestAnimationFrame(step); | |
| } | |
| function onResize(){ | |
| W = canvas.width = innerWidth; | |
| H = canvas.height = innerHeight; | |
| } | |
| addEventListener('resize', onResize); | |
| requestAnimationFrame(step); | |
| })(); | |
| /* ============================ | |
| Circular menu logic with curved SVG ribbons | |
| - Each menu item now contains an SVG "ribbon" (pill + text + dot) | |
| - We compute per-frame transforms so each ribbon rides the globe and is billboarded + tilted, | |
| producing a correct 3D bending/tilt in perspective. | |
| - Continuous rotation controlled by slider; snapping animates currentRotation; keyboard works. | |
| ============================ */ | |
| $(function(){ | |
| const mainLabels = ["games","video","music","memory","network"]; | |
| const gamesList = ["Tomb Raider 3","Tekken 3","Crash Bandicoot","Final Fantasy VII","Metal Gear Solid"]; | |
| const menu3d = $('#menu3d'); | |
| const n = mainLabels.length; | |
| const angleStep = 360 / n; | |
| // radius and runtime state | |
| let radius = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--menu-radius')) || 380; | |
| let ribbonThickness = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--ribbon-thickness')) || 72; | |
| let currentRotation = 0; // degrees (0 => index 0 front) | |
| let speedDeg = Number($('#speedRange').val()); $('#speedVal').text(speedDeg); | |
| let lastTS = performance.now(); | |
| let currentGameIndex = 0; | |
| // Create an SVG ribbon: a curved pill path, text on center arc, and a dot at the arc end | |
| // viewBox is centered at 0,0 so transforms are straightforward | |
| function createRibbonSVG(id, labelText, arcAngleDeg, R){ | |
| const half = arcAngleDeg/2; | |
| const a1 = -half * Math.PI/180; | |
| const a2 = half * Math.PI/180; | |
| // radii for outside and inside of the pill | |
| const outR = Math.max(20, (R * 0.92) + (ribbonThickness/2)); | |
| const inR = Math.max(6, (R * 0.92) - (ribbonThickness/2)); | |
| const midR = (outR + inR) / 2; | |
| // coordinates for outer arc endpoints | |
| const x1o = outR * Math.sin(a1), y1o = -outR * Math.cos(a1); | |
| const x2o = outR * Math.sin(a2), y2o = -outR * Math.cos(a2); | |
| // coordinates for inner arc endpoints (reverse direction) | |
| const x2i = inR * Math.sin(a2), y2i = -inR * Math.cos(a2); | |
| const x1i = inR * Math.sin(a1), y1i = -inR * Math.cos(a1); | |
| const laf = arcAngleDeg > 180 ? 1 : 0; | |
| const pathD = [ | |
| `M ${x1o.toFixed(3)} ${y1o.toFixed(3)}`, | |
| `A ${outR.toFixed(3)} ${outR.toFixed(3)} 0 ${laf} 1 ${x2o.toFixed(3)} ${y2o.toFixed(3)}`, | |
| `L ${x2i.toFixed(3)} ${y2i.toFixed(3)}`, | |
| `A ${inR.toFixed(3)} ${inR.toFixed(3)} 0 ${laf} 0 ${x1i.toFixed(3)} ${y1i.toFixed(3)}`, | |
| 'Z' | |
| ].join(' '); | |
| const xm1 = midR * Math.sin(a1), ym1 = -midR * Math.cos(a1); | |
| const xm2 = midR * Math.sin(a2), ym2 = -midR * Math.cos(a2); | |
| const textPathD = `M ${xm1.toFixed(3)} ${ym1.toFixed(3)} A ${midR.toFixed(3)} ${midR.toFixed(3)} 0 ${laf} 1 ${xm2.toFixed(3)} ${ym2.toFixed(3)}`; | |
| // dot position on outer edge near x2o,y2o | |
| const dotOut = outR + (ribbonThickness * 0.06); | |
| const dotX = dotOut * Math.sin(a2), dotY = -dotOut * Math.cos(a2); | |
| // gradient id unique | |
| const gradId = `grad_${id}_${Math.round(Math.random()*10000)}`; | |
| // viewBox extents centered at 0 | |
| const margin = Math.ceil(ribbonThickness*1.6 + 40); | |
| const extent = Math.ceil(outR + margin); | |
| const viewBox = `${-extent} ${-extent} ${extent*2} ${extent*2}`; | |
| const svg = ` | |
| <svg class="ribbon" viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> | |
| <defs> | |
| <linearGradient id="${gradId}" x1="0" x2="1"> | |
| <stop offset="0" stop-color="#c7ff45"/> | |
| <stop offset="1" stop-color="#8ecc23"/> | |
| </linearGradient> | |
| </defs> | |
| <path class="ribbon-shape" d="${pathD}" fill="url(#${gradId})" opacity="0.995" stroke="rgba(0,0,0,0.08)" stroke-width="1.2"/> | |
| <path id="tpath_${id}" d="${textPathD}" fill="none" stroke="none"/> | |
| <text> | |
| <textPath href="#tpath_${id}" startOffset="50%" text-anchor="middle" dominant-baseline="middle" style="font-family:'Trebuchet MS', Arial, sans-serif; font-weight:900; font-size:${getComputedStyle(document.documentElement).getPropertyValue('--label-size')};"> | |
| <tspan fill="url(#${gradId})">${labelText}</tspan> | |
| </textPath> | |
| </text> | |
| <circle class="dot" cx="${dotX.toFixed(2)}" cy="${dotY.toFixed(2)}" r="${(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--dot-size'))/2)}" fill="url(#${gradId})" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/> | |
| </svg> | |
| `; | |
| return svg; | |
| } | |
| // Build items using the original mainLabels array, replacing div.label + .dot with SVG ribbon | |
| function buildItems(){ | |
| menu3d.empty(); | |
| for(let i=0;i<n;i++){ | |
| const lab = mainLabels[i]; | |
| const approxAngle = Math.min(82, Math.max(26, lab.length * 7.2)); | |
| const $item = $(`<div class="menu-item" data-i="${i}" data-arc="${approxAngle}"></div>`); | |
| $item.html(createRibbonSVG(i, lab, approxAngle, radius)); | |
| menu3d.append($item); | |
| } | |
| // initial layout after build | |
| updateVisuals(); // positions & classes | |
| } | |
| // On resize, recalc radius & rebuild so arcs remain correct | |
| function onResize(){ | |
| const wrapperW = $('.menu-wrapper').width(); | |
| radius = Math.max(220, Math.round(wrapperW * 0.44)); | |
| ribbonThickness = Math.max(46, Math.round(radius * 0.18)); | |
| document.documentElement.style.setProperty('--ribbon-thickness', ribbonThickness + 'px'); | |
| buildItems(); | |
| } | |
| $(window).on('resize', onResize); | |
| // compute which index is front given currentRotation | |
| function frontIndexFromRotation(rot){ | |
| const normalized = ((-rot % 360)+360)%360; | |
| let idx = Math.round(normalized/angleStep) % n; | |
| idx = (idx + n) % n; | |
| return idx; | |
| } | |
| // update visuals: set per-item transforms so each ribbon rides globe & tilts/curves in perspective | |
| let lastFront = -1; | |
| function updateVisuals(){ | |
| // for each menu-item compute effective angle and set transform | |
| menu3d.find('.menu-item').each(function(){ | |
| const i = Number($(this).attr('data-i')); | |
| const baseAngle = i * angleStep; | |
| const eff = baseAngle + currentRotation; // degrees | |
| const effRad = eff * Math.PI / 180; | |
| // the ribbon should tilt to follow the sphere surface: compute tilt using sin(eff) | |
| const maxTilt = 20; // degrees of tilt at edges; tune for stronger/softer curvature | |
| const curveDeg = Math.sin(effRad) * maxTilt; | |
| // Transform pipeline: | |
| // 1) rotateY(eff) to place the ribbon around the ring | |
| // 2) translateZ(radius) to push it out to the ring radius | |
| // 3) translateX(-50%) translateY(-50%) to center the SVG (because its viewBox centered at 0) | |
| // 4) rotateY(-eff) billboard so the SVG faces the camera | |
| // 5) rotateX(curveDeg) tilt the ribbon so it follows globe curvature in perspective | |
| const t = `rotateY(${eff}deg) translateZ(${radius}px) translateX(-50%) translateY(-50%) rotateY(${-eff}deg) rotateX(${curveDeg}deg)`; | |
| $(this).css('transform', t); | |
| }); | |
| // manage front/side/back classes and detail text | |
| const front = frontIndexFromRotation(currentRotation); | |
| if(front !== lastFront){ | |
| lastFront = front; | |
| $('#detailMain').text(capitalize(mainLabels[front])); | |
| if(mainLabels[front] === 'games'){ | |
| $('#detailSub').text(gamesList[currentGameIndex]); | |
| } else { | |
| $('#detailSub').text(''); | |
| } | |
| } | |
| menu3d.find('.menu-item').each(function(){ | |
| const i = Number($(this).attr('data-i')); | |
| let d = Math.abs(i - front); | |
| d = Math.min(d, n - d); | |
| $(this).removeClass('front side back'); | |
| if(i === front) $(this).addClass('front'); | |
| else if(d === 1) $(this).addClass('side'); | |
| else $(this).addClass('back'); | |
| }); | |
| } | |
| // continuous animation loop (integrates currentRotation) | |
| function animateLoop(ts){ | |
| const dt = (ts - lastTS) / 1000; | |
| lastTS = ts; | |
| currentRotation += speedDeg * dt; | |
| currentRotation = ((currentRotation % 360)+360)%360; | |
| updateVisuals(); | |
| requestAnimationFrame(animateLoop); | |
| } | |
| // snapping helper: animate currentRotation to target index | |
| function snapToIndex(targetIndex, duration=560){ | |
| const targetAngle = -targetIndex * angleStep; | |
| let k = Math.round((currentRotation - targetAngle) / 360); | |
| let target = targetAngle + 360 * k; | |
| $({r: currentRotation}).stop(true).animate({r: target}, { | |
| duration: duration, | |
| easing: 'swing', | |
| step: function(now){ | |
| currentRotation = ((now % 360)+360)%360; | |
| updateVisuals(); | |
| }, | |
| complete: function(){ | |
| currentRotation = ((target % 360)+360)%360; | |
| updateVisuals(); | |
| } | |
| }); | |
| } | |
| // build initial items and start animation | |
| onResize(); // builds items and positions them for the initial radius | |
| lastTS = performance.now(); | |
| requestAnimationFrame(animateLoop); | |
| // slider control | |
| $('#speedRange').on('input change', function(){ | |
| speedDeg = Number($(this).val()); | |
| $('#speedVal').text(speedDeg); | |
| }); | |
| // keyboard nav (same behavior as your version) | |
| function handleLeftRight(isRight){ | |
| const front = frontIndexFromRotation(currentRotation); | |
| if(mainLabels[front] === 'games'){ | |
| // cycle inner games | |
| currentGameIndex = (currentGameIndex + (isRight?1:-1) + gamesList.length) % gamesList.length; | |
| $('#detailSub').stop(true).fadeOut(80, function(){ | |
| $(this).text(gamesList[currentGameIndex]).fadeIn(240); | |
| }); | |
| $('#starImg').stop(true).animate({opacity:0.92},150).animate({opacity:1},380); | |
| } else { | |
| const targetIdx = (front + (isRight?1:-1) + n) % n; | |
| snapToIndex(targetIdx, 520); | |
| currentGameIndex = 0; | |
| } | |
| } | |
| $(window).on('keydown', function(e){ | |
| if(e.which === 37){ | |
| e.preventDefault(); | |
| handleLeftRight(false); | |
| } else if(e.which === 39){ | |
| e.preventDefault(); | |
| handleLeftRight(true); | |
| } else if(e.which === 13){ | |
| e.preventDefault(); | |
| // Enter toggles selected visual (a quick pop) | |
| $('.menu-item.front').toggleClass('selected'); | |
| $('.menu-item.front').animate({opacity:0.86},120).animate({opacity:1},260); | |
| } else if(e.which === 27){ | |
| snapToIndex(0,600); | |
| } | |
| }); | |
| // helper | |
| function capitalize(s){ return s.charAt(0).toUpperCase()+s.slice(1); } | |
| // initial detail text | |
| currentGameIndex = 0; | |
| $('#detailMain').text(capitalize(mainLabels[0])); | |
| $('#detailSub').text(gamesList[currentGameIndex]); | |
| // star PNG upload support (unchanged) | |
| const starImg = document.getElementById('starImg'); | |
| const defaultSVG = `data:image/svg+xml;utf8,${encodeURIComponent( | |
| `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"> | |
| <defs><linearGradient id="g" x1="0" x2="1"><stop offset="0" stop-color="#ff7a2c"/><stop offset="1" stop-color="#d92c2c"/></linearGradient></defs> | |
| <path d="M300 40 C350 120 430 140 520 120 C450 200 480 300 420 380 C320 330 260 400 200 520 C120 400 40 320 -20 220 C20 170 40 120 120 80 C200 40 260 10 300 40 Z" fill="url(#g)" opacity="0.98" /> | |
| </svg>` )}`; | |
| fetch('starfish.png', {method:'HEAD'}).then(r=>{ | |
| if(r.ok){ starImg.src = 'starfish.png'; } else { starImg.src = defaultSVG; } | |
| }).catch(_=>{ | |
| starImg.src = defaultSVG; | |
| }); | |
| $('#starFile').on('change', function(ev){ | |
| const f = ev.target.files && ev.target.files[0]; | |
| if(!f) return; | |
| const reader = new FileReader(); | |
| reader.onload = function(e){ starImg.src = e.target.result; }; | |
| reader.readAsDataURL(f); | |
| }); | |
| // gentle spin of star | |
| let starAngle = 0; | |
| (function starSpinLoop(){ | |
| starAngle += 0.03; | |
| $('#starImg').css('transform', `rotate(${starAngle}deg) scale(1)`); | |
| requestAnimationFrame(starSpinLoop); | |
| })(); | |
| // reset button | |
| $('#resetBtn').on('click', function(){ | |
| snapToIndex(0,600); | |
| $('#speedRange').val(-6).trigger('change'); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment