Initial commit 🚀
This commit is contained in:
commit
fd58a8d32f
235 files changed
+19775
No files matched your search
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
import sharp from 'sharp';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
const [,, dir, out, colsArg = '4', wArg = '360'] = process.argv;
|
||||
const cols = Number(colsArg), w = Number(wArg), h = Math.round(w * 0.66);
|
||||
const files = readdirSync(dir).filter(f => /\.(jpe?g|png|webp)$/i.test(f)).sort();
|
||||
const tiles = [];
|
||||
for (const [i, f] of files.entries()) {
|
||||
try {
|
||||
const buf = await sharp(join(dir, f)).resize(w, h, { fit: 'cover' }).toBuffer();
|
||||
const label = Buffer.from(`<svg width="${w}" height="24"><rect width="${w}" height="24" fill="black" opacity="0.6"/><text x="6" y="17" font-family="sans-serif" font-size="14" fill="white">${i}: ${f.slice(0, 40)}</text></svg>`);
|
||||
const tile = await sharp(buf).composite([{ input: label, top: h - 24, left: 0 }]).toBuffer();
|
||||
tiles.push({ input: tile, left: (i % cols) * w, top: Math.floor(i / cols) * h });
|
||||
} catch (e) { console.error('skip', f, e.message); }
|
||||
}
|
||||
const rows = Math.ceil(tiles.length / cols);
|
||||
await sharp({ create: { width: cols * w, height: rows * h, channels: 3, background: '#222' } }).composite(tiles).jpeg({ quality: 80 }).toFile(out);
|
||||
console.log('wrote', out, files.length, 'images');
|
||||
@@ -0,0 +1,21 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Jumps to card 3 via its dot, then back to card 1 via its dot, and reports where the page rests. */
|
||||
const [,, url, width = '1440', height = '900'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
|
||||
const dots = page.locator('[data-deck-dots] a');
|
||||
await page.evaluate((y) => window.scrollTo(0, y), snaps[2]);
|
||||
await page.waitForTimeout(800);
|
||||
const before = await page.evaluate(() => Math.round(window.scrollY));
|
||||
await dots.nth(0).click();
|
||||
await page.waitForTimeout(1500);
|
||||
const back = await page.evaluate(() => Math.round(window.scrollY));
|
||||
await dots.nth(3).click();
|
||||
await page.waitForTimeout(1500);
|
||||
const fwd = await page.evaluate(() => Math.round(window.scrollY));
|
||||
const active = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-dots] a')).map((a) => a.getAttribute('aria-current') === 'true'));
|
||||
const hint = await page.evaluate(() => { const h = document.querySelector('[data-deck-hint]'); return { href: h?.getAttribute('href'), last: h?.hasAttribute('data-last') }; });
|
||||
console.log({ snaps, before, backTo1: back, expected1: snaps[0], fwdTo4: fwd, expected4: snaps[3], active, hint });
|
||||
await browser.close();
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Turns a generated clip into a seamless looping hero background:
|
||||
# the clip plays forward, then backward, so the loop point cannot be seen.
|
||||
# Usage: encode-hero-video.sh <source.mp4> <out-basename> [width]
|
||||
set -euo pipefail
|
||||
FFMPEG="${FFMPEG:-ffmpeg}"
|
||||
SRC="$1"; OUT="$2"; W="${3:-1920}"
|
||||
FILTER="[0:v]scale=${W}:-2:flags=lanczos,fps=25,setpts=PTS-STARTPTS,split[a][b];[b]reverse,trim=start_frame=1,setpts=PTS-STARTPTS[r];[a][r]concat=n=2:v=1[v]"
|
||||
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
|
||||
-c:v libx264 -profile:v high -crf 30 -preset slow -pix_fmt yuv420p -movflags +faststart "${OUT}.mp4"
|
||||
"$FFMPEG" -y -i "$SRC" -filter_complex "$FILTER" -map "[v]" -an \
|
||||
-c:v libvpx-vp9 -crf 40 -b:v 0 -row-mt 1 -deadline good -cpu-used 2 "${OUT}.webm"
|
||||
ls -lh "${OUT}.mp4" "${OUT}.webm"
|
||||
@@ -0,0 +1,39 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Checks the hero video: does it load and play, is it gated off on phones and under reduced
|
||||
motion, and does the hero fill the viewport. */
|
||||
const [,, url, prefix] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
|
||||
async function probe(label, opts, shots = []) {
|
||||
const ctx = await browser.newContext(opts);
|
||||
const page = await ctx.newPage();
|
||||
const requested = [];
|
||||
page.on('request', (r) => { if (/\.(mp4|webm)$/.test(r.url())) requested.push(r.url().split('/').pop()); });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(2500);
|
||||
const info = await page.evaluate(() => {
|
||||
const v = document.querySelector('video');
|
||||
const hero = document.querySelector('.hero');
|
||||
return {
|
||||
hasVideo: !!v,
|
||||
ready: v?.hasAttribute('data-ready') ?? null,
|
||||
paused: v?.paused ?? null,
|
||||
currentTime: v ? Math.round(v.currentTime * 10) / 10 : null,
|
||||
videoSize: v ? `${v.videoWidth}x${v.videoHeight}` : null,
|
||||
heroHeight: Math.round(hero.getBoundingClientRect().height),
|
||||
viewport: window.innerHeight,
|
||||
headerH: Math.round(document.querySelector('header').getBoundingClientRect().height),
|
||||
};
|
||||
});
|
||||
for (const t of shots) {
|
||||
await page.waitForTimeout(t);
|
||||
await page.screenshot({ path: `${prefix}-${label}-${t}.png` });
|
||||
}
|
||||
console.log(label, JSON.stringify({ ...info, requested }));
|
||||
await ctx.close();
|
||||
}
|
||||
|
||||
await probe('desktop', { viewport: { width: 1440, height: 900 } }, [0, 2500]);
|
||||
await probe('mobile', { viewport: { width: 390, height: 844 } });
|
||||
await probe('reduced', { viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
|
||||
await browser.close();
|
||||
@@ -0,0 +1,20 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Reports where the chevron hint sits at the first snap and whether anything covers it. */
|
||||
const [,, url, width = '1440', height = '900'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const snaps = await page.evaluate(() => Array.from(document.querySelectorAll('[data-deck-snap]')).map((a) => Math.round(a.getBoundingClientRect().top + window.scrollY - parseFloat(getComputedStyle(a).scrollMarginTop))));
|
||||
await page.evaluate((y) => window.scrollTo(0, y), snaps[0]);
|
||||
await page.waitForTimeout(800);
|
||||
const info = await page.evaluate(() => {
|
||||
const h = document.querySelector('[data-deck-hint]');
|
||||
const r = h.getBoundingClientRect();
|
||||
const cs = getComputedStyle(h);
|
||||
const svg = h.querySelector('svg').getBoundingClientRect();
|
||||
const atPoint = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 10);
|
||||
const next = document.querySelectorAll('[data-deck-card]')[1].getBoundingClientRect();
|
||||
return { hint: { top: r.top, bottom: r.bottom, h: r.height }, svg: { top: svg.top, bottom: svg.bottom }, position: cs.position, display: cs.display, bottom: cs.bottom, zIndex: cs.zIndex, opacity: cs.opacity, atPoint: atPoint && (atPoint.tagName + '.' + atPoint.className), nextCardTop: next.top, innerHeight: window.innerHeight };
|
||||
});
|
||||
console.log(JSON.stringify(info, null, 1));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,14 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Hovers one node group of a schematic and captures the drawing. */
|
||||
const [,, url, out, selector, group] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const svg = page.locator(selector).first();
|
||||
await svg.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(3500);
|
||||
await svg.locator(`[data-group="${group}"] .s-ring`).first().hover({ force: true, position: { x: 6, y: 36 } });
|
||||
await page.waitForTimeout(400);
|
||||
await svg.screenshot({ path: out });
|
||||
await browser.close();
|
||||
console.log('hovered', group);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { chromium } from 'playwright';
|
||||
const browser = await chromium.launch();
|
||||
/* Phone: the sweep should still run, the hover state should not exist. */
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(process.argv[2], { waitUntil: 'networkidle' });
|
||||
await page.locator('.stages__list').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(2000);
|
||||
console.log('mobile shown:', JSON.stringify(await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('[data-reveal]')).map((o) => o.hasAttribute('data-shown')))));
|
||||
console.log('grid opacity (no hover):', await page.evaluate(() =>
|
||||
getComputedStyle(document.querySelector('.grid-object__base')).opacity));
|
||||
await page.screenshot({ path: process.argv[3] });
|
||||
await browser.close();
|
||||
@@ -0,0 +1,16 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Scrolls a target into view and captures it at several moments, to see a draw-in happen. */
|
||||
const [,, url, prefix, selector, width = '1440', times = '150,700,1600,3200'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const el = page.locator(selector).first();
|
||||
await el.scrollIntoViewIfNeeded();
|
||||
const t0 = Date.now();
|
||||
for (const t of times.split(',').map(Number)) {
|
||||
const wait = t - (Date.now() - t0);
|
||||
if (wait > 0) await page.waitForTimeout(wait);
|
||||
await el.screenshot({ path: `${prefix}-${t}.png` });
|
||||
}
|
||||
await browser.close();
|
||||
console.log('captured', selector);
|
||||
@@ -0,0 +1,31 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* For every grid object: does the drawn geometry fit inside its viewBox, and is the drawing
|
||||
centred in the space its card gives it. */
|
||||
const [,, url, selector, width = '1440'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 1000 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const rows = await page.evaluate((sel) => {
|
||||
return Array.from(document.querySelectorAll(sel)).map((svg, i) => {
|
||||
const vb = svg.viewBox.baseVal;
|
||||
const b = svg.querySelector('g').getBBox();
|
||||
const overflow = {
|
||||
left: +(vb.x - b.x).toFixed(1),
|
||||
right: +((b.x + b.width) - (vb.x + vb.width)).toFixed(1),
|
||||
top: +(vb.y - b.y).toFixed(1),
|
||||
bottom: +((b.y + b.height) - (vb.y + vb.height)).toFixed(1),
|
||||
};
|
||||
const clipped = Object.values(overflow).some((v) => v > 0.5);
|
||||
/* Vertical centring: the gap above the drawing vs the gap below it, inside the card. */
|
||||
const card = svg.closest('li');
|
||||
const title = card.querySelector('h2, h3');
|
||||
const body = card.querySelector('p');
|
||||
const r = svg.getBoundingClientRect();
|
||||
const above = +(r.top - title.getBoundingClientRect().bottom).toFixed(1);
|
||||
const below = +(body.getBoundingClientRect().top - r.bottom).toFixed(1);
|
||||
return { i, viewBox: `${vb.x} ${vb.y} ${vb.width} ${vb.height}`, clipped, overflow, above, below, offBy: +(above - below).toFixed(1) };
|
||||
});
|
||||
}, selector);
|
||||
console.table(rows);
|
||||
console.log('any clipped:', rows.some((r) => r.clipped));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,39 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Does the panel start at the title and end with the list, and hold that box across steps? */
|
||||
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
|
||||
const rows = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.evaluate((i) => {
|
||||
const t = document.querySelectorAll('[data-trigger]')[i];
|
||||
const r = t.getBoundingClientRect();
|
||||
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
|
||||
}, i);
|
||||
await page.waitForTimeout(900);
|
||||
rows.push(await page.evaluate(() => {
|
||||
const title = document.querySelector('.how__title').getBoundingClientRect();
|
||||
const steps = Array.from(document.querySelectorAll('[data-step]'));
|
||||
const last = steps[steps.length - 1].getBoundingClientRect();
|
||||
const fig = document.querySelector('[data-figure][data-active]').getBoundingClientRect();
|
||||
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
|
||||
const container = document.querySelector('.how__sticky');
|
||||
const cs = getComputedStyle(container);
|
||||
const rightEdge = sticky.right - parseFloat(cs.paddingRight);
|
||||
return {
|
||||
active: steps.findIndex((s) => s.hasAttribute('data-active')),
|
||||
panel: `${Math.round(fig.width)}x${Math.round(fig.height)}`,
|
||||
topVsTitle: Math.round(fig.top - title.top),
|
||||
bottomVsList: Math.round(fig.bottom - last.bottom),
|
||||
rightVsEdge: Math.round(fig.right - rightEdge),
|
||||
listFits: Math.round(sticky.bottom - last.bottom),
|
||||
spaceAbove: Math.round(fig.top - sticky.top),
|
||||
spaceBelow: Math.round(sticky.bottom - fig.bottom),
|
||||
};
|
||||
}));
|
||||
await page.screenshot({ path: `${prefix}-${i}.png` });
|
||||
}
|
||||
console.table(rows);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, prefix, selector, width = '390'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const root = page.locator(selector);
|
||||
const tabs = root.locator('[role="tab"]');
|
||||
const n = await tabs.count();
|
||||
for (let i = 0; i < n; i++) {
|
||||
await tabs.nth(i).click();
|
||||
await page.waitForTimeout(500);
|
||||
const panel = root.locator('[role="tabpanel"][data-open]').first();
|
||||
await panel.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(200);
|
||||
await panel.screenshot({ path: `${prefix}-${i}.png` });
|
||||
}
|
||||
console.log('panels', n);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Walks the pinned section step by step: which step is active, does the text column fit
|
||||
inside the pinned area, and is the panel where it should be. */
|
||||
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const count = await page.evaluate(() => document.querySelectorAll('[data-trigger]').length);
|
||||
const rows = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.evaluate((i) => {
|
||||
const t = document.querySelectorAll('[data-trigger]')[i];
|
||||
const r = t.getBoundingClientRect();
|
||||
window.scrollTo(0, window.scrollY + r.top + r.height / 2 - window.innerHeight / 2);
|
||||
}, i);
|
||||
await page.waitForTimeout(900);
|
||||
const info = await page.evaluate(() => {
|
||||
const steps = Array.from(document.querySelectorAll('[data-step]'));
|
||||
const active = steps.findIndex((s) => s.hasAttribute('data-active'));
|
||||
const sticky = document.querySelector('.how__sticky').getBoundingClientRect();
|
||||
const text = document.querySelector('.how__text');
|
||||
const first = text.getBoundingClientRect();
|
||||
const last = steps[steps.length - 1].getBoundingClientRect();
|
||||
const fig = document.querySelector('[data-figure][data-active]');
|
||||
const f = fig ? fig.getBoundingClientRect() : null;
|
||||
const svg = fig ? fig.querySelector('svg').getBoundingClientRect() : null;
|
||||
return {
|
||||
active,
|
||||
textTop: Math.round(first.top - sticky.top),
|
||||
spaceBelowList: Math.round(sticky.bottom - last.bottom),
|
||||
panel: f ? `${Math.round(f.width)}x${Math.round(f.height)}` : null,
|
||||
drawing: svg ? `${Math.round(svg.width)}x${Math.round(svg.height)}` : null,
|
||||
panelInside: f ? (f.top >= sticky.top - 1 && f.bottom <= sticky.bottom + 1) : null,
|
||||
};
|
||||
});
|
||||
rows.push({ step: i, ...info });
|
||||
await page.screenshot({ path: `${prefix}-${i}.png` });
|
||||
}
|
||||
console.table(rows);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Give every paper in src/data/research.json its own Chladni figure and colour field.
|
||||
|
||||
Each paper gets `figure` (parameters for src/scripts/chladni.ts) and `field` (three colour tokens
|
||||
and their positions), seeded from the paper id so a re-run gives the same result and a new paper
|
||||
gets its own. Generation follows the rules learned while hand-tuning the first figures:
|
||||
|
||||
- plate windows stay clear of both axes and of any coordinate where n·t and m·t are both whole
|
||||
(the field is zero along a straight line there), plates are never mirrored (the seams read as
|
||||
straight lines), and the
|
||||
weights avoid a = ±b (which zeroes the diagonals u = ±v);
|
||||
- hover keeps both plate terms in play (a single sin·sin term is a straight grid);
|
||||
- source figures use three to six sources (two sources draw a straight bisector), spread over
|
||||
enough of a wavelength that they interfere (radius × frequency >= 0.6 for five or six sources,
|
||||
0.35 for three or four); packed tighter they collapse into plain concentric rings;
|
||||
- one colour patch sits in the middle of the card, so no field is mostly black;
|
||||
- line density, the share of grid cells a nodal line passes through, stays within the range of
|
||||
the hand-picked figures, so no card is near-empty or a scribble.
|
||||
|
||||
A paper that already has a figure keeps it unless --regenerate is passed, so hand edits survive.
|
||||
Usage: python3 scripts/research-figures.py [--regenerate] [--report]
|
||||
"""
|
||||
import hashlib, json, math, random, sys
|
||||
|
||||
PATH = 'src/data/research.json'
|
||||
ASPECT = 3 # the card canvas is 3:1
|
||||
Q = 61 # grid for the density check (the site renders at 121)
|
||||
|
||||
COLOURS = [
|
||||
'logo-deep', 'logo-mid', 'logo-teal', 'logo-cyan',
|
||||
'field-indigo', 'field-petrol', 'field-sea', 'field-steel', 'field-dusk',
|
||||
'field-cobalt', 'field-ocean', 'field-glacier', 'field-aqua', 'field-jade', 'field-slate', 'field-plum',
|
||||
]
|
||||
# Light tones carry the glow; at most one per field, so every card keeps a dark side.
|
||||
LIGHT = {'logo-cyan', 'field-glacier', 'field-aqua', 'logo-teal'}
|
||||
|
||||
|
||||
def field_fn(f, shape=None):
|
||||
s = {**f, **(shape or {})}
|
||||
if f['mode'] == 'plate':
|
||||
a, b, n, m = s['a'], s['b'], s['n'], s['m']
|
||||
rot = s.get('rotation', 0)
|
||||
c, si = math.cos(rot), math.sin(rot)
|
||||
ox, oy = f['origin']
|
||||
def z(x, y):
|
||||
dx, dy = x - ox, y - oy
|
||||
u, v = ox + dx * c - dy * si, oy + dx * si + dy * c
|
||||
return a * math.sin(math.pi * n * u) * math.sin(math.pi * m * v) + b * math.sin(math.pi * m * u) * math.sin(math.pi * n * v)
|
||||
return z
|
||||
pts = []
|
||||
for i in range(f['sources']):
|
||||
t = i / f['sources'] * 2 * math.pi - math.pi / 2 + s.get('spin', 0)
|
||||
pts.append((f['origin'][0] + s['radius'] * math.cos(t), f['origin'][1] + s['radius'] * math.sin(t)))
|
||||
k = 2 * math.pi * s['frequency']
|
||||
inv = 1 / s['elasticity']
|
||||
return lambda x, y: sum(math.sin(k * (s['phase'] - inv * math.hypot(x - px, y - py))) for px, py in pts)
|
||||
|
||||
|
||||
def density(f, shape=None):
|
||||
z = field_fn(f, shape)
|
||||
h = f['length']; w = h * ASPECT
|
||||
x0, y0 = f['origin'][0] - w / 2, f['origin'][1] - h / 2
|
||||
g = [[z(x0 + c * w / (Q - 1), y0 + r * h / (Q - 1)) > 0 for c in range(Q)] for r in range(Q)]
|
||||
cells = sum(1 for r in range(Q - 1) for c in range(Q - 1) if len({g[r][c], g[r][c + 1], g[r + 1][c], g[r + 1][c + 1]}) > 1)
|
||||
return cells / (Q - 1) ** 2
|
||||
|
||||
|
||||
def straight_lines(n, m, origin, hx, hy):
|
||||
"""True if the window crosses a coordinate where n·t and m·t are both whole: both terms of the
|
||||
plate vanish there for every value of the other coordinate, which draws a straight line."""
|
||||
for centre, half in ((origin[0], hx), (origin[1], hy)):
|
||||
steps = 400
|
||||
for i in range(steps + 1):
|
||||
t = centre - half + 2 * half * i / steps
|
||||
if abs(n * t - round(n * t)) < 0.03 and abs(m * t - round(m * t)) < 0.03:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def rnd(rng, lo, hi, step=0.01):
|
||||
return round(rng.uniform(lo, hi) / step) * step
|
||||
|
||||
|
||||
def make_plate(rng, rotated):
|
||||
while True:
|
||||
n, m = rnd(rng, 1.1, 4.6), rnd(rng, 1.1, 4.6)
|
||||
if abs(n - m) < 0.35: continue
|
||||
length = rnd(rng, 1.0, 2.8)
|
||||
if not (2.4 <= max(n, m) * length <= 8.5): continue
|
||||
a = rnd(rng, 0.8, 1.7) * rng.choice([-1, 1])
|
||||
b = rnd(rng, 0.4, 1.5) * rng.choice([-1, 1])
|
||||
if abs(abs(a) - abs(b)) < 0.25: continue
|
||||
rotation = rnd(rng, 0.2, 1.3) if rotated else 0
|
||||
# Keep the whole (rotated) window off both axes.
|
||||
half_diag = math.hypot(length * ASPECT / 2, length / 2) if rotated else None
|
||||
hx = half_diag if rotated else length * ASPECT / 2
|
||||
hy = half_diag if rotated else length / 2
|
||||
origin = [-round(hx + rnd(rng, 0.3, 2.5), 2), round(hy + rnd(rng, 0.3, 2.5), 2)]
|
||||
f = {'mode': 'plate', 'a': a, 'b': b, 'n': n, 'm': m, 'origin': origin, 'length': length}
|
||||
if rotated: f['rotation'] = rotation
|
||||
if straight_lines(n, m, origin, hx, hy): continue
|
||||
# Hover: flip the dominant weight and bring it down to 40-75 % of the other. Never near zero
|
||||
# (a single sin·sin term draws a grid of straight lines) and never near a = ±b.
|
||||
ha = None
|
||||
for _ in range(20):
|
||||
cand = round(-math.copysign(1, a) * abs(b) * rng.uniform(0.4, 0.75), 2)
|
||||
if abs(abs(cand) - abs(b)) >= 0.2:
|
||||
ha = cand; break
|
||||
if ha is None: continue
|
||||
f['hover'] = {'a': ha}
|
||||
return f
|
||||
|
||||
|
||||
def make_sources(rng):
|
||||
while True:
|
||||
count = rng.choice([3, 3, 4, 4, 5, 6])
|
||||
length = rnd(rng, 1.8, 2.8)
|
||||
f = {
|
||||
'mode': 'sources', 'sources': count,
|
||||
'frequency': rnd(rng, 1.0, 2.5), 'phase': rnd(rng, 0, 0.5), 'elasticity': 1,
|
||||
'radius': rnd(rng, 0.3, min(1.1, length / 2.2)),
|
||||
'origin': [rnd(rng, -1.3, 1.3), rnd(rng, -0.4, 0.4)], 'length': length,
|
||||
'spin': rnd(rng, 0, 2 * math.pi / count),
|
||||
}
|
||||
if f['radius'] * f['frequency'] < (0.6 if count >= 5 else 0.35): continue
|
||||
f['hover'] = {'phase': round(f['phase'] + rnd(rng, 0.35, 0.55), 2), 'spin': round(f['spin'] + rnd(rng, 0.35, 0.65), 2)}
|
||||
return f
|
||||
|
||||
|
||||
def make_field(rng):
|
||||
while True:
|
||||
cols = rng.sample(COLOURS, 3)
|
||||
if sum(c in LIGHT for c in cols) <= 1: break
|
||||
pos = [[rnd(rng, 30, 70, 1), rnd(rng, 30, 70, 1)]] + [[rnd(rng, 5, 95, 1), rnd(rng, 5, 95, 1)] for _ in range(2)]
|
||||
return {'colors': cols, 'positions': pos}
|
||||
|
||||
|
||||
def generate(pid, lo, hi):
|
||||
rng = random.Random(int(hashlib.sha256(pid.encode()).hexdigest(), 16))
|
||||
for _ in range(200):
|
||||
kind = rng.choices(['sources', 'plate', 'rotated'], [0.45, 0.35, 0.2])[0]
|
||||
f = make_sources(rng) if kind == 'sources' else make_plate(rng, kind == 'rotated')
|
||||
d0, d1 = density(f), density(f, f['hover'])
|
||||
if lo <= d0 <= hi and lo * 0.8 <= d1 <= hi * 1.2:
|
||||
return f, make_field(rng), d0
|
||||
raise SystemExit('no acceptable figure for ' + pid)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
data = json.load(open(PATH))
|
||||
# Density band, calibrated on the 18 hand-picked figures (0.24 to 0.51 on this grid), without
|
||||
# the two busiest, which read as scribbles at small card sizes.
|
||||
lo, hi = 0.23, 0.45
|
||||
regen = '--regenerate' in sys.argv
|
||||
for p in data['papers']:
|
||||
if 'figure' in p and not regen: continue
|
||||
p['figure'], p['field'], d = generate(p['id'], lo, hi)
|
||||
if '--report' in sys.argv: print(f"{d:.3f} {p['figure']['mode']:8} {p['id'][:50]}")
|
||||
json.dump(data, open(PATH, 'w'), indent=2, ensure_ascii=False)
|
||||
@@ -0,0 +1,19 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, prefix, width = '1440', height = '900', max = '12'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
// trigger lazy loading by scrolling through
|
||||
const total = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
for (let y = 0; y < total; y += 600) { await page.evaluate((y) => window.scrollTo(0, y), y); await page.waitForTimeout(150); }
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(800);
|
||||
const h = Number(height);
|
||||
const n = Math.min(Number(max), Math.ceil(total / h));
|
||||
for (let i = 0; i < n; i++) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), i * h);
|
||||
await page.waitForTimeout(700);
|
||||
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png`, fullPage: false });
|
||||
}
|
||||
console.log('total height', total, 'shots', n);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,12 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, out, width = '1440', full = 'true'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 900 }, deviceScaleFactor: 1 });
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
const total = await page.evaluate(() => document.documentElement.scrollHeight);
|
||||
for (let y = 0; y < total; y += 500) { await page.evaluate((y) => window.scrollTo(0, y), y); await page.waitForTimeout(120); }
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(1200);
|
||||
await page.screenshot({ path: out, fullPage: full === 'true' });
|
||||
await browser.close();
|
||||
console.log('saved', out);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Scrolls to positions around each deck card's snap point and reports where the page comes to rest. */
|
||||
const [,, url, width = '1440', height = '900'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const info = await page.evaluate(() => {
|
||||
const cards = Array.from(document.querySelectorAll('[data-deck-snap]'));
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
return cards.map((c, i) => {
|
||||
const st = getComputedStyle(c);
|
||||
return { i, naturalTop: c.offsetTop + (c.offsetParent?.getBoundingClientRect().top ?? 0) + window.scrollY, marginTop: st.scrollMarginTop, snapAlign: st.scrollSnapAlign, snapStop: st.scrollSnapStop, htmlSnap: cs.scrollSnapType };
|
||||
});
|
||||
});
|
||||
console.log(JSON.stringify(info, null, 1));
|
||||
// expected snap points: naturalTop - scrollMarginTop
|
||||
const results = [];
|
||||
for (const c of info) {
|
||||
const expected = Math.round(c.naturalTop - parseFloat(c.marginTop));
|
||||
for (const off of [-220, 180, 320, 352]) {
|
||||
await page.evaluate((y) => window.scrollTo({ top: y, behavior: 'auto' }), expected + off);
|
||||
await page.waitForTimeout(1200);
|
||||
const y = await page.evaluate(() => Math.round(window.scrollY));
|
||||
results.push({ card: c.i, expected, offset: off, landed: y, snapped: Math.abs(y - expected) < 2 });
|
||||
}
|
||||
}
|
||||
console.table(results);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,13 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
|
||||
for (let i = 0; i < Number(count); i++) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
|
||||
await page.waitForTimeout(i === 0 ? 3000 : 1800);
|
||||
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
|
||||
}
|
||||
await browser.close();
|
||||
console.log('stack shots', count);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
|
||||
for (let i = 0; i < Number(count); i++) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
|
||||
await page.waitForTimeout(i === 0 ? 2500 : 1500);
|
||||
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
|
||||
}
|
||||
await browser.close();
|
||||
console.log('stack shots', count);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Scrolls from an anchor downwards in steps, capturing the viewport each time. */
|
||||
const [,, url, prefix, anchor, width = '1440', height = '900', step = '450', count = '10'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const start = await page.evaluate((sel) => document.querySelector(sel).getBoundingClientRect().top + window.scrollY, anchor);
|
||||
for (let i = 0; i < Number(count); i++) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), start + i * Number(step));
|
||||
await page.waitForTimeout(i === 0 ? 3000 : 1800);
|
||||
await page.screenshot({ path: `${prefix}-${String(i).padStart(2, '0')}.png` });
|
||||
}
|
||||
await browser.close();
|
||||
console.log('stack shots', count);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { chromium } from 'playwright';
|
||||
/* Does the pinned panel hold from the first step to the last, and let go cleanly after? */
|
||||
const [,, url, prefix, width = '1440', height = '900'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const geo = await page.evaluate(() => {
|
||||
const sc = document.querySelector('.how__scroller').getBoundingClientRect();
|
||||
return { top: Math.round(sc.top + window.scrollY), height: Math.round(sc.height) };
|
||||
});
|
||||
const marks = { 'enter': geo.top, 'mid': geo.top + geo.height / 2, 'lastStep': geo.top + geo.height - (window?.innerHeight ?? 900), 'exit': geo.top + geo.height - 200 };
|
||||
for (const [name, y] of Object.entries(marks)) {
|
||||
await page.evaluate((y) => window.scrollTo(0, y), y);
|
||||
await page.waitForTimeout(800);
|
||||
const info = await page.evaluate(() => {
|
||||
const st = document.querySelector('.how__sticky').getBoundingClientRect();
|
||||
const active = Array.from(document.querySelectorAll('[data-step]')).findIndex((s) => s.hasAttribute('data-active'));
|
||||
return { stickyTop: Math.round(st.top), stickyBottom: Math.round(st.bottom), active };
|
||||
});
|
||||
console.log(name.padEnd(9), JSON.stringify(info));
|
||||
await page.screenshot({ path: `${prefix}-${name}.png` });
|
||||
}
|
||||
console.log('scroller', JSON.stringify(geo));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, prefix, selector, width = '1440'] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: Number(width), height: 1100 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const root = page.locator(selector);
|
||||
const tabs = root.locator('[role="tab"]');
|
||||
const n = await tabs.count();
|
||||
for (let i = 0; i < n; i++) {
|
||||
await tabs.nth(i).click();
|
||||
await page.waitForTimeout(450);
|
||||
const stage = root.locator('[data-stage-item][data-active]').first();
|
||||
await stage.scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(200);
|
||||
await stage.screenshot({ path: `${prefix}-${i}.png` });
|
||||
}
|
||||
console.log('tabs', n);
|
||||
await browser.close();
|
||||
@@ -0,0 +1,17 @@
|
||||
import { chromium } from 'playwright';
|
||||
const [,, url, out] = process.argv;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
await page.goto(url, { waitUntil: 'networkidle' });
|
||||
const tabs = page.locator('[role="tab"]');
|
||||
await tabs.nth(0).focus();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.waitForTimeout(500);
|
||||
const selected = await page.evaluate(() => Array.from(document.querySelectorAll('[role="tab"]')).map(t => t.getAttribute('aria-selected')));
|
||||
const activeImg = await page.evaluate(() => Array.from(document.querySelectorAll('[data-stage-item]')).map(p => p.hasAttribute('data-active')));
|
||||
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim());
|
||||
console.log({ selected, activeImg, focused });
|
||||
await page.locator('#sectors').scrollIntoViewIfNeeded();
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: out, fullPage: false });
|
||||
await browser.close();
|
||||
Reference in new issue
Block a user