1(()=>{ 2 const canvases=[...document.querySelectorAll('[data-installation-globe],#homeInstallationGlobe')]; 3 if(!canvases.length)return; 4 const hq=[60.2055,24.6559],dots=[[51.44,5.47],[42.16,-8.62],[45.62,5.09],[1.35,103.82],[48.39,-4.49],[46.81,-71.21],[45.89,-72.48],[40.76,-111.89],[-12.37,130.86],[34.68,-82.84],[35.23,-80.84],[43.23,-.89],[44.84,-.58],[41.98,2.82],[38.91,121.61],[31.3,120.59],[33.78,-84.4],[57.7,11.99],[41.16,-8.63],[41.66,-.88],[31.9,34.82],[24.45,54.38],[26.31,50.15],[52.38,9.74],[45.78,3.09],[42.73,-84.48],[45.5,-73.57],[43.86,-79.34],[45.42,-75.7],[63.43,10.4],[41.55,-8.43],[22.32,114.17],[37.77,-122.42],[40.43,-86.91],[42.73,-73.68],[51.51,-.13],[58.41,15.62],[51.99,4.33],[23.02,113.39],[50.85,4.35],[43.26,-2.94],[33.21,-87.57],[53.35,-6.26],[32.88,-117.23],[49.84,24.03],[49.26,-123.25],[52.68,-8.57],[53.38,-1.47],[32.74,-97.11],[50.77,15.05],[39.9,116.41],[17.385,78.4867]]; 5 const tours=[ 6 {label:'Nordics · engineering and integration',lat:58,lng:18,zoom:1.42,focus:{box:[-30,34,53,72]}}, 7 {label:'Europe · dense production network',lat:47,lng:4,zoom:1.52,focus:{continent:['Europe'],box:[-12,44,34,72]}}, 8 {label:'North America · research to production',lat:42,lng:-91,zoom:1.4,focus:{continent:['North America']}}, 9 {label:'Asia-Pacific · growing installation base',lat:24,lng:112,zoom:1.42,focus:{continent:['Asia'],not:['Western Asia']}}, 10 {label:'Middle East · advanced manufacturing',lat:26,lng:52,zoom:1.5,focus:{subregi
10on:['Western Asia']}}, 11 {label:'Oceania · applied composites research',lat:-18,lng:135,zoom:1.42,focus:{continent:['Oceania']}} 12 ]; 13 let features=[],centroids=[],dotFeature=[]; 14 const geo=fetch('/media/globe/countries.geojson').then(r=>r.ok?r.json():Promise.reject()).then(d=>(features=d.features||[])).catch(()=>[]); 15 const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches,con=navigator.connection||{},reducedData=!!(con.saveData||/(^|-)2g$/.test(con.effectiveType||'')); 16 const rings=g=>{if(!g)return[];if(g.type==='Polygon')return g.coordinates;const out=[];(g.coordinates||[]).forEach(p=>p.forEach(r=>out.push(r)));return out}; 17 const clamp=(n,a,b)=>Math.max(a,Math.min(b,n)); 18 const wrap=n=>{while(n>Math.PI)n-=Math.PI*2;while(n< -Math.PI)n+=Math.PI*2;return n}; 19 const mix=(a,b,t)=>a+(b-a)*t; 20 const inBox=(lng,lat,b)=>!!b&&lng>=b[0]&&lng<=b[1]&&lat>=b[2]&&lat<=b[3]; 21 // Region focus uses the geojson's own CONTIENT/SUBREGION properties plus the tour's 22 // bounding box, so a tour lights the real countries it covers rather than a circle. 23 const matchFocus=(f,lng,lat,props)=>{ 24 if(!f)return false; 25 if(f.not&&props&&f.not.includes(props.SUBREGION))return false; 26 if(f.continent&&props&&f.continent.includes(props.CONTINENT))return true; 27 if(f.subregion&&props&&f.subregion.includes(props.SUBREGION))return true; 28 return inBox(lng,lat,f.box); 29 }; 30 const ensureDerived=()=>{ 31 if(centroids.length===features.length&&dotFeature.length===dots.length)return; 32 centroids=features.map(f=>{const rs=rings(f.geometry);if(!rs.length||!rs[0].length)return{cx:0,cy:0};const r=rs[0];let x=0,y=0;for(const c of r){x+=c[0];y+=c[1]}return{cx:x/r.length,cy:y/r.length}}); 33 dotFeature=dots.map(d=>{let best=0,score=Infinity;for(let i=0;i<centroids.length;i++){const c=centroids[i],dl=((c.cx-d[1]+540)%360)-180,s=Math.pow(c.cy-d[0],2)+Math.pow(dl*Math.cos(d[0]*Math.PI/180),2);if(s<score){score=s;best=i}}return best}); 34 }; 35 canvases.forEach((canvas,instance)=>{ 36 const section=canvas.closest('[data-globe-section],.global-trust')||canvas.parentElement; 37 const toggle=section?.querySelector('[data-globe-toggle],.installation-toggle'),tourButton=section?.querySelector('[data-globe-tour]'),resetButton=section?.querySelector('[data-globe-reset]'),zoomIn=section?.querySelector('[data-globe-zoom-in]'),zoomOut=section?.querySelector('[data-globe-zoom-out]'),status=section?.querySelector('[data-globe-status]'); 38 const ctx=canvas.getContext('2d');if(!ctx)return; 39 let running=false,near=false,last=0,yaw=-.18,pitch=-.13,zoom=1,renderZoom=1,dragging=false,moved=false,dragX=0,dragY=0,dragYaw=0,dragPitch=0,resumeTimer=0,W=0,H=0,R=0,CX=0,CY=0,selected=-1,animating=false,target=null,tourIndex=-1,tourTimer=0,lastFrame=0,autoTourStarted=false; 40 let focusAmt=[],focusTarget=[],tourAmt=0,morphing=false; 41 const dpr=Math.min(1.5,devicePixelRatio||1); 42 // Pause is intentionally page-local. Persisting it made a single manual 43 // pause silently disable the default tour on every later visit. 44 let userPaused=reduced||reducedData; 45 const project=(lat,lng)=>{const p=lat*Math.PI/180,l=lng*Math.PI/180+yaw,c=Math.cos(p),x=c*Math.sin(l),y0=-Math.sin(p),z0=c*Math.cos(l),cp=Math.cos(pitch),sp=Math.sin(pitch),y=y0*cp-z0*sp,z=y0*sp+z0*cp,rr=R*renderZoom;return[CX+rr*x,CY+rr*y,z]}; 46 const setStatus=(label,state='focus')=>{if(!status)return;status.dataset.state=state;const text=status.querySelector('span');if(text)text.textContent=label}; 47 const base=now=>{ctx.clearRect(0,0,W,H);let halo=ctx.createRadialGradient(CX-R*.2,CY-R*.15,R*.12,CX,CY,R*1.28);halo.addColorStop(0,'#215b91');halo.addColorStop(.55,'#092b50');halo.addColorStop(1,'rgba(2,12,24,0)');ctx.fillStyle=halo;ctx.beginPath();ctx.arc(CX,CY,R*1.2,0,Math.PI*2);ctx.fill();let g=ctx.createRadialGradient(CX-R*.3,CY-R*.32,R*.05,CX,CY,R*renderZoom);g.addColorStop(0,'#28689f');g.addColorStop(.58,'#103c69');g.addColorStop(1,'#03152b');ctx.fillStyle=g;ctx.beginPath();ctx.arc(CX,CY,R*renderZoom,0,Math.PI*2);ctx.fill();ctx.save();ctx.beginPath();ctx.arc(CX,CY,R*renderZoom,0,Math.PI*2);ctx.clip();const sweep=ctx.createLinearGradient(CX-R,CY-R,CX+R,CY+R);const phase=((now||0)/4200)%1;sweep.addColorStop(clamp(phase-.12,0,1),'rgba(72,159,226,0)');sweep.addColorStop(phase,'rgba(145,211,255,.12)');sweep.addColorStop(clamp(phase+.12,0,1),'rgba(72,159,226,0)');ctx.fillStyle=sweep;ctx.fillRect(CX-R*renderZoom,CY-R*renderZoom,R*2*renderZoom,R*2*renderZoom)}; 48 // Only the front-facing runs of a ring are kept, so fills never bridge across the 49 // far side of the globe. Each run is closed on itself. 50 const runsFor=r=>{const runs=[];let cur=null;for(const c of r){const p=project(c[1],c[0]);if(p[2]>.02){if(!cur){cur=[];runs.push(cur)}cur.push(p)}else cur=null}return runs.filter(run=>run.length>2)}; 51 const polygonPath=run=>{ctx.beginPath();ctx.moveTo(run[0][0],run[0][1]);for(let i=1;i<run.length;i++)ctx.lineTo(run[i][0],run[i][1]);ctx.closePath()}; 52 const collectRuns=()=>features.map(f=>{const out=[];
52rings(f.geometry).forEach(r=>runsFor(r).forEach(run=>out.push(run)));return out}); 53 const computeFocus=()=>{if(!features.length){focusTarget=[];return}if(tourIndex<0){focusTarget=features.map(()=>0);return}const f=tours[tourIndex].focus;focusTarget=features.map((ft,i)=>matchFocus(f,centroids[i].cx,centroids[i].cy,ft.properties)?1:0)}; 54 const dotInFocus=i=>{if(tourIndex<0||!features.length)return false;const ft=features[dotFeature[i]];return matchFocus(tours[tourIndex].focus,dots[i][1],dots[i][0],ft&&ft.properties)}; 55 // Polygon land fills: steel/slate by default, receding slate when a tour is running, 56 // and coral for the focused region each country is classified into. 57 const drawLand=allRuns=>{for(let i=0;i<features.length;i++){const amt=focusAmt[i]||0,t=tourAmt;const r=mix(mix(126,58,t),255,amt),g=mix(mix(152,84,t),126,amt),b=mix(mix(178,112,t),90,amt),a=mix(mix(.5,.3,t),.86,amt);ctx.fillStyle=`rgba(${r|0},${g|0},${b|0},${a.toFixed(3)})`;allRuns[i].forEach(run=>{polygonPath(run);ctx.fill()})}}; 58 // Sharp pale borders after the fills; the focused region keeps an amber edge. 59 const drawBorders=allRuns=>{for(let i=0;i<features.length;i++){const amt=focusAmt[i]||0,focused=amt>.5;ctx.strokeStyle=focused?`rgba(255,205,158,${(.48+.34*amt).toFixed(3)})`:'rgba(214,236,255,.58)';ctx.lineWidth=focused?1.1:(W<520?.7:1);allRuns[i].forEach(run=>{polygonPath(run);ctx.stroke()})}}; 60 const arcTo=dest=>{const a=hq[0],b=hq[1],dl=((dest[1]-b+540)%360)-180;let started=false;ctx.beginPath();for(let i=0;i<=24;i++){const t=i/24,lat=a+(dest[0]-a)*t+Math.sin(Math.PI*t)*9,lng=b+dl*t,p=project(lat,lng);if(p[2]>.03){if(!started){ctx.moveTo(p[0],p[1]);started=true}else ctx.lineTo(p[0],p[1])}else started=false}ctx.stroke()}; 61 const draw=now=>{base(now);if(features.length){const allRuns=collectRuns();drawLand(allRuns);drawBorders(allRuns)}ctx.strokeStyle='rgba(92,143,194,.25)';ctx.lineWidth=.65;dots.forEach(arcTo);dots.forEach((d,i)=>{const p=project(d[0],d[1]);if(p[2]<=0)return;const focused=dotInFocus(i);const r=(2.2+1.3*p[2])*(focused?1.4:1);ctx.shadowColor=focused?'#ffb27a':'#ff6f59';ctx.shadowBlur=i===selected?15:(focused?12:8);ctx.fillStyle=i===selected?'#fff1ec':(focused?'#ffd0a8':'#ff624a');ctx.beginPath();ctx.arc(p[0],p[1],i===selected?r+1.5:r,0,Math.PI*2);ctx.fill();if(i===selected){const pulse=8+10*((now||0)%1500)/1500;ctx.shadowBlur=0;ctx.strokeStyle=`rgba(255,128,105,${.72-pulse/34})`;ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(p[0],p[1],pulse,0,Math.PI*2);ctx.stroke()}});const p=project(hq[0],hq[1]);if(p[2]>0){ctx.shadowColor='#fff';ctx.shadowBlur=12;ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(p[0],p[1],4.5,0,Math.PI*2);ctx.fill()}ctx.shadowBlur=0;ctx.restore();ctx.strokeStyle='rgba(110,181,238,.58)';ctx.lineWidth=1;ctx.beginPath();ctx.arc(CX,CY,R*renderZoom,0,Math.PI*2);ctx.stroke();ctx.strokeStyle='rgba(104,172,228,.13)';ctx.beginPath();ctx.arc(CX,CY,R*renderZoom+7,0,Math.PI*2);ctx.stroke()}; 62 const resize=()=>{const b=canvas.getBoundingClientRect();W=Math.max(1,Math.round(b.width));H=Math.max(1,Math.round(b.height));canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);R=Math.min(W,H)*.42;CX=W*(W<820?.56:.68);CY=H*(W<820?.62:.52);draw(performance.now())}; 63 const focus=(lat,lng,nextZoom,label,index=-1,state='focus')=>{clearTimeout(tourTimer);target={yaw:-lng*Math.PI/180,pitch:clamp(-lat*Math.PI/180,-1.15,1.15),zoom:clamp(nextZoom,.9,1.65)};selected=index;animating=true;setStatus(label,state);computeFocus();morphing=true;sync()}; 64 const reset=()=>{clearTimeout(tourTimer);tourIndex=-1;selected=-1;target={yaw:-.18,pitch:-.13,zoom:1};animating=true;setStatus('Global overview','overview');computeFocus();morphing=true;if(tourButton){tourButton.textContent='Tour network';tourButton.setAttribute('aria-pressed','false')}sync()}; 65 const nearestDot=(lat,lng)=>{let best=-1,score=Infinity;dots.forEach((d,i)=>{const dl=((d[1]-lng+540)%360)-180,s=Math.pow(d[0]-lat,2)+Math.pow(dl*Math.cos(lat*Math.PI/180),2);if(s<score){score=s;best=i}});return best}; 66 const nextTour=()=>{tourIndex=(tourIndex+1)%tours.length;const stop=tours[tourIndex];if(tourButton){tourButton.textContent='Touring network';tourButton.setAttribute('aria-pressed','true')}focus(stop.lat,stop.lng,stop.zoom,stop.label,nearestDot(stop.lat,stop.lng),'tour');tourTimer=setTimeout(nextTour,3600)}; 67 const stopTour=()=>{clearTimeout(tourTimer);if(tourIndex>=0){tourIndex=-1;computeFocus();morphing=true}if(tourButton){tourButton.textContent='Tour network';tourButton.setAttribute('aria-pressed','false')}}; 68 const startDefaultTour=()=>{if(autoTourStarted||userPaused||reduced||reducedData)return;autoTourStarted=true;nextTour()}; 69 const tick=t=>{if(!running&&!animating&&!morphing)return;const dt=Math.min(80,Math.max(0,t-last));if(t-lastFrame>32){if(target){yaw+=wrap(target.yaw-yaw)*.085;pitch+=(target.pitch-pitch)*.085;zoom+=(target.zoom-zoom)*.075;if(Math.abs(wrap(target.yaw-yaw))<.002&&Math.abs(target.pitch-pitch)<.002&&Math.abs(target.zoom-zoom)<.003){
69yaw=target.yaw;pitch=target.pitch;zoom=target.zoom;target=null;animating=false}}else if(running&&!dragging){yaw+=.0000065*dt;if(tourIndex<0)pitch+=((Math.sin(t/13500+instance)*.105-.08)-pitch)*.008}renderZoom=zoom*(1+(running&&tourIndex<0&&!reduced?.012*Math.sin(t/1700):0));let stillMorphing=false;const tt=tourIndex>=0?1:0;if(Math.abs(tt-tourAmt)>.004){tourAmt+=(tt-tourAmt)*.14;stillMorphing=true}else tourAmt=tt;for(let i=0;i<focusAmt.length;i++){const tv=focusTarget[i]||0,v=focusAmt[i];if(Math.abs(tv-v)>.004){focusAmt[i]=v+(tv-v)*.14;stillMorphing=true}else focusAmt[i]=tv}morphing=stillMorphing;draw(t);lastFrame=t}last=t;if(running||animating||morphing)requestAnimationFrame(tick)}; 70 const updateToggle=()=>{if(!toggle)return;toggle.textContent=userPaused?'Resume rotation':'Pause rotation';toggle.setAttribute('aria-pressed',userPaused?'true':'false')}; 71 const sync=()=>{const shouldRun=near&&!userPaused&&!dragging&&!document.hidden;if((shouldRun||animating||morphing)&&!running){running=shouldRun;last=performance.now();requestAnimationFrame(tick)}else{running=shouldRun;if(!running&&!animating&&!morphing)draw(performance.now())}updateToggle()}; 72 const setPaused=paused=>{userPaused=paused;sync()}; 73 const setZoom=value=>{stopTour();target={yaw,pitch,zoom:clamp(value,.9,1.65)};animating=true;selected=-1;setStatus(target.zoom>1.05?'Closer network view':'Global overview',target.zoom>1.05?'focus':'overview');sync()}; 74 toggle?.addEventListener('click',()=>{clearTimeout(resumeTimer);const pause=!userPaused;if(pause)stopTour();setPaused(pause);if(!pause&&near&&!reduced&&!reducedData)nextTour()}); 75 tourButton?.addEventListener('click',()=>{if(tourIndex>=0){stopTour();reset()}else{setPaused(false);nextTour()}}); 76 resetButton?.addEventListener('click',()=>{stopTour();reset()});zoomIn?.addEventListener('click',()=>setZoom(zoom+.18));zoomOut?.addEventListener('click',()=>setZoom(zoom-.18)); 77 canvas.addEventListener('pointerdown',e=>{stopTour();dragging=true;moved=false;clearTimeout(resumeTimer);dragX=e.clientX;dragY=e.clientY;dragYaw=yaw;dragPitch=pitch;canvas.classList.add('is-dragging');canvas.setPointerCapture(e.pointerId);sync()}); 78 canvas.addEventListener('pointermove',e=>
78{if(!dragging)return;const dx=e.clientX-dragX,dy=e.clientY-dragY;moved=moved||Math.hypot(dx,dy)>6;yaw=dragYaw+dx/Math.max(180,R)*1.35;pitch=clamp(dragPitch+dy/Math.max(180,R)*1.15,-1.15,1.15);selected=-1;target=null;setStatus('Manual globe view','overview');draw(performance.now())}); 79 const pick=(x,y)=>{let best=-1,dist=30;dots.forEach((d,i)=>{const p=project(d[0],d[1]);if(p[2]<=0)return;const dd=Math.hypot(p[0]-x,p[1]-y);if(dd<dist){best=i;dist=dd}});if(best>=0){const d=dots[best];focus(d[0],d[1],Math.max(1.48,zoom),'Inspecting anonymous installation region',best)}}; 80 const endDrag=e=>{if(!dragging)return;dragging=false;canvas.classList.remove('is-dragging');if(!moved&&e){const b=canvas.getBoundingClientRect();pick(e.clientX-b.left,e.clientY-b.top)}clearTimeout(resumeTimer);resumeTimer=setTimeout(sync,1800)};canvas.addEventListener('pointerup',endDrag);canvas.addEventListener('pointercancel',()=>endDrag()); 81 canvas.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','+','=','-','_','Escape','Enter'].includes(e.key))return;e.preventDefault();stopTour();clearTimeout(resumeTimer);running=false;if(e.key==='Escape')reset();else if(e.key==='+'||e.key==='=')setZoom(zoom+.14);else if(e.key==='-'||e.key==='_')setZoom(zoom-.14);else if(e.key==='Enter')pick(CX,CY);else{target=null;selected=-1;if(e.key==='ArrowLeft'||e.key==='ArrowRight')yaw+=(e.key==='ArrowLeft'?-1:1)*.12;else pitch=clamp(pitch+(e.key==='ArrowUp'?-1:1)*.1,-1.15,1.15);setStatus('Manual globe view','overview');draw(performance.now())}resumeTimer=setTimeout(sync,1800)}); 82 document.addEventListener('visibilitychange',sync); 83 addEventListener('pageshow',()=>{if(near&&tourIndex<0)startDefaultTour();else sync()}); 84 new IntersectionObserver(([entry])=>{near=entry.isIntersecting;if(near){geo.then(()=>{ensureDerived();if(focusAmt.length!==features.length)focusAmt=features.map(()=>0);computeFocus();morphing=true;sync()});startDefaultTour()}sync()},{rootMargin:'120px 0px',threshold:0}).observe(section); 85 new ResizeObserver(resize).observe(canvas);resize();setStatus('Global overview','overview');updateToggle(); 86 // A hash-restored or BFCache-restored story can place this section before the 87 // observer delivers its first entry. Establish visibility after layout so the 88 // default tour is deterministic rather than dependent on observer timing. 89 requestAnimationFrame(()=>requestAnimationFrame(()=>{const b=section.getBoundingClientRect();near=b.bottom>-120&&b.top<innerHeight+120;if(near)startDefaultTour();sync()})); 90 }); 91})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.