Найден корректный robots.txt
Файл управления сканированием сайта роботами
?
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
Robots.txt настроен корректно. Размер файла: 30572 байт. Загружен за: 1сек.
Проверяемая страница не запрещена в robots.txt.
Цепочка редиректов для файла robots.txt:
http://trace.ethz.ch/robots.txt
302 Found
https://avi.ethz.ch/
200 OK
Показать содержимое robots.txt
<!DOCTYPE html><html lang="en"> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="Artificial Visual Intelligence (AVI) Group at ETH Zurich. We research computer vision and autonomous systems."><title>Artificial Visual Intelligence Group | ETH Zurich</title><link rel="icon" type="image/svg+xml" href="/favicon.svg"><style>@font-face{font-family:'Inter';font-style:normal;font-weight:300 700;font-display:swap;src:url('/_astro/inter-latin.Dx4kXJAl.woff2') format('woff2');}</style><script>
// Runs before paint to avoid a flash of the wrong theme.
(function () {
try {
var saved = localStorage.getItem('theme');
if (saved === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else if (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
} catch (_) {
/* localStorage unavailable — silently fall back to OS preference at runtime */
}
})();
</script><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/_astro/BaseLayout.CbcfRoXx.css"></head> <body> <header class="top-bar" data-astro-cid-3ef6ksr2> <a href="https://ethz.ch/" target="_blank" rel="noopener" class="affil-logo affil-eth-lead" title="ETH Zurich" data-astro-cid-3ef6ksr2> <img src="/_astro/eth_zurich_logo_black.CNu93lwx.svg" alt="ETH Zurich" height="24" class="invert-dark" data-astro-cid-3ef6ksr2> </a> <a href="/" class="affil-logo logo-container" aria-label="AVI — Artificial Visual Intelligence (home)" data-astro-cid-3ef6ksr2> <div class="logo-text" data-astro-cid-3ef6ksr2>AVI</div> <div class="logo-subtext" data-astro-cid-3ef6ksr2> <span class="logo-line logo-line-1" data-astro-cid-3ef6ksr2>Artificial Visual</span> <span class="logo-line logo-line-2" data-astro-cid-3ef6ksr2>Intelligence</span> </div> </a> <button type="button" class="nav-toggle" aria-expanded="false" aria-controls="site-menu" aria-label="Open menu" data-astro-cid-3ef6ksr2> <span class="nav-toggle-bar" aria-hidden="true" data-astro-cid-3ef6ksr2></span> <span class="nav-toggle-bar" aria-hidden="true" data-astro-cid-3ef6ksr2></span> <span class="nav-toggle-bar" aria-hidden="true" data-astro-cid-3ef6ksr2></span> </button> <div class="nav-panel" id="site-menu" data-astro-cid-3ef6ksr2> <nav class="main-nav" data-astro-cid-3ef6ksr2> <a href="/research" data-astro-cid-3ef6ksr2> Research </a><a href="/teaching" data-astro-cid-3ef6ksr2> Teaching </a><a href="/people" data-astro-cid-3ef6ksr2> People </a> </nav> <div class="affiliations" data-astro-cid-3ef6ksr2> <a href="https://prs.igp.ethz.ch/" target="_blank" rel="noopener" class="affil-logo" title="Photogrammetry and Remote Sensing" data-astro-cid-3ef6ksr2> <img src="/_astro/prs_logo_transparent.Z95ox2U7.svg" alt="PRS Logo" height="20" class="logo-light" data-astro-cid-3ef6ksr2> <img src="/_astro/prs_logo_darkmode.CWHGeMja.svg" alt="PRS Logo" height="20" class="logo-dark" data-astro-cid-3ef6ksr2> </a> <a href="https://baug.ethz.ch/" target="_blank" rel="noopener" class="affil-logo" title="D-BAUG" data-astro-cid-3ef6ksr2> <img src="/_astro/dbaug_logo_transparent.CUYEg2J3.svg" alt="D-BAUG Logo" height="20" class="invert-dark" data-astro-cid-3ef6ksr2> </a> </div> </div> </header> <script type="module">const t=document.querySelector(".top-bar"),n=t?.querySelector(".nav-toggle"),a=t?.querySelector(".nav-panel");if(t&&n&&a){const s=e=>{t.classList.toggle("is-open",e),n.setAttribute("aria-expanded",String(e)),n.setAttribute("aria-label",e?"Close menu":"Open menu")};n.addEventListener("click",()=>{s(!t.classList.contains("is-open"))}),a.addEventListener("click",e=>{e.target.closest("a")&&s(!1)}),document.addEventListener("click",e=>{t.classList.contains("is-open")&&!t.contains(e.target)&&s(!1)}),document.addEventListener("keydown",e=>{e.key==="Escape"&&t.classList.contains("is-open")&&(s(!1),n.focus())}),window.matchMedia("(max-width: 1024px)").addEventListener?.("change",e=>{e.matches||s(!1)})}</script> <main class="dashboard-container"> <!-- LEFT SIDE: HERO --> <section class="hero-section"> <div class="hero-content"> <h1 class="hero-title" id="brand-name"> <span class="hero-title-line hero-title-line-1">Artificial Visual</span> <span class="hero-title-line hero-title-line-2">Intelligence</span> </h1> <figure class="hero-photo parallax-photo" data-image="/group-photo.jpg" data-depth="/group-photo-depth.png" data-mask="/group-photo-mask.png" data-mask-people="[{"value":1,"slug":"matthias-neuwirth-trapp"},{"value":2,"slug":"christos"},{"value":3,"slug":"danilo"},{"value":4,"slug":"david"},{"value":5,"slug":"vincent"}]" data-pan-x="0" data-pan-y="0" data-scale="1.0"> <img src="/group-photo.jpg" alt="Artificial Visual Intelligence Group photo" fetchpriority="high"> <canvas class="parallax-canvas" aria-hidden="true"></canvas> </figure> <p class="hero-description" id="hero-desc">Our group researches and develops 3D and semantic visual perceptual intelligence with a focus on embodied AI settings such as autonomous cars and robots. AVI is hosted under the umbrella of the Chair of Photogrammetry and Remote Sensing (PRS).</p> </div> </section> <!-- RIGHT SIDE: TILES --> <section class="tiles-section"> <!-- PEOPLE TILE --> <div class="tile tile-people" id="people"> <div class="tile-header"> <h2><a class="tile-title-link" href="/people">People</a></h2> </div> <div class="tile-content people-list" id="people-container"> <a href="/people/christos" title="Christos Sakaridis - Head of AVI"> <img src="/_astro/christos.DwcOkv0V.jpg" alt="Portrait of Christos Sakaridis" class="person-avatar"> </a><a href="/people/konrad-schindler" title="Konrad Schindler - Host Professor (PRS)"> <img src="/_astro/konrad.9XkBA0Ry.png" alt="Portrait of Konrad Schindler" class="person-avatar"> </a><a href="/people/danilo" title="Danilo Đorđević - PhD Student"> <img src="/_astro/danilo.nmjNIwWp.png" alt="Portrait of Danilo Đorđević" class="person-avatar"> </a><a href="/people/david" title="David Borts - PhD Student"> <img src="/_astro/david.D53RTDXQ.png" alt="Portrait of David Borts" class="person-avatar"> </a><a href="/people/vincent" title="Vincent van der Brugge - PhD Student"> <img src="/_astro/vincent.DKZbzjjQ.jpg" alt="Portrait of Vincent van der Brugge" class="person-avatar"> </a><a href="/people/matthias-neuwirth-trapp" title="Matthias Neuwirth-Trapp - PhD Student"> <img src="/_astro/matthias-neuwirth-trapp.Bc8sAEnb.jpg" alt="Portrait of Matthias Neuwirth-Trapp" class="person-avatar"> </a> </div> </div> <!-- RESEARCH TILE --> <div class="tile tile-research" id="research"> <div class="tile-header"> <h2><a href="/research" class="tile-title-link">Research</a></h2> <a href="/research" class="see-more">See more</a> </div> <div class="tile-content research-carousel" id="research-container"> <a href="https://arxiv.org/abs/2605.12006" class="paper-card"> <img src="/_astro/2026-cvpr-robust-pvos.BhXrSlYN.png" alt="Robust Promptable Video Object Segmentation: real and synthetic data overview" class="card-image" loading="lazy" decoding="async"> <h3 class="card-title">Robust Promptable Video Object Segmentation</h3> <div class="card-meta"> <span class="card-venue"> CVPR 2026 </span> <span class="card-authors-line">Lee, Gwon, Hoyer, Schindler, Sakaridis, Kwak</span> </div> </a><a href="https://arxiv.org/abs/2603.14701" class="paper-card"> <img src="/_astro/2026-arxiv-aurora-kitti.Dc9mNfp3.png" alt="AURORA-KITTI: depth completion under clean, snow, and night-snow conditions" class="card-image" loading="lazy" decoding="async"> <h3 class="card-title">AURORA-KITTI: Any-Weather Depth Completion and Denoising in the Wild</h3> <div class="card-meta"> <span class="card-venue card-venue-asis"> arXiv 2026 </span> <span class="card-authors-line">Wang, Brödermann, Haghighi, Zhao, Sakaridis, Debattista, Donzella</span> </div> </a> </div> </div> <!-- NEWS TILE --> <div class="tile tile-news" id="news"> <div class="tile-header"> <h2>News</h2> </div> <div class="tile-content" id="news-container"> <ul class="news-list"> <li class="news-item"> <span class="news-date">07/2025</span> <span class="news-text">The Artificial Visual Intelligence (AVI) group is established!</span> </li> </ul> </div> </div> </section> </main> <footer class="main-footer" data-astro-cid-sz7xmlte> <div class="footer-left" data-astro-cid-sz7xmlte> <a href="/imprint" class="footer-link" data-astro-cid-sz7xmlte>Imprint</a> </div> <div class="footer-center" data-astro-cid-sz7xmlte> <p data-astro-cid-sz7xmlte>© 2026 Artificial Visual Intelligence Group. ETH Zurich.</p> </div> <div class="footer-right" data-astro-cid-sz7xmlte> <button id="theme-toggle" class="theme-btn" aria-label="Toggle dark mode" type="button" data-astro-cid-sz7xmlte> <svg id="theme-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" data-astro-cid-sz7xmlte> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" data-astro-cid-sz7xmlte></path> </svg> </button> </div> </footer> <script type="module">const l='<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>',c='<circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>',e=document.documentElement,r=document.getElementById("theme-toggle"),a=document.getElementById("theme-icon");function i(){a&&(a.innerHTML=e.getAttribute("data-theme")==="dark"?c:l)}i();r?.addEventListener("click",()=>{const t=e.getAttribute("data-theme")==="dark"?"light":"dark";t==="dark"?e.setAttribute("data-theme","dark"):e.removeAttribute("data-theme");try{localStorage.setItem("theme",t)}catch{}i()});window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",t=>{let n=null;try{n=localStorage.getItem("theme")}catch{}n||(t.matches?e.setAttribute("data-theme","dark"):e.removeAttribute("data-theme"),i())});</script> <script>
// Depth-map parallax for the hero group photo.
// Loads both the photo and its depth map, then runs a WebGL fragment shader
// that displaces each pixel by (depth - 0.5) * strength * mouseOffset.
(function () {
const root = document.querySelector('.parallax-photo');
if (!root) return;
const imgEl = root.querySelector('img');
const canvas = root.querySelector('canvas');
const imageUrl = root.dataset.image;
const depthUrl = root.dataset.depth;
if (!imgEl || !canvas || !imageUrl || !depthUrl) return;
const gl = canvas.getContext('webgl', { antialias: true, premultipliedAlpha: false });
if (!gl) return; // fallback: plain <img> stays visible
const loadImage = (src) =>
new Promise((resolve, reject) => {
const i = new Image();
i.crossOrigin = 'anonymous';
i.onload = () => resolve(i);
i.onerror = reject;
i.src = src;
});
const vs = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = (a_pos + 1.0) * 0.5;
v_uv.y = 1.0 - v_uv.y;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
const fs = `
precision mediump float;
uniform sampler2D u_image;
uniform sampler2D u_depth;
uniform vec2 u_offset;
uniform vec2 u_texel;
uniform vec2 u_pan;
uniform float u_scale;
uniform sampler2D u_mask;
// Hovered person's mask byte (0..255), or -1.0 when nothing is hovered.
// The mask texture is 8-bit and sampled with NEAREST filtering, so
// texture2D(u_mask, uv).r * 255.0 is exactly the painted byte value.
uniform float u_hoverByte;
varying vec2 v_uv;
// 9-tap blur on depth so silhouettes transition smoothly
// and the displacement doesn't tear at sharp depth boundaries.
// Larger radius = softer ghosting at higher STRENGTH.
float sampleDepth(vec2 uv) {
vec2 t = u_texel * 5.0;
float d = texture2D(u_depth, uv).r * 0.36;
d += texture2D(u_depth, uv + vec2(t.x, 0.0)).r * 0.12;
d += texture2D(u_depth, uv - vec2(t.x, 0.0)).r * 0.12;
d += texture2D(u_depth, uv + vec2(0.0, t.y)).r * 0.12;
d += texture2D(u_depth, uv - vec2(0.0, t.y)).r * 0.12;
d += texture2D(u_depth, uv + t).r * 0.04;
d += texture2D(u_depth, uv - t).r * 0.04;
d += texture2D(u_depth, uv + vec2(t.x, -t.y)).r * 0.04;
d += texture2D(u_depth, uv + vec2(-t.x, t.y)).r * 0.04;
return d;
}
void main() {
// Static framing: pan + zoom of the sampling region, applied
// before the depth lookup so the parallax follows the framing.
vec2 base = (v_uv - 0.5) / u_scale + 0.5 + u_pan;
float d = sampleDepth(base);
// depth: 1.0 = near (foreground), 0.0 = far (background)
// Pivot near foreground (0.8) so front-row stays nearly still
// and the background absorbs most of the motion.
vec2 disp = u_offset * (d - 0.5);
vec2 uv = clamp(base + disp, vec2(0.0), vec2(1.0));
vec4 color = texture2D(u_image, uv);
// Hover dim: when a person is hovered (u_hoverByte >= 0),
// slightly desaturate + dim every pixel whose mask byte doesn't match.
if (u_hoverByte >= 0.0) {
float maskByte = floor(texture2D(u_mask, uv).r * 255.0 + 0.5);
float keep = 1.0 - step(0.5, abs(maskByte - u_hoverByte));
float gray = dot(color.rgb, vec3(0.299, 0.587, 0.114));
vec3 muted = mix(color.rgb, vec3(gray), 0.45) * 0.85;
color.rgb = mix(muted, color.rgb, keep);
}
gl_FragColor = color;
}
`;
const compile = (type, src) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.warn('parallax shader compile failed', gl.getShaderInfoLog(sh));
return null;
}
return sh;
};
const vsh = compile(gl.VERTEX_SHADER, vs);
const fsh = compile(gl.FRAGMENT_SHADER, fs);
if (!vsh || !fsh) return;
const prog = gl.createProgram();
gl.attachShader(prog, vsh);
gl.attachShader(prog, fsh);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
gl.STATIC_DRAW,
);
const aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uImage = gl.getUniformLocation(prog, 'u_image');
const uDepth = gl.getUniformLocation(prog, 'u_depth');
const uOffset = gl.getUniformLocation(prog, 'u_offset');
const uTexel = gl.getUniformLocation(prog, 'u_texel');
const uPan = gl.getUniformLocation(prog, 'u_pan');
const uScale = gl.getUniformLocation(prog, 'u_scale');
const uMask = gl.getUniformLocation(prog, 'u_mask');
const uHoverByte = gl.getUniformLocation(prog, 'u_hoverByte');
const panX = parseFloat(root.dataset.panX || '0');
const panY = parseFloat(root.dataset.panY || '0');
const scale = parseFloat(root.dataset.scale || '1') || 1;
gl.uniform2f(uPan, panX, panY);
gl.uniform1f(uScale, scale);
gl.uniform1f(uHoverByte, -1.0); // -1 means "no person hovered"
const maskUrl = root.dataset.mask;
let maskPeople = [];
try {
maskPeople = JSON.parse(root.dataset.maskPeople || '[]');
} catch (e) {
maskPeople = [];
}
const makeTex = (img, nearest) => {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const filter = nearest ? gl.NEAREST : gl.LINEAR;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
return t;
};
// 1x1 white fallback so u_mask has a valid binding even without a mask.
const makeFallbackMask = () => {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
);
return t;
};
const loaders = [loadImage(imageUrl), loadImage(depthUrl)];
if (maskUrl) loaders.push(loadImage(maskUrl).catch(() => null));
Promise.all(loaders)
.then(([img, depth, maskImg]) => {
const imageTex = makeTex(img, false);
const depthTex = makeTex(depth, false);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, imageTex);
gl.uniform1i(uImage, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, depthTex);
gl.uniform1i(uDepth, 1);
gl.uniform2f(uTexel, 1.0 / depth.width, 1.0 / depth.height);
// Mask texture (real one if loaded, otherwise 1x1 fallback).
gl.activeTexture(gl.TEXTURE2);
gl.bindTexture(gl.TEXTURE_2D, maskImg ? makeTex(maskImg, true) : makeFallbackMask());
gl.uniform1i(uMask, 2);
// Build a 2D canvas readback for hit-testing under the cursor.
let mask2D = null;
if (maskImg) {
const mc = document.createElement('canvas');
mc.width = maskImg.width;
mc.height = maskImg.height;
const ctx = mc.getContext('2d', { willReadFrequently: true });
ctx.drawImage(maskImg, 0, 0);
mask2D = { ctx, width: mc.width, height: mc.height };
}
const hitTest = (clientX, clientY) => {
if (!mask2D) return null;
const rect = root.getBoundingClientRect();
const sx = (clientX - rect.left) / rect.width;
const sy = (clientY - rect.top) / rect.height;
const bx = (sx - 0.5) / scale + 0.5 + panX;
const by = (sy - 0.5) / scale + 0.5 + panY;
if (bx < 0 || bx > 1 || by < 0 || by > 1) return null;
const mx = Math.min(mask2D.width - 1, Math.max(0, Math.floor(bx * mask2D.width)));
const my = Math.min(mask2D.height - 1, Math.max(0, Math.floor(by * mask2D.height)));
const v = mask2D.ctx.getImageData(mx, my, 1, 1).data[0];
// Mask is painted with discrete integer values, one per person.
const matched = maskPeople.find((p) => p.value === v);
return matched || null;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = root.getBoundingClientRect();
const w = Math.max(1, Math.round(rect.width * dpr));
const h = Math.max(1, Math.round(((rect.width * img.height) / img.width) * dpr));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
}
};
resize();
window.addEventListener('resize', resize);
// Media queries can change the figure's width without a window
// resize (e.g. scrollbar appearing); track the element directly.
if (window.ResizeObserver) {
new ResizeObserver(resize).observe(root);
}
// Render only while the figure is on screen and the tab visible.
let heroVisible = true;
if (window.IntersectionObserver) {
new IntersectionObserver((entries) => {
// Entries are oldest-first; only the newest reflects reality.
heroVisible = entries[entries.length - 1].isIntersecting;
}).observe(root);
}
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const noHover = window.matchMedia('(hover: none)');
root.classList.add('is-active');
// Strength: max UV displacement (in texture coords) at full mouse deflection.
const STRENGTH = 0.009;
// Ambient breathing while idle so the effect is visible without mouse input.
let targetX = 0;
let targetY = 0;
let curX = 0;
let curY = 0;
let mouseInside = false;
let currentHoverByte = -1;
const setHover = (person) => {
const v = person ? person.value : -1;
if (v !== currentHoverByte) {
currentHoverByte = v;
gl.uniform1f(uHoverByte, v);
root.style.cursor = person ? 'pointer' : '';
}
};
const onMove = (e) => {
// Touch browsers replay a tap as synthetic mouse events before
// the click; ignore them so hover state stays owned by the
// click handler (two-tap flow) and the ambient drift keeps
// running on touch devices.
if (noHover.matches) return;
const rect = root.getBoundingClientRect();
const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const ny = ((e.clientY - rect.top) / rect.height) * 2 - 1;
targetX = -nx;
targetY = ny;
mouseInside = true;
setHover(hitTest(e.clientX, e.clientY));
};
const onLeave = () => {
mouseInside = false;
setHover(null);
};
const onClick = (e) => {
const person = hitTest(e.clientX, e.clientY);
if (!person || !person.slug) {
if (noHover.matches) setHover(null);
return;
}
// Touch has no hover preview: first tap highlights the person,
// a second tap on the same person navigates.
if (noHover.matches && currentHoverByte !== person.value) {
setHover(person);
return;
}
window.location.href = '/people/' + person.slug;
};
root.addEventListener('mousemove', onMove);
root.addEventListener('mouseleave', onLeave);
root.addEventListener('click', onClick);
const draw = (now) => {
if (!heroVisible || document.hidden) {
requestAnimationFrame(draw);
return;
}
if (reducedMotion.matches) {
// Static render: no ambient breathing, no pointer parallax.
targetX = 0;
targetY = 0;
} else if (!mouseInside) {
const t = now * 0.0006;
targetX = Math.sin(t) * 0.45;
targetY = Math.cos(t * 0.8) * 0.3;
}
// ease toward target
curX += (targetX - curX) * 0.08;
curY += (targetY - curY) * 0.08;
gl.uniform2f(uOffset, curX * STRENGTH, curY * STRENGTH);
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(draw);
};
requestAnimationFrame(draw);
})
.catch((err) => {
console.warn('parallax image load failed', err);
});
})();
</script> <script>
(function () {
const carousel = document.getElementById('research-container');
if (!carousel) return;
const SPEED = 18; // pixels per second
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let paused = false;
let touching = false;
let inView = true;
let halfWidth = 0;
let pos = 0;
let manualUntil = 0;
let enabled = false; // only loop/auto-scroll when the cards overflow
let cloned = false;
// The original (server-rendered) cards, before any loop duplicates.
const originals = Array.from(carousel.children);
const gapPx = () => parseFloat(getComputedStyle(carousel).columnGap) || 0;
const singleSetWidth = () => {
const gap = gapPx();
let w = 0;
originals.forEach((el, i) => {
w += el.offsetWidth + (i ? gap : 0);
});
return w;
};
const addClones = () => {
originals.forEach((node) => {
const c = node.cloneNode(true);
c.setAttribute('aria-hidden', 'true');
c.setAttribute('tabindex', '-1');
c.classList.add('carousel-clone');
carousel.appendChild(c);
});
};
const removeClones = () => {
carousel.querySelectorAll('.carousel-clone').forEach((n) => n.remove());
};
// Duplicate the cards for a seamless loop and auto-scroll only when a
// single set is wider than the viewport. With few papers they fit, so
// they stay static — no repetition and no rotation.
const update = () => {
const shouldScroll = singleSetWidth() > carousel.clientWidth + 4;
if (shouldScroll && !cloned) {
addClones();
cloned = true;
} else if (!shouldScroll && cloned) {
removeClones();
cloned = false;
pos = 0;
carousel.scrollLeft = 0;
}
enabled = shouldScroll;
// Loop period = one full set plus the seam gap to its duplicate.
halfWidth = cloned ? singleSetWidth() + gapPx() : 0;
};
const wrap = () => {
if (halfWidth <= 0) return;
if (pos >= halfWidth) pos -= halfWidth;
else if (pos < 0) pos += halfWidth;
};
requestAnimationFrame(update);
window.addEventListener('resize', update);
if (window.ResizeObserver) {
new ResizeObserver(update).observe(carousel);
}
// Card width is fixed, but recheck once images settle just in case.
carousel.querySelectorAll('img').forEach((img) => {
if (img.complete) return;
img.addEventListener('load', update, { once: true });
img.addEventListener('error', update, { once: true });
});
// Auto-advance only while the carousel is actually on screen.
if (window.IntersectionObserver) {
new IntersectionObserver((entries) => {
// Entries are oldest-first; only the newest reflects reality.
inView = entries[entries.length - 1].isIntersecting;
}).observe(carousel);
}
carousel.addEventListener('mouseenter', () => {
paused = true;
});
carousel.addEventListener('mouseleave', () => {
paused = false;
});
// Touch: hand control to native swipe scrolling; track the user's
// position so the auto-scroll resumes from where they left off.
carousel.addEventListener(
'touchstart',
() => {
touching = true;
},
{ passive: true },
);
const endTouch = () => {
touching = false;
manualUntil = performance.now() + 1500;
};
carousel.addEventListener('touchend', endTouch, { passive: true });
// A touch that turns into a page scroll ends with touchcancel, not
// touchend — without this the auto-scroll would stay off forever.
carousel.addEventListener('touchcancel', endTouch, { passive: true });
carousel.addEventListener(
'scroll',
() => {
// Covers both the drag itself and post-release momentum; each
// momentum event extends the window so a long fling is never
// interrupted by the auto-scroll snapping back mid-flight.
if (touching || performance.now() < manualUntil) {
pos = carousel.scrollLeft;
if (!touching) {
manualUntil = Math.max(manualUntil, performance.now() + 200);
}
}
},
{ passive: true },
);
carousel.addEventListener(
'wheel',
(e) => {
if (!enabled) return; // nothing to scroll — let the page scroll
const dy = e.deltaY;
const dx = e.deltaX;
const delta = Math.abs(dx) > Math.abs(dy) ? dx : dy;
if (delta === 0) return;
// While hovering the carousel, the wheel drives it (vertical
// deltas convert to horizontal motion); move the cursor off the
// tile to scroll the page.
e.preventDefault();
pos += delta;
wrap();
carousel.scrollLeft = pos;
manualUntil = performance.now() + 600;
},
{ passive: false },
);
let last = performance.now();
const tick = (now) => {
const dt = (now - last) / 1000;
last = now;
const idle = !paused && !touching && now > manualUntil;
if (enabled && idle && inView && !document.hidden && !reducedMotion.matches && halfWidth > 0) {
pos += SPEED * dt;
wrap();
carousel.scrollLeft = pos;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
})();
</script> </body></html>