diff --git a/404.html b/404.html index a6032e8..11c4b4b 100644 --- a/404.html +++ b/404.html @@ -17,9 +17,7 @@
- +

- This cell died out + Nothing to draw here

diff --git a/README.md b/README.md index f93ec6e..e5068d3 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ Live at **https://eggzec.github.io/**. A hand built static site: Vite and TypeScript, with no UI framework. Animation uses [Motion](https://motion.dev) plus -[Lenis](https://lenis.darkroom.engineering) for smooth scrolling, and a canvas -Game of Life for the hero texture. +[Lenis](https://lenis.darkroom.engineering) for smooth scrolling, and an SVG +build-on of the logo mark for the hero. ## Layout @@ -15,13 +15,13 @@ Game of Life for the hero texture. index.html landing page 404.html custom not found page projects/ full project catalog, filterable by category -community/ maintainers, plus one page each +community/ maintainers partials/head.html shared , injected at build time public/brand/ logo, project icons src/ main.ts single entry, mounts whatever a page contains data/site.ts projects and people. Edit content here - components/ nav, footer, cards, life field, reveals, marquee + components/ nav, footer, cards, mark draw, reveals, marquee lib/eggzec-block.ts the eggzec Block bitmap display font, as TypeScript styles/ tokens, base, layout, components, pages ``` @@ -48,18 +48,28 @@ Almost everything is data rather than markup, and it all lives in | --- | --- | | Projects, taglines, repository and docs and PyPI links | `PROJECTS` | | Human facing name when the repo name is not one | `display` on a project | -| Which projects lead the page | any project with an `icon` | +| Which projects lead the landing page | `LEAD` in `src/data/site.ts` | +| Who builds on our packages | `src/data/used-by.ts` | | Maintainers | `PEOPLE` | | Nav links | `NAV` | | Footer links | `COLUMNS` in `src/components/footer.ts` | -A project is featured on the landing page and in the nav menu when it has an -`icon`. That keeps the featured set in sync with the brand icon files under -`public/brand/icons/` automatically, with no second list to maintain. +`LEAD` names the projects the landing page and the nav menu open with, in +order. That used to be derived from which projects carried a brand icon, but +nearly all of them do now, so it stopped selecting anything — what a first-time +visitor should see first is a decision, not a side effect. The full catalog on +`/projects/` is grouped by category only, with no separate featured block, so no +card appears twice. Landing page statistics are counted from the catalog at runtime, so the numbers cannot drift from what is actually listed. +`src/data/used-by.ts` is the one generated file. It comes from GitHub's +dependency graph — the `network/dependents` listing for each repository — +filtered to named organisations and checked one at a time against the API. Only +pydoe, pyswarm, mcerp and soerp have any dependents, so that is what the list +draws on. Regenerate it by hand rather than trusting it to stay current. + ## Design Colours are the Electropop palette on white: @@ -68,7 +78,7 @@ Colours are the Electropop palette on white: | --- | --- | | Block fills, buttons, knockouts | Kiwi `#CCFF00` | | Accent text and links | Violet `#5200FF` | -| Sparks and newly born cells | Magenta `#F900FF` | +| Sparks and the byte cells | Magenta `#F900FF` | | Third accent | Orange `#FF6B00` | Kiwi is brilliant as a field with black on top of it and illegible as type on @@ -80,9 +90,13 @@ size, so there is no font file and no layout shift. Because the font is monospaced, the hero solves for letter tracking per line, which makes every line land at the same width and the same cap height. -The hero, footer and 404 page run Conway's Game of Life on a coarse grid. That -is the same glider hidden in the logo mark. Everything animated respects -`prefers-reduced-motion`. +The hero, the call to action panels, the footer rule and the 404 page draw the +logo mark rather than decorating around it. `src/components/mark-draw.ts` emits +one SVG: stepped violet traces run in from outside the frame and spark where +they meet the silhouette, a stack of kiwi bars grows out from the centre line to +fill the egg, the outline is traced, and the nine magenta byte cells land last. +It is CSS keyframes with a `--i` index per element, so it plays once, holds, and +costs nothing after that. Everything animated respects `prefers-reduced-motion`. ## Deploy diff --git a/community/index.html b/community/index.html index dac22cf..a76bb26 100644 --- a/community/index.html +++ b/community/index.html @@ -37,9 +37,7 @@

- +

Want to join?

diff --git a/community/laraib/index.html b/community/laraib/index.html deleted file mode 100644 index 86960e8..0000000 --- a/community/laraib/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - -

- -
- - -
- -
-
- -
- - - diff --git a/community/noor/index.html b/community/noor/index.html deleted file mode 100644 index ede10dd..0000000 --- a/community/noor/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - -
- -
- - -
- -
-
- -
- - - diff --git a/community/saud/index.html b/community/saud/index.html deleted file mode 100644 index c94513e..0000000 --- a/community/saud/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - -
- -
- - -
- -
-
- -
- - - diff --git a/index.html b/index.html index 4ebf631..5710c19 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ @@ -17,9 +17,7 @@
- +

@@ -30,18 +28,16 @@

- We build the numerical layer: - Fortran and C++ cores wrapped for Python, with uncertainty - propagation, design of experiments, and optimization on top. Everything is - published to PyPI, documented, and tested on every push. + Libraries for people who compute for a living, and the tools + that keep that work moving. All of it open source, and free to use.

@@ -58,26 +54,13 @@ - github.com/eggzec + GitHub
-

Shipping

-
- pyswarm - pydoe - mcerp - soerp - ad - nlpql - smolpack - sdepack - ogrep - polpack - kronrod - vatic -
+

Used by

+
@@ -115,8 +98,8 @@

Projects

Tools we needed, so we wrote them

- Every project below is a repository you can clone today. Most ship a package, a - documentation site, and a CI pipeline that runs the tests on every push. + Every project below is a repository you can clone today, and a package you can + install.

@@ -133,71 +116,40 @@

Tools we needed, so we wrote them

How we work

-

Fast where it matters, readable everywhere else

+

Open, and kept that way

01

-

Native cores, Python surface

+

Open by default

- Quadrature, linear algebra, and special functions stay in Fortran and C++. The API - you import is Python, built with f2py, SIP, and Meson. + Every repository is public and takes issues and pull requests. The way in is the + same one we use ourselves.

02

-

Uncertainty is first class

+

Kept alive

- Propagate error by moments, by Monte Carlo, or by derivative. mcerp, soerp, and ad - cover all three against the same modelling code. + Projects stay released and documented for as long as people depend on them. Work + we no longer maintain is retired rather than left looking current.

03

-

Published, not parked

+

Used in the wild

- Packages go to PyPI with a versioned docs site, Ruff and pytest in CI, and coverage - reported on the pull request that changed it. + National labs, universities, and industry build on these packages. Every name we + list links to the repository that does.

- -
-
-
-

Toolchain

-

What we build with

-

- uv for environments, Meson and CMake for native builds, Ruff and pytest for the gate, - Zensical and Doxygen for the docs. -

-
-
- -
- PyPython - FFortran - C++C++ - GoGo - JJava - NpNumPy - SpSciPy - uvuv - RfRuff - MMeson - ptpytest - QtQt - DkDocker - ZnZensical -
-
-
@@ -207,8 +159,8 @@

What we build with

Community

Small group, long horizon

- eggzec is maintained by three engineers working across Python, C++, Fortran, Java, and - Go. Issues and pull requests are welcome on every repository. + eggzec is maintained by a small group of engineers, in the open. Issues and pull + requests are welcome on every repository.

@@ -222,9 +174,7 @@

Small group, long horizon

- +

Start with a repository

@@ -252,5 +202,7 @@

Start with a repository

+ + diff --git a/projects/index.html b/projects/index.html index 8763481..8c26294 100644 --- a/projects/index.html +++ b/projects/index.html @@ -41,11 +41,10 @@

- They are all built the same way. + Something missing, or something wrong?

- A numerical core in Fortran or C++, a small Python API on top, and a test suite - that runs on every push. Pick one, read its README, and open an issue on + Pick a project, read its documentation, and open an issue on \ No newline at end of file diff --git a/public/brand/icons/kronrod.svg b/public/brand/icons/kronrod.svg new file mode 100644 index 0000000..13efbb0 --- /dev/null +++ b/public/brand/icons/kronrod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/icons/mcerp.svg b/public/brand/icons/mcerp.svg index 3f19811..ea3795a 100644 --- a/public/brand/icons/mcerp.svg +++ b/public/brand/icons/mcerp.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/public/brand/icons/smolpack.svg b/public/brand/icons/smolpack.svg new file mode 100644 index 0000000..915f2f1 --- /dev/null +++ b/public/brand/icons/smolpack.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/icons/spinterp.svg b/public/brand/icons/spinterp.svg new file mode 100644 index 0000000..9108d41 --- /dev/null +++ b/public/brand/icons/spinterp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/icons/vatic.svg b/public/brand/icons/vatic.svg new file mode 100644 index 0000000..34fcf39 --- /dev/null +++ b/public/brand/icons/vatic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/brand/users/cmu-db.png b/public/brand/users/cmu-db.png new file mode 100644 index 0000000..1a9634a Binary files /dev/null and b/public/brand/users/cmu-db.png differ diff --git a/public/brand/users/codecentric.png b/public/brand/users/codecentric.png new file mode 100644 index 0000000..26f0dc6 Binary files /dev/null and b/public/brand/users/codecentric.png differ diff --git a/public/brand/users/emsl-computing.png b/public/brand/users/emsl-computing.png new file mode 100644 index 0000000..d134785 Binary files /dev/null and b/public/brand/users/emsl-computing.png differ diff --git a/public/brand/users/gemseo.png b/public/brand/users/gemseo.png new file mode 100644 index 0000000..803e789 Binary files /dev/null and b/public/brand/users/gemseo.png differ diff --git a/public/brand/users/icb-dcm.png b/public/brand/users/icb-dcm.png new file mode 100644 index 0000000..11500d4 Binary files /dev/null and b/public/brand/users/icb-dcm.png differ diff --git a/public/brand/users/icrar.png b/public/brand/users/icrar.png new file mode 100644 index 0000000..a2366b9 Binary files /dev/null and b/public/brand/users/icrar.png differ diff --git a/public/brand/users/lanl.png b/public/brand/users/lanl.png new file mode 100644 index 0000000..c17cc50 Binary files /dev/null and b/public/brand/users/lanl.png differ diff --git a/public/brand/users/llnl.png b/public/brand/users/llnl.png new file mode 100644 index 0000000..b37e4c8 Binary files /dev/null and b/public/brand/users/llnl.png differ diff --git a/public/brand/users/nasa.png b/public/brand/users/nasa.png new file mode 100644 index 0000000..d2151c2 Binary files /dev/null and b/public/brand/users/nasa.png differ diff --git a/public/brand/users/national-digital-twin.png b/public/brand/users/national-digital-twin.png new file mode 100644 index 0000000..bfa0d25 Binary files /dev/null and b/public/brand/users/national-digital-twin.png differ diff --git a/public/brand/users/openmdao.png b/public/brand/users/openmdao.png new file mode 100644 index 0000000..a0add32 Binary files /dev/null and b/public/brand/users/openmdao.png differ diff --git a/public/brand/users/openworm.png b/public/brand/users/openworm.png new file mode 100644 index 0000000..775a373 Binary files /dev/null and b/public/brand/users/openworm.png differ diff --git a/public/brand/users/philipmorrisintl.png b/public/brand/users/philipmorrisintl.png new file mode 100644 index 0000000..37452cd Binary files /dev/null and b/public/brand/users/philipmorrisintl.png differ diff --git a/public/brand/users/qubole.png b/public/brand/users/qubole.png new file mode 100644 index 0000000..da998e8 Binary files /dev/null and b/public/brand/users/qubole.png differ diff --git a/public/brand/users/sandialabs.png b/public/brand/users/sandialabs.png new file mode 100644 index 0000000..e4da5b6 Binary files /dev/null and b/public/brand/users/sandialabs.png differ diff --git a/public/brand/users/smtorg.png b/public/brand/users/smtorg.png new file mode 100644 index 0000000..d016ca3 Binary files /dev/null and b/public/brand/users/smtorg.png differ diff --git a/public/brand/users/wwu-mmll.png b/public/brand/users/wwu-mmll.png new file mode 100644 index 0000000..f695406 Binary files /dev/null and b/public/brand/users/wwu-mmll.png differ diff --git a/src/components/content.ts b/src/components/content.ts index 130ff06..fed6327 100644 --- a/src/components/content.ts +++ b/src/components/content.ts @@ -1,20 +1,19 @@ /** - * Data-driven page bodies: the maintainer grid and the individual maintainer - * profiles, both read from `src/data/site.ts` so a person is described once. + * Data-driven page bodies: the maintainer grid, read from `src/data/site.ts` + * so a person is described once. */ -import { PEOPLE, PROJECTS, type Person } from '../data/site' +import { PEOPLE, PROJECTS } from '../data/site' +import { USED_BY } from '../data/used-by' -function linkList(person: Person): string { - return person.links - .map((link) => { - const external = !link.href.startsWith('mailto:') - return `${link.label}` - }) - .join('') -} - -/** `

` on /community/. */ +/** + * `
` on /community/. + * + * A name, what they do here, and how they describe their work — nothing else. + * No avatar, no handle, no badge wall: a maintainer is a person to talk to, not + * a profile to skim, and the card links straight to where their work actually + * is rather than to a page about them. + */ export function mountPeople(root: ParentNode = document): void { const host = root.querySelector('[data-people]') if (!host) return @@ -23,42 +22,36 @@ export function mountPeople(root: ParentNode = document): void { host.innerHTML = PEOPLE.map( (person) => `
- - +

${person.name}

${person.role}

${person.bio}

-
- ${person.stack.map((s) => `${s}`).join('')} -
`, ).join('') } -/** `
` on each maintainer page. */ -export function mountProfile(root: ParentNode = document): void { - const host = root.querySelector('[data-profile]') +/** + * `
` in the hero ticker. + * + * Logos rather than names: at a glance a reader recognises a mark faster than + * they read an org login. Filled before the marquee mounts so it measures real + * content, and each one links to the repository the dependency was verified in, + * because a claim like this is only worth making if it can be checked. + */ +export function mountUsedBy(root: ParentNode = document): void { + const host = root.querySelector('[data-used-by]') if (!host) return - const person = PEOPLE.find((p) => p.slug === host.dataset.profile) - if (!person) return - - document.title = `${person.name} · eggzec` - - host.classList.add('profile') - host.innerHTML = ` - ${person.name} -
-

${person.role}

-

${person.name}

-

${person.bio}

- -
- ${person.stack.map((s) => `${s}`).join('')} -
-
` + host.innerHTML = USED_BY.map( + (d) => ` + + + `, + ).join('') } /** diff --git a/src/components/draw-field.ts b/src/components/draw-field.ts new file mode 100644 index 0000000..a4ed3ac --- /dev/null +++ b/src/components/draw-field.ts @@ -0,0 +1,415 @@ +/** + * Draw field — the animated plate behind the page. + * + * astral.sh reveals the mark behind its headline with a Lottie built from an + * alpha track matte: one filled shape, uncovered by a stack of rounded bars + * whose widths grow from zero on staggered in-points, with trimmed strokes + * tracing in alongside. Nothing is random, it plays once, it resolves. + * + * The choreography here is that one; the subject is what eggzec actually + * builds. The anchor is ∇ℒ, traced straight out of `brand/icons/nlpql.svg` — + * the nabla outlined then filled through its bar matte, the script L written on + * block by block. Around it sit the methods: a contour plot with a descent + * stepping to the optimum, a residual curve falling to tolerance, a Wiener path + * with pollen drifting off it, a block of binary, and the equations themselves. + * + * All type is the site's own eggzec Block, the bitmap face the headlines use, + * extended here with the handful of maths glyphs the equations need. Every + * glyph is therefore drawn on the same grid, with the same merged runs and + * quarter-turn corners, as the nabla it sits beside. + * + * The build is declarative — one SVG plus CSS keyframes, delays driven by a + * `--i` index per element. JS emits markup and flips `animation-play-state` + * when the host scrolls in, so there is no animation loop and no per-frame + * work. The pollen is the one thing that keeps moving afterwards, and it does + * it on the compositor. + */ + +import { inView } from 'motion' +import { GLYPHS, bitmapPath } from '../lib/eggzec-block' +import { + NABLA_BOX, + NABLA_COUNTER, + NABLA_OUTLINE, + SCRIPT_L, + SCRIPT_L_BOX, + SCRIPT_L_RADIUS, +} from '../lib/nlpql-glyphs' +import { prefersReducedMotion } from '../lib/env' + +type Point = [number, number] + +function round(n: number): number { + return Math.round(n * 1000) / 1000 +} + +function polyline(points: Point[]): string { + return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${round(p[0])} ${round(p[1])}`).join('') +} + +/** An ellipse as a path, so `pathLength` normalisation works everywhere. */ +function ellipsePath(cx: number, cy: number, rx: number, ry: number): string { + return `M${cx - rx} ${cy}A${rx} ${ry} 0 1 0 ${cx + rx} ${cy}A${rx} ${ry} 0 1 0 ${cx - rx} ${cy}Z` +} + +/** + * Deterministic noise. The plate must be identical on every load — a hero that + * reshuffles between refreshes reads as a bug — so the Wiener path and the + * pollen drift come from a counter, not from `Math.random`. + */ +function noise(seed: number): number { + const x = Math.sin(seed * 12.9898) * 43758.5453 + return x - Math.floor(x) +} + +/* ========================================================================== * + * Type — eggzec Block, extended for maths + * ========================================================================== */ + +/** + * The glyphs the equations need and the display font does not carry, authored + * on the same 5x7 body so they set on the same baseline and advance. + */ +const MATHS: Readonly> = { + '=': '00000/00000/11111/00000/11111/00000/00000', + '(': '00110/01000/01000/01000/01000/01000/00110', + ')': '01100/00010/00010/00010/00010/00010/01100', + ',': '00000/00000/00000/00000/00000/00100/01000', + α: '00000/00000/01110/10001/10001/10011/01101', + '∇': '11111/10001/01010/01010/00100/00100/00000', + Σ: '11111/10000/01000/00100/01000/10000/11111', + '∫': '00110/01010/01000/01000/01000/01010/01100', + '≈': '00000/01010/10101/00000/01010/10101/00000', +} + +/** Grid rows in a glyph body, and the advance between glyph origins. */ +const BODY_ROWS = 7 +const ADVANCE = 6 +/** Subscripts and superscripts, as a fraction of the base size. */ +const SCRIPT_SCALE = 0.66 + +function glyphSpec(char: string): string | undefined { + return MATHS[char] ?? GLYPHS[char.toUpperCase()] +} + +interface Run { + char: string + /** 0 for the baseline run, +1 for a subscript, -1 for a superscript. */ + level: number +} + +/** Splits `A_{K+1}` into runs, so scripts can be set smaller and offset. */ +function parseRuns(source: string): Run[] { + const runs: Run[] = [] + let i = 0 + + while (i < source.length) { + const ch = source[i]! + if ((ch === '_' || ch === '^') && source[i + 1] === '{') { + const end = source.indexOf('}', i + 2) + if (end > 0) { + const level = ch === '_' ? 1 : -1 + for (const c of source.slice(i + 2, end)) runs.push({ char: c, level }) + i = end + 1 + continue + } + } + runs.push({ char: ch, level: 0 }) + i++ + } + + return runs +} + +/** + * Sets one line of maths in eggzec Block. Each glyph is its own `` inside + * a `` that carries the placement — the animation writes `transform` on the + * path, and a CSS transform replaces the attribute rather than composing with + * it, so the two must never live on the same element. + */ +function blockText( + source: string, + x: number, + y: number, + size: number, + className: string, + indexFrom = 0, +): string { + const unit = size / BODY_ROWS + let cursor = x + let index = indexFrom + let out = '' + + for (const run of parseRuns(source)) { + const scale = run.level === 0 ? unit : unit * SCRIPT_SCALE + const advance = ADVANCE * scale + + if (run.char === ' ') { + cursor += advance * 0.7 + continue + } + + const spec = glyphSpec(run.char) + if (!spec) { + cursor += advance + continue + } + + // Scripts hang below the baseline; superscripts ride above the cap line. + const dy = run.level === 1 ? size * 0.42 : run.level === -1 ? -size * 0.18 : 0 + const { d } = bitmapPath(spec) + + out += + `` + + `` + + cursor += advance + index++ + } + + return out +} + +/* ========================================================================== * + * Motifs + * ========================================================================== */ + +/** Bit patterns are fixed, so the plate is identical on every load. */ +const BITS = ['1011010011', '0110101101', '1101001010'] + +function binaryBlock( + x: number, + y: number, + size: number, + pitchX: number, + pitchY: number, + rows: number, + cols: number, +): string { + const unit = size / BODY_ROWS + let out = '' + let index = 0 + + for (let r = 0; r < rows; r++) { + const bits = BITS[r % BITS.length]! + for (let c = 0; c < cols; c++) { + const { d } = bitmapPath(GLYPHS[bits[c % bits.length]!]!) + // Every seventh cell sparks, which keeps the block from reading as an + // even grey and echoes the byte hidden in the logo. + const lit = index % 7 === 0 ? ' draw-field__bit--lit' : '' + out += + `` + + `` + index++ + } + } + + return out +} + +/** + * ∇ℒ, traced from the nlpql icon, fitted to `[x, y, height]` and returning the + * pair's full advance so a scene can check it fits. The nabla is outlined and + * then filled through a matte of bars growing off the centre line; the L is + * written on block by block, the way the stroke would be made. + */ +function gradLagrangian(id: string, x: number, y: number, height: number): string { + const scale = height / NABLA_BOX[3] + const place = (bx: number, by: number): string => + `translate(${round(x - bx * scale)} ${round(y - by * scale)}) scale(${round(scale)})` + + const ring = `${NABLA_OUTLINE}${NABLA_COUNTER}` + const nablaW = NABLA_BOX[2] * scale + + // Bars are laid out in the icon's own units, then ride the same transform. + const steps = 12 + const pitch = NABLA_BOX[3] / steps + const barHeight = pitch * 0.62 + const bars = Array.from({ length: steps }, (_, row) => { + // Scattered in-points, so the glyph assembles rather than wipes. + const rank = (row * 5) % steps + const barY = round(NABLA_BOX[1] + row * pitch + (pitch - barHeight) / 2) + return `` + }).join('') + + // The L trails the nabla by a hair so the pair reads left to right. + const lScale = (height * 0.66) / SCRIPT_L_BOX[3] + const lx = x + nablaW * 0.96 + const ly = y + height * 0.3 + const blocks = SCRIPT_L.map( + ([bx, by, bw, bh], i) => + ``, + ).join('') + + return ` + + + ${bars} + + ${blocks}` +} + +/** + * A quadratic bowl in level sets, with the iterates of a descent stepping into + * the optimum — the picture behind pyswarm and nlpql. + */ +function contourPlot(cx: number, cy: number, rx: number, ry: number, path: Point[]): string { + const rings = [1, 0.76, 0.52, 0.28] + .map( + (k, i) => + ``, + ) + .join('') + + const trail = `` + + const nodes = path + .map((p, i) => { + const s = i === path.length - 1 ? 0.62 : 0.42 + return `` + }) + .join('') + + return rings + trail + nodes +} + +/** Residual against iteration, falling to the tolerance line. */ +function convergencePlot(x: number, y: number, w: number, h: number, samples: number): string { + const axes = `` + const tol = `` + + const points: Point[] = Array.from({ length: samples }, (_, k) => [ + round(x + (w * k) / (samples - 1)), + round(y + h * 0.06 + h * 0.82 * (1 - Math.exp(-0.46 * k))), + ]) + + const curve = `` + const marks = points + .map( + (p, i) => + ``, + ) + .join('') + + return axes + tol + curve + marks +} + +/** + * A sample path of dX = a dt + b dW, drawn as the walk it is, with pollen + * suspended off it. The grains never settle: each carries its own looping + * jitter, which is the only thing on the plate still moving once it resolves. + */ +function wienerPath( + x: number, + y: number, + w: number, + h: number, + steps: number, + seed: number, + grains: number, +): string { + const points: Point[] = [] + let level = 0 + + for (let k = 0; k <= steps; k++) { + // Drift plus a bounded increment — a walk that reads as one, but stays in + // its box rather than wandering out of the frame. + level += (noise(seed + k) - 0.5) * 1.35 + level = Math.max(-1, Math.min(1, level * 0.94)) + points.push([round(x + (w * k) / steps), round(y + h / 2 + (level * h) / 2)]) + } + + const walk = `` + + const pollen = Array.from({ length: grains }, (_, i) => { + const at = points[Math.floor((noise(seed + 90 + i) * (points.length - 1)))] ?? points[0]! + const size = 0.26 + noise(seed + 40 + i) * 0.2 + // Three drift tracks, so neighbouring grains never move in lockstep. + const track = i % 3 + return `` + }).join('') + + return walk + pollen +} + +/* ========================================================================== * + * Scenes + * ========================================================================== */ + +const DESCENT: Point[] = [ + [1.9, 5.2], + [4.4, 7.1], + [3.5, 9.9], + [6.1, 9], + [6.5, 11.5], + [7.3, 10.4], +] + +/** The hero plate: the whole method on one sheet. */ +function heroScene(id: string): string { + return `` +} + +/** A panel plate: quadrature and the bowl it integrates over. */ +function panelScene(id: string): string { + return `` +} + +/** The rule: a single run of binary across the strip. */ +function ruleScene(): string { + return `` +} + +let instance = 0 + +/** + * Mount on every `[data-draw-field]` host. The value picks the scene: + * `hero` (default), `panel`, or `rule`. + */ +export function mountDrawFields(root: ParentNode = document): void { + const hosts = Array.from(root.querySelectorAll('[data-draw-field]')) + if (hosts.length === 0) return + + const reduced = prefersReducedMotion() + + for (const host of hosts) { + if (host.dataset.drawn === 'true') continue + host.dataset.drawn = 'true' + + host.classList.add('draw-field') + host.setAttribute('aria-hidden', 'true') + + const id = `draw-field-${++instance}` + const scene = host.dataset.drawField + + host.innerHTML = + scene === 'rule' ? ruleScene() : scene === 'panel' ? panelScene(id) : heroScene(id) + + // Reduced motion still gets the plate, just already drawn and still. + if (reduced) { + host.classList.add('is-static') + continue + } + + // Plays once, like the reference: it resolves and holds rather than + // looping. Only the pollen keeps going. + inView(host, () => host.classList.add('is-playing'), { amount: 0.15 }) + } +} diff --git a/src/components/footer.ts b/src/components/footer.ts index 273547a..87e7c8c 100644 --- a/src/components/footer.ts +++ b/src/components/footer.ts @@ -8,19 +8,15 @@ const COLUMNS: Array<{ title: string; links: Array<{ label: string; href: string title: 'Projects', links: [ { label: 'All projects', href: '/projects/' }, - { label: 'pyswarm', href: 'https://eggzec.github.io/pyswarm/', external: true }, - { label: 'pydoe', href: 'https://pydoe.github.io/pydoe/', external: true }, - { label: 'mcerp', href: 'https://eggzec.github.io/mcerp/', external: true }, - { label: 'nlpql', href: 'https://eggzec.github.io/nlpql/', external: true }, + { label: 'PySwarm', href: 'https://eggzec.github.io/pyswarm/', external: true }, + { label: 'PyDOE', href: 'https://pydoe.github.io/pydoe/', external: true }, + { label: 'MCERP', href: 'https://eggzec.github.io/mcerp/', external: true }, + { label: 'NLPQL', href: 'https://eggzec.github.io/nlpql/', external: true }, ], }, { title: 'Community', - links: [ - { label: 'Maintainers', href: '/community/' }, - { label: 'M. Saud Zahir', href: 'mailto:m.saud.zahir@gmail.com' }, - { label: 'M. Laraib Ali', href: 'mailto:laraibg786@outlook.com' }, - ], + links: [{ label: 'Maintainers', href: '/community/' }], }, { title: 'Elsewhere', @@ -71,14 +67,12 @@ export function mountFooter(): void {
- +
` diff --git a/src/components/life-field.ts b/src/components/life-field.ts deleted file mode 100644 index 172601c..0000000 --- a/src/components/life-field.ts +++ /dev/null @@ -1,422 +0,0 @@ -/** - * Life field — the hero's background texture. - * - * The eggzec mark hides a Conway glider and a "byte" of magenta data cells in - * the egg, so the hero runs the actual automaton behind the wordmark: a coarse - * toroidal Game of Life grid, drawn as rounded pixels. Newly born cells flash - * magenta and decay to kiwi, which makes the propagation legible instead of - * just busy. - * - * Cheap by construction: one Uint8Array per buffer, an integer neighbour count, - * a fixed ~9fps simulation tick decoupled from the paint loop, and no work at - * all while off-screen or when the tab is hidden. - */ - -import { prefersReducedMotion } from '../lib/env' - -/** Still lifes and oscillators the field seeds itself with. */ -const PATTERNS: Record> = { - glider: [ - [1, 0], - [2, 1], - [0, 2], - [1, 2], - [2, 2], - ], - lwss: [ - [0, 0], - [3, 0], - [4, 1], - [0, 2], - [4, 2], - [1, 3], - [2, 3], - [3, 3], - [4, 3], - ], - pulsarSeed: [ - [0, 0], - [1, 0], - [2, 0], - [0, 1], - [1, 2], - ], - rPentomino: [ - [1, 0], - [2, 0], - [0, 1], - [1, 1], - [1, 2], - ], -} - -export interface LifeFieldOptions { - /** Nominal cell size in CSS px. */ - cell?: number - /** Simulation steps per second. */ - tps?: number - /** Fraction of cells seeded live in the initial soup. */ - density?: number - /** Peak opacity of a settled live cell. */ - intensity?: number - /** Reseed automatically once the population stops changing. */ - autoReseed?: boolean - /** Let the pointer paint live cells. */ - interactive?: boolean -} - -interface Palette { - live: string - born: string -} - -export class LifeField { - readonly #canvas: HTMLCanvasElement - readonly #ctx: CanvasRenderingContext2D - readonly #opts: Required - - #cols = 0 - #rows = 0 - #cellPx = 16 - #dpr = 1 - - /** Current generation. 1 = live. */ - #grid = new Uint8Array(0) - #next = new Uint8Array(0) - /** Per-cell age in generations; 0 = dead, 1 = just born. */ - #age = new Uint8Array(0) - /** Render alpha, eased toward the target each frame so births/deaths fade. */ - #alpha = new Float32Array(0) - - #palette: Palette = { live: '#ccff00', born: '#f900ff' } - - #raf = 0 - #lastTick = 0 - #running = false - #visible = true - #stableFor = 0 - #generation = 0 - - #pointer: { x: number; y: number; active: boolean } = { x: -1, y: -1, active: false } - - #resizeObserver: ResizeObserver | null = null - readonly #onVisibility = () => { - this.#visible = !document.hidden - if (this.#visible && this.#running) this.#lastTick = performance.now() - } - - constructor(canvas: HTMLCanvasElement, options: LifeFieldOptions = {}) { - this.#canvas = canvas - const ctx = canvas.getContext('2d', { alpha: true }) - if (!ctx) throw new Error('LifeField: 2D canvas context unavailable') - this.#ctx = ctx - - this.#opts = { - cell: options.cell ?? 16, - tps: options.tps ?? 9, - density: options.density ?? 0.12, - intensity: options.intensity ?? 0.5, - autoReseed: options.autoReseed ?? true, - interactive: options.interactive ?? true, - } - } - - /** - * Read the field colours from the stylesheet rather than hard-coding them — - * on a white ground kiwi vanishes, so the tokens resolve to violet/magenta. - */ - syncPalette(): void { - const cs = getComputedStyle(document.documentElement) - const live = cs.getPropertyValue('--field-live').trim() || '#5200ff' - const born = cs.getPropertyValue('--field-born').trim() || '#f900ff' - this.#palette = { live, born } - } - - start(): void { - if (this.#running) return - this.#running = true - - this.syncPalette() - this.#resize() - this.seed() - - this.#resizeObserver = new ResizeObserver(() => this.#resize()) - this.#resizeObserver.observe(this.#canvas.parentElement ?? this.#canvas) - document.addEventListener('visibilitychange', this.#onVisibility) - - if (this.#opts.interactive) { - this.#canvas.addEventListener('pointermove', this.#onPointerMove, { passive: true }) - this.#canvas.addEventListener('pointerleave', this.#onPointerLeave, { passive: true }) - } - - // Reduced motion still gets the texture, just frozen after a few steps. - if (prefersReducedMotion()) { - for (let i = 0; i < 4; i++) this.#step() - this.#settleAlpha() - this.#paint() - return - } - - this.#lastTick = performance.now() - this.#raf = requestAnimationFrame(this.#frame) - } - - stop(): void { - this.#running = false - cancelAnimationFrame(this.#raf) - this.#resizeObserver?.disconnect() - this.#resizeObserver = null - document.removeEventListener('visibilitychange', this.#onVisibility) - this.#canvas.removeEventListener('pointermove', this.#onPointerMove) - this.#canvas.removeEventListener('pointerleave', this.#onPointerLeave) - } - - /** Wipe and re-seed: a soup band plus a scatter of gliders heading inward. */ - seed(): void { - const { density } = this.#opts - this.#grid.fill(0) - this.#age.fill(0) - this.#stableFor = 0 - this.#generation = 0 - - for (let i = 0; i < this.#grid.length; i++) { - if (Math.random() < density) { - this.#grid[i] = 1 - // Seeded cells start "mature" so the first paint is kiwi, not a - // magenta flash across the whole field. - this.#age[i] = 2 + Math.floor(Math.random() * 6) - } - } - - const gliders = Math.max(3, Math.round((this.#cols * this.#rows) / 2200)) - const names = Object.keys(PATTERNS) - for (let i = 0; i < gliders; i++) { - const name = names[Math.floor(Math.random() * names.length)] ?? 'glider' - this.#stamp( - PATTERNS[name] ?? PATTERNS.glider!, - Math.floor(Math.random() * this.#cols), - Math.floor(Math.random() * this.#rows), - Math.random() < 0.5, - ) - } - } - - #stamp(cells: Array<[number, number]>, ox: number, oy: number, flipX: boolean): void { - for (const [dx, dy] of cells) { - const x = (((flipX ? -dx : dx) + ox) % this.#cols + this.#cols) % this.#cols - const y = ((dy + oy) % this.#rows + this.#rows) % this.#rows - const i = y * this.#cols + x - this.#grid[i] = 1 - this.#age[i] = 1 - } - } - - #resize(): void { - const host = this.#canvas.parentElement ?? this.#canvas - const rect = host.getBoundingClientRect() - const w = Math.max(1, Math.round(rect.width)) - const h = Math.max(1, Math.round(rect.height)) - - this.#dpr = Math.min(window.devicePixelRatio || 1, 2) - this.#canvas.width = Math.round(w * this.#dpr) - this.#canvas.height = Math.round(h * this.#dpr) - this.#canvas.style.width = `${w}px` - this.#canvas.style.height = `${h}px` - - // Coarser cells on small screens so the automaton stays readable. - const base = w < 640 ? this.#opts.cell * 0.75 : this.#opts.cell - const cols = Math.max(8, Math.ceil(w / base)) - const rows = Math.max(6, Math.ceil(h / base)) - this.#cellPx = w / cols - - if (cols !== this.#cols || rows !== this.#rows) { - this.#cols = cols - this.#rows = rows - const n = cols * rows - this.#grid = new Uint8Array(n) - this.#next = new Uint8Array(n) - this.#age = new Uint8Array(n) - this.#alpha = new Float32Array(n) - this.seed() - } - - this.#ctx.setTransform(this.#dpr, 0, 0, this.#dpr, 0, 0) - } - - #step(): void { - const { grid, next, age, cols, rows } = { - grid: this.#grid, - next: this.#next, - age: this.#age, - cols: this.#cols, - rows: this.#rows, - } - - let changed = 0 - - for (let y = 0; y < rows; y++) { - const yUp = ((y - 1 + rows) % rows) * cols - const yMid = y * cols - const yDn = ((y + 1) % rows) * cols - - for (let x = 0; x < cols; x++) { - const xL = (x - 1 + cols) % cols - const xR = (x + 1) % cols - - const n = - grid[yUp + xL]! + grid[yUp + x]! + grid[yUp + xR]! + - grid[yMid + xL]! + grid[yMid + xR]! + - grid[yDn + xL]! + grid[yDn + x]! + grid[yDn + xR]! - - const i = yMid + x - const alive = grid[i] === 1 - const born = alive ? n === 2 || n === 3 : n === 3 - - next[i] = born ? 1 : 0 - if (born !== alive) changed++ - - if (born) { - age[i] = alive ? Math.min(255, age[i]! + 1) : 1 - } else { - age[i] = 0 - } - } - } - - // Swap buffers rather than copying. - const tmp = this.#grid - this.#grid = this.#next - this.#next = tmp - - this.#generation++ - this.#stableFor = changed < Math.max(2, (cols * rows) / 900) ? this.#stableFor + 1 : 0 - - if (this.#opts.autoReseed && (this.#stableFor > 26 || this.#generation > 900)) { - this.seed() - } - } - - /** Ease every cell's alpha to its target in one go (used for static renders). */ - #settleAlpha(): void { - for (let i = 0; i < this.#grid.length; i++) { - this.#alpha[i] = this.#grid[i] === 1 ? this.#opts.intensity : 0 - } - } - - readonly #frame = (now: number): void => { - if (!this.#running) return - this.#raf = requestAnimationFrame(this.#frame) - if (!this.#visible) return - - const interval = 1000 / this.#opts.tps - if (now - this.#lastTick >= interval) { - this.#lastTick = now - ((now - this.#lastTick) % interval) - this.#step() - if (this.#pointer.active) this.#paintPointer() - } - - this.#paint() - } - - #paintPointer(): void { - const cx = Math.floor(this.#pointer.x / this.#cellPx) - const cy = Math.floor(this.#pointer.y / this.#cellPx) - if (cx < 0 || cy < 0 || cx >= this.#cols || cy >= this.#rows) return - // Drop a live cell under the cursor so the field reacts without exploding. - if (Math.random() < 0.7) { - const i = cy * this.#cols + cx - this.#grid[i] = 1 - this.#age[i] = 1 - } - } - - #paint(): void { - const ctx = this.#ctx - const { cols, rows } = this - const size = this.#cellPx - const w = this.#canvas.width / this.#dpr - const h = this.#canvas.height / this.#dpr - - ctx.clearRect(0, 0, w, h) - - const target = this.#opts.intensity - const inset = Math.max(0.5, size * 0.14) - const box = Math.max(1, size - inset * 2) - const radius = Math.min(box / 2, Math.max(1, size * 0.22)) - - let prevFill = '' - - for (let y = 0; y < rows; y++) { - for (let x = 0; x < cols; x++) { - const i = y * cols + x - const want = this.#grid[i] === 1 ? target : 0 - // Births snap in fast, deaths linger — reads as a trail. - const rate = want > this.#alpha[i]! ? 0.42 : 0.09 - const a = this.#alpha[i]! + (want - this.#alpha[i]!) * rate - this.#alpha[i] = a - if (a < 0.01) continue - - // Only the generation that was *just* born flashes magenta; anything - // older settles to kiwi. Widening this washes the whole field pink. - const young = this.#age[i] === 1 - const fill = young ? this.#palette.born : this.#palette.live - if (fill !== prevFill) { - ctx.fillStyle = fill - prevFill = fill - } - ctx.globalAlpha = young ? Math.min(1, a * 1.5) : a - - ctx.beginPath() - ctx.roundRect(x * size + inset, y * size + inset, box, box, radius) - ctx.fill() - } - } - - ctx.globalAlpha = 1 - } - - get cols(): number { - return this.#cols - } - - get rows(): number { - return this.#rows - } - - readonly #onPointerMove = (event: PointerEvent): void => { - const rect = this.#canvas.getBoundingClientRect() - this.#pointer = { - x: event.clientX - rect.left, - y: event.clientY - rect.top, - active: true, - } - } - - readonly #onPointerLeave = (): void => { - this.#pointer = { x: -1, y: -1, active: false } - } -} - -/** Mount a life field on every `[data-life-field]` canvas in the document. */ -export function mountLifeFields(root: ParentNode = document): LifeField[] { - const fields: LifeField[] = [] - - for (const canvas of root.querySelectorAll('canvas[data-life-field]')) { - const field = new LifeField(canvas, { - cell: Number(canvas.dataset.cell ?? 16), - tps: Number(canvas.dataset.tps ?? 9), - density: Number(canvas.dataset.density ?? 0.12), - intensity: Number(canvas.dataset.intensity ?? 0.5), - interactive: canvas.dataset.interactive !== 'false', - }) - field.start() - fields.push(field) - } - - // Keep the automaton on-brand when the theme flips. - window.addEventListener('eggzec:themechange', () => { - for (const field of fields) field.syncPalette() - }) - - return fields -} diff --git a/src/components/motes.ts b/src/components/motes.ts new file mode 100644 index 0000000..1c9737c --- /dev/null +++ b/src/components/motes.ts @@ -0,0 +1,274 @@ +/** + * Motes — specks suspended across the whole page. + * + * A fixed layer behind every section, holding dust in water. Each speck + * integrates a damped random walk, which is what Brownian motion actually is — + * momentum plus a fresh impulse every step — so the drift wanders instead of + * oscillating the way a looped keyframe would. + * + * They are deliberately far back: small, plain circles, faint enough to read as + * depth rather than as content. The cursor is the only thing that brings one + * forward — specks inside its radius are pushed off it and *excite*, growing + * and brightening, then decaying back to a drifting speck a second or so after + * the cursor has gone. + * + * One canvas, one loop, one pass per frame. The loop stops when the tab is + * hidden, and never starts at all under reduced motion — that case gets a + * single static scatter, which keeps the texture without the movement. + */ + +import { isCoarsePointer, prefersReducedMotion } from '../lib/env' + +/** Motes per million device-independent pixels of viewport. */ +const DENSITY = 150 +const MIN_MOTES = 50 +const MAX_MOTES = 320 + +/** How far the cursor reaches, in CSS pixels. */ +const CURSOR_RADIUS = 140 +/** Impulse the cursor imparts at point blank. */ +const CURSOR_PUSH = 0.34 +/** Fresh Brownian impulse per frame. */ +const JITTER = 0.055 +/** Velocity retained each frame — under 1, or the walk runs away. */ +const DAMPING = 0.955 +/** Ceiling on speed, so a fast cursor cannot fling a mote off screen. */ +const MAX_SPEED = 2.6 +/** Excitement lost per frame; roughly a second back to rest. */ +const CALM = 0.978 + +interface Mote { + x: number + y: number + vx: number + vy: number + /** Radius at rest, in CSS pixels. */ + radius: number + /** Base opacity at rest. */ + alpha: number + hue: string + /** 0 at rest, 1 just disturbed. */ + heat: number +} + +export class MoteField { + readonly #canvas: HTMLCanvasElement + readonly #ctx: CanvasRenderingContext2D + + #motes: Mote[] = [] + #width = 0 + #height = 0 + #dpr = 1 + + #raf = 0 + #running = false + #pointer = { x: -9999, y: -9999, active: false } + + #resizeTimer = 0 + readonly #palette: string[] + + constructor(canvas: HTMLCanvasElement) { + this.#canvas = canvas + const ctx = canvas.getContext('2d', { alpha: true }) + if (!ctx) throw new Error('MoteField: 2D canvas context unavailable') + this.#ctx = ctx + + // Kiwi is a field colour, not a hairline one — it vanishes on white at this + // size, so the motes take the three hues that hold. + const cs = getComputedStyle(document.documentElement) + const read = (name: string, fallback: string) => + cs.getPropertyValue(name).trim() || fallback + this.#palette = [ + read('--violet', '#5200ff'), + read('--violet', '#5200ff'), + read('--magenta', '#f900ff'), + read('--orange', '#ff6b00'), + ] + } + + start(): void { + if (this.#running) return + this.#running = true + + this.#measure() + this.#seed() + + window.addEventListener('resize', this.#onResize, { passive: true }) + document.addEventListener('visibilitychange', this.#onVisibility) + + if (prefersReducedMotion()) { + this.#paint() + return + } + + if (!isCoarsePointer()) { + window.addEventListener('pointermove', this.#onPointerMove, { passive: true }) + window.addEventListener('pointerleave', this.#onPointerLeave, { passive: true }) + } + + this.#raf = requestAnimationFrame(this.#frame) + } + + stop(): void { + this.#running = false + cancelAnimationFrame(this.#raf) + window.clearTimeout(this.#resizeTimer) + window.removeEventListener('resize', this.#onResize) + window.removeEventListener('pointermove', this.#onPointerMove) + window.removeEventListener('pointerleave', this.#onPointerLeave) + document.removeEventListener('visibilitychange', this.#onVisibility) + } + + #measure(): void { + this.#width = window.innerWidth + this.#height = window.innerHeight + this.#dpr = Math.min(window.devicePixelRatio || 1, 2) + + this.#canvas.width = Math.round(this.#width * this.#dpr) + this.#canvas.height = Math.round(this.#height * this.#dpr) + this.#ctx.setTransform(this.#dpr, 0, 0, this.#dpr, 0, 0) + } + + #count(): number { + const area = (this.#width * this.#height) / 1_000_000 + return Math.max(MIN_MOTES, Math.min(MAX_MOTES, Math.round(area * DENSITY))) + } + + #seed(): void { + const target = this.#count() + + // Grow or trim rather than rebuilding, so a resize does not teleport every + // mote that was already on screen. + while (this.#motes.length > target) this.#motes.pop() + + while (this.#motes.length < target) { + const i = this.#motes.length + this.#motes.push({ + x: Math.random() * this.#width, + y: Math.random() * this.#height, + vx: (Math.random() - 0.5) * 0.24, + vy: (Math.random() - 0.5) * 0.24, + radius: 0.7 + Math.random() * 1.35, + alpha: 0.13 + Math.random() * 0.2, + hue: this.#palette[i % this.#palette.length]!, + heat: 0, + }) + } + + for (const mote of this.#motes) { + mote.x = Math.min(mote.x, this.#width) + mote.y = Math.min(mote.y, this.#height) + } + } + + readonly #frame = (): void => { + if (!this.#running) return + this.#raf = requestAnimationFrame(this.#frame) + this.#step() + this.#paint() + } + + #step(): void { + const { x: px, y: py, active } = this.#pointer + + for (const mote of this.#motes) { + // The random walk: a fresh impulse every frame, momentum carried over, + // and damping so speed stays bounded without clamping it by hand. + mote.vx = (mote.vx + (Math.random() - 0.5) * JITTER) * DAMPING + mote.vy = (mote.vy + (Math.random() - 0.5) * JITTER) * DAMPING + + if (active) { + const dx = mote.x - px + const dy = mote.y - py + const dist = Math.hypot(dx, dy) + + if (dist < CURSOR_RADIUS && dist > 0.001) { + // Falls off with distance, so the edge of the reach is a nudge and + // the centre is a shove. + const force = (1 - dist / CURSOR_RADIUS) ** 2 * CURSOR_PUSH + mote.vx += (dx / dist) * force + mote.vy += (dy / dist) * force + mote.heat = Math.min(1, mote.heat + force * 2.2) + } + } + + // Push is bounded, and the walk itself refills the void the cursor + // leaves behind — that is what diffusion does, given a moment. + const speed = Math.hypot(mote.vx, mote.vy) + if (speed > MAX_SPEED) { + mote.vx = (mote.vx / speed) * MAX_SPEED + mote.vy = (mote.vy / speed) * MAX_SPEED + } + + mote.x += mote.vx + mote.y += mote.vy + mote.heat *= CALM + + // Wrap, so the field never thins out at the edges. + if (mote.x < -12) mote.x = this.#width + 12 + else if (mote.x > this.#width + 12) mote.x = -12 + if (mote.y < -12) mote.y = this.#height + 12 + else if (mote.y > this.#height + 12) mote.y = -12 + } + } + + #paint(): void { + const ctx = this.#ctx + ctx.clearRect(0, 0, this.#width, this.#height) + + // Circles only, and no transform stack: at this size anything with a + // corner reads as an artefact rather than as a speck. + for (const mote of this.#motes) { + const heat = mote.heat + + ctx.globalAlpha = Math.min(0.9, mote.alpha + heat * 0.5) + ctx.fillStyle = mote.hue + + ctx.beginPath() + ctx.arc(mote.x, mote.y, mote.radius * (1 + heat * 1.3), 0, Math.PI * 2) + ctx.fill() + } + + ctx.globalAlpha = 1 + } + + readonly #onPointerMove = (event: PointerEvent): void => { + this.#pointer = { x: event.clientX, y: event.clientY, active: true } + } + + readonly #onPointerLeave = (): void => { + this.#pointer = { x: -9999, y: -9999, active: false } + } + + readonly #onResize = (): void => { + window.clearTimeout(this.#resizeTimer) + this.#resizeTimer = window.setTimeout(() => { + this.#measure() + this.#seed() + if (prefersReducedMotion()) this.#paint() + }, 160) + } + + readonly #onVisibility = (): void => { + if (document.hidden) { + cancelAnimationFrame(this.#raf) + this.#raf = 0 + } else if (this.#running && !prefersReducedMotion() && !this.#raf) { + this.#raf = requestAnimationFrame(this.#frame) + } + } +} + +/** Mount the page-wide mote layer. Idempotent. */ +export function mountMotes(): MoteField | null { + if (document.querySelector('.motes')) return null + + const canvas = document.createElement('canvas') + canvas.className = 'motes' + canvas.setAttribute('aria-hidden', 'true') + document.body.prepend(canvas) + + const field = new MoteField(canvas) + field.start() + return field +} diff --git a/src/components/project-cards.ts b/src/components/project-cards.ts index 0954045..7531b8b 100644 --- a/src/components/project-cards.ts +++ b/src/components/project-cards.ts @@ -68,16 +68,18 @@ export function mountFeaturedGrid(root: ParentNode = document): void { } /** - * `
` on /projects/. Featured projects lead, then the - * rest grouped by category. + * `
` on /projects/. + * + * Every project, grouped by category and nothing else. There is no featured + * block here: almost everything carries an icon now, so "featured" stopped + * separating anything, and it meant a reader met the same card twice. */ export function mountCatalog(root: ParentNode = document): void { const host = root.querySelector('[data-project-catalog]') if (!host) return - const rest = PROJECTS.filter((p) => !p.icon) const counts = new Map() - for (const p of rest) counts.set(p.category, (counts.get(p.category) ?? 0) + 1) + for (const p of PROJECTS) counts.set(p.category, (counts.get(p.category) ?? 0) + 1) const sections = CATEGORY_ORDER.filter((c) => (counts.get(c) ?? 0) > 0) const filters = ` @@ -95,20 +97,9 @@ export function mountCatalog(root: ParentNode = document): void { }).join('')}
` - const featuredSection = ` -
-
-

Featured

-

The projects we build on most, and keep documented and released.

-
-
- ${FEATURED.map((p) => cardMarkup(p, 40)).join('')} -
-
` - const body = sections .map((category) => { - const items = rest.filter((p) => p.category === category) + const items = PROJECTS.filter((p) => p.category === category) return `
@@ -122,17 +113,13 @@ export function mountCatalog(root: ParentNode = document): void { }) .join('') - host.innerHTML = filters + `
${featuredSection}${body}
` + host.innerHTML = filters + `
${body}
` wireFilters(host) } function wireFilters(host: HTMLElement): void { const chips = Array.from(host.querySelectorAll('[data-filter]')) const sections = Array.from(host.querySelectorAll('[data-section]')) - const featured = host.querySelector('[data-featured-section]') - const featuredCards = featured - ? Array.from(featured.querySelectorAll('.card')) - : [] for (const chip of chips) { chip.addEventListener('click', () => { @@ -144,22 +131,11 @@ function wireFilters(host: HTMLElement): void { section.hidden = filter !== 'all' && section.dataset.section !== filter } - // The featured block spans categories, so filter its cards individually - // and hide the whole block only when nothing in it matches. - let featuredVisible = 0 - for (const card of featuredCards) { - const show = filter === 'all' || card.dataset.category === filter - card.hidden = !show - if (show) featuredVisible++ - } - if (featured) featured.hidden = featuredVisible === 0 - if (prefersReducedMotion()) return - const cards = [ - ...featuredCards.filter((c) => !c.hidden), - ...sections.filter((s) => !s.hidden).flatMap((s) => Array.from(s.querySelectorAll('.card'))), - ] + const cards = sections + .filter((s) => !s.hidden) + .flatMap((s) => Array.from(s.querySelectorAll('.card'))) animate( cards, { opacity: [0, 1], transform: ['translateY(10px) scale(0.99)', 'translateY(0) scale(1)'] }, diff --git a/src/data/site.ts b/src/data/site.ts index f6b6ee1..360a9bb 100644 --- a/src/data/site.ts +++ b/src/data/site.ts @@ -42,6 +42,7 @@ export const PROJECTS: Project[] = [ // ---- Uncertainty ------------------------------------------------------ { name: 'mcerp', + display: 'MCERP', tagline: 'Monte Carlo error propagation in real time using Latin hypercube sampling', category: 'Uncertainty', github: gh('mcerp'), @@ -51,6 +52,7 @@ export const PROJECTS: Project[] = [ }, { name: 'soerp', + display: 'SOERP', tagline: 'Second order error propagation that tracks uncertainty through models by moments', category: 'Uncertainty', github: gh('soerp'), @@ -69,16 +71,19 @@ export const PROJECTS: Project[] = [ }, { name: 'vatic', + display: 'Vatic', tagline: 'Open source risk analysis with predictive modelling and Monte Carlo simulation', category: 'Uncertainty', github: gh('vatic'), pypi: 'vatic', + icon: 'vatic.svg', }, // ---- Optimization ----------------------------------------------------- { name: 'pyswarm', - tagline: 'Particle swarm optimization with a small API for constrained problems without derivatives', + display: 'PySwarm', + tagline: 'Particle swarm optimization with built-in support for constraints', category: 'Optimization', github: gh('pyswarm'), docs: eggzecDocs('pyswarm'), @@ -87,6 +92,7 @@ export const PROJECTS: Project[] = [ }, { name: 'pydoe', + display: 'PyDOE', tagline: 'Design of experiments with factorial, response surface, space filling, and optimal designs', category: 'Optimization', github: 'https://github.com/pydoe/pydoe', @@ -96,6 +102,7 @@ export const PROJECTS: Project[] = [ }, { name: 'nlpql', + display: 'NLPQL', tagline: 'Sequential quadratic programming for smooth, constrained nonlinear problems', category: 'Optimization', github: gh('nlpql'), @@ -113,6 +120,7 @@ export const PROJECTS: Project[] = [ // ---- Numerics --------------------------------------------------------- { name: 'sdepack', + display: 'SDEPack', tagline: 'Stochastic Runge-Kutta solvers for scalar Ito SDEs, from Euler-Maruyama upward', category: 'Numerics', github: gh('sdepack'), @@ -122,40 +130,23 @@ export const PROJECTS: Project[] = [ }, { name: 'smolpack', + display: 'SmolPack', tagline: 'Sparse grid cubature over the unit hypercube using Smolyak with Clenshaw-Curtis rules', category: 'Numerics', github: gh('smolpack'), docs: eggzecDocs('smolpack'), pypi: 'smolpack', - }, - { - name: 'polpack', - tagline: 'Special functions and polynomial families over a Fortran numerical core', - category: 'Numerics', - github: gh('polpack'), - docs: eggzecDocs('polpack'), + icon: 'smolpack.svg', }, { name: 'kronrod', + display: 'Kronrod', tagline: 'Gauss-Kronrod quadrature rule generator for reusable high accuracy integration', category: 'Numerics', github: gh('kronrod'), docs: eggzecDocs('kronrod'), - }, - { - name: 'NULAPACK', - tagline: 'Numerical linear algebra with Fortran core subroutines and Python and C++ interfaces', - category: 'Numerics', - github: 'https://github.com/NULAPACK/NULAPACK', - docs: 'https://nulapack.github.io/NULAPACK/', - }, - { - name: 'sparse_grid', - display: 'Sparse Grid', - tagline: 'Hierarchical index generation and fast hat basis evaluation in pure Python', - category: 'Numerics', - github: gh('sparse_grid'), - docs: eggzecDocs('sparse_grid'), + pypi: 'kronrod', + icon: 'kronrod.svg', }, { name: 'spinterp', @@ -163,13 +154,8 @@ export const PROJECTS: Project[] = [ category: 'Numerics', github: gh('spinterp'), docs: eggzecDocs('spinterp'), - }, - { - name: 'cordic', - tagline: 'CORDIC evaluation of trigonometric, hyperbolic, exponential, and root functions', - category: 'Numerics', - github: gh('cordic'), - docs: eggzecDocs('cordic'), + pypi: 'spinterp', + icon: 'spinterp.svg', }, // ---- Tooling ---------------------------------------------------------- @@ -193,6 +179,7 @@ export const PROJECTS: Project[] = [ category: 'Tooling', github: gh('gnspy'), pypi: 'gnspy', + icon: 'gnspy.svg', }, ] @@ -200,11 +187,17 @@ export const PROJECTS: Project[] = [ export const projectLabel = (project: Project): string => project.display ?? project.name /** - * Featured = the projects with a hand-drawn brand icon. That is the honest - * signal of which ones we have invested in, and it keeps the landing grid and - * the nav menu in sync with the icon set automatically. + * What the landing page leads with, in this order. + * + * This used to be derived from which projects had a brand icon. Nearly all of + * them do now, so that signal stopped selecting anything — a lead set is a + * decision about what to show a first-time visitor, and it is made here. */ -export const FEATURED = PROJECTS.filter((p) => p.icon) +const LEAD = ['pydoe', 'ad', 'pyswarm', 'soerp', 'mcerp', 'bb'] + +export const FEATURED: Project[] = LEAD.map((name) => + PROJECTS.find((project) => project.name === name), +).filter((project): project is Project => project !== undefined) /** The nav dropdown stays short — four, then a link to the full catalog. */ export const NAV_MENU = FEATURED.slice(0, 4) @@ -224,7 +217,7 @@ export const ORG = { name: 'eggzec', tagline: 'Science + Computing', description: - 'A community building open source high performance scientific computing. Numerical cores in Fortran and C++, wrapped for Python, published with docs and CI.', + 'A community building open source scientific computing and automation tools, published with documentation and CI.', github: 'https://github.com/eggzec', pypi: 'https://pypi.org/org/eggzec/', email: 'm.saud.zahir@gmail.com', @@ -232,56 +225,36 @@ export const ORG = { } as const export interface Person { - slug: string name: string role: string + /** One paragraph, in their own terms. */ bio: string - avatar: string - links: Array<{ label: string; href: string }> - stack: string[] + github: string } export const PEOPLE: Person[] = [ { - slug: 'saud', name: 'M. Saud Zahir', role: 'Co-founder & maintainer', bio: 'Software engineer working across the full lifecycle: problem formulation, system design, implementation, optimization, and deployment. Experienced interfacing Python with C, C++, and Fortran, and writing directly in each.', - avatar: 'https://github.com/saudzahirr.png', - links: [ - { label: 'GitHub', href: 'https://github.com/saudzahirr' }, - { label: 'GitLab', href: 'https://gitlab.com/saudzahirr' }, - { label: 'PyPI', href: 'https://pypi.org/user/saudzahirr/' }, - { label: 'LinkedIn', href: 'https://www.linkedin.com/in/saudzahirr/' }, - { label: 'Email', href: 'mailto:m.saud.zahir@gmail.com' }, - ], - stack: ['Python', 'C++', 'Java', 'Fortran', 'Qt'], + github: 'https://github.com/saudzahirr', }, { - slug: 'laraib', name: 'M. Laraib Ali', role: 'Co-founder & maintainer', bio: 'Backend and systems engineer specializing in Python and Go, building reliable services and automation on Linux and containers. Works on distributed systems, PostgreSQL design, and observability through logging, tracing, and metrics.', - avatar: 'https://github.com/laraibg786.png', - links: [ - { label: 'GitHub', href: 'https://github.com/laraibg786' }, - { label: 'GitLab', href: 'https://gitlab.com/laraibg786' }, - { label: 'PyPI', href: 'https://pypi.org/user/Laraibg786/' }, - { label: 'LinkedIn', href: 'https://www.linkedin.com/in/m-laraib-ali/' }, - { label: 'Email', href: 'mailto:laraibg786@outlook.com' }, - ], - stack: ['Python', 'Go', 'PostgreSQL', 'Docker', 'Linux', 'Redis'], + github: 'https://github.com/laraibg786', }, { - slug: 'noor', name: 'Noor Mustafa', role: 'Maintainer', - bio: 'Java developer focused on reliable backend applications with Spring Boot. Enjoys solving complex problems, learning continuously, and collaborating to deliver clean, maintainable software.', - avatar: 'https://github.com/Noor-Mustafa123.png', - links: [ - { label: 'GitHub', href: 'https://github.com/Noor-Mustafa123' }, - { label: 'LinkedIn', href: 'https://www.linkedin.com/in/noormustafa715/' }, - ], - stack: ['Java', 'Spring', 'Kotlin', 'Nginx', 'Python'], + bio: 'Python and Java developer, building reliable backend applications and the services around them.', + github: 'https://github.com/Noor-Mustafa123', + }, + { + name: 'Saif ur Rehman', + role: 'Maintainer', + bio: 'Python and C++ developer working in CAE: computational fluid dynamics, numerical codes, and simulation.', + github: 'https://github.com/saifrehman945', }, ] diff --git a/src/data/used-by.ts b/src/data/used-by.ts new file mode 100644 index 0000000..def34a0 --- /dev/null +++ b/src/data/used-by.ts @@ -0,0 +1,46 @@ +/** + * Organisations that build on these packages. + * + * Every entry was verified against GitHub's live dependency graph, one + * repository at a time: either the SBOM for that repository lists the package, + * or its manifest on the default branch declares it. Entries that only appeared + * in the dependents *index* did not survive — that index lags, and several of + * them had since dropped the dependency or commented it out. Anything that + * could not be confirmed was left off, however good the name would have looked. + * + * Only pydoe and pyswarm have organisational users. mcerp and soerp have + * dependents, but individuals rather than organisations. + * + * Logos are the organisations' own GitHub avatars, desaturated at build time + * rather than in CSS so the strip stays even and nothing flashes colour while + * it loads. + */ + +export interface Dependent { + /** GitHub org, and the basename of its logo under `/brand/users/`. */ + login: string + name: string + /** The repository the dependency was verified in. */ + repo: string + uses: string +} + +export const USED_BY: Dependent[] = [ + { login: 'nasa', name: 'NASA', repo: 'nasa/GlennOPT', uses: 'pyDOE' }, + { login: 'llnl', name: 'Lawrence Livermore National Laboratory', repo: 'llnl/merlin', uses: 'pyDOE' }, + { login: 'lanl', name: 'Los Alamos National Laboratory', repo: 'lanl/impala', uses: 'pyswarm' }, + { login: 'sandialabs', name: 'Sandia National Laboratories', repo: 'sandialabs/pvOps', uses: 'pyDOE' }, + { login: 'OpenMDAO', name: 'OpenMDAO', repo: 'OpenMDAO/OpenMDAO', uses: 'pyDOE' }, + { login: 'philipmorrisintl', name: 'Philip Morris International', repo: 'philipmorrisintl/GOBench', uses: 'pyswarm' }, + { login: 'cmu-db', name: 'CMU Database Group', repo: 'cmu-db/ottertune', uses: 'pyDOE' }, + { login: 'qubole', name: 'Qubole', repo: 'qubole/uchit', uses: 'pyDOE' }, + { login: 'codecentric', name: 'codecentric', repo: 'codecentric/maximum-entropy', uses: 'pyswarm' }, + { login: 'ICRAR', name: 'ICRAR', repo: 'ICRAR/daliuge', uses: 'pyswarm' }, + { login: 'EMSL-Computing', name: 'EMSL Computing', repo: 'EMSL-Computing/CoreMS', uses: 'pyswarm' }, + { login: 'National-Digital-Twin', name: 'National Digital Twin', repo: 'National-Digital-Twin/NOVA', uses: 'pyswarm' }, + { login: 'gemseo', name: 'GEMSEO', repo: 'gemseo/gemseo', uses: 'pyDOE' }, + { login: 'SMTorg', name: 'SMT', repo: 'SMTorg/smt', uses: 'pyDOE' }, + { login: 'ICB-DCM', name: 'ICB-DCM', repo: 'ICB-DCM/pyPESTO', uses: 'pyswarm' }, + { login: 'openworm', name: 'OpenWorm', repo: 'openworm/ChannelWorm', uses: 'pyswarm' }, + { login: 'wwu-mmll', name: 'Medical Machine Learning Lab', repo: 'wwu-mmll/photonai', uses: 'pyDOE' }, +] diff --git a/src/lib/eggzec-block.ts b/src/lib/eggzec-block.ts index efda443..e7a7e04 100644 --- a/src/lib/eggzec-block.ts +++ b/src/lib/eggzec-block.ts @@ -78,54 +78,65 @@ function roundedRect(x: number, y: number, w: number, h: number): string { ) } -function glyphPath(spec: string, dx: number): { d: string; cells: Array<[number, number]> } { +/** + * Render one bitmap as merged runs. The grid is read from the spec rather than + * assumed, so callers can author glyphs on a taller or wider body than the 5x7 + * font — the maths glyphs in `components/draw-field` do exactly that. + */ +export function bitmapPath(spec: string, dx = 0, dy = 0): { d: string; cells: Array<[number, number]> } { const rows = spec.split('/') + const rowCount = rows.length + const colCount = rows[0]?.length ?? 0 const cells: Array<[number, number]> = [] let d = '' const on = (col: number, row: number): boolean => rows[row]?.[col] === '1' // Horizontal runs. - for (let r = 0; r < ROWS; r++) { + for (let r = 0; r < rowCount; r++) { let start: number | null = null - for (let c = 0; c <= COLS; c++) { - const lit = c < COLS && on(c, r) + for (let c = 0; c <= colCount; c++) { + const lit = c < colCount && on(c, r) if (lit) { if (start === null) start = c cells.push([c, r]) } if (!lit && start !== null) { - d += roundedRect(dx + start + OFF, r + OFF, c - start - 2 * OFF, T) + d += roundedRect(dx + start + OFF, dy + r + OFF, c - start - 2 * OFF, T) start = null } } } // Vertical runs. - for (let c = 0; c < COLS; c++) { + for (let c = 0; c < colCount; c++) { let start: number | null = null - for (let r = 0; r <= ROWS; r++) { - const lit = r < ROWS && on(c, r) + for (let r = 0; r <= rowCount; r++) { + const lit = r < rowCount && on(c, r) if (lit && start === null) start = r if (!lit && start !== null) { - d += roundedRect(dx + c + OFF, start + OFF, T, r - start - 2 * OFF) + d += roundedRect(dx + c + OFF, dy + start + OFF, T, r - start - 2 * OFF) start = null } } } // Bridge diagonal junctions so stairs read as continuous strokes. - for (let r = 0; r < ROWS; r++) { - for (let c = 0; c < COLS; c++) { + for (let r = 0; r < rowCount; r++) { + for (let c = 0; c < colCount; c++) { if (!on(c, r)) continue - if (on(c + 1, r + 1)) d += roundedRect(dx + c + 1 - 0.36, r + 1 - 0.36, 0.72, 0.72) - if (on(c + 1, r - 1)) d += roundedRect(dx + c + 1 - 0.36, r - 0.36, 0.72, 0.72) + if (on(c + 1, r + 1)) d += roundedRect(dx + c + 1 - 0.36, dy + r + 1 - 0.36, 0.72, 0.72) + if (on(c + 1, r - 1)) d += roundedRect(dx + c + 1 - 0.36, dy + r - 0.36, 0.72, 0.72) } } return { d, cells } } +function glyphPath(spec: string, dx: number): { d: string; cells: Array<[number, number]> } { + return bitmapPath(spec, dx, 0) +} + /** Lay out a string of block lettering. `tracking` adds grid units between glyphs. */ export function letter(text: string, tracking = 0): Lettering { const t = String(text ?? '').toUpperCase() diff --git a/src/lib/nlpql-glyphs.ts b/src/lib/nlpql-glyphs.ts new file mode 100644 index 0000000..22589d4 --- /dev/null +++ b/src/lib/nlpql-glyphs.ts @@ -0,0 +1,63 @@ +/** + * Traced from `public/brand/icons/nlpql.svg`. + * + * That icon is the mark for the SQP solver, and it is literally what the solver + * differentiates: the gradient of the Lagrangian. The nabla is one path plus a + * counter; the script L is a run-merged bitmap, the same construction as the + * eggzec Block font, so it is kept as its own blocks and written on stroke by + * stroke rather than filled all at once. + * + * Coordinates are the icon's own, and both glyphs share that space, so they can + * be placed together and stay in register. + */ + +/** Nabla outline. Pair with the counter below under an `evenodd` rule. */ +export const NABLA_OUTLINE = + 'M4.42 2.40Q4.00 2.40 4.00 2.82L4.00 4.88Q4.00 5.30 4.42 5.30L4.91 5.30Q5.33 5.30 5.33 5.72L5.33 7.78Q5.33 8.20 5.75 8.20L6.25 8.20Q6.67 8.20 6.67 8.62L6.67 10.68Q6.67 11.10 7.09 11.10L7.58 11.10Q8.00 11.10 8.00 11.52L8.00 13.58Q8.00 14.00 8.42 14.00L8.91 14.00Q9.33 14.00 9.33 14.42L9.33 16.48Q9.33 16.90 9.75 16.90L10.25 16.90Q10.67 16.90 10.67 17.32L10.67 19.38Q10.67 19.80 11.09 19.80L12.91 19.80Q13.33 19.80 13.33 19.38L13.33 17.32Q13.33 16.90 13.75 16.90L14.25 16.90Q14.67 16.90 14.67 16.48L14.67 14.42Q14.67 14.00 15.09 14.00L15.58 14.00Q16.00 14.00 16.00 13.58L16.00 11.52Q16.00 11.10 16.42 11.10L16.91 11.10Q17.33 11.10 17.33 10.68L17.33 8.62Q17.33 8.20 17.75 8.20L18.25 8.20Q18.67 8.20 18.67 7.78L18.67 5.72Q18.67 5.30 19.09 5.30L19.58 5.30Q20.00 5.30 20.00 4.88L20.00 2.82Q20.00 2.40 19.58 2.40Z' + +/** The hole in the nabla. */ +export const NABLA_COUNTER = + 'M9.22 6.60Q8.80 6.60 8.80 7.02L8.80 8.68Q8.80 9.10 9.22 9.10L9.45 9.10Q9.87 9.10 9.87 9.52L9.87 11.18Q9.87 11.60 10.29 11.60L10.51 11.60Q10.93 11.60 10.93 12.02L10.93 13.68Q10.93 14.10 11.35 14.10L12.65 14.10Q13.07 14.10 13.07 13.68L13.07 12.02Q13.07 11.60 13.49 11.60L13.71 11.60Q14.13 11.60 14.13 11.18L14.13 9.52Q14.13 9.10 14.55 9.10L14.78 9.10Q15.20 9.10 15.20 8.68L15.20 7.02Q15.20 6.60 14.78 6.60Z' + +/** Nabla bounding box in icon units: `[x, y, width, height]`. */ +export const NABLA_BOX = [4, 2.4, 16, 17.4] as const + +/** Script L, as `[x, y, width, height]` blocks ordered top to bottom. */ +export const SCRIPT_L: ReadonlyArray = [ + [17.699, 6.3, 3.257, 1.036], + [17.699, 6.3, 1.036, 1.777], + [19.92, 6.3, 1.036, 1.777], + [16.959, 7.04, 1.777, 1.036], + [16.959, 7.04, 1.036, 2.517], + [19.92, 7.04, 1.777, 1.036], + [20.66, 7.04, 1.036, 1.777], + [16.218, 7.781, 1.777, 1.036], + [16.218, 7.781, 1.036, 3.257], + [20.66, 7.781, 1.036, 1.036], + [16.218, 8.521, 1.777, 1.036], + [15.478, 9.261, 1.777, 1.036], + [15.478, 9.261, 1.036, 3.257], + [15.478, 10.001, 1.777, 1.036], + [14.738, 10.742, 1.777, 1.036], + [14.738, 10.742, 1.036, 3.257], + [14.738, 11.482, 1.777, 1.036], + [13.998, 12.222, 1.777, 1.036], + [13.998, 12.222, 1.036, 3.257], + [13.998, 12.962, 1.777, 1.036], + [13.257, 13.703, 1.777, 1.036], + [13.257, 13.703, 1.036, 3.257], + [13.257, 14.443, 1.777, 1.036], + [12.517, 15.183, 1.777, 1.036], + [12.517, 15.183, 1.036, 1.777], + [19.179, 15.183, 2.517, 1.036], + [19.179, 15.183, 1.036, 1.777], + [11.777, 15.923, 8.439, 1.036], + [11.777, 15.923, 1.036, 1.777], + [11.037, 16.664, 1.777, 1.036], +] + +/** Corner radius the icon uses on those blocks. */ +export const SCRIPT_L_RADIUS = 0.252 + +/** Script L bounding box in icon units. */ +export const SCRIPT_L_BOX = [11.037, 6.3, 10.66, 11.4] as const diff --git a/src/main.ts b/src/main.ts index 43a1087..759ba38 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,12 +18,13 @@ import { mountNav } from './components/nav' import { mountFooter } from './components/footer' import { mountCounters, mountReveals } from './components/reveal' import { mountDisplayType } from './components/wordmark' -import { mountLifeFields } from './components/life-field' +import { mountDrawFields } from './components/draw-field' import { mountMarquees } from './components/marquee' import { mountPixelRules } from './components/pixel-rule' import { mountPointerEffects } from './components/pointer' +import { mountMotes } from './components/motes' import { mountFeaturedGrid, mountCatalog } from './components/project-cards' -import { mountPeople, mountProfile, syncStats } from './components/content' +import { mountPeople, mountUsedBy, syncStats } from './components/content' import { defineEggzecText } from './lib/eggzec-block' import { prefersReducedMotion } from './lib/env' @@ -68,17 +69,18 @@ function boot(): void { mountFeaturedGrid() mountCatalog() mountPeople() - mountProfile() + mountUsedBy() syncStats() mountDisplayType() // Behaviour over whatever ended up in the DOM. - mountLifeFields() + mountDrawFields() mountMarquees() mountPixelRules() mountReveals() mountCounters() mountPointerEffects() + mountMotes() mountSmoothScroll() document.documentElement.dataset.ready = 'true' diff --git a/src/styles/components.css b/src/styles/components.css index d5f676d..1cb17b1 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -371,20 +371,404 @@ opacity: 0; } -/* ---- life field -------------------------------------------------------- */ - -.life-field { - position: absolute; +/* ---- motes -------------------------------------------------------------- */ + +/* Fixed rather than scrolled: the page moves past the specks the way a glass of + water stays put while you turn it. The negative z-index is what puts them back + stage — behind every section's content, and behind the hero plate too, which + is its own stacking context — while still sitting above the page ground. + Nothing here is clickable; the cursor is read from `window`. + + The header bar is translucent on purpose, so anything behind it shows through + the blur and reads as sitting *on* the header. Rather than making the bar + opaque and losing that, the field is masked out of the band the header can + occupy, measured against its tallest unscrolled height so the exclusion still + holds once it shrinks. */ +.motes { + position: fixed; inset: 0; - z-index: 0; + z-index: -1; + width: 100%; + height: 100%; + pointer-events: none; + mask-image: linear-gradient( + to bottom, + transparent 0, + transparent var(--nav-h-top), + #000 calc(var(--nav-h-top) + 72px) + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent 0, + transparent var(--nav-h-top), + #000 calc(var(--nav-h-top) + 72px) + ); +} + +/* ---- draw field --------------------------------------------------------- */ + +/* The plate plays once and holds. Delays come from a `--i` index on each + element, so the whole sequence is CSS and JS only flips `is-playing`: + contours and axes draw, the nabla is outlined and then filled through its bar + matte, the descent and residual curves run, and the binary lands last. */ + +/* The host keeps whatever box its own page rule gave it — the footer rule is a + 54px strip in flow, the hero field is an absolute panel — so only the SVG is + positioned here. */ +.draw-field { pointer-events: none; overflow: hidden; } -.life-field canvas { +.draw-field__svg { + position: absolute; + inset: 0; width: 100%; height: 100%; - pointer-events: auto; + overflow: visible; +} + +.draw-field__svg--rule { + overflow: hidden; +} + +/* Paused until the host scrolls in — a paused animation holds its start frame + and does not burn through its delay, so this is the whole "not yet" state. */ +.draw-field [class^='draw-field__'] { + animation-play-state: paused; +} + +.draw-field.is-playing [class^='draw-field__'] { + animation-play-state: running; +} + +/* -- contour plot --------------------------------------------------------- */ + +.draw-field__ring { + fill: none; + stroke: var(--mark-trace); + stroke-width: 0.1; + stroke-dasharray: 1 1; + animation: df-draw 1.1s var(--ease-out) both; + animation-delay: calc(0.2s + var(--i, 0) * 0.14s); +} + +.draw-field__descent { + fill: none; + stroke: var(--mark-line); + stroke-width: 0.14; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 1 1; + animation: df-draw 1.3s var(--ease-out) both; + animation-delay: 1.5s; +} + +.draw-field__iterate { + fill: var(--mark-spark); + transform-box: fill-box; + transform-origin: center; + animation: df-pop 0.5s var(--ease-spring) both; + animation-delay: calc(1.9s + var(--i, 0) * 0.13s); +} + +/* -- residual curve ------------------------------------------------------- */ + +.draw-field__axis { + fill: none; + stroke: var(--mark-line); + stroke-width: 0.1; + stroke-dasharray: 1 1; + animation: df-draw 0.9s var(--ease-out) both; + animation-delay: 1.7s; +} + +.draw-field__tol { + fill: none; + stroke: var(--mark-trace); + stroke-width: 0.07; + stroke-dasharray: 0.02 0.012; + animation: df-fade 0.6s var(--ease-out) both; + animation-delay: 2.4s; +} + +.draw-field__curve { + fill: none; + stroke: var(--mark-line); + stroke-width: 0.14; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 1 1; + animation: df-draw 1.2s var(--ease-out) both; + animation-delay: 2.1s; +} + +.draw-field__sample { + fill: var(--mark-heat); + transform-box: fill-box; + transform-origin: center; + animation: df-pop 0.44s var(--ease-spring) both; + animation-delay: calc(2.5s + var(--i, 0) * 0.1s); +} + +/* -- nabla ---------------------------------------------------------------- */ + +.draw-field__glyph { + fill: none; + stroke: var(--mark-line); + stroke-width: 0.12; + stroke-dasharray: 1 1; + animation: df-draw 1.5s var(--ease-out) both; + animation-delay: 0.45s; +} + +/* Two moves in one keyframe track, because both write `transform`: the bar + grows out from the centre line, waits, then swells vertically to close the + gap to its neighbours so the stack resolves into a solid glyph. */ +.draw-field__bar { + fill: var(--mark-fill); + transform-box: fill-box; + transform-origin: center; + animation: df-bar 1.8s both; + animation-delay: calc(0.85s + var(--i, 0) * 0.09s); +} + +/* -- type ----------------------------------------------------------------- */ + +.draw-field__bit { + fill: var(--mark-text); + transform-box: fill-box; + transform-origin: center; + animation: df-bit 0.42s var(--ease-out) both; + animation-delay: calc(2.35s + var(--i, 0) * 0.045s); +} + +.draw-field__bit--lit { + fill: var(--mark-spark); +} + +.draw-field__svg--rule .draw-field__bit { + animation-delay: calc(var(--i, 0) * 0.05s); +} + +/* Equations are set in eggzec Block, so each glyph is geometry rather than + text: it wipes up the way the display headings do. */ +.draw-field__eq { + fill: var(--mark-text); + transform-box: fill-box; + transform-origin: center; + animation: df-bit 0.4s var(--ease-out) both; + animation-delay: calc(0.6s + var(--i, 0) * 0.035s); +} + +.draw-field__label { + fill: var(--mark-trace); + transform-box: fill-box; + transform-origin: center; + animation: df-bit 0.4s var(--ease-out) both; + animation-delay: calc(2.7s + var(--i, 0) * 0.05s); +} + +/* -- script L ------------------------------------------------------------- */ + +/* Written on block by block, in the order the stroke would be made. */ +.draw-field__stroke { + fill: var(--mark-line); + transform-box: fill-box; + transform-origin: center; + animation: df-pop 0.34s var(--ease-out) both; + animation-delay: calc(1.5s + var(--i, 0) * 0.035s); +} + +/* -- Wiener path and pollen ----------------------------------------------- */ + +.draw-field__walk { + fill: none; + stroke: var(--mark-trace); + stroke-width: 0.1; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 1 1; + animation: df-draw 1.8s linear both; + animation-delay: 2.6s; +} + +/* The one thing that never settles. Three tracks at staggered durations, so + no two grains drift together; transforms only, so it stays on the + compositor once the rest of the plate has resolved. */ +.draw-field__pollen { + fill: var(--mark-spark); + transform-box: fill-box; + transform-origin: center; + opacity: 0; + animation: + df-fade 0.5s var(--ease-out) forwards, + df-drift-0 var(--dur, 8s) ease-in-out infinite alternate; + animation-delay: calc(3.2s + var(--i, 0) * 0.06s), calc(3.2s + var(--i, 0) * -0.9s); +} + +.draw-field__pollen--1 { + fill: var(--mark-heat); + animation-name: df-fade, df-drift-1; +} + +.draw-field__pollen--2 { + fill: var(--mark-trace); + animation-name: df-fade, df-drift-2; +} + +@keyframes df-draw { + from { + stroke-dashoffset: 1; + } + to { + stroke-dashoffset: 0; + } +} + +@keyframes df-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes df-pop { + from { + opacity: 0; + transform: scale(0); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes df-bit { + from { + opacity: 0; + transform: scaleY(0.15); + } + to { + opacity: 1; + transform: scaleY(1); + } +} + +/* Brownian drift. The waypoints are irregular on purpose — evenly spaced ones + read as a pendulum rather than as a grain being knocked about. */ +@keyframes df-drift-0 { + 0% { + transform: translate(0, 0); + } + 18% { + transform: translate(0.22px, -0.5px); + } + 37% { + transform: translate(-0.3px, 0.34px); + } + 55% { + transform: translate(0.46px, 0.6px); + } + 71% { + transform: translate(-0.18px, -0.28px); + } + 88% { + transform: translate(0.34px, -0.62px); + } + 100% { + transform: translate(-0.24px, 0.2px); + } +} + +@keyframes df-drift-1 { + 0% { + transform: translate(0, 0); + } + 22% { + transform: translate(-0.4px, 0.44px); + } + 41% { + transform: translate(0.26px, 0.7px); + } + 63% { + transform: translate(0.5px, -0.3px); + } + 84% { + transform: translate(-0.32px, -0.56px); + } + 100% { + transform: translate(0.18px, 0.38px); + } +} + +@keyframes df-drift-2 { + 0% { + transform: translate(0, 0); + } + 15% { + transform: translate(0.36px, 0.28px); + } + 34% { + transform: translate(-0.5px, -0.42px); + } + 58% { + transform: translate(0.2px, -0.66px); + } + 79% { + transform: translate(-0.28px, 0.52px); + } + 100% { + transform: translate(0.42px, -0.16px); + } +} + +/* The reference's own easing, read off the Lottie keyframes. */ +@keyframes df-bar { + 0% { + transform: scaleX(0) scaleY(1); + animation-timing-function: cubic-bezier(0.42, 0, 0.07, 0.95); + } + 41% { + transform: scaleX(1) scaleY(1); + animation-timing-function: linear; + } + 72% { + transform: scaleX(1) scaleY(1); + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + } + /* Slightly past the row pitch, so neighbours overlap and the rounded bar + ends stop notching the glyph. */ + 100% { + transform: scaleX(1) scaleY(1.72); + } +} + +/* Reduced motion gets the finished frame, never the build. */ +.draw-field.is-static [class^='draw-field__'] { + animation: none; +} + +.draw-field.is-static .draw-field__bar { + transform-box: fill-box; + transform-origin: center; + transform: scaleY(1.72); +} + +.draw-field.is-static .draw-field__ring, +.draw-field.is-static .draw-field__descent, +.draw-field.is-static .draw-field__axis, +.draw-field.is-static .draw-field__curve, +.draw-field.is-static .draw-field__walk, +.draw-field.is-static .draw-field__glyph { + stroke-dasharray: none; +} + +/* Pollen is suspended rather than drifting — it opts out of motion entirely. */ +.draw-field.is-static .draw-field__pollen { + opacity: 1; } /* ---- stats ------------------------------------------------------------- */ @@ -468,43 +852,31 @@ /* ---- tool chips inside the marquee ------------------------------------- */ -.tool { +/* ---- used by ------------------------------------------------------------ */ + +/* Sized for a landing-page logo strip, not for GitHub's own "Used by" widget, + which runs at 24px because it is a dense sidebar list of user avatars. These + are marks meant to be recognised in passing, so they get roughly double that. + + Hover darkens and nothing else. The strip is moving, so anything that changes + the logo's size or position under the cursor reads as a glitch. */ +.used-by { display: inline-flex; align-items: center; - gap: 0.65rem; - color: var(--ink-muted); - font-size: var(--text-base); - font-weight: 520; - white-space: nowrap; - transition: color var(--dur-fast) var(--ease-out); + padding: 0.4rem 1.15rem; } -.tool:hover { - color: var(--ink); +.used-by__logo { + width: auto; + height: 52px; + max-width: 170px; + object-fit: contain; + opacity: 0.55; + transition: opacity var(--dur-base) var(--ease-out); } -.tool__mark { - display: grid; - place-items: center; - width: 34px; - height: 34px; - flex: 0 0 auto; - overflow: hidden; - border-radius: var(--radius-sm); - border: var(--border) solid var(--line); - /* Lifted off the page ground so logos with transparent backgrounds — and - dark marks in dark mode — still read. */ - background: color-mix(in srgb, var(--ink) 9%, var(--bg-raised)); - font-family: var(--font-mono); - font-size: var(--text-xs); - font-weight: 600; - color: var(--ink-faint); -} - -.tool__mark img { - width: 100%; - height: 100%; - object-fit: cover; +.used-by:hover .used-by__logo { + opacity: 1; } /* ---- spotlight surface -------------------------------------------------- */ diff --git a/src/styles/layout.css b/src/styles/layout.css index aa4dd90..ecd06d9 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -445,12 +445,8 @@ html.is-nav-open body { -webkit-mask-image: linear-gradient(to bottom, #000, transparent); } -.site-footer__rule canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - opacity: 0.5; +.site-footer__rule .draw-field__svg { + opacity: 0.55; } .site-footer__bottom { diff --git a/src/styles/pages.css b/src/styles/pages.css index 210b537..3803e93 100644 --- a/src/styles/pages.css +++ b/src/styles/pages.css @@ -11,21 +11,37 @@ isolation: isolate; } -/* Life field sits behind everything, faded out toward the centre so the - wordmark never fights it for attention. */ +/* The plate takes the right of the hero, flush with the top — the section + already starts below the nav, so it reads from just under it and the first + equation lands above the headline rather than behind it. It is never allowed + past the viewport edge, so nothing on it is half a glyph. Two masks + intersect: one fades the foot, one dissolves the left edge, which keeps the + lower motifs off the lede's measure without moving them. */ .hero__field { position: absolute; - inset: -10% -5% auto; - height: 130%; + inset: 0 0 auto auto; + width: min(52%, 660px); + height: 54%; z-index: -2; - opacity: 0.62; - mask-image: radial-gradient(115% 95% at 32% 40%, transparent 24%, #000 74%); - -webkit-mask-image: radial-gradient(115% 95% at 32% 40%, transparent 24%, #000 74%); + opacity: 0.7; + mask-image: + linear-gradient(to bottom, #000 93%, transparent 100%), + linear-gradient(to right, transparent, #000 30%); + mask-composite: intersect; + -webkit-mask-image: + linear-gradient(to bottom, #000 93%, transparent 100%), + linear-gradient(to right, transparent, #000 30%); + -webkit-mask-composite: source-in; } -.hero__field canvas { - width: 100%; - height: 100%; +@media (max-width: 900px) { + /* Nothing is beside it at this width, so it spreads and drops back. */ + .hero__field { + inset: -2% 0 auto 0; + width: auto; + height: 76%; + opacity: 0.4; + } } .hero__grid { @@ -70,7 +86,11 @@ } } +/* Block lettering scales to its container, so the cap height is set by this + width and nothing else. Four lines want to sit smaller than three did, and + holding it back from the full measure also leaves the plate room to read. */ .hero__display { + width: min(100%, 860px); margin-block: var(--space-2); } @@ -303,14 +323,6 @@ transform: translateY(-3px); } -.person__avatar { - width: 72px; - height: 72px; - border-radius: var(--radius-md); - object-fit: cover; - background: var(--bg-sunken); -} - .person__name { font-size: var(--text-lg); } @@ -325,14 +337,6 @@ color: var(--ink-muted); } -.person__links { - position: relative; - z-index: 2; - display: flex; - flex-wrap: wrap; - gap: var(--space-3); -} - .person__hit { position: absolute; inset: 0; @@ -345,32 +349,6 @@ gap: var(--space-2); } -/* ---- profile ------------------------------------------------------------ */ - -.profile { - display: grid; - grid-template-columns: 200px minmax(0, 1fr); - gap: clamp(var(--space-5), 4vw, var(--space-8)); - align-items: start; -} - -.profile__avatar { - width: 100%; - border-radius: var(--radius-lg); - border: var(--border) solid var(--line); - background: var(--bg-raised); -} - -@media (max-width: 720px) { - .profile { - grid-template-columns: 1fr; - } - - .profile__avatar { - width: 140px; - } -} - /* ---- contact ------------------------------------------------------------ */ .contact-grid { @@ -438,16 +416,18 @@ .cta__field { position: absolute; - inset: 0; + inset: -20% -4% -20% auto; + width: min(46%, 460px); z-index: 0; - opacity: 0.4; - mask-image: radial-gradient(80% 120% at 50% 50%, transparent 22%, #000 78%); - -webkit-mask-image: radial-gradient(80% 120% at 50% 50%, transparent 22%, #000 78%); + opacity: 0.5; + mask-image: linear-gradient(to left, #000 40%, transparent); + -webkit-mask-image: linear-gradient(to left, #000 40%, transparent); } -.cta__field canvas { - width: 100%; - height: 100%; +@media (max-width: 760px) { + .cta__field { + display: none; + } } /* ---- 404 ---------------------------------------------------------------- */ @@ -461,18 +441,18 @@ overflow: hidden; } +/* Centred here, because the 404 copy is centred too — the mark draws straight + behind it at low contrast. */ .notfound__field { position: absolute; - inset: 0; + inset: 2% 0 auto; + width: min(70%, 720px); + height: 52%; + margin-inline: auto; z-index: 0; - opacity: 0.4; - mask-image: radial-gradient(70% 70% at 50% 45%, transparent 20%, #000 75%); - -webkit-mask-image: radial-gradient(70% 70% at 50% 45%, transparent 20%, #000 75%); -} - -.notfound__field canvas { - width: 100%; - height: 100%; + opacity: 0.3; + mask-image: radial-gradient(80% 80% at 50% 50%, #000 40%, transparent 92%); + -webkit-mask-image: radial-gradient(80% 80% at 50% 50%, #000 40%, transparent 92%); } .notfound__inner { diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 5d128b7..32185c8 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -51,10 +51,16 @@ --spark: var(--magenta); --heat: var(--orange); - /* Life field: kiwi disappears on white, so the automaton runs in violet - and flashes magenta on birth. */ - --field-live: var(--violet); - --field-born: var(--magenta); + /* Draw field: the nabla fills in kiwi the way blocks do in the logo, and + every line that draws it is violet, because kiwi cannot hold a hairline on + white. Iterates and lit bits spark magenta; samples on the residual curve + take the orange so all four hues are in play. */ + --mark-fill: var(--kiwi); + --mark-line: rgba(82, 0, 255, 0.55); + --mark-trace: rgba(82, 0, 255, 0.4); + --mark-text: rgba(82, 0, 255, 0.5); + --mark-spark: var(--magenta); + --mark-heat: var(--orange); /* ---- type ----------------------------------------------------------- */ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI Variable Display', diff --git a/vite-plugin-partials.ts b/vite-plugin-partials.ts index efd0168..7aa9eee 100644 --- a/vite-plugin-partials.ts +++ b/vite-plugin-partials.ts @@ -25,7 +25,7 @@ const DEFAULTS: Record = { path: '/', title: 'eggzec · Science + Computing', description: - 'eggzec builds open source high performance scientific computing: Fortran and C++ numerical cores wrapped for Python.', + 'eggzec builds open source scientific computing and automation tools.', } const META_RE = // diff --git a/vite.config.ts b/vite.config.ts index d6662ae..ddf01e9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,9 +27,6 @@ export default defineConfig({ home: r('index.html'), projects: r('projects/index.html'), community: r('community/index.html'), - saud: r('community/saud/index.html'), - laraib: r('community/laraib/index.html'), - noor: r('community/noor/index.html'), notFound: r('404.html'), }, output: {