| |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| <title>The Shape of Words</title> |
| <style> |
| :root{ |
| --ink:#11131A; --ink-2:#1A1D27; --mist:#9AA3B2; --mist-2:#5E6678; |
| --paper:#ECE7DB; --line:#2A2E3A; --accent:#E8A33D; --accent-soft:#C98A2E; |
| --glow:#FFFFFF; |
| } |
| *{box-sizing:border-box} |
| html,body{margin:0;height:100%} |
| body{ |
| background: |
| radial-gradient(120% 90% at 50% -10%, #1C2030 0%, var(--ink) 55%, #0C0E14 100%) |
| no-repeat fixed; |
| background-color:#0C0E14; |
| color:var(--paper); font-family:"Iowan Old Style","Palatino Linotype",Georgia,serif; |
| -webkit-font-smoothing:antialiased; min-height:100%; |
| display:flex; flex-direction:column; align-items:center; |
| } |
| .wrap{width:min(960px,94vw); padding:30px 0 80px} |
| header{display:flex; align-items:baseline; justify-content:space-between; margin-bottom:6px} |
| h1{font-size:26px; font-weight:600; letter-spacing:.3px; margin:0} |
| h1 em{font-style:italic; color:var(--accent)} |
| .sub{font-family:ui-monospace,"SF Mono",Menlo,monospace; font-size:11px; |
| letter-spacing:.18em; text-transform:uppercase; color:var(--mist-2)} |
| .progress{font-family:ui-monospace,monospace; font-size:11px; color:var(--mist-2); |
| letter-spacing:.1em; margin-top:2px} |
| |
| |
| .story{margin:22px 0 0; min-height:40px} |
| .beat{padding:9px 0; border-bottom:1px solid var(--line); display:flex; gap:12px; |
| align-items:flex-start; opacity:0; transform:translateY(6px); |
| animation:rise .5s ease forwards} |
| @keyframes rise{to{opacity:1; transform:none}} |
| .beat .n{font-family:ui-monospace,monospace; font-size:11px; color:var(--mist-2); |
| padding-top:3px; min-width:22px} |
| .beat .txt{font-size:17px; line-height:1.5} |
| .beat.ai .txt{color:var(--mist)} .beat.ai .txt::before{content:"✎ "; color:var(--accent-soft)} |
| |
| |
| .comment{font-style:italic; color:var(--accent); font-size:15px; min-height:22px; |
| margin:14px 2px; transition:opacity .3s; opacity:0} |
| .comment.show{opacity:1} |
| .thinking{display:inline-block; color:var(--mist-2); font-style:normal; |
| font-family:ui-monospace,monospace; font-size:12px; letter-spacing:.1em} |
| |
| |
| .inputrow{display:flex; gap:10px; margin-top:18px} |
| textarea{flex:1; resize:none; background:var(--ink-2); color:var(--paper); |
| border:1px solid var(--line); border-radius:10px; padding:13px 15px; |
| font-family:inherit; font-size:16px; line-height:1.4; outline:none; |
| transition:border-color .2s} |
| textarea:focus{border-color:var(--accent-soft)} |
| .btns{display:flex; flex-direction:column; gap:8px} |
| button{font-family:ui-monospace,monospace; font-size:12px; letter-spacing:.08em; |
| border:1px solid var(--line); background:var(--ink-2); color:var(--paper); |
| padding:0 16px; height:46px; border-radius:10px; cursor:pointer; |
| transition:all .15s; white-space:nowrap} |
| button:hover{border-color:var(--accent); color:var(--accent)} |
| button.primary{background:var(--accent); color:#1A1206; border-color:var(--accent); font-weight:600} |
| button.primary:hover{background:var(--accent-soft); color:#1A1206} |
| button:disabled{opacity:.35; cursor:default; border-color:var(--line); color:var(--mist-2)} |
| .hint{font-family:ui-monospace,monospace; font-size:11px; color:var(--mist-2); |
| margin-top:8px; letter-spacing:.04em} |
| .hintrow{display:flex; align-items:center; justify-content:space-between; gap:12px} |
| .textbtn{background:none; border:none; color:var(--mist-2); font-family:ui-monospace,monospace; |
| font-size:11px; letter-spacing:.06em; cursor:pointer; padding:6px 4px; height:auto; |
| transition:color .15s} |
| .textbtn:hover{color:var(--accent); border:none} |
| |
| |
| .choice{margin-top:22px; display:none; gap:18px; align-items:stretch} |
| .choice.show{display:flex} |
| .card{flex:1; background:var(--ink-2); border:1px solid var(--line); border-radius:14px; |
| padding:16px; text-align:center; cursor:pointer; transition:all .18s} |
| .card:hover{border-color:var(--accent); transform:translateY(-2px)} |
| .card svg{width:100%; height:150px; display:block} |
| .card .cap{font-family:ui-monospace,monospace; font-size:11px; letter-spacing:.1em; |
| text-transform:uppercase; color:var(--mist-2); margin-top:8px} |
| .card.freeform{display:flex; flex-direction:column; align-items:center; justify-content:center; |
| color:var(--mist-2); font-family:ui-monospace,monospace; font-size:12px} |
| |
| |
| .scene{margin-top:26px; display:none} |
| .scene.show{display:block} |
| .canvas-wrap{position:relative; width:100%; aspect-ratio:1/1; background: |
| radial-gradient(130% 100% at 50% 0%, #14171F, #0A0C11); border:1px solid var(--line); |
| border-radius:16px; overflow:hidden} |
| .canvas-wrap svg{position:absolute; inset:0; width:100%; height:100%} |
| .canvas-wrap svg path.blooming{ transition: fill 0.65s ease-in-out, filter 0.15s; } |
| .legend{margin-top:14px; display:flex; flex-direction:column; gap:6px; max-width:420px} |
| .legend-head{font-family:ui-monospace,monospace; font-size:10px; letter-spacing:.12em; |
| text-transform:uppercase; color:var(--mist-2); margin-bottom:2px} |
| .chip{font-size:13px; padding:7px 10px; border:1px solid var(--line); border-radius:9px; |
| cursor:default; transition:all .15s; display:flex; align-items:center; gap:9px} |
| .chip:hover{border-color:var(--accent)} |
| .chip .dot{width:11px; height:11px; border-radius:50%; flex:none} |
| .chip .chip-label{flex:1} |
| .chip.ai{font-style:italic; color:var(--mist)} |
| .chip .tag{font-family:ui-monospace,monospace; font-size:9px; color:var(--accent-soft)} |
| .layerbtns{display:flex; gap:4px; flex:none} |
| .layerbtns .lb{height:24px; width:26px; padding:0; font-size:10px; line-height:1; |
| border-radius:6px; color:var(--mist-2)} |
| .layerbtns .lb:hover:not(:disabled){border-color:var(--accent); color:var(--accent)} |
| .layerbtns .lb:disabled{opacity:.25} |
| .layerbtns .lb.rot{ font-size:13px; width:28px; } |
| |
| .toggle{width:46px; height:26px; border-radius:14px; background:var(--line); |
| border:1px solid var(--line); padding:0; position:relative; cursor:pointer; |
| transition:background .18s; flex:none} |
| .toggle .knob{position:absolute; top:2px; left:2px; width:20px; height:20px; |
| border-radius:50%; background:var(--mist); transition:left .18s,background .18s} |
| .toggle[aria-checked="true"]{background:var(--accent-soft); border-color:var(--accent)} |
| .toggle[aria-checked="true"] .knob{left:22px; background:#1A1206} |
| .toggle:hover{border-color:var(--accent)} |
| |
| .chip.hidden-shape{opacity:.45} |
| .chip.hidden-shape .chip-label{text-decoration:line-through} |
| .lb.eye{font-size:12px} |
| .scenebtns{margin-top:14px; display:flex; gap:10px; flex-wrap:wrap} |
| .paintbox{margin-top:28px; padding-top:22px; border-top:1px solid var(--line)} |
| .paintbox-label{font-family:ui-monospace,monospace; font-size:10px; letter-spacing:.14em; |
| text-transform:uppercase; color:var(--mist-2); margin-bottom:10px} |
| .paint-row{display:flex; gap:10px; align-items:center; margin-bottom:10px; flex-wrap:wrap} |
| #paintTheme{flex:1; background:var(--ink-2); color:var(--paper); border:1px solid var(--line); |
| border-radius:10px; padding:12px 14px; font-family:inherit; font-size:15px; outline:none} |
| #paintTheme:focus{border-color:var(--accent-soft)} |
| .toggle-pill{height:30px; padding:0 14px; border-radius:16px; font-family:ui-monospace,monospace; |
| font-size:11px; letter-spacing:.05em} |
| .toggle-pill[data-mode="strong"]{border-color:var(--accent); color:var(--accent)} |
| .paint-hint{font-style:italic} |
| .paint-result{margin-top:14px; min-height:0} |
| .paint-result img{width:100%; border-radius:14px; border:1px solid var(--line); display:block} |
| .paint-result .painting-wait{font-family:ui-monospace,monospace; font-size:12px; color:var(--mist-2); |
| padding:30px; text-align:center; letter-spacing:.08em} |
| .paint-result .painting-actions{margin-top:10px; display:flex; gap:10px} |
| |
| |
| .overlay{position:fixed; inset:0; background:rgba(8,10,16,.72); backdrop-filter:blur(3px); |
| display:none; align-items:center; justify-content:center; z-index:50} |
| .overlay.show{display:flex} |
| .modal{width:min(380px,90vw); background:var(--ink-2); border:1px solid var(--line); |
| border-radius:16px; padding:24px} |
| .modal h2{margin:0 0 4px; font-size:20px; font-weight:600} |
| .modal p.note{margin:0 0 20px; font-size:13px; color:var(--mist-2); line-height:1.5} |
| .setting{margin:18px 0} |
| .setting .lbl{font-family:ui-monospace,monospace; font-size:11px; letter-spacing:.12em; |
| text-transform:uppercase; color:var(--mist); display:block; margin-bottom:8px} |
| .stepper{display:flex; align-items:center; gap:0} |
| .stepper button{width:46px; height:46px; padding:0; font-size:20px; border-radius:0} |
| .stepper button:first-child{border-radius:10px 0 0 10px} |
| .stepper button:last-child{border-radius:0 10px 10px 0} |
| .stepper .val{flex:1; text-align:center; height:46px; line-height:46px; font-size:20px; |
| background:var(--ink); border-top:1px solid var(--line); border-bottom:1px solid var(--line)} |
| .setting .range{font-family:ui-monospace,monospace; font-size:10px; color:var(--mist-2); margin-top:6px} |
| .setting .range.locked{color:var(--accent-soft)} |
| .modal .actions{display:flex; gap:10px; margin-top:24px} |
| .modal .actions button{flex:1; height:44px} |
| |
| |
| .card-overlay{z-index:60} |
| .card-overlay .card-modal{width:min(540px,92vw); max-height:94vh; background:transparent; |
| border:none; padding:0; display:flex; flex-direction:column; align-items:center; gap:14px} |
| .card-overlay .card-img{width:100%; height:auto; max-height:82vh; object-fit:contain; |
| border-radius:14px; box-shadow:0 16px 56px rgba(0,0,0,.6); display:block} |
| .card-overlay .card-actions{display:flex; gap:10px} |
| .card-overlay .card-actions button{height:44px; padding:0 22px} |
| |
| |
| .tooltip{position:fixed; z-index:80; pointer-events:none; max-width:min(300px,80vw); |
| background:var(--ink-2); color:var(--paper); border:1px solid var(--line); |
| border-radius:9px; padding:9px 12px; box-shadow:0 8px 28px rgba(0,0,0,.5); |
| opacity:0; transform:translateY(4px); transition:opacity .12s, transform .12s} |
| .tooltip.show{opacity:1; transform:none} |
| .tooltip .tt-head{font-family:ui-monospace,monospace; font-size:10px; letter-spacing:.12em; |
| text-transform:uppercase; color:var(--accent-soft); margin-bottom:4px} |
| .tooltip .tt-body{font-size:14px; line-height:1.45; font-style:italic; color:var(--paper)} |
| .tooltip.companion .tt-head{color:var(--mist-2)} |
| .tooltip.companion .tt-body{font-style:normal; color:var(--mist)} |
| </style> |
|
|
| <div class="wrap"> |
| <header> |
| <div> |
| <h1>The Shape of Words</h1> |
| <div class="progress" id="progress">beat 0 of 5 · shapes 0</div> |
| </div> |
| <div class="sub" style="display:flex;align-items:center;gap:14px"> |
| <span>exploration</span> |
| <button id="gear" title="Settings" style="height:30px;padding:0 12px">⚙ Settings</button> |
| </div> |
| </header> |
|
|
| <div class="story" id="story"></div> |
| <div class="comment" id="comment"></div> |
|
|
| <div class="choice" id="choice"></div> |
|
|
| <div class="inputrow" id="inputrow"> |
| <textarea id="input" rows="2" placeholder="Tell the next part of your story… (up to ~3 sentences)"></textarea> |
| <div class="btns"> |
| <button class="primary" id="send">Tell</button> |
| <button id="pen" title="Let the story continue itself">Pass the pen</button> |
| </div> |
| </div> |
| <div class="hintrow"> |
| <div class="hint" id="hint">A story unfolds one beat at a time. Some beats will become shapes.</div> |
| <button id="midReset" class="textbtn" style="display:none">↺ Start over</button> |
| </div> |
|
|
| <div class="scene" id="scene"> |
| <div class="canvas-wrap"><svg id="sceneSvg" viewBox="0 0 1000 1000"></svg></div> |
| <div class="legend" id="legend"></div> |
| <div class="scenebtns"> |
| <button id="save">↓ Save image</button> |
| <button id="cardBtn" class="primary">✦ Share card</button> |
| <button id="replayBtn" title="Replay the colour + sound reveal">↻ Replay reveal</button> |
| <button id="reset">Reset arrangement</button> |
| <button id="restart">New story</button> |
| </div> |
| <div id="cardResult" class="paint-result"></div> |
|
|
| <div class="paintbox"> |
| <div class="paintbox-label">Paint this story</div> |
| <div class="paint-row"> |
| <input id="paintTheme" type="text" placeholder="style — e.g. 'misty watercolor', 'oil impasto', 'neon dusk'"> |
| <button id="paintBtn" class="primary">✦ Paint this</button> |
| <button id="surpriseBtn" title="Pick a random style and paint">🎲 Surprise me</button> |
| </div> |
| <div id="paintResult" class="paint-result"></div> |
| <div id="paintCardResult"></div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="overlay" id="overlay"> |
| <div class="modal"> |
| <h2>Settings</h2> |
| <p class="note">Changes apply to your next story.</p> |
| <div class="setting"> |
| <span class="lbl">Story beats</span> |
| <div class="stepper"> |
| <button id="beatsDown">−</button> |
| <div class="val" id="beatsVal">5</div> |
| <button id="beatsUp">+</button> |
| </div> |
| <div class="range" id="beatsRange">3 to 7 beats</div> |
| </div> |
| <div class="setting"> |
| <span class="lbl">Shapes</span> |
| <div class="stepper"> |
| <button id="shapesDown">−</button> |
| <div class="val" id="shapesVal">3</div> |
| <button id="shapesUp">+</button> |
| </div> |
| <div class="range" id="shapesRange">3 to 3</div> |
| </div> |
| <div class="setting"> |
| <span class="lbl">Companion shapes</span> |
| <button id="companionToggle" class="toggle" role="switch" aria-checked="true"> |
| <span class="knob"></span> |
| </button> |
| <div class="range">the AI adds its own shapes at the end</div> |
| </div> |
| <div class="setting"> |
| <span class="lbl">Reveal sound</span> |
| <button id="soundToggle" class="toggle" role="switch" aria-checked="true"> |
| <span class="knob"></span> |
| </button> |
| <div class="range">chimes & atmosphere during the colour reveal</div> |
| </div> |
| <div class="actions"> |
| <button id="settingsCancel">Cancel</button> |
| <button class="primary" id="settingsApply">Apply</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| |
| |
| |
| |
| const clamp=(x,a,b)=>Math.max(a,Math.min(b,x)); |
| const DEBUG=true; |
| function flog(tag, ...args){ if(DEBUG) console.log(`%c[${tag}]`,"color:#E8A33D;font-weight:bold",...args); } |
| |
| function affectToGeometry(v,a,d){ |
| const threat=1-v; |
| |
| |
| |
| |
| const amp=0.4+0.9*a; |
| return { |
| spikiness: clamp(0.62*a+0.45*threat*a,0,1), |
| compactness: clamp(0.35+0.40*v-0.25*a,0,1), |
| segmentability: clamp(0.18+0.45*a+0.18*threat*a,0,1), |
| symmetry: clamp(0.45+0.35*d+0.20*v-0.30*a,0,1), |
| scale: clamp(0.62+0.63*d,0.5,1.3), |
| gx: (d-0.5)*amp, |
| gy: (0.5-v)*amp |
| }; |
| } |
| |
| function gestureLeanPhi(gx,gy){ |
| return { lean: Math.min(0.5, Math.hypot(gx||0, gy||0)), phi: Math.atan2(gy||0, gx||0) }; |
| } |
| |
| function lerpHueDeg(h0,h1,t){ let d=((h1-h0+180)%360+360)%360-180; return ((h0+d*t)%360+360)%360; } |
| function affectToColor(v,a,d){ |
| |
| |
| |
| |
| |
| const lowA=lerpHueDeg(264,145,Math.pow(v,1.6)), highA=lerpHueDeg(29,85,v); |
| const H=lerpHueDeg(lowA,highA,a); |
| const L=clamp(0.48+0.40*v-0.10*d,0.40,0.90); |
| const C=clamp(0.040+0.13*a+0.03*d,0,0.18); |
| return oklch2rgb(L,C,H); |
| } |
| function oklch2rgb(L,C,H){ |
| const h=H*Math.PI/180, a=C*Math.cos(h), b=C*Math.sin(h); |
| const l_=L+0.3963377774*a+0.2158037573*b; |
| const m_=L-0.1055613458*a-0.0638541728*b; |
| const s_=L-0.0894841775*a-1.2914855480*b; |
| const l=l_*l_*l_, m=m_*m_*m_, s=s_*s_*s_; |
| let r= 4.0767416621*l-3.3077115913*m+0.2309699292*s; |
| let g=-1.2684380046*l+2.6097574011*m-0.3413193965*s; |
| let bl=-0.0041960863*l-0.7034186147*m+1.7076147010*s; |
| const gm=x=>{ x=x<0?0:x>1?1:x; return x<=0.0031308?12.92*x:1.055*Math.pow(x,1/2.4)-0.055; }; |
| return `rgb(${Math.round(gm(r)*255)},${Math.round(gm(g)*255)},${Math.round(gm(bl)*255)})`; |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const MAT_STYLES = { |
| paper: { opacity: 0.90, filter: "url(#mat-paper)", stroke: "rgba(255,255,255,.18)", sw: 3 }, |
| ink: { opacity: 0.68, filter: "url(#mat-ink)", stroke: "rgba(20,20,50,.30)", sw: 2 }, |
| glass: { opacity: 0.46, filter: null, stroke: "rgba(255,255,255,.55)", sw: 4 }, |
| enamel: { opacity: 1.00, filter: null, stroke: "rgba(255,255,255,.42)", sw: 3 }, |
| chalk: { opacity: 0.80, filter: "url(#mat-chalk)", stroke: "rgba(200,200,200,.28)", sw: 2 }, |
| metal: { opacity: 0.96, filter: null, stroke: "rgba(140,155,200,.38)", sw: 3 }, |
| }; |
| function matStyle(mat){ return MAT_STYLES[mat] || MAT_STYLES.paper; } |
| |
| |
| |
| function _rgbNums(s){ const m=(s.match(/-?\d+\.?\d*/g)||[]).map(Number); return [m[0]||0,m[1]||0,m[2]||0]; } |
| function _rgbStr(r,g,b){ return `rgb(${Math.round(clamp(r,0,255))},${Math.round(clamp(g,0,255))},${Math.round(clamp(b,0,255))})`; } |
| function matColor(col, mat){ |
| if(mat==="metal"){ const [r,g,b]=_rgbNums(col); |
| return _rgbStr(0.72*r+0.06*255, 0.78*g+0.07*255, 0.88*b+0.09*255); } |
| if(mat==="enamel"){ const [r,g,b]=_rgbNums(col); const l=0.2126*r+0.7152*g+0.0722*b; |
| return _rgbStr(l+(r-l)*1.45, l+(g-l)*1.45, l+(b-l)*1.45); } |
| return col; |
| } |
| |
| |
| let _noiseTile=null; |
| function noiseTile(){ |
| if(_noiseTile) return _noiseTile; |
| const N=256, c=document.createElement("canvas"); c.width=c.height=N; |
| const x=c.getContext("2d"), img=x.createImageData(N,N), d=img.data; |
| for(let i=0;i<d.length;i+=4){ const v=Math.random()*255; d[i]=d[i+1]=d[i+2]=v; d[i+3]=255; } |
| x.putImageData(img,0,0); _noiseTile=c; return c; |
| } |
| |
| |
| function bakeMaterials(ctx, f){ |
| const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[a].depth-state.shapes[b].depth); |
| for(const i of order){ |
| const s=state.shapes[i]; if(s.hidden) continue; |
| const mat=s.material||"paper"; |
| if(mat!=="paper" && mat!=="chalk" && mat!=="ink") continue; |
| const poly=shapePolygon(s).map(p=>[p[0]*f,p[1]*f]); |
| ctx.save(); |
| ctx.beginPath(); |
| poly.forEach((p,k)=> k ? ctx.lineTo(p[0],p[1]) : ctx.moveTo(p[0],p[1])); |
| ctx.closePath(); ctx.clip(); |
| if(mat==="ink"){ |
| |
| ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=0.6; |
| ctx.strokeStyle="rgba(6,6,18,1)"; ctx.lineWidth=46*f/2; ctx.lineJoin="round"; |
| try{ ctx.filter="blur(20px)"; }catch(e){} |
| ctx.beginPath(); |
| poly.forEach((p,k)=> k ? ctx.lineTo(p[0],p[1]) : ctx.moveTo(p[0],p[1])); |
| ctx.closePath(); ctx.stroke(); ctx.filter="none"; |
| } else { |
| const xs=poly.map(p=>p[0]), ys=poly.map(p=>p[1]); |
| const x0=Math.min(...xs), y0=Math.min(...ys), x1=Math.max(...xs), y1=Math.max(...ys); |
| const pat=ctx.createPattern(noiseTile(),"repeat"); |
| ctx.fillStyle=pat; |
| if(mat==="paper"){ ctx.globalCompositeOperation="multiply"; ctx.globalAlpha=0.22; } |
| else { ctx.globalCompositeOperation="screen"; ctx.globalAlpha=0.30; } |
| ctx.fillRect(x0,y0,x1-x0,y1-y0); |
| } |
| ctx.restore(); |
| } |
| ctx.globalCompositeOperation="source-over"; ctx.globalAlpha=1; |
| } |
| |
| |
| |
| const MAT_FILTER_SVG = ` |
| <filter id="mat-paper" x="-2%" y="-2%" width="104%" height="104%" color-interpolation-filters="sRGB"> |
| <feTurbulence type="fractalNoise" baseFrequency="0.75 0.80" numOctaves="4" seed="7" result="noise"/> |
| <feColorMatrix type="matrix" values="0 0 0 0 0.92 0 0 0 0 0.89 0 0 0 0 0.84 0 0 0 0.38 0" in="noise" result="warmNoise"/> |
| <feBlend in="SourceGraphic" in2="warmNoise" mode="multiply" result="textured"/> |
| <feComposite in="textured" in2="SourceAlpha" operator="in"/> |
| </filter> |
| <filter id="mat-ink" x="-5%" y="-5%" width="110%" height="110%" color-interpolation-filters="sRGB"> |
| <feGaussianBlur in="SourceAlpha" stdDeviation="14" result="softA"/> |
| <feColorMatrix type="matrix" values="0 0 0 0 0.02 0 0 0 0 0.02 0 0 0 0 0.06 0 0 0 0.65 0" in="softA" result="poolDark"/> |
| <feMerge><feMergeNode in="SourceGraphic"/><feMergeNode in="poolDark"/></feMerge> |
| </filter> |
| <filter id="mat-chalk" x="-2%" y="-2%" width="104%" height="104%" color-interpolation-filters="sRGB"> |
| <feTurbulence type="fractalNoise" baseFrequency="1.10 0.90" numOctaves="3" seed="13" result="noise"/> |
| <feColorMatrix type="matrix" values="0 0 0 0 0.96 0 0 0 0 0.94 0 0 0 0 0.92 0 0 0 0.45 0" in="noise" result="dustNoise"/> |
| <feBlend in="SourceGraphic" in2="dustNoise" mode="screen" result="lightened"/> |
| <feComposite in="lightened" in2="SourceAlpha" operator="in"/> |
| </filter>`; |
| |
| |
| |
| function stableUnit(n,key){ |
| let out=[],h=0; for(let i=0;i<key.length;i++){h=(h*31+key.charCodeAt(i))>>>0;} |
| for(let k=0;k<n;k++){ h=(1103515245*h+12345)>>>0; out.push((h>>>8)/16777216); } |
| return out; |
| } |
| |
| |
| |
| |
| |
| |
| function ringToPath(pts, spk){ |
| const n=pts.length; if(n<3) return ""; |
| spk=Math.max(0,Math.min(1,spk||0)); |
| |
| |
| const t=(1/6)*(1-0.6*spk); |
| |
| |
| const cornerCos=-0.2+0.9*spk; |
| const corner=new Array(n); |
| for(let i=0;i<n;i++){ |
| const a=pts[(i-1+n)%n], b=pts[i], c=pts[(i+1)%n]; |
| const v1x=b[0]-a[0], v1y=b[1]-a[1], v2x=c[0]-b[0], v2y=c[1]-b[1]; |
| const m1=Math.hypot(v1x,v1y)||1e-9, m2=Math.hypot(v2x,v2y)||1e-9; |
| corner[i]=((v1x*v2x+v1y*v2y)/(m1*m2))<cornerCos; |
| } |
| const f=v=>v.toFixed(1); |
| let d="M"+f(pts[0][0])+" "+f(pts[0][1]); |
| for(let i=0;i<n;i++){ |
| const p0=pts[(i-1+n)%n], p1=pts[i], p2=pts[(i+1)%n], p3=pts[(i+2)%n]; |
| const t1=corner[i]?0:t, t2=corner[(i+1)%n]?0:t; |
| const c1x=p1[0]+(p2[0]-p0[0])*t1, c1y=p1[1]+(p2[1]-p0[1])*t1; |
| const c2x=p2[0]-(p3[0]-p1[0])*t2, c2y=p2[1]-(p3[1]-p1[1])*t2; |
| d+="C"+f(c1x)+" "+f(c1y)+" "+f(c2x)+" "+f(c2y)+" "+f(p2[0])+" "+f(p2[1]); |
| } |
| return d+"Z"; |
| } |
| |
| function geomToPath(g,key,N=120,cx=500,cy=500,R=300){ |
| const spk=g.spikiness,cmp=g.compactness,seg=g.segmentability,sym=g.symmetry; |
| const u=stableUnit(6,key); |
| const baseR=(0.16+0.16*cmp), aspect=1+(1-cmp)*1.2; |
| const L=Math.round(1+seg*4); |
| const spikes=Math.round(5+4*spk); |
| const {lean:glean, phi:gphi}=gestureLeanPhi(g.gx, g.gy); |
| let pts=[]; |
| for(let i=0;i<N;i++){ |
| const th=2*Math.PI*i/N; |
| let elong=1/Math.sqrt(Math.cos(th)**2/aspect+Math.sin(th)**2*aspect); |
| let r=baseR*elong; |
| if(L>=2){ |
| let lobe=Math.pow(Math.cos(L*th)*0.5+0.5,1.5); |
| r=r*(1-0.55*seg)+r*0.55*seg*lobe; r=r*(1+0.25*seg*(lobe-0.5)); |
| } |
| if(spk>0){ |
| |
| let tri=(2/Math.PI)*Math.asin(Math.sin(spikes*th)); |
| let peak=Math.pow(Math.abs(tri),1+1.4*spk); |
| let tooth=peak*2-1; |
| let amp=0.34*Math.pow(spk,1.6); |
| r=r*(1+amp*tooth); |
| } |
| if(sym<1){ |
| let j=0; for(let k=0;k<3;k++){ j+=u[k+3]*Math.sin((1+k)*th+u[k]*6.283); } |
| j/=3; r=r*(1+(1-sym)*0.62*j); |
| } |
| |
| if(glean>0.001) r=r*(1+glean*Math.cos(th-gphi)); |
| r=Math.max(r,0.03); |
| pts.push([cx+r*Math.cos(th)*R/0.34, cy+r*Math.sin(th)*R/0.34]); |
| } |
| return ringToPath(pts, spk); |
| } |
| |
| function geomToPathSegmented(segments, weights, key, N=120, cx=500, cy=500, R=300){ |
| const n=segments.length; |
| const total=weights.reduce((a,b)=>a+b,0); |
| const normW=weights.map(w=>w/total); |
| |
| |
| const anchors=[]; |
| let cum=0; |
| for(let i=0;i<n;i++){ |
| anchors.push(-Math.PI/2 + 2*Math.PI*(cum+normW[i]/2)); |
| cum+=normW[i]; |
| } |
| |
| const keys=["spikiness","compactness","segmentability","symmetry","scale"]; |
| |
| function interpAt(th){ |
| const vals={}; keys.forEach(k=>vals[k]=0); |
| let tw=0; |
| for(let i=0;i<n;i++){ |
| let diff=((th-anchors[i])+Math.PI)%(2*Math.PI)-Math.PI; |
| const dist=Math.abs(diff); |
| const w=dist<Math.PI ? Math.cos(dist*Math.PI/2)+1.0 : 0; |
| keys.forEach(k=>vals[k]+=w*segments[i][k]); |
| tw+=w; |
| } |
| if(tw<1e-9)tw=1e-9; |
| keys.forEach(k=>vals[k]/=tw); |
| return vals; |
| } |
| |
| const u=stableUnit(6,key); |
| |
| let meanSpk=0,meanSeg=0,meanSym=0,meanScale=0,meanGx=0,meanGy=0; |
| for(let i=0;i<n;i++){ |
| meanSpk+=normW[i]*segments[i].spikiness; |
| meanSeg+=normW[i]*segments[i].segmentability; |
| meanSym+=normW[i]*segments[i].symmetry; |
| meanScale+=normW[i]*segments[i].scale; |
| meanGx+=normW[i]*(segments[i].gx||0); |
| meanGy+=normW[i]*(segments[i].gy||0); |
| } |
| const L=Math.round(1+meanSeg*4); |
| const spikes=Math.round(5+4*meanSpk); |
| const {lean:glean, phi:gphi}=gestureLeanPhi(meanGx, meanGy); |
| |
| let pts=[]; |
| for(let i=0;i<N;i++){ |
| |
| |
| |
| const th=2*Math.PI*i/N; |
| |
| |
| const th_a=th - Math.PI/2; |
| const g=interpAt(th_a); |
| const spk=g.spikiness, cmp=g.compactness, seg=g.segmentability, sym=g.symmetry; |
| |
| const aspect=1+(1-cmp)*1.2; |
| let elong=1/Math.sqrt(Math.cos(th)**2/aspect+Math.sin(th)**2*aspect); |
| let r=(0.16+0.16*cmp)*elong; |
| |
| if(L>=2){ |
| let lobe=Math.pow(Math.cos(L*th)*0.5+0.5,1.5); |
| const depth=0.55*seg; |
| r=r*(1-depth)+r*depth*lobe; r=r*(1+0.25*seg*(lobe-0.5)); |
| } |
| if(spikes>0){ |
| let tri=(2/Math.PI)*Math.asin(Math.sin(spikes*th)); |
| let peak=Math.pow(Math.abs(tri),1+1.4*spk); |
| let tooth=peak*2-1; |
| let amp=0.34*Math.pow(spk,1.6); |
| r=r*(1+amp*tooth); |
| } |
| let j=0; for(let k=0;k<3;k++) j+=u[k+3]*Math.sin((1+k)*th+u[k]*6.283); |
| j/=3; r=r*(1+(1-sym)*0.62*j); |
| if(glean>0.001) r=r*(1+glean*Math.cos(th-gphi)); |
| r=Math.max(r,0.03)*g.scale; |
| |
| pts.push([cx+r*Math.cos(th)*R/0.34, cy+r*Math.sin(th)*R/0.34]); |
| } |
| return ringToPath(pts, meanSpk); |
| } |
| |
| |
| |
| |
| |
| const LEX={ |
| sharp:[0.15,0.85,0.7],blade:[0.1,0.85,0.75],fury:[0.1,0.9,0.8],storm:[0.2,0.85,0.8], |
| fire:[0.35,0.85,0.75],scream:[0.1,0.95,0.7],crash:[0.2,0.85,0.7],dark:[0.25,0.5,0.5], |
| calm:[0.8,0.15,0.4],quiet:[0.7,0.2,0.35],soft:[0.8,0.2,0.4],snow:[0.75,0.2,0.4], |
| gentle:[0.8,0.2,0.4],still:[0.7,0.15,0.4],warm:[0.8,0.4,0.5],bloom:[0.85,0.4,0.5], |
| sad:[0.2,0.25,0.3],rain:[0.35,0.3,0.35],empty:[0.25,0.2,0.3],alone:[0.25,0.3,0.3], |
| vast:[0.55,0.55,0.95],mountain:[0.55,0.5,0.9],loom:[0.3,0.6,0.85],tiny:[0.5,0.4,0.1], |
| joy:[0.9,0.8,0.6],laugh:[0.9,0.8,0.6],dance:[0.85,0.8,0.6],bright:[0.85,0.6,0.55] |
| }; |
| const VOICE_REACT=["Ooh.","Go on…","I didn't expect that.","Mm.","And then?","Tell me more.","Hm, a turn."]; |
| const VOICE_SHAPE=["Here are two shapes your beat could take — which one fits?", |
| "Two forms for that moment. Pick the one that feels right.", |
| "Your words could become either of these."]; |
| |
| |
| |
| |
| |
| |
| let BACKEND_OK = true; |
| |
| |
| let CALL_BASE = null; |
| const CALL_BASES = ["/gradio_api/call", "/call"]; |
| |
| async function gradioCall(name, payload){ |
| const bases = CALL_BASE ? [CALL_BASE] : CALL_BASES; |
| let lastErr; |
| for(const base of bases){ |
| try{ |
| const post = await fetch(`${base}/${name}`, { |
| method:"POST", headers:{"Content-Type":"application/json"}, |
| body: JSON.stringify({ data: payload }) |
| }); |
| if(!post.ok) throw new Error(`POST ${name} ${post.status}`); |
| const { event_id } = await post.json(); |
| const res = await fetch(`${base}/${name}/${event_id}`); |
| if(!res.ok) throw new Error(`GET ${name} ${res.status}`); |
| const text = await res.text(); |
| const data = parseSSE(text, name); |
| CALL_BASE = base; |
| flog("api←", name, data); |
| return data; |
| }catch(e){ lastErr = e; } |
| } |
| throw lastErr; |
| } |
| |
| |
| function parseSSE(text, name){ |
| const blocks = text.split(/\n\n+/); |
| let completeData = null, lastData = null; |
| for(const b of blocks){ |
| const lines = b.split("\n"); |
| const ev = lines.find(l=>l.startsWith("event:")); |
| const dl = lines.find(l=>l.startsWith("data:")); |
| if(!dl) continue; |
| const payload = dl.slice(5).trim(); |
| if(payload==="null" || payload==="") continue; |
| lastData = payload; |
| if(ev && ev.includes("complete")) completeData = payload; |
| } |
| const chosen = completeData || lastData; |
| if(!chosen) throw new Error(`no data from ${name}`); |
| const data = JSON.parse(chosen); |
| return Array.isArray(data) ? data[0] : data; |
| } |
| |
| async function apiJudgeBeat(text, story, mode, pacing){ |
| try{ const r = await gradioCall("judge_beat",[text, story, mode, pacing||null]); BACKEND_OK=true; return r; } |
| catch(e){ console.warn("judge_beat backend failed (this call), using stub:",e); BACKEND_OK=false; return stubModel(text,story); } |
| } |
| async function apiJudgeBeatSegmented(text, story, pacing){ |
| try{ |
| const r=await gradioCall("judge_beat_segmented",[text, story, pacing||null]); |
| BACKEND_OK=true; return r; |
| }catch(e){ |
| console.warn("judge_beat_segmented failed, falling back to judge_beat:",e); |
| return null; |
| } |
| } |
| async function apiContinue(story){ |
| try{ const r=await gradioCall("continue_story",[story]); BACKEND_OK=true; return r.sentence||r; } |
| catch(e){ console.warn("continue backend failed (this call), using stub:",e); BACKEND_OK=false; return stubContinue(story); } |
| } |
| async function apiReveal(story, labels){ |
| try{ const r=await gradioCall("reveal",[story, labels]); BACKEND_OK=true; return r; } |
| catch(e){ console.warn("reveal backend failed (this call), using stub:",e); BACKEND_OK=false; return null; } |
| } |
| async function apiTitle(story){ |
| try{ const r=await gradioCall("title_story",[story]); BACKEND_OK=true; return r.title||r; } |
| catch(e){ console.warn("title backend failed, using stub:",e); return stubTitle(); } |
| } |
| function stubTitle(){ |
| const t=["The Shape of It","Quiet and Loud","What the Shapes Knew","A Story in Forms","Between the Beats"]; |
| return t[Math.floor(Math.random()*t.length)]; |
| } |
| async function apiPaint(compositionPngDataUrl, theme, labels, mode){ |
| |
| try{ const r=await gradioCall("paint",[compositionPngDataUrl, theme, labels, mode]); return r.image||null; } |
| catch(e){ console.warn("paint backend failed:",e); return null; } |
| } |
| function storyText(){ return state.beats.map(b=>b.text).join(" "); } |
| |
| |
| |
| function storyContext(upToExclusive){ |
| const n = upToExclusive!=null ? upToExclusive : state.beats.length; |
| if(n===0) return "(none yet)"; |
| return state.beats.slice(0,n).map((b,i)=>{ |
| const tag = b.hasShape ? "[has shape]" : "[no shape]"; |
| return `${i+1}. ${b.text} ${tag}`; |
| }).join("\n"); |
| } |
| |
| function stubModel(text, story){ |
| const words=text.toLowerCase().split(/[^a-z]+/).filter(Boolean); |
| let v=[],a=[],d=[]; |
| for(const w of words){ if(LEX[w]){v.push(LEX[w][0]);a.push(LEX[w][1]);d.push(LEX[w][2]);} } |
| const avg=arr=>arr.length?arr.reduce((x,y)=>x+y,0)/arr.length:null; |
| let V=avg(v),A=avg(a),D=avg(d); |
| |
| if(V===null){ const ex=(text.match(/!/g)||[]).length; |
| V=0.5; A=clamp(0.3+0.15*ex+words.length*0.01,0,1); D=0.45; } |
| |
| const r=stableUnit(3,text); V=clamp(V+(r[0]-0.5)*0.08,0,1); |
| A=clamp(A+(r[1]-0.5)*0.08,0,1); D=clamp(D+(r[2]-0.5)*0.08,0,1); |
| |
| const intensity=Math.abs(V-0.5)+Math.abs(A-0.4)+Math.abs(D-0.5); |
| const deserves = intensity>0.35; |
| const label=words.filter(w=>w.length>3).slice(0,2).join(" ")||"a beat"; |
| return {valence:V,arousal:A,dominance:D,deserves_shape:deserves, |
| label, comment:VOICE_REACT[Math.floor(r[2]*VOICE_REACT.length)], material:"paper"}; |
| } |
| function stubContinue(story){ |
| const conts=[ |
| "And then the light shifted, and nothing was where it had been.", |
| "Far off, something answered. The sound did not stop.", |
| "The quiet did not last. A shape moved at the edge of the room, and then another.", |
| "What came next, no one had planned for.", |
| "A door opened that should have stayed shut, and cold air poured through." |
| ]; |
| return conts[Math.floor(Math.random()*conts.length)]; |
| } |
| |
| |
| |
| |
| const DEFAULT_WARMUP=1; |
| const MIN_BEATS=3; |
| const MAX_BEATS=10; |
| const MIN_SHAPES=2; |
| const SHAPES_DIFF=1; |
| let USE_SEGMENTS = true; |
| |
| |
| let cfg={ beats:6, shapes:4, companions:true }; |
| function shapesMaxFor(n){ return Math.max(MIN_SHAPES, n-SHAPES_DIFF); } |
| function shapesLockedFor(n){ return shapesMaxFor(n)===MIN_SHAPES; } |
| let N_BEATS=cfg.beats, BUDGET_MAX=cfg.shapes; |
| let state={beats:[],shapes:[],shapesLeft:BUDGET_MAX,pending:null}; |
| |
| const $=id=>document.getElementById(id); |
| function setProgress(){ $("progress").textContent= |
| `beat ${state.beats.length} of ${N_BEATS} · shapes ${state.shapes.length}`; } |
| |
| function setGearState(progressing=null){ |
| if(progressing == null) progressing = state.beats.length>0; |
| const g=$("gear"); |
| g.disabled = progressing; |
| g.title = progressing ? "Finish or reset your story to change settings" : "Settings"; |
| |
| const inReveal = $("scene").classList.contains("show"); |
| $("midReset").style.display = (progressing && !inReveal) ? "block" : "none"; |
| } |
| |
| function addBeat(text,isAI){ |
| state.beats.push({text,isAI,hasShape:false}); |
| const el=document.createElement("div"); |
| el.className="beat"+(isAI?" ai":""); |
| el.innerHTML=`<div class="n">${String(state.beats.length).padStart(2,"0")}</div><div class="txt">${text}</div>`; |
| $("story").appendChild(el); |
| setProgress(); |
| setGearState(); |
| } |
| function showVoice(t){ const e=$("comment"); e.innerHTML=t; e.classList.add("show"); } |
| function thinking(){ const e=$("comment"); e.innerHTML='<span class="thinking">…feeling that…</span>'; e.classList.add("show"); } |
| function pondering(){ const e=$("comment"); e.innerHTML='<span class="thinking">…imagining the next story beat…</span>'; e.classList.add("show"); } |
| function arranging(){ const e=$("comment"); e.innerHTML='<span class="thinking">…finishing up…</span>'; e.classList.add("show"); } |
| |
| function shapeSVG(affect,key,greyscale){ |
| const g=affectToGeometry(affect.valence,affect.arousal,affect.dominance); |
| const path=geomToPath(g,key); |
| const col=greyscale?"#8A8F9C":affectToColor(affect.valence,affect.arousal,affect.dominance); |
| return {svg:`<svg viewBox="0 0 1000 1000"><path d="${path}" fill="${col}" stroke="rgba(255,255,255,.25)" stroke-width="3"/></svg>`, |
| geom:g, color:affectToColor(affect.valence,affect.arousal,affect.dominance)}; |
| } |
| function shapeSVGSegment(affect, key, greyscale, segments, segWeights){ |
| let path; |
| if(segments && segments.length >= 2){ |
| |
| const geoms = segments.map(s=>affectToGeometry(s.valence,s.arousal,s.dominance)); |
| path = geomToPathSegmented(geoms, segWeights||segments.map(s=>s.words||1), key); |
| } else { |
| const g=affectToGeometry(affect.valence,affect.arousal,affect.dominance); |
| path = geomToPath(g,key); |
| } |
| const col=greyscale?"#8A8F9C":affectToColor(affect.valence,affect.arousal,affect.dominance); |
| return {svg:`<svg viewBox="0 0 1000 1000"><path d="${path}" fill="${col}" stroke="rgba(255,255,255,.25)" stroke-width="3"/></svg>`, |
| geom:null, color:affectToColor(affect.valence,affect.arousal,affect.dominance)}; |
| } |
| |
| function offerChoice(text,judg){ |
| const c=$("choice"); c.innerHTML=""; c.classList.add("show"); |
| Sound.tick(); |
| $("send").disabled=true; $("pen").disabled=true; |
| |
| const segs = judg && judg.segments; |
| const segWts = segs ? segs.map(s=>s.words||1) : null; |
| |
| |
| let affect; |
| if(Number.isFinite(judg.valence) && Number.isFinite(judg.arousal) && Number.isFinite(judg.dominance)){ |
| affect = {valence:judg.valence, arousal:judg.arousal, dominance:judg.dominance}; |
| } else if(segs && segs.length){ |
| const tot = segWts.reduce((a,b)=>a+b,0) || segs.length; |
| affect = { |
| valence: segs.reduce((a,s,i)=>a+s.valence *(segWts[i]||1),0)/tot, |
| arousal: segs.reduce((a,s,i)=>a+s.arousal *(segWts[i]||1),0)/tot, |
| dominance: segs.reduce((a,s,i)=>a+s.dominance*(segWts[i]||1),0)/tot, |
| }; |
| } else { |
| affect = {valence:0.5, arousal:0.4, dominance:0.5}; |
| } |
| |
| const own = shapeSVGSegment(affect, text+"own", true, segs, segWts); |
| |
| const ess={valence:clamp(affect.valence+(affect.valence-0.5)*0.5,0,1), |
| arousal:clamp(affect.arousal+(affect.arousal-0.4)*0.5,0,1), |
| dominance:affect.dominance}; |
| const essr=shapeSVG(ess,text+"ess",true); |
| const mk=(title,obj,aff,comment)=>{ |
| const card=document.createElement("div"); card.className="card"; |
| card.innerHTML=obj.svg+`<div class="cap">${title}</div>`; |
| card.onclick=()=>commit(aff,text,comment); return card; |
| }; |
| c.appendChild(mk("the essence",essr,ess,judg.comment)); |
| c.appendChild(mk("your beat",own,affect,judg.comment)); |
| const ff=document.createElement("div"); ff.className="card freeform"; |
| ff.innerHTML="<div>✦</div><div style='margin-top:8px'>freeform<br>(surprise me)</div>"; |
| ff.onclick=()=>{ const ra={valence:Math.random(),arousal:Math.random(),dominance:Math.random()}; |
| commit(ra,text,judg.comment); }; |
| c.appendChild(ff); |
| } |
| function commit(affect,text,comment){ |
| flog("commit", state.pending?state.pending.label:"a beat", affect); |
| |
| if(state.pendingBeatIdx!=null && state.beats[state.pendingBeatIdx]){ |
| state.beats[state.pendingBeatIdx].hasShape=true; |
| } |
| state.shapes.push({ |
| affect, key:text+state.shapes.length, |
| label:state.pending?state.pending.label:"a beat", |
| |
| |
| beatText: (state.pendingBeatIdx!=null && state.beats[state.pendingBeatIdx]) |
| ? state.beats[state.pendingBeatIdx].text : text, |
| isAI:false, |
| fromAIBeat: !!state.pendingIsAI, |
| segments: state.pending?._segments||null, |
| segWeights: state.pending?._segWeights||null, |
| material: state.pending?.material || "paper", |
| }); |
| state.shapesLeft--; |
| $("choice").classList.remove("show"); $("choice").innerHTML=""; |
| state.pending=null; state.pendingBeatIdx=null; |
| $("send").disabled=false; $("pen").disabled=false; |
| setProgress(); |
| showVoice(comment); |
| if(state.beats.length>=N_BEATS) reveal(); |
| } |
| |
| async function handleInput(text,isAI){ |
| if(!text.trim()) return; |
| |
| if(!isAI){ |
| const sentences = text.trim().split(/[.!?]+(?:\s|$)/).filter(s=>s.trim().length); |
| if(sentences.length > 3){ |
| $("hint").textContent = "Try to keep each beat to 1–3 sentences."; |
| $("hint").style.color = "var(--accent)"; |
| return; |
| } |
| $("hint").textContent = "A story unfolds one beat at a time. Some beats will become shapes."; |
| $("hint").style.color = ""; |
| } |
| $("input").value=""; $("input").disabled=true; |
| $("send").disabled=true; $("pen").disabled=true; |
| addBeat(text,isAI); |
| thinking(); |
| |
| |
| const beatIdx = state.beats.length - 1; |
| const priorCtx = storyContext(beatIdx); |
| const pacing = { |
| shapes_placed: state.shapes.length, |
| shapes_left: state.shapesLeft, |
| beats_left: N_BEATS - state.beats.length |
| }; |
| const warmup = Math.max(0, Math.min(DEFAULT_WARMUP, N_BEATS - BUDGET_MAX)); |
| const inWarmup = state.beats.length <= warmup; |
| let judg; |
| if(inWarmup || state.shapesLeft<=0){ |
| judg = await apiJudgeBeat(text, priorCtx, "exploration"); |
| judg.deserves_shape = false; |
| flog("beat", isAI?"(AI)":"(user)", text, "| warmup/no-budget → no shape"); |
| } else if(USE_SEGMENTS){ |
| const sjudg = await apiJudgeBeatSegmented(text, priorCtx, pacing); |
| if(sjudg && sjudg.segments && sjudg.segments.length >= 2){ |
| judg = sjudg; |
| flog("beat", "segmented →", sjudg.segments.map(s=>s.phrase), sjudg.deserves_shape); |
| } else { |
| judg = await apiJudgeBeat(text, priorCtx, "exploration", pacing); |
| flog("beat", "segmented fallback → single", judg); |
| } |
| } else { |
| judg = await apiJudgeBeat(text, priorCtx, "exploration", pacing); |
| flog("beat", isAI?"(AI)":"(user)", text, "→", judg, "| backend:", BACKEND_OK?"on":"STUB"); |
| } |
| const canShape = !inWarmup && state.shapesLeft>0 |
| && (judg.deserves_shape || (N_BEATS-state.beats.length)<=state.shapesLeft); |
| if(canShape){ |
| state.pending=judg; |
| state.pendingBeatIdx=beatIdx; |
| state.pendingIsAI=isAI; |
| state.pending._segments = judg.segments||null; |
| state.pending._segWeights = judg.segments ? judg.segments.map(s=>s.words||1) : null; |
| showVoice(VOICE_SHAPE[Math.floor(Math.random()*VOICE_SHAPE.length)]); |
| offerChoice(text,judg); |
| } else { |
| showVoice(judg.comment); |
| if(state.beats.length>=N_BEATS) reveal(); |
| } |
| if(!state.pending){ $("send").disabled=false; $("pen").disabled=false; } |
| $("input").disabled=false; $("input").focus(); |
| } |
| |
| |
| const ANCHORS={ "upper-left":[.28,.74],"upper":[.5,.78],"upper-right":[.72,.74], |
| "left":[.24,.5],"center":[.5,.5],"right":[.76,.5], |
| "lower-left":[.28,.26],"lower":[.5,.22],"lower-right":[.72,.26] }; |
| const REGIONS=Object.keys(ANCHORS); |
| |
| |
| |
| |
| |
| |
| |
| |
| function revealModel(shapes, beats){ |
| const n=shapes.length; |
| const avg=k=>shapes.reduce((s,x)=>s+x.affect[k],0)/n; |
| const aV=avg("valence"),aA=avg("arousal"),aD=avg("dominance"); |
| |
| const companions = cfg.companions ? [{ |
| affect:{ valence:clamp(1-aV,0,1), arousal:clamp(0.5-(aA-0.5),0,1), dominance:clamp(1-aD,0,1) }, |
| label: aA>0.5 ? "a quiet after" : "a stirring beneath" |
| }] : []; |
| |
| |
| const all=[...shapes, ...companions]; |
| const layout=all.map((s,i)=>{ |
| const d=s.affect.dominance, a=s.affect.arousal; |
| |
| let region; |
| if(d>0.7) region = a>0.6 ? "upper" : "center"; |
| else if(d<0.35) region = ["lower-left","lower","lower-right"][i%3]; |
| else region = ["left","center","right","upper-left","upper-right"][i%5]; |
| return { |
| ref: i<n ? `existing:${i}` : `companion:${i-n}`, |
| region, |
| scale: clamp(0.35+0.55*d,0,1), |
| rotation: Math.round((stableUnit(1,(s.key||s.label)+"rot")[0]-0.5)*40), |
| depth: i |
| }; |
| }); |
| return { coherence:0.85, companions, layout }; |
| } |
| |
| const DELAY_DURATION = 1000 |
| const INTERVAL_DURATION = 700 |
| async function reveal(){ |
| if(state.revealed) return; |
| state.revealed=true; |
| Sound.resume(); |
| arranging() |
| $("inputrow").style.display="none"; $("hint").style.display="none"; |
| |
| $("choice").classList.remove("show"); |
| |
| |
| let rv = null; |
| if(cfg.companions || true){ |
| const labels = state.shapes.map(s=>s.label); |
| const br = await apiReveal(storyText(), labels); |
| if(br){ |
| |
| const companions = cfg.companions ? (br.companions||[]).map(c=>{ |
| const a = c.affect || c; |
| return { affect:{ valence:+a.valence, arousal:+a.arousal, dominance:+a.dominance }, label:c.label||"a beat" }; |
| }).filter(c=>Number.isFinite(c.affect.valence)) : []; |
| rv = { coherence: br.coherence ?? 0.85, companions, layout: br.layout||[] }; |
| } |
| } |
| if(!rv) rv = revealModel(state.shapes, state.beats); |
| flog("reveal", "source:", BACKEND_OK?"backend":"stub", "| coherence:", rv.coherence, |
| "| companions:", rv.companions.map(c=>c.label), "| layout:", rv.layout); |
| |
| |
| rv.companions.forEach((c,k)=>{ |
| state.shapes.push({ affect:c.affect, key:"companion"+k+c.label, label:c.label, isCompanion:true, material: c.material || "glass" }); |
| }); |
| |
| |
| const coh=rv.coherence, spread=1+(1-coh)*0.6; |
| rv.layout.forEach((role,i)=>{ |
| const s=state.shapes[i]; if(!s) return; |
| s.region=role.region; |
| s.scaleRole=role.scale; |
| s.rot=role.rotation; |
| s.depth=role.depth; |
| const a=ANCHORS[role.region]||[0.5,0.5]; |
| s.cx=clamp(0.5+(a[0]-0.5)*spread,0.14,0.86); |
| s.cy=clamp(0.5+(a[1]-0.5)*spread,0.14,0.86); |
| s.rad=0.16+0.13*s.scaleRole; |
| }); |
| |
| state.shapes.forEach((s,i)=>{ if(s.cx===undefined){ |
| s.region="center"; s.scaleRole=0.5; s.rot=0; s.depth=i; |
| s.cx=0.5; s.cy=0.5; s.rad=0.22; } }); |
| |
| enforceVisibility(); |
| |
| |
| drawScene({greyscale:true}); |
| buildLegend(); |
| $("comment").classList.remove("show"); |
| $("scene").classList.add("show"); |
| setGearState(); |
| |
| requestAnimationFrame(() => |
| document.querySelector(".canvas-wrap").scrollIntoView({ behavior: "smooth", block: "start" }) |
| ); |
| |
| bloomColors(DELAY_DURATION, INTERVAL_DURATION); |
| } |
| |
| function shapePolygon(s){ |
| const d = s.segments && s.segments.length>=2 |
| ? geomToPathSegmented( |
| s.segments.map(sg=>affectToGeometry(sg.valence,sg.arousal,sg.dominance)), |
| s.segWeights||s.segments.map(sg=>sg.words||1), |
| s.key) |
| : geomToPath(affectToGeometry(s.affect.valence,s.affect.arousal,s.affect.dominance), s.key); |
| |
| |
| const nums=d.replace(/[A-Za-z]/g," ").trim().split(/\s+/).map(Number).filter(v=>!isNaN(v)); |
| const pts=[]; |
| if(nums.length>=2){ |
| pts.push([nums[0],nums[1]]); |
| for(let i=2;i+5<nums.length;i+=6) pts.push([nums[i+4],nums[i+5]]); |
| |
| if(pts.length>1){ const a=pts[0],b=pts[pts.length-1]; |
| if(Math.abs(a[0]-b[0])<0.05 && Math.abs(a[1]-b[1])<0.05) pts.pop(); } |
| } |
| const th=(s.rot||0)*Math.PI/180, c=Math.cos(th), sn=Math.sin(th); |
| const sc=(s.rad/0.3); |
| return pts.map(([x,y])=>{ |
| let lx=(x-500)/1000, ly=(y-500)/1000; |
| let rx=lx*c-ly*sn, ry=lx*sn+ly*c; |
| return [(s.cx+rx*sc)*1000,(s.cy+ry*sc)*1000]; |
| }); |
| } |
| function polyArea(p){let a=0;for(let i=0;i<p.length;i++){const j=(i+1)%p.length;a+=p[i][0]*p[j][1]-p[j][0]*p[i][1];}return Math.abs(a)/2;} |
| function pointIn(poly,x,y){let i,j,c=false;for(i=0,j=poly.length-1;i<poly.length;j=i++){ |
| if(((poly[i][1]>y)!=(poly[j][1]>y))&&(x<(poly[j][0]-poly[i][0])*(y-poly[i][1])/(poly[j][1]-poly[i][1])+poly[i][0]))c=!c;}return c;} |
| function visFraction(idx,polys){ |
| const me=polys[idx]; let inside=0,vis=0; |
| const xs=me.map(p=>p[0]),ys=me.map(p=>p[1]); |
| const x0=Math.min(...xs),x1=Math.max(...xs),y0=Math.min(...ys),y1=Math.max(...ys); |
| for(let gx=0;gx<14;gx++)for(let gy=0;gy<14;gy++){ |
| const x=x0+(x1-x0)*(gx+.5)/14, y=y0+(y1-y0)*(gy+.5)/14; |
| if(!pointIn(me,x,y))continue; inside++; |
| let covered=false; |
| for(let j=0;j<polys.length;j++){ if(!state.shapes[j].hidden && state.shapes[j].depth>state.shapes[idx].depth && pointIn(polys[j],x,y)){covered=true;break;} } |
| if(!covered)vis++; |
| } |
| return inside?vis/inside:1; |
| } |
| function enforceVisibility(){ |
| for(let it=0;it<40;it++){ |
| const polys=state.shapes.map(shapePolygon); |
| let worst=-1,worstV=1; |
| state.shapes.forEach((s,i)=>{ if(s.hidden){s._vis=1;return;} const f=visFraction(i,polys); s._vis=f; if(f<worstV){worstV=f;worst=i;}}); |
| if(worstV>=0.20||worst<0)break; |
| const above=state.shapes.filter(s=>s.depth>state.shapes[worst].depth); |
| if(!above.length){state.shapes[worst].depth=Math.max(...state.shapes.map(s=>s.depth))+1;continue;} |
| const ax=above.reduce((a,s)=>a+s.cx,0)/above.length, ay=above.reduce((a,s)=>a+s.cy,0)/above.length; |
| let dx=state.shapes[worst].cx-ax,dy=state.shapes[worst].cy-ay,nrm=Math.hypot(dx,dy)||1; |
| state.shapes[worst].cx=clamp(state.shapes[worst].cx+dx/nrm*0.03,0.12,0.88); |
| state.shapes[worst].cy=clamp(state.shapes[worst].cy+dy/nrm*0.03,0.12,0.88); |
| } |
| } |
| |
| let dragIdx=-1,dragOff=[0,0]; |
| const Tooltip=(()=>{ |
| let el=null, head=null, body=null; |
| function ensure(){ |
| if(el) return; |
| el=document.createElement("div"); el.className="tooltip"; |
| head=document.createElement("div"); head.className="tt-head"; |
| body=document.createElement("div"); body.className="tt-body"; |
| el.appendChild(head); el.appendChild(body); |
| document.body.appendChild(el); |
| } |
| function showFor(s, x, y){ |
| ensure(); |
| if(s.isCompanion){ |
| el.classList.add("companion"); |
| head.textContent="✦ AI added"; |
| head.style.display=""; |
| body.textContent=`${s.label} — added to complete the scene`; |
| body.style.display=""; |
| } else { |
| const beat=s.beatText||""; |
| if(!beat && !s.label) return; |
| el.classList.remove("companion"); |
| head.textContent=s.label||""; |
| head.style.display=s.label?"":"none"; |
| body.textContent=beat; |
| body.style.display=beat?"":"none"; |
| } |
| move(x,y); |
| el.classList.add("show"); |
| } |
| function move(x,y){ |
| if(!el) return; |
| const pad=14, w=el.offsetWidth, h=el.offsetHeight; |
| let nx=x+pad, ny=y+pad; |
| if(nx+w>window.innerWidth-8) nx=x-w-pad; |
| if(ny+h>window.innerHeight-8) ny=y-h-pad; |
| el.style.left=Math.max(8,nx)+"px"; |
| el.style.top =Math.max(8,ny)+"px"; |
| } |
| function hide(){ if(el) el.classList.remove("show"); } |
| return { showFor, move, hide }; |
| })(); |
| |
| function drawScene(opts={}){ |
| const greyscale = opts.greyscale || false; |
| const svg=$("sceneSvg"); svg.innerHTML=""; |
| |
| const defsEl=document.createElementNS("http://www.w3.org/2000/svg","defs"); |
| defsEl.innerHTML=MAT_FILTER_SVG; |
| svg.appendChild(defsEl); |
| const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[a].depth-state.shapes[b].depth); |
| for(const i of order){ |
| const s=state.shapes[i]; |
| if(s.hidden) continue; |
| const col = greyscale ? "#8A8F9C" |
| : matColor(affectToColor(s.affect.valence,s.affect.arousal,s.affect.dominance), s.material); |
| const ms = matStyle(s.material || "paper"); |
| |
| const poly=shapePolygon(s); |
| |
| |
| |
| const spk=affectToGeometry(s.affect.valence,s.affect.arousal,s.affect.dominance).spikiness; |
| const pathStr=ringToPath(poly, spk); |
| |
| const el=document.createElementNS("http://www.w3.org/2000/svg","path"); |
| el.setAttribute("d",pathStr); |
| el.setAttribute("fill",col); |
| el.setAttribute("fill-opacity", ms.opacity); |
| el.setAttribute("stroke", ms.stroke); |
| el.setAttribute("stroke-width", String(ms.sw)); |
| |
| el.setAttribute("data-stroke", ms.stroke); |
| el.setAttribute("data-sw", String(ms.sw)); |
| if(ms.filter) el.setAttribute("filter", ms.filter); |
| el.setAttribute("data-i",i); el.style.cursor="grab"; |
| el.addEventListener("pointerdown",startDrag); |
| el.addEventListener("pointerenter",e=>{ highlight(i,true); Tooltip.showFor(s,e.clientX,e.clientY); }); |
| el.addEventListener("pointermove",e=>Tooltip.move(e.clientX,e.clientY)); |
| el.addEventListener("pointerleave",()=>{ highlight(i,false); Tooltip.hide(); }); |
| el.addEventListener("pointercancel",()=>Tooltip.hide()); |
| svg.appendChild(el); |
| } |
| } |
| |
| |
| |
| |
| |
| |
| |
| |
| const Sound=(()=>{ |
| let ctx=null, master=null; |
| let on=(localStorage.getItem("soundOn")??"1")==="1"; |
| |
| const SCALE=[220,261.63,293.66,329.63,392,440,523.25,587.33,659.25,784,880]; |
| function ensure(){ |
| if(ctx||typeof window==="undefined") return; |
| try{ |
| ctx=new (window.AudioContext||window.webkitAudioContext)(); |
| master=ctx.createGain(); master.gain.value=0.16; master.connect(ctx.destination); |
| }catch(e){ ctx=null; } |
| } |
| function resume(){ ensure(); if(ctx&&ctx.state==="suspended") ctx.resume(); } |
| function note(v){ const i=Math.round((v||0)*(SCALE.length-1)); |
| return SCALE[Math.max(0,Math.min(SCALE.length-1,i))]; } |
| |
| function chime(f, arousal=0.4){ |
| if(!on) return; ensure(); if(!ctx) return; |
| if(ctx.state==="suspended") ctx.resume(); |
| const t=ctx.currentTime; |
| const g=ctx.createGain(); |
| g.gain.setValueAtTime(0.0001,t); |
| g.gain.exponentialRampToValueAtTime(0.9,t+0.012); |
| g.gain.exponentialRampToValueAtTime(0.0001,t+1.6); |
| const lp=ctx.createBiquadFilter(); lp.type="lowpass"; |
| lp.frequency.value=1200+2600*(arousal||0); |
| g.connect(lp); lp.connect(master); |
| const o1=ctx.createOscillator(); o1.type="sine"; o1.frequency.value=f; o1.connect(g); |
| const o2=ctx.createOscillator(); o2.type="sine"; o2.frequency.value=f*2.005; |
| const g2=ctx.createGain(); g2.gain.value=0.22; o2.connect(g2); g2.connect(g); |
| o1.start(t); o2.start(t); o1.stop(t+1.7); o2.stop(t+1.7); |
| } |
| |
| |
| function tick(){ |
| if(!on) return; ensure(); if(!ctx) return; |
| if(ctx.state==="suspended") ctx.resume(); |
| const t=ctx.currentTime; |
| const g=ctx.createGain(); |
| g.gain.setValueAtTime(0.0001,t); |
| g.gain.exponentialRampToValueAtTime(0.4,t+0.008); |
| g.gain.exponentialRampToValueAtTime(0.0001,t+0.45); |
| const lp=ctx.createBiquadFilter(); lp.type="lowpass"; lp.frequency.value=1800; |
| g.connect(lp); lp.connect(master); |
| const o=ctx.createOscillator(); o.type="sine"; o.frequency.value=523.25; o.connect(g); |
| o.start(t); o.stop(t+0.5); |
| } |
| |
| function pad(dur=4){ |
| if(!on) return; ensure(); if(!ctx) return; |
| if(ctx.state==="suspended") ctx.resume(); |
| const t=ctx.currentTime, hold=Math.max(1.4,dur-1.4); |
| const g=ctx.createGain(); |
| g.gain.setValueAtTime(0.0001,t); |
| g.gain.exponentialRampToValueAtTime(0.32,t+1.4); |
| g.gain.setValueAtTime(0.32,t+hold); |
| g.gain.exponentialRampToValueAtTime(0.0001,t+dur); |
| const lp=ctx.createBiquadFilter(); lp.type="lowpass"; |
| lp.frequency.setValueAtTime(380,t); |
| lp.frequency.linearRampToValueAtTime(900,t+dur); |
| g.connect(lp); lp.connect(master); |
| [[110,0.6],[110*1.5,0.3],[110*2.005,0.25]].forEach(([f,v])=>{ |
| const o=ctx.createOscillator(); o.type="sine"; o.frequency.value=f; |
| const og=ctx.createGain(); og.gain.value=v; o.connect(og); og.connect(g); |
| o.start(t); o.stop(t+dur+0.1); |
| }); |
| } |
| function toggle(){ on=!on; localStorage.setItem("soundOn",on?"1":"0"); if(on) resume(); return on; } |
| return { chime, tick, pad, note, resume, toggle, isOn:()=>on }; |
| })(); |
| |
| function bloomColors(delay=400, interval=600){ |
| |
| const n=state.shapes.length; |
| |
| const visible=state.shapes.filter(s=>!s.hidden).length; |
| |
| Sound.pad(delay/1000 + visible*interval/1000 + 1); |
| state.shapes.forEach((s,i)=>{ |
| if(s.hidden) return; |
| const realCol=matColor(affectToColor(s.affect.valence,s.affect.arousal,s.affect.dominance), s.material); |
| setTimeout(()=>{ |
| const svg=$("sceneSvg"); |
| const el=[...svg.children].find(p=>{ |
| const di=p.getAttribute("data-i"); |
| return di!==null && di!=="" && Number(di)===i; |
| }); |
| if(!el) return; |
| el.classList.add("blooming"); |
| |
| el.getBoundingClientRect(); |
| el.setAttribute("fill", realCol); |
| |
| Sound.chime(Sound.note(s.affect.valence), s.affect.arousal); |
| }, delay + i * interval); |
| }); |
| } |
| function highlight(i,on){ |
| const svg=$("sceneSvg"); |
| [...svg.children].forEach(p=>{ if(+p.getAttribute("data-i")===i){ |
| p.setAttribute("stroke",on?"#FFFFFF":(p.getAttribute("data-stroke")||"rgba(255,255,255,.25)")); |
| p.setAttribute("stroke-width",on?"7":(p.getAttribute("data-sw")||"3")); |
| |
| p.style.filter=on?"drop-shadow(0 0 12px rgba(255,255,255,.5))":""; }}); |
| [...document.querySelectorAll(".chip")].forEach(ch=>{ |
| if(+ch.getAttribute("data-i")===i) ch.style.borderColor=on?"#fff":"";}); |
| } |
| function startDrag(e){ dragIdx=+e.target.getAttribute("data-i"); |
| const r=$("sceneSvg").getBoundingClientRect(); |
| dragOff=[ (e.clientX-r.left)/r.width-state.shapes[dragIdx].cx, |
| (e.clientY-r.top)/r.height-state.shapes[dragIdx].cy ]; |
| e.target.setPointerCapture(e.pointerId); e.target.style.cursor="grabbing"; } |
| $("sceneSvg")&&$("sceneSvg").addEventListener("pointermove",e=>{ |
| if(dragIdx<0)return; const r=$("sceneSvg").getBoundingClientRect(); |
| state.shapes[dragIdx].cx=clamp((e.clientX-r.left)/r.width-dragOff[0],0.1,0.9); |
| state.shapes[dragIdx].cy=clamp((e.clientY-r.top)/r.height-dragOff[1],0.1,0.9); |
| drawScene(); }); |
| window.addEventListener("pointerup",()=>{dragIdx=-1; Tooltip.hide();}); |
| |
| function normalizeDepths(){ |
| |
| const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[a].depth-state.shapes[b].depth); |
| order.forEach((idx,rank)=>{ state.shapes[idx].depth=rank; }); |
| } |
| function moveLayer(i,dir){ |
| |
| normalizeDepths(); |
| const cur=state.shapes[i].depth; |
| const tgt=cur+dir; |
| if(tgt<0||tgt>state.shapes.length-1) return; |
| const other=state.shapes.findIndex(s=>s.depth===tgt); |
| state.shapes[other].depth=cur; state.shapes[i].depth=tgt; |
| drawScene(); buildLegend(); |
| } |
| function buildLegend(){ |
| const L=$("legend"); L.innerHTML=""; |
| const head=document.createElement("div"); |
| head.className="legend-head"; head.textContent="Layers — front at top"; |
| L.appendChild(head); |
| |
| const order=[...state.shapes.keys()].sort((a,b)=>state.shapes[b].depth-state.shapes[a].depth); |
| order.forEach((i,rank)=>{ |
| const s=state.shapes[i]; |
| const col=affectToColor(s.affect.valence,s.affect.arousal,s.affect.dominance); |
| const chip=document.createElement("div"); |
| |
| chip.setAttribute("data-i",i); |
| const tag = s.isCompanion ? ` <span class="tag">✦ AI added</span>` |
| : s.fromAIBeat ? ` <span class="tag" style="color:var(--mist-2)">from AI's turn</span>` |
| : ""; |
| chip.className="chip"+(s.isCompanion?" ai":""); |
| const atFront=rank===0, atBack=rank===order.length-1; |
| chip.innerHTML= |
| `<span class="dot" style="background:${col}"></span>` |
| +`<span class="chip-label">${s.label}</span>` |
| +tag |
| +`<span class="layerbtns">` |
| +`<button class="lb eye" data-eye="${i}" title="${s.hidden?'Show':'Hide'} shape">${s.hidden?'🚫':'👁'}</button>` |
| +`<button class="lb rot" data-rotl="${i}" title="Rotate left 15°">↺</button>` |
| +`<button class="lb rot" data-rotr="${i}" title="Rotate right 15°">↻</button>` |
| +`<button class="lb" data-up="${i}" ${atFront||s.hidden?"disabled":""} title="Bring forward">▲</button>` |
| +`<button class="lb" data-down="${i}" ${atBack||s.hidden?"disabled":""} title="Send back">▼</button>` |
| +`</span>`; |
| if(s.hidden) chip.classList.add("hidden-shape"); |
| chip.addEventListener("pointerenter",()=>highlight(i,true)); |
| chip.addEventListener("pointerleave",()=>highlight(i,false)); |
| |
| |
| const lbl=chip.querySelector(".chip-label"); lbl.style.cursor="help"; |
| lbl.addEventListener("pointerenter",e=>Tooltip.showFor(s,e.clientX,e.clientY)); |
| lbl.addEventListener("pointermove",e=>Tooltip.move(e.clientX,e.clientY)); |
| lbl.addEventListener("pointerleave",()=>Tooltip.hide()); |
| L.appendChild(chip); |
| }); |
| |
| L.querySelectorAll(".lb[data-up]").forEach(b=>b.onclick=e=>{e.stopPropagation();moveLayer(+b.getAttribute("data-up"),+1);}); |
| L.querySelectorAll(".lb[data-down]").forEach(b=>b.onclick=e=>{e.stopPropagation();moveLayer(+b.getAttribute("data-down"),-1);}); |
| L.querySelectorAll(".lb[data-eye]").forEach(b=>b.onclick=e=>{e.stopPropagation();toggleHidden(+b.getAttribute("data-eye"));}); |
| |
| L.querySelectorAll(".lb[data-rotl]").forEach(b=>b.onclick=e=>{ |
| e.stopPropagation(); |
| const i=+b.getAttribute("data-rotl"); |
| state.shapes[i].rot=(state.shapes[i].rot||0)-15; |
| drawScene(); |
| }); |
| L.querySelectorAll(".lb[data-rotr]").forEach(b=>b.onclick=e=>{ |
| e.stopPropagation(); |
| const i=+b.getAttribute("data-rotr"); |
| state.shapes[i].rot=(state.shapes[i].rot||0)+15; |
| drawScene(); |
| }); |
| } |
| function toggleHidden(i){ |
| state.shapes[i].hidden=!state.shapes[i].hidden; |
| drawScene(); buildLegend(); |
| } |
| let savedLayout=null; |
| let _cachedTitle=null; |
| $("reset").onclick=()=>{ if(savedLayout) state.shapes.forEach((s,i)=>{s.cx=savedLayout[i].cx;s.cy=savedLayout[i].cy;s.depth=savedLayout[i].depth;}); drawScene(); buildLegend(); }; |
| function doRestart(){ |
| N_BEATS=cfg.beats; BUDGET_MAX=cfg.shapes; |
| state={beats:[],shapes:[],shapesLeft:BUDGET_MAX,pending:null}; |
| savedLayout=null; |
| $("story").innerHTML=""; $("choice").classList.remove("show"); $("choice").innerHTML=""; |
| $("comment").classList.remove("show"); $("comment").innerHTML=""; |
| $("scene").classList.remove("show"); $("legend").innerHTML=""; |
| if($("cardResult")) $("cardResult").innerHTML=""; |
| if($("paintResult")) $("paintResult").innerHTML=""; |
| if($("paintCardResult")) $("paintCardResult").innerHTML=""; |
| _cachedTitle=null; |
| $("inputrow").style.display="flex"; $("hint").style.display="block"; |
| $("input").value=""; setProgress(); setGearState(); |
| $("send").disabled=false; $("pen").disabled=false; |
| $("input").disabled=false; $("input").focus(); |
| } |
| $("restart").onclick=doRestart; |
| $("midReset").onclick=()=>{ |
| if(state.beats.length===0 || confirm("Start over? Your current story will be cleared.")) doRestart(); |
| }; |
| |
| |
| function renderSceneToCanvas(cb, onerr, opts){ |
| opts=opts||{}; |
| const src=$("sceneSvg"); |
| const SZE=1000; |
| const clone=src.cloneNode(true); |
| clone.setAttribute("xmlns","http://www.w3.org/2000/svg"); |
| clone.setAttribute("width",SZE); clone.setAttribute("height",SZE); |
| clone.setAttribute("viewBox","0 0 1000 1000"); |
| |
| |
| |
| |
| clone.querySelectorAll("[filter]").forEach(el=>el.removeAttribute("filter")); |
| |
| |
| if(!opts.transparent){ |
| const defs=document.createElementNS("http://www.w3.org/2000/svg","defs"); |
| defs.innerHTML=`<radialGradient id="bg" cx="50%" cy="0%" r="130%"> |
| <stop offset="0%" stop-color="#14171F"/><stop offset="100%" stop-color="#0A0C11"/></radialGradient>`; |
| clone.insertBefore(defs,clone.firstChild); |
| const bg=document.createElementNS("http://www.w3.org/2000/svg","rect"); |
| bg.setAttribute("x",0);bg.setAttribute("y",0);bg.setAttribute("width",1000);bg.setAttribute("height",1000); |
| bg.setAttribute("fill","url(#bg)"); |
| clone.insertBefore(bg,defs.nextSibling); |
| } |
| const svgText=new XMLSerializer().serializeToString(clone); |
| const blob=new Blob([svgText],{type:"image/svg+xml;charset=utf-8"}); |
| const url=URL.createObjectURL(blob); |
| const img=new Image(); |
| img.onload=()=>{ |
| const scale=2; |
| const canvas=document.createElement("canvas"); |
| canvas.width=SZE*scale; canvas.height=SZE*scale; |
| const ctx=canvas.getContext("2d"); |
| ctx.drawImage(img,0,0,canvas.width,canvas.height); |
| bakeMaterials(ctx, canvas.width/SZE); |
| URL.revokeObjectURL(url); |
| cb(canvas); |
| }; |
| img.onerror=()=>{ URL.revokeObjectURL(url); (onerr||(()=>alert("Could not render image in this browser.")))(); }; |
| img.src=url; |
| } |
| |
| |
| $("save").onclick=()=>{ |
| renderSceneToCanvas(canvas=>{ |
| canvas.toBlob(b=>{ |
| const a=document.createElement("a"); |
| a.href=URL.createObjectURL(b); |
| a.download="story-shapes.png"; |
| a.click(); |
| setTimeout(()=>URL.revokeObjectURL(a.href),1000); |
| },"image/png"); |
| }, ()=>alert("Could not export image in this browser. Try a different browser.")); |
| }; |
| |
| |
| function wrapText(ctx, text, maxWidth){ |
| const words=(text||"").split(/\s+/).filter(Boolean); const lines=[]; let line=""; |
| for(const w of words){ const test=line?line+" "+w:w; |
| if(ctx.measureText(test).width>maxWidth && line){ lines.push(line); line=w; } |
| else line=test; } |
| if(line) lines.push(line); return lines; |
| } |
| async function _getTitle(){ |
| if(_cachedTitle) return _cachedTitle; |
| let title; |
| try{ title=await apiTitle(storyText()); }catch(e){ title=stubTitle(); } |
| _cachedTitle=title||"A Story in Shapes"; |
| return _cachedTitle; |
| } |
| |
| function essenceText(){ |
| const labels=state.shapes.filter(s=>!s.hidden).map(s=>s.label).filter(Boolean); |
| return labels.length ? labels.join("; ") : storyText(); |
| } |
| |
| function showCardPopup(dataUrl, filename){ |
| const ov=document.createElement("div"); ov.className="overlay show card-overlay"; |
| const modal=document.createElement("div"); modal.className="card-modal"; |
| const im=new Image(); im.src=dataUrl; im.className="card-img"; im.alt="share card"; |
| const actions=document.createElement("div"); actions.className="card-actions"; |
| const dl=document.createElement("button"); dl.className="primary"; dl.textContent="↓ Save card"; |
| dl.onclick=()=>{ const a=document.createElement("a"); a.href=dataUrl; a.download=filename; a.click(); }; |
| const close=document.createElement("button"); close.textContent="Close"; |
| const done=()=>{ ov.remove(); document.removeEventListener("keydown",onkey); }; |
| close.onclick=done; |
| ov.addEventListener("pointerdown",e=>{ if(e.target===ov) done(); }); |
| function onkey(e){ if(e.key==="Escape") done(); } |
| document.addEventListener("keydown",onkey); |
| actions.appendChild(dl); actions.appendChild(close); |
| modal.appendChild(im); modal.appendChild(actions); |
| ov.appendChild(modal); document.body.appendChild(ov); |
| } |
| function _buildCard(title, artImg, floatArt, statusEl, filename, triggerBtn){ |
| const W=1080, H=1500, m=70; |
| const c=document.createElement("canvas"); c.width=W; c.height=H; |
| const x=c.getContext("2d"); |
| |
| const g=x.createLinearGradient(0,0,0,H); |
| g.addColorStop(0,"#1C2030"); g.addColorStop(0.5,"#11131A"); g.addColorStop(1,"#0C0E14"); |
| x.fillStyle=g; x.fillRect(0,0,W,H); |
| |
| x.textAlign="center"; x.fillStyle="#E8A33D"; |
| x.font="600 64px 'Iowan Old Style','Palatino Linotype',Georgia,serif"; |
| const titleLines=wrapText(x,title,W-2*m); |
| let ty=128; |
| for(const l of titleLines){ x.fillText(l,W/2,ty); ty+=72; } |
| |
| const hy=ty+2; |
| x.strokeStyle="rgba(232,163,61,.55)"; x.lineWidth=2; |
| x.beginPath(); x.moveTo(W/2-60,hy); x.lineTo(W/2+60,hy); x.stroke(); |
| |
| const aw=W-2*m, ax=m, ay=hy+44; |
| if(floatArt){ |
| x.drawImage(artImg,ax,ay,aw,aw); |
| }else{ |
| x.save(); |
| const r=22; const X=ax,Y=ay,Wd=aw,Hd=aw; |
| x.beginPath(); |
| x.moveTo(X+r,Y); x.arcTo(X+Wd,Y,X+Wd,Y+Hd,r); x.arcTo(X+Wd,Y+Hd,X,Y+Hd,r); |
| x.arcTo(X,Y+Hd,X,Y,r); x.arcTo(X,Y,X+Wd,Y,r); x.closePath(); |
| x.clip(); x.drawImage(artImg,X,Y,Wd,Hd); x.restore(); |
| } |
| |
| x.textAlign="center"; x.fillStyle="#9AA3B2"; |
| x.font="italic 30px 'Iowan Old Style','Palatino Linotype',Georgia,serif"; |
| const lines=wrapText(x,essenceText(),W-2*m); |
| let sy=ay+aw+66; const cap=Math.min(lines.length,4); |
| for(let i=0;i<cap;i++){ |
| let line=lines[i]; |
| if(i===cap-1&&lines.length>cap) line=line.replace(/\s*\S*$/,"…"); |
| x.fillText(line,W/2,sy); sy+=44; |
| } |
| |
| x.textAlign="center"; x.fillStyle="#5E6678"; |
| x.font="22px ui-monospace,'SF Mono',Menlo,monospace"; |
| x.fillText("made with The Shape of Words",W/2,H-50); |
| if(statusEl) statusEl.innerHTML=""; |
| if(triggerBtn) triggerBtn.disabled=false; |
| showCardPopup(c.toDataURL("image/png"), filename); |
| } |
| async function buildShareCard(){ |
| const btn=$("cardBtn"), res=$("cardResult"); |
| btn.disabled=true; |
| const alreadyHad=!!_cachedTitle; |
| res.innerHTML=`<div class="painting-wait">${alreadyHad?"composing card…":"naming your story…"}</div>`; |
| const title=await _getTitle(); |
| res.innerHTML=`<div class="painting-wait">composing card…</div>`; |
| renderSceneToCanvas(art=>{ |
| _buildCard(title,art,true,res,"story-shapes-card.png",btn); |
| }, ()=>{ res.innerHTML=`<div class="painting-wait">Could not compose the card.</div>`; btn.disabled=false; }, {transparent:true}); |
| } |
| $("cardBtn").onclick=buildShareCard; |
| |
| function replayReveal(){ |
| if(!state.revealed) return; |
| $("scene").scrollIntoView({behavior:"smooth", block:"start"}); |
| Sound.resume(); |
| drawScene({greyscale:true}); |
| bloomColors(300, INTERVAL_DURATION); |
| } |
| $("replayBtn").onclick=replayReveal; |
| |
| |
| const PAINT_STYLES=["Art Print","Cut Paper","Ink & Wash","Neon Glass","Oil Impasto","Risograph","Dream Poster"]; |
| |
| function doPaint(){ |
| const result=$("paintResult"); |
| |
| result.innerHTML=`<div class="painting-wait">painting…</div>`; |
| $("paintBtn").disabled=true; $("surpriseBtn").disabled=true; |
| renderSceneToCanvas(canvas=>{ |
| const b64 = canvas.toDataURL("image/png"); |
| const theme = $("paintTheme").value || ""; |
| const labels = state.shapes.filter(s=>!s.hidden).map(s=>s.label); |
| const mode = "strong"; |
| apiPaint(b64, theme, labels, mode).then(img=>{ |
| $("paintBtn").disabled=false; $("surpriseBtn").disabled=false; |
| if(!img){ result.innerHTML=`<div class="painting-wait">Painting unavailable — is the image model running?</div>`; return; } |
| result.innerHTML=""; |
| const el=new Image(); |
| |
| el.onload=()=>result.scrollIntoView({behavior:"smooth", block:"center"}); |
| el.src="data:image/png;base64,"+img; |
| result.appendChild(el); |
| const actions=document.createElement("div"); actions.className="painting-actions"; |
| const dl=document.createElement("button"); dl.textContent="↓ Save painting"; |
| dl.onclick=()=>{ const a=document.createElement("a"); a.href=el.src; a.download="story-painting.png"; a.click(); }; |
| actions.appendChild(dl); |
| const paintCardBtn=document.createElement("button"); paintCardBtn.className="primary"; paintCardBtn.textContent="✦ Share card"; |
| paintCardBtn.onclick=async()=>{ |
| paintCardBtn.disabled=true; |
| const pcr=$("paintCardResult"); |
| const alreadyHad=!!_cachedTitle; |
| pcr.innerHTML=`<div class="painting-wait">${alreadyHad?"composing card…":"naming your story…"}</div>`; |
| const title=await _getTitle(); |
| pcr.innerHTML=`<div class="painting-wait">composing card…</div>`; |
| _buildCard(title,el,false,pcr,"story-painting-card.png",paintCardBtn); |
| }; |
| actions.appendChild(paintCardBtn); result.appendChild(actions); |
| }); |
| }, ()=>{ $("paintBtn").disabled=false; $("surpriseBtn").disabled=false; result.innerHTML=`<div class="painting-wait">Could not capture the composition.</div>`; }); |
| } |
| $("paintBtn").onclick=doPaint; |
| $("surpriseBtn").onclick=()=>{ |
| const style=PAINT_STYLES[Math.floor(Math.random()*PAINT_STYLES.length)]; |
| $("paintTheme").value=style; |
| doPaint(); |
| }; |
| |
| $("send").onclick=()=>handleInput($("input").value,false); |
| $("input").addEventListener("keydown",e=>{ if(e.key==="Enter"&&!e.shiftKey){e.preventDefault();handleInput($("input").value,false);} }); |
| $("pen").onclick=async()=>{ |
| setGearState(true) |
| $("input").value=""; $("input").disabled=true; |
| $("send").disabled=true; $("pen").disabled=true; |
| pondering(); |
| const c=await apiContinue(storyText()); |
| await handleInput(c,true); |
| }; |
| |
| |
| let draft={beats:cfg.beats, shapes:cfg.shapes, companions:cfg.companions}; |
| function renderSettings(){ |
| $("beatsVal").textContent=draft.beats; |
| |
| const mx=shapesMaxFor(draft.beats), locked=shapesLockedFor(draft.beats); |
| draft.shapes=clamp(draft.shapes,MIN_SHAPES,mx); |
| $("shapesVal").textContent=draft.shapes; |
| $("beatsRange").textContent=`${MIN_BEATS} to ${MAX_BEATS} beats`; |
| const sr=$("shapesRange"); |
| if(locked){ sr.textContent=`locked at ${MIN_SHAPES} (needs ${MIN_SHAPES+SHAPES_DIFF+1}+ beats for more)`; sr.classList.add("locked"); } |
| else { sr.textContent=`${MIN_SHAPES} to ${mx}`; sr.classList.remove("locked"); } |
| |
| $("beatsDown").disabled=draft.beats<=MIN_BEATS; $("beatsUp").disabled=draft.beats>=MAX_BEATS; |
| $("shapesDown").disabled=draft.shapes<=MIN_SHAPES; $("shapesUp").disabled=draft.shapes>=mx; |
| $("companionToggle").setAttribute("aria-checked", draft.companions ? "true" : "false"); |
| $("soundToggle").setAttribute("aria-checked", Sound.isOn() ? "true" : "false"); |
| } |
| function openSettings(){ draft={beats:cfg.beats,shapes:cfg.shapes,companions:cfg.companions}; renderSettings(); $("overlay").classList.add("show"); } |
| function closeSettings(){ $("overlay").classList.remove("show"); } |
| $("gear").onclick=openSettings; |
| $("settingsCancel").onclick=closeSettings; |
| $("overlay").addEventListener("pointerdown",e=>{ if(e.target===$("overlay")) closeSettings(); }); |
| $("beatsDown").onclick=()=>{ draft.beats=clamp(draft.beats-1,MIN_BEATS,MAX_BEATS); renderSettings(); }; |
| $("beatsUp").onclick =()=>{ draft.beats=clamp(draft.beats+1,MIN_BEATS,MAX_BEATS); renderSettings(); }; |
| $("shapesDown").onclick=()=>{ draft.shapes=clamp(draft.shapes-1,MIN_SHAPES,shapesMaxFor(draft.beats)); renderSettings(); }; |
| $("shapesUp").onclick =()=>{ draft.shapes=clamp(draft.shapes+1,MIN_SHAPES,shapesMaxFor(draft.beats)); renderSettings(); }; |
| $("companionToggle").onclick=()=>{ draft.companions=!draft.companions; renderSettings(); }; |
| |
| $("soundToggle").onclick=()=>{ const on=Sound.toggle(); renderSettings(); |
| if(on) Sound.chime(Sound.note(0.7), 0.4); }; |
| $("settingsApply").onclick=()=>{ |
| cfg.beats=draft.beats; cfg.shapes=clamp(draft.shapes,MIN_SHAPES,shapesMaxFor(draft.beats)); |
| cfg.companions=draft.companions; |
| closeSettings(); |
| if(state.beats.length===0){ |
| N_BEATS=cfg.beats; BUDGET_MAX=cfg.shapes; |
| state={beats:[],shapes:[],shapesLeft:BUDGET_MAX,pending:null}; |
| setProgress(); |
| } |
| }; |
| setProgress(); |
| setGearState(); |
| const _reveal=reveal; |
| reveal=async function(){ await _reveal(); savedLayout=state.shapes.map(s=>({cx:s.cx,cy:s.cy,depth:s.depth})); }; |
| </script> |
|
|