This commit is contained in:
Felix Klement committed 2026-10-03 19:35:22 +02:00
1 parent c8740fb1d3
commit c3f4651e71
11 files changed
+3827 -2

No files matched your search

+332
View File
@@ -0,0 +1,332 @@
---
import data from '../../data/research.json';
import { t, type Lang } from '../../i18n/ui';
import type { ChladniParams } from '../../scripts/chladni';
interface Props { lang: Lang }
const { lang } = Astro.props;
const ui = t(lang);
const r = ui.team.research;
/* Papers from both founders' Google Scholar profiles, deduplicated (src/data/research.json).
Rendered most cited first, so the page reads right without script; the script below re-sorts,
filters and folds the list. Entries marked display: false stay in the data only. */
const papers = data.papers.filter((p) => p.display && p.href);
const categories = data.categories.filter((c) => papers.some((p) => p.categories.includes(c)));
const countIn = (c: string) => papers.filter((p) => p.categories.includes(c)).length;
const INITIAL = 9;
const labels = { showAll: r.showAll(papers.length), showAllTpl: r.showAll(999).replace('999', '{n}'), showFewer: r.showFewer, count: r.count(111, 999).replace('111', '{shown}').replace('999', '{total}') };
/* Every paper carries its own figure (Chladni parameters for src/scripts/chladni.ts) and colour
field (three tokens and their positions) in research.json, generated per paper by
scripts/research-figures.py. They travel with the paper, so sorting and filtering never move them. */
const figure = (paper: (typeof papers)[number]) => ({ quality: 121, ...paper.figure }) as ChladniParams;
const fieldStyle = (paper: (typeof papers)[number]) =>
paper.field.colors.map((c, k) => `--c${k + 1}: var(--${c}); --ax${k + 1}: ${paper.field.positions[k][0]}%; --ay${k + 1}: ${paper.field.positions[k][1]}%;`).join(' ');
---
<section class="section research" aria-labelledby="research-title">
<div class="container">
<div class="grid research__head">
<h2 id="research-title" class="h2 research__title">
{ui.team.research.title.map((line) => <span class="research__line">{line}</span>)}
</h2>
<p class="research__intro muted">{ui.team.research.intro}</p>
</div>
<div class="research__controls" data-research-controls hidden>
<fieldset class="research__group">
<legend class="research__legend">{r.sortLabel}</legend>
<div class="research__options">
{(['cites', 'newest', 'oldest'] as const).map((key, i) => (
<label class="research__option">
<input type="radio" name="research-sort" value={key} checked={i === 0} />
<span>{r.sort[key]}</span>
</label>
))}
</div>
</fieldset>
<fieldset class="research__group">
<legend class="research__legend">{r.filterLabel}</legend>
<div class="research__options research__options--wrap">
<label class="research__option">
<input type="radio" name="research-category" value="" checked />
<span>{r.all} <span class="research__n">{papers.length}</span></span>
</label>
{categories.map((c) => (
<label class="research__option">
<input type="radio" name="research-category" value={c} />
<span>{r.categories[c] ?? c} <span class="research__n">{countIn(c)}</span></span>
</label>
))}
</div>
</fieldset>
</div>
<ul class="research__list" role="list" data-research-list data-initial={INITIAL}>
{papers.map((paper, i) => (
<li
class="research__item"
data-cites={paper.cites}
data-date={paper.date ?? String(paper.year)}
data-order={i}
data-categories={paper.categories.join('|')}
>
<a class="paper" href={paper.href} rel="noopener" lang="en">
<div class="paper__field" style={fieldStyle(paper)}>
<canvas class="paper__lines" aria-hidden="true" data-chladni={JSON.stringify(figure(paper))}></canvas>
<span class="paper__year">{paper.year}</span>
</div>
<div class="paper__body">
<h3 class="paper__title">{paper.title}</h3>
<p class="paper__venue small muted">{paper.venue}</p>
<div class="paper__meta small">
<span class="muted" lang={lang}>{paper.categories.map((c) => r.categories[c] ?? c).join(', ')}</span>
</div>
</div>
</a>
</li>
))}
</ul>
<div class="research__foot">
<p class="small muted" aria-live="polite" data-research-count></p>
<button class="research__more" type="button" data-research-more hidden data-labels={JSON.stringify(labels)}>{labels.showAll}</button>
</div>
</div>
</section>
<style>
/* Registered so the field positions can transition on hover. */
@property --x1 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y1 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --x2 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y2 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --x3 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y3 { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
.research__line { display: block; }
.research__title { grid-column: 1 / -1; }
.research__intro { grid-column: 1 / -1; max-width: var(--measure); }
@media (min-width: 48rem) {
.research__title { grid-column: 1 / span 7; }
.research__intro { grid-column: 9 / span 4; align-self: end; }
}
.research__list {
margin-top: var(--space-8);
display: grid;
gap: var(--gap);
}
@media (min-width: 40rem) { .research__list { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .research__list { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.paper {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
border: var(--hairline) solid var(--line);
text-decoration: none;
transition: border-color var(--dur-fast) var(--ease);
}
.paper:hover { border-color: var(--ink-muted); }
.paper:hover .paper__title {
text-decoration: underline;
text-decoration-thickness: var(--hairline);
text-underline-offset: 0.14em;
}
.paper__field {
--x1: var(--ax1); --y1: var(--ay1);
--x2: var(--ax2); --y2: var(--ay2);
--x3: var(--ax3); --y3: var(--ay3);
position: relative;
aspect-ratio: 316 / 170;
overflow: hidden;
background-color: var(--night);
background-image:
radial-gradient(80% 110% at var(--x1) var(--y1), var(--c1), transparent 70%),
radial-gradient(70% 100% at var(--x2) var(--y2), var(--c2), transparent 68%),
radial-gradient(95% 130% at var(--x3) var(--y3), var(--c3), transparent 72%);
transition:
--x1 var(--dur-slow) var(--ease), --y1 var(--dur-slow) var(--ease),
--x2 var(--dur-slow) var(--ease), --y2 var(--dur-slow) var(--ease),
--x3 var(--dur-slow) var(--ease), --y3 var(--dur-slow) var(--ease);
}
/* Hover answers the pointer: the fields drift a little, nothing lifts or glows. */
.paper:hover .paper__field {
--x1: calc(var(--ax1) + 7%); --y1: calc(var(--ay1) - 6%);
--x2: calc(var(--ax2) - 6%); --y2: calc(var(--ay2) + 7%);
--x3: calc(var(--ax3) + 5%); --y3: calc(var(--ay3) + 5%);
}
/* Film grain over the colour. */
.paper__field::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: overlay;
opacity: 0.6;
}
/* The Chladni lines: a 3:1 canvas centred on the field and cropped by it, so the figure keeps
its proportions at every card width. Stroke colour comes from `color`. */
.paper__lines {
position: absolute;
top: 0;
left: 50%;
height: 100%;
aspect-ratio: 3 / 1;
transform: translateX(-50%);
color: color-mix(in srgb, var(--paper) 72%, transparent);
pointer-events: none;
}
.paper__year {
position: absolute;
top: var(--space-3);
left: var(--space-3);
z-index: 1;
padding: var(--space-1) var(--space-2);
background: var(--paper);
color: var(--ink);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
line-height: 1;
}
.paper__body {
display: grid;
align-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
}
.paper__title {
font-size: var(--step-body);
font-weight: var(--weight-medium);
line-height: var(--leading-small);
letter-spacing: 0;
}
/* Controls: segmented rows in the menu bar's language, hairline cells and mono caps. */
.research__controls {
margin-top: var(--space-7);
display: grid;
gap: var(--space-4);
}
.research__group { border: 0; padding: 0; margin: 0; min-width: 0; display: grid; gap: var(--space-2); }
.research__legend { padding: 0; color: var(--ink-muted); font-size: var(--step-small); }
/* Each option draws its own frame and overlaps its neighbours by one line, so a wrapped row
never leaves a rule running past its last option. */
.research__options { display: flex; flex-wrap: wrap; padding: 0 var(--hairline) var(--hairline) 0; }
.research__option { position: relative; display: flex; margin: 0 calc(-1 * var(--hairline)) calc(-1 * var(--hairline)) 0; }
.research__option input { position: absolute; opacity: 0; inset: 0; margin: 0; cursor: pointer; }
.research__option > span {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border: var(--hairline) solid var(--line);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
text-transform: uppercase;
line-height: 1;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.research__option:hover > span { background: color-mix(in srgb, var(--ink) var(--tint-hover), transparent); }
.research__option input:checked + span { background: var(--ink); color: var(--paper); }
.research__option input:focus-visible + span { outline: var(--focus-width) solid var(--ink); outline-offset: calc(-1 * var(--focus-width) - 2px); }
.research__n { color: inherit; opacity: 0.6; }
.research__list { margin-top: var(--space-6); scroll-margin-top: calc(var(--header-height) + var(--space-5)); }
.research__item[hidden] { display: none; }
.paper__meta {
padding-top: var(--space-3);
border-top: var(--hairline) solid var(--line);
}
.research__foot {
margin-top: var(--space-6);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4) var(--space-6);
}
.research__more {
padding: var(--space-3) var(--space-5);
border: var(--hairline) solid var(--ink);
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
letter-spacing: var(--track-caps);
text-transform: uppercase;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.research__more:hover { background: var(--ink); color: var(--paper); }
</style>
<script>
import { mountChladni, type ChladniParams } from '../../scripts/chladni';
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/* Figures draw when their card comes near the viewport: 39 canvases, most never seen. */
const lazy = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const canvas = entry.target as HTMLCanvasElement;
lazy.unobserve(canvas);
const card = canvas.closest<HTMLElement>('.paper');
if (card) mountChladni(canvas, card, JSON.parse(canvas.dataset.chladni!) as ChladniParams, reduce);
}
}, { rootMargin: '300px 0px' });
document.querySelectorAll<HTMLCanvasElement>('[data-chladni]').forEach((c) => lazy.observe(c));
/* Sort, filter, fold. The server renders every card most cited first; this only re-orders and
hides list items, so links, figures and focus stay intact. */
const list = document.querySelector<HTMLElement>('[data-research-list]');
const controls = document.querySelector<HTMLElement>('[data-research-controls]');
const more = document.querySelector<HTMLButtonElement>('[data-research-more]');
const count = document.querySelector<HTMLElement>('[data-research-count]');
if (list && controls && more && count) {
const items = [...list.querySelectorAll<HTMLElement>('.research__item')];
const initial = Number(list.dataset.initial) || 9;
const labels = JSON.parse(more.dataset.labels!) as { showAllTpl: string; showFewer: string; count: string };
let expanded = false;
controls.hidden = false;
const value = (name: string) => controls.querySelector<HTMLInputElement>(`input[name="${name}"]:checked`)?.value ?? '';
const compare: Record<string, (a: HTMLElement, b: HTMLElement) => number> = {
cites: (a, b) => Number(b.dataset.cites) - Number(a.dataset.cites) || Number(a.dataset.order) - Number(b.dataset.order),
newest: (a, b) => (b.dataset.date ?? '').localeCompare(a.dataset.date ?? '') || Number(b.dataset.cites) - Number(a.dataset.cites),
oldest: (a, b) => (a.dataset.date ?? '').localeCompare(b.dataset.date ?? '') || Number(b.dataset.cites) - Number(a.dataset.cites),
};
const render = () => {
const sort = value('research-sort') || 'cites';
const category = value('research-category');
const matching = items.filter((li) => !category || li.dataset.categories!.split('|').includes(category));
matching.sort(compare[sort]);
const visible = expanded ? matching : matching.slice(0, initial);
for (const li of items) li.hidden = true;
for (const li of matching) { list.append(li); }
for (const li of visible) li.hidden = false;
count.textContent = labels.count.replace('{shown}', String(visible.length)).replace('{total}', String(matching.length));
more.hidden = matching.length <= initial;
more.textContent = expanded ? labels.showFewer : labels.showAllTpl.replace('{n}', String(matching.length));
more.setAttribute('aria-expanded', String(expanded));
};
controls.addEventListener('change', () => { expanded = false; render(); });
more.addEventListener('click', () => {
expanded = !expanded;
render();
if (!expanded) list.scrollIntoView({ block: 'start' });
});
render();
}
</script>