308 lines
13 KiB
Plaintext
308 lines
13 KiB
Plaintext
---
|
|
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="choice-group">
|
|
<legend class="choice-group__legend">{r.sortLabel}</legend>
|
|
<div class="choices">
|
|
{(['cites', 'newest', 'oldest'] as const).map((key, i) => (
|
|
<label class="choice">
|
|
<input type="radio" name="research-sort" value={key} checked={i === 0} />
|
|
<span>{r.sort[key]}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
<fieldset class="choice-group">
|
|
<legend class="choice-group__legend">{r.filterLabel}</legend>
|
|
<div class="choices">
|
|
<label class="choice">
|
|
<input type="radio" name="research-category" value="" checked />
|
|
<span>{r.all} <span class="choice__n">{papers.length}</span></span>
|
|
</label>
|
|
{categories.map((c) => (
|
|
<label class="choice">
|
|
<input type="radio" name="research-category" value={c} />
|
|
<span>{r.categories[c] ?? c} <span class="choice__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 (.choice-group, base.css). */
|
|
.research__controls {
|
|
margin-top: var(--space-7);
|
|
display: grid;
|
|
gap: var(--space-4);
|
|
}
|
|
|
|
.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>
|