navier-stokes-vortex-webgpu / annotations.mjs
Xenova's picture
Xenova HF Staff
Upload 23 files
7483b04 verified
Raw History Blame Contribute Delete
6.31 kB
import { CORE } from './core-profile.mjs';
import { DEFAULT_H } from './proof-model.mjs';
import { VISUAL_RADIAL_GAIN, axialMap } from './presentation.mjs';
import { storyDiagramCue, storyAnnotationSlot, storyOpeningCaption, storyEditOpacity, storyEnding, storyEquatorialOpacity } from './story.mjs';
import { projectScenePoint, projectWorldPoint, scalePoint, unprojectAtDepth } from './camera.mjs';
// Reference coordinates use the renderer's [x,z,y] convention.
function fieldPoint(X,eta,theta) {
const q=1/(1-eta*eta), r=Math.sqrt(2*X*q)*VISUAL_RADIAL_GAIN*CORE.worldScale;
return [r*Math.cos(theta),axialMap(q**(.5-DEFAULT_H)*eta)*CORE.worldScale,r*Math.sin(theta)];
}
// The two opening mechanisms are shown together until the top view arrives.
function showPairedCallouts(state) {
return state.storyChoice===null ? state.storySeconds<24 : state.storyChoice!==2;
}
// Sampled field anchors and the world-space endpoints their leaders reach.
const OPENING_CALLOUTS=Object.freeze([
{id:'story-axial-note', X:.00002, eta:.10, end:[5.5,10,2]},
{id:'story-inward-note', X:.045, eta:.0008, end:[8.2,-1.8,2]},
]);
// HTML captions occupy stable reading slots. Their leaders have two actual
// world-space endpoints and share the flow depth buffer. The label endpoint
// is projected onto the caption plane, keeping the words steady as the flow turns.
export function createAnnotations(document) {
const $=id=>document.getElementById(id);
const notes=['story-diagram-note','story-axial-note','story-inward-note'].map($);
function placeNote(label,view,start,zone,opacity,reference=[6,8,2]) {
const box=storyAnnotationSlot(view.rect,zone,view.radialBound/view.frame.worldPerPixel,projectWorldPoint(view,[0,0,0]).y);
label.style.width=`${box.width}px`;label.style.left=`${box.x}px`;label.style.top=`${box.y}px`;
label.style.opacity=String(opacity);label.dataset.zone=zone;
const endpoint=box.attach==='top'?{x:box.x+box.width/2,y:box.y-12}:
{x:box.attach==='right'?box.x+box.width+12:box.x-12,y:box.y+16};
const end=unprojectAtDepth(view,endpoint,reference);
return [start,end,opacity];
}
function positionStoryDiagram(state,views){
const view=views.find(v=>v.featured),layer=$('story-diagram-layer');
layer.hidden=!view;if(!view)return [];
const cue=storyDiagramCue(state.storySeconds,state.storyChoice);
const paired=showPairedCallouts(state);
notes[0].hidden=paired;notes[1].hidden=notes[2].hidden=!paired;
const reduced=!!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const fading=state.playing&&state.storyChoice===null&&!reduced;
const opacity=fading?storyEditOpacity(state.storySeconds):1;
const ending=storyEnding(state.storyChoice===null?state.storySeconds:0);
layer.style.opacity=String(ending.annotations);
const conclusion=$('story-ending'),rect=view.rect;
conclusion.hidden=ending.conclusion<=0;
conclusion.style.opacity=String(ending.conclusion);
conclusion.style.width=`${Math.min(530,rect.width*(rect.width>=760?.52:.9))}px`;
conclusion.style.left=`${rect.x+rect.width*(rect.width>=760?.64:.5)}px`;
conclusion.style.top=`${rect.y+rect.height*.5}px`;
const lines=[];
if(paired){
for(const definition of OPENING_CALLOUTS){
const axial=definition.id==='story-axial-note',label=$(definition.id);
const caption=storyOpeningCaption(state.storyChoice===null?state.storySeconds:0,axial);
const body=label.querySelector('p');body.textContent=caption.text;
body.style.opacity=String(fading?storyEditOpacity(state.storySeconds,caption.edits):1);
// Titles and leaders remain present while only the sentence changes.
// The pair leaves together once the top-view explanation arrives.
const alpha=fading?storyEditOpacity(state.storySeconds,[0,24]):1;
const theta=axial?0:view.pose.yaw+Math.PI;
const start=scalePoint(view,fieldPoint(definition.X,definition.eta,theta));
lines.push(placeNote(label,view,start,axial?'upper-right':'inflow',alpha,definition.end));
}
}else{
const theta=cue.zone==='rotation'?-Math.PI/2+.45:cue.radiusGuide?view.pose.yaw-.55:view.pose.yaw;
const start=scalePoint(view,fieldPoint(cue.X,cue.eta,theta));
lines.push(placeNote(notes[0],view,start,cue.zone,opacity));
}
const path=points=>points.map((p,i)=>`${i?'L':'M'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`).join(' ');
const point=(X,eta,theta=0)=>projectScenePoint(view,fieldPoint(X,eta,theta));
// The rotation arrow sits just outside the visible circle and follows
// increasing physical theta. It is not hidden inside the dense ribbons.
const top=storyEquatorialOpacity(state.storySeconds,state.storyChoice,view.pose.top);
let direction='',width='';
if(top>.001){
const radius=view.radialBound*1.10;
direction=path(Array.from({length:37},(_,i)=>{
const theta=.25+i*.045;
return projectWorldPoint(view,[radius*Math.cos(theta),0,radius*Math.sin(theta)]);
}));
}
if(cue.radiusGuide){
// A fixed similarity radius, just below the central layer. The leader
// starts at this same endpoint, clear of the coordinate-axis guides.
const a=point(0,cue.eta),b=point(cue.X,cue.eta,view.pose.yaw-.55);
const length=Math.hypot(b.x-a.x,b.y-a.y),nx=-(b.y-a.y)/Math.max(length,1),ny=(b.x-a.x)/Math.max(length,1);
width=path([a,b])+' '+path([{x:a.x-6*nx,y:a.y-6*ny},{x:a.x+6*nx,y:a.y+6*ny}])+' '+path([{x:b.x-6*nx,y:b.y-6*ny},{x:b.x+6*nx,y:b.y+6*ny}]);
}
const arrow=$('story-direction-guide'),radial=$('story-width-guide');
arrow.setAttribute('d',direction);arrow.style.opacity=String(top);
radial.setAttribute('d',width);radial.style.opacity=String(opacity);
const plane=$('story-plane-label');
plane.hidden=top<.001;plane.style.opacity=String(top);
const center=projectWorldPoint(view,[0,0,0]);
plane.style.left=`${Math.min(center.x+24,rect.x+rect.width-(plane.offsetWidth||220)-12)}px`;
plane.style.top=`${center.y-1.10*view.radialBound/view.frame.worldPerPixel-58}px`;
// WebGPU leaders do not inherit the DOM layer's opacity.
return lines.map(([a,b,alpha])=>[a,b,alpha*ending.annotations]);
}
return {update:positionStoryDiagram};
}