diff --git a/.gitignore b/.gitignore index 4de9cb6..76a1847 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ pnpm-debug.log* src/pages/writing/*/ src/pages/projects/*/ public/figures/ +.pixi/ diff --git a/Makefile b/Makefile index 63acab0..cf603c1 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,26 @@ .PHONY: install dev build preview check banner post -# ── npm ─────────────────────────────────────────────────────────────────────── +# ── environment ─────────────────────────────────────────────────────────────── install: - npm install + pixi run install dev: - npm run dev + pixi run dev build: - npm run build + pixi run build preview: - npm run preview + pixi run preview check: - npm run check + pixi run check # ── assets ──────────────────────────────────────────────────────────────────── banner: - node scripts/gen-banner.mjs + pixi run banner # ── content ─────────────────────────────────────────────────────────────────── # @@ -33,4 +33,4 @@ ifndef TITLE $(error TITLE is required. make post TITLE="my title" TAG="debugging" TYPE=note) endif @chmod +x scripts/new-post.sh - @scripts/new-post.sh "$(TITLE)" "$(or $(TAG),field note)" "$(or $(TYPE),note)" + @pixi run node scripts/new-post.sh "$(TITLE)" "$(or $(TAG),field note)" "$(or $(TYPE),note)" diff --git a/astro.config.mjs b/astro.config.mjs index 106490e..803569f 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -3,10 +3,12 @@ import { defineConfig } from 'astro/config'; import mdx from '@astrojs/mdx'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; export default defineConfig({ site: 'https://viniciusdc.github.io', - integrations: [mdx()], + integrations: [mdx({ remarkPlugins: [remarkMath], rehypePlugins: [rehypeKatex] })], devToolbar: { enabled: false }, vite: { plugins: [tailwindcss()], diff --git a/cspell.json b/cspell.json index 0be4e1a..632e0ef 100644 --- a/cspell.json +++ b/cspell.json @@ -10,7 +10,7 @@ "keycloak", "oidc", "oauth", "saml", "jwt", "jwks", "fastapi", "pydantic", "scrapy", "uvicorn", "vinicius", "cerutti", "Ceture", "viniciusdc", "Catarina", "UFSC", - "astro", "tailwind", "shadcn", "fontsource", "lhci", + "astro", "tailwind", "shadcn", "fontsource", "lhci", "lightbox", "woff", "woff2", "oklch", "rgb", "rgba", "hsl", "clamp", "minmax", "prefers", "nebari", "jmespath", "snistrict", @@ -39,9 +39,11 @@ "semidefinite", "semidefiniteness", "Barzilai", "Borwein", "nonmonotone", "GLL", - "Hessian", "quasi-Newton", + "Hessian", "quasi-Newton", "eigencomponents", + "mathbb", "mathbf", "mathcal", "bigl", "bigr", "NMR", "PDB", "crystallography", - "Gram" + "Gram", "Schoenberg", "Wüthrich", "ChimeraX", "PIBIC", "RMSD", "MDE", "RCSB", + "PGDm", "Gonçalves", "Birgin", "Martínez", "Raydan", "UCSF" ], "ignorePaths": [ "node_modules/**", diff --git a/package-lock.json b/package-lock.json index 66ea24e..4f7ed35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "@fontsource-variable/geist": "^5.2.8", "@tailwindcss/vite": "^4.2.4", "astro": "^6.2.1", + "katex": "^0.16.45", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "tailwindcss": "^4.2.4" }, "devDependencies": { @@ -164,6 +167,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -769,6 +773,7 @@ "integrity": "sha512-IQA++Idqb8fZzkCbHq3+T+9yG9WpeaBxomOrG2KcR/Pj0CgnovzuApYKL2cc35UWLePboKinMeqEPiweFpHVug==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=22.18.0" } @@ -850,7 +855,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-css/-/dict-css-4.1.1.tgz", "integrity": "sha512-y/Vgo6qY08e1t9OqR56qjoFLBCpi4QfWMf2qzD1l9omRZwvSMQGRPz4x0bxkkkU4oocMAeztjzCsmLew//c/8w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-dart": { "version": "2.3.2", @@ -990,14 +996,16 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-html/-/dict-html-4.0.15.tgz", "integrity": "sha512-GJYnYKoD9fmo2OI0aySEGZOjThnx3upSUvV7mmqUu8oG+mGgzqm82P/f7OqsuvTaInZZwZbo+PwJQd/yHcyFIw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-html-symbol-entities": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@cspell/dict-html-symbol-entities/-/dict-html-symbol-entities-4.0.5.tgz", "integrity": "sha512-429alTD4cE0FIwpMucvSN35Ld87HCyuM8mF731KU5Rm4Je2SG6hmVx7nkBsLyrmH3sQukTcr1GaiZsiEg8svPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-java": { "version": "5.0.12", @@ -1195,7 +1203,8 @@ "resolved": "https://registry.npmjs.org/@cspell/dict-typescript/-/dict-typescript-3.2.3.tgz", "integrity": "sha512-zXh1wYsNljQZfWWdSPYwQhpwiuW0KPW1dSd8idjMRvSD0aSvWWHoWlrMsmZeRl4qM4QCEAjua8+cjflm41cQBg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@cspell/dict-vue": { "version": "3.0.5", @@ -2667,6 +2676,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2772,6 +2782,7 @@ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2795,6 +2806,7 @@ "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" }, @@ -2808,6 +2820,7 @@ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, @@ -2834,6 +2847,7 @@ "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -3264,6 +3278,7 @@ "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" @@ -3291,6 +3306,7 @@ "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", @@ -3319,6 +3335,7 @@ "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -4369,6 +4386,12 @@ "@types/unist": "*" } }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -4415,6 +4438,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -4524,6 +4548,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4718,6 +4743,7 @@ "resolved": "https://registry.npmjs.org/astro/-/astro-6.2.1.tgz", "integrity": "sha512-3g1sYNly+QAkuO5ErNEQBYvsxorNDSCUNIeStBs+kcXGchvKQl1Q9EuDNOvSg010XLlHJFLVFZs9LV18Jjp4Hg==", "license": "MIT", + "peer": true, "dependencies": { "@astrojs/compiler": "^4.0.0", "@astrojs/internal-helpers": "0.9.0", @@ -5052,6 +5078,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -6257,7 +6284,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz", "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "8.0.4", @@ -7611,6 +7639,21 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-html": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", @@ -7629,6 +7672,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-from-parse5": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", @@ -7860,6 +7919,7 @@ "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -8510,6 +8570,31 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -9288,6 +9373,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", @@ -9670,6 +9774,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-extension-mdx-expression": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", @@ -11497,6 +11620,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -11575,6 +11717,22 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", @@ -11830,6 +11988,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -13336,6 +13495,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -13665,6 +13825,7 @@ "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "devOptional": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -13815,6 +13976,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 1ab9260..26bfd33 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,9 @@ "@fontsource-variable/geist": "^5.2.8", "@tailwindcss/vite": "^4.2.4", "astro": "^6.2.1", + "katex": "^0.16.45", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0", "tailwindcss": "^4.2.4" }, "devDependencies": { diff --git a/pixi.toml b/pixi.toml new file mode 100644 index 0000000..a563247 --- /dev/null +++ b/pixi.toml @@ -0,0 +1,16 @@ +[workspace] +name = "viniciusdc-github-io" +version = "0.1.0" +channels = ["conda-forge"] +platforms = ["osx-arm64", "osx-64", "linux-64", "linux-aarch64"] + +[dependencies] +nodejs = ">=22.12.0,<25" + +[tasks] +install = "npm install" +dev = { cmd = "npm run dev", depends-on = ["install"] } +build = { cmd = "npm run build", depends-on = ["install"] } +preview = { cmd = "npm run preview", depends-on = ["build"] } +check = { cmd = "npm run check", depends-on = ["install"] } +banner = "node scripts/gen-banner.mjs" diff --git a/src/layouts/ArticleLayout.astro b/src/layouts/ArticleLayout.astro index 7d94f35..66098f5 100644 --- a/src/layouts/ArticleLayout.astro +++ b/src/layouts/ArticleLayout.astro @@ -16,6 +16,13 @@ const { title, description, meta, subtitle, backHref, backLabel, nextHref, nextL --- + + +
@@ -45,6 +52,34 @@ const { title, description, meta, subtitle, backHref, backLabel, nextHref, nextL const headings = document.querySelectorAll('.article-content h2, .article-content h3'); const nav = document.getElementById('toc-nav'); + // Lightbox + const lightbox = document.getElementById('lightbox')!; + const lbImg = document.getElementById('lightbox-img') as HTMLImageElement; + const lbCap = document.getElementById('lightbox-caption')!; + const lbClose = document.getElementById('lightbox-close')!; + + document.querySelectorAll('.article-content figure img').forEach(img => { + img.style.cursor = 'zoom-in'; + img.addEventListener('click', () => { + lbImg.src = img.src; + lbImg.alt = img.alt; + lbCap.textContent = img.closest('figure')?.querySelector('figcaption')?.textContent ?? ''; + lbCap.style.display = lbCap.textContent ? '' : 'none'; + lightbox.classList.add('open'); + document.body.style.overflow = 'hidden'; + }); + }); + + const closeLightbox = () => { + lightbox.classList.remove('open'); + document.body.style.overflow = ''; + }; + + lbClose.addEventListener('click', closeLightbox); + lightbox.addEventListener('click', e => { if (e.target === lightbox) closeLightbox(); }); + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); }); + + // TOC if (nav && headings.length > 1) { headings.forEach(h => { if (!h.id) { @@ -68,7 +103,7 @@ const { title, description, meta, subtitle, backHref, backLabel, nextHref, nextL }, { rootMargin: '-10% 0px -80% 0px' }); headings.forEach(h => observer.observe(h)); - } + } // end TOC diff --git a/src/pages/projects/protein-refinement/index.astro b/src/pages/projects/protein-refinement/index.astro new file mode 100644 index 0000000..936f0c3 --- /dev/null +++ b/src/pages/projects/protein-refinement/index.astro @@ -0,0 +1,128 @@ +--- +import Layout from "@/layouts/Layout.astro"; +import Badge from "@/components/ui/Badge.astro"; + +export const metadata = { + label: "research · thesis · 2019–2020", + title: "Protein conformation from distances", + description: "Recovering 3D molecular structure from a sparse set of inter-atomic distances using SDP relaxation and spectral projected gradient refinement.", + tags: ["python", "matlab", "numpy", "scipy", "sdpt3"], +}; +--- + + +
+
+ + +
+
+

The problem

+

+ Given a protein with N atoms and a subset of known inter-atomic distances + (from NMR experiments or crystallography), find a set of 3D coordinates that + is consistent with those distances. This is the Distance Geometry Problem (DGP). +

+

+ In practice the distance data is incomplete and noisy — not every pair of atoms has a + measured distance, and measurements come with upper and lower bounds rather than exact + values. The goal is a conformation where all known distance bounds are satisfied and the + overall geometric stress is minimized. +

+ +

Two-phase approach

+

+ The solver runs in two phases. First, a semidefinite programming relaxation (SDP) + via YALMIP in MATLAB computes a low-rank Gram matrix that approximately satisfies the + distance constraints. This gives a convex lower bound and a good initial point. +

+

+ Second, the Python implementation refines that initial point using a Spectral Projected + Gradient (SPG) method that minimizes a weighted stress function while projecting + candidate distances back onto the feasible interval after each step. The Barzilai–Borwein + spectral step replaces a fixed learning rate with a local curvature estimate, and a + nonmonotone GLL line search prevents the method from stalling near saddle points. +

+ +

Interesting problems

+
    +
  • Atom reordering: the PDB file and the distance file use different atom indices — reconciling them before the solver starts.
  • +
  • Centering: coordinates need to be centered at the origin to remove translation degrees of freedom.
  • +
  • Convex relaxation quality: how good the SDP solution is as a starting point strongly affects how quickly SPG converges.
  • +
  • Handling incomplete distance data: not every protein structure had a full distance matrix; the solver needed to be robust to sparse inputs.
  • +
+ +

Results

+

+ Tested against 12 PDB structures ranging from 30 to 656 atoms: 2Y2A, 2JMY, 6G4U, 6HN9, + 6FS5, 2K35, 1DT4, 6HKC, 1A91, 1B4R, 2JS9, and 1SXL. SDP-initialized SPG produced MDE + on the order of 10⁻⁵ to 10⁻⁶, versus 10⁻² for random initialization — and at lower + total runtime for larger proteins where fewer SPG iterations were needed. +

+

+ 1SXL (656 atoms) was recovered in about 7.7 minutes total. Backbone alignment against + the PDB ground truth was visually indistinguishable. +

+
+ + +
+
+
+
+ + diff --git a/src/pages/writing/protein-structure-from-interval-distances/index.mdx b/src/pages/writing/protein-structure-from-interval-distances/index.mdx new file mode 100644 index 0000000..daa5a57 --- /dev/null +++ b/src/pages/writing/protein-structure-from-interval-distances/index.mdx @@ -0,0 +1,111 @@ +import ArticleLayout from "@/layouts/ArticleLayout.astro"; + +export const metadata = { + title: "Protein structure from interval distances", + date: "2026-04-30", + tag: "research", + description: "How a convex SDP relaxation gives a non-convex local solver good enough starting points to recover protein 3D structure from NMR-style distance intervals.", +}; + + + +## Context + +This is from a 2019/2020 PIBIC undergraduate research project at UFSC (Department of Mathematics, advised by Prof. Douglas Gonçalves). The full report — in Portuguese — lives at [viniciusdc/protein-refinement](https://github.com/viniciusdc/protein-refinement); the implementation is at [viniciusdc/Protein-Refinement_prime](https://github.com/viniciusdc/Protein-Refinement_prime). + +Proteins are usually solved by X-ray crystallography or, since Wüthrich's work in the 1980s, by nuclear magnetic resonance on proteins in solution. NMR resembles the in-vivo environment more closely than crystals do, but it doesn't return exact pairwise atomic distances — it returns **intervals** $[d_{\text{lower}},\, d_{\text{upper}}]$. Each measurement varies, and the experimentalists honestly report a range rather than a number. + +That gives the **molecular distance geometry problem with intervals** (PGDm): given interval constraints between atom pairs, recover positions $x_1, \ldots, x_n \in \mathbb{R}^3$ such that + +$$ +d_{\text{lower}}(i,j) \;\le\; \|x_i - x_j\| \;\le\; d_{\text{upper}}(i,j) +\quad \text{for every observed pair } (i,j). +$$ + +A small mathematical statement of an experimentally consequential question. + +## The problem + +The natural optimization formulation is non-convex: + +$$ +\begin{aligned} +\min_{x,\,y} \quad & \sum_{(i,j)\in E} \bigl(\|x_i - x_j\| - y_{ij}\bigr)^2 \\ +\text{s.t.} \quad & d_{\text{lower}}(i,j) \le y_{ij} \le d_{\text{upper}}(i,j) +\end{aligned} +$$ + +Because of the squared norm, the objective has many local minima. The Spectral Projected Gradient method (SPG, Birgin–Martínez–Raydan) is a fine local solver — it converges quickly when started near a true solution — but the cost surface has many plausible-looking basins and a random initial guess almost always lands in a wrong one. + +In practice we don't know the answer, so we can't seed SPG from the truth. We need a way to find a good initial point without already knowing the structure. + +## Design direction + +The trick is to relax the problem until it becomes convex, then snap back. We work with the Gram matrix $G = X^\top X$ instead of $X$ itself. Schoenberg's theorem connects Euclidean distance matrices to positive semidefinite matrices of bounded rank, and the interval constraints — when expressed in $G$ — become **linear** in the entries of $G$. The remaining sources of non-convexity are the rank constraint and the original norm. Drop the rank constraint and we're left with a semidefinite program: + +$$ +\begin{aligned} +\min_{G} \quad & \gamma \langle I,\, G \rangle \\ +\text{s.t.} \quad & d_{\text{lower}}^2(i,j) \;\le\; \langle G,\, E_{ij} \rangle \;\le\; d_{\text{upper}}^2(i,j) \quad \forall\,(i,j)\in E \\ +& G \succeq 0,\quad G\mathbf{e} = 0 +\end{aligned} +$$ + +The objective $\gamma\langle I, G\rangle$ with $\gamma < 0$ is a rank-reduction heuristic — minimizing the negative trace pulls solutions toward low-dimensional embeddings without forcing it. + +Solving this SDP gives a Gram matrix $G^* = Q\Lambda Q^\top$. Take the top-$K$ eigencomponents to get an approximate realization $\bar{X}$, project the per-pair distances back onto their boxes, and feed $(\bar{X}, \bar{y})$ into SPG as the initial point. The SDP gives a "convex shadow" of the answer; SPG sharpens it. + +## Tradeoffs + +The SDP relaxation throws away the rank-$K$ constraint to gain convexity. As a result the SDP solution may violate some distance constraints — projecting onto $K$ dimensions can stretch some pairs outside their boxes. That's fine: the SDP answer doesn't need to be feasible, it needs to be **near** something feasible. SPG handles the violations. + +The cost is computational. The SDP is dense and grows quickly with $n$. For 30 atoms it takes 0.3 seconds; for 656 atoms it climbs to about 9 minutes (SDPT3 solver). Beyond that the SDP becomes the bottleneck — divide-and-conquer was flagged as future work, decomposing the protein into overlapping fragments and aligning the pieces. + +## Results + +Numerical experiments used 12 proteins from the RCSB Protein Data Bank ranging from 30 to 656 atoms. Distance intervals were generated artificially — backbone bonds (N–Cα–C) kept exact, all other pairs within 6 Å became intervals of width $\Delta = 2/3$ Å centered on the true distance. + +### SPG sensitivity to the initial point + +To confirm the diagnosis, SPG was run starting from $X_{\text{sol}} + \sigma \mathcal{N}(0,1)$ — perturbations of the known solution, with $\sigma \in \{10^{-2},\, 10,\, 10^{2}\}$. At $\sigma = 10$ (a substantial perturbation) the final RMSD was on the order of 1 Å, which is acceptable. At $\sigma = 10^2$ the solver drifted entirely. Without an anchor, SPG is unreliable. + +### Random vs SDP-initialized + +The actual test ran SPG from 10 random initial points and compared against a single SPG run started from the SDP relaxation. For proteins around 500 atoms (1A91, 1B4R, 2JS9, 6HKC), SDP-initialized SPG produced **MDE on the order of $10^{-5}$ to $10^{-6}$**, versus $10^{-2}$ for random. The total time (SDP + SPG) was also lower than the average random-init time, because SPG converged in far fewer iterations. + +
+ SDP-initialized SPG versus random-initialized SPG, by metric and protein +
SDP-initialized SPG (red) versus 10 random-initialized SPG runs (blue) on three test proteins. Yellow dashed line: random-init mean. The convex relaxation consistently lands in a better basin.
+
+ +### Recovered structures + +For 6HN9 (225 atoms) and 1SXL (656 atoms), the recovered backbone overlaps the PDB ground truth almost exactly: + +
+ 6HN9 backbone overlay: PDB ground truth, SDP initial point, SPG-refined output +
6HN9 — original PDB structure (green), SDP initial point (orange), SPG-refined output (blue). The orange points are roughly correct in shape but with constraint violations; SPG snaps them back into a chemically plausible backbone.
+
+ +
+ 1SXL backbone overlay between PDB ground truth and the recovered structure +
1SXL (656 atoms, the largest test case) recovered in about 7.7 minutes total. Backbone alignment is visually indistinguishable from the PDB structure.
+
+ +## Boundaries + +A few honest scope limits: + +- **Synthetic intervals.** The intervals here come from corrupting exact PDB distances. Real NMR data has wider intervals, missing pairs, and outliers. The method should still work, but the failure modes are different. +- **No global guarantee.** SPG is local. A bad SDP solution still leads to a bad SPG solution. The convex relaxation makes the bad case rare, not impossible. +- **SDP scaling.** Dense semidefinite programs scale poorly. For proteins beyond about 1000 atoms, divide-and-conquer (decompose the protein into overlapping fragments, solve each, align) is the natural next step. + +The SDP phase was implemented in Matlab against SDPT3; SPG and the orchestration are in Python. Visualizations in the original report were rendered with UCSF ChimeraX. + +
diff --git a/src/styles/global.css b/src/styles/global.css index 7760bdb..1227a43 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "@fontsource-variable/geist"; +@import "katex/dist/katex.min.css"; @custom-variant dark (&:is(.dark *)); @@ -78,3 +79,11 @@ text-rendering: optimizeLegibility; } } + +/* KaTeX dark-theme overrides */ +.katex { color: inherit; font-size: 1.05em; } +.katex-display { color: var(--foreground); overflow-x: auto; padding: 0.25em 0; } +.katex-display > .katex { color: var(--foreground); } +.katex .mord, .katex .mrel, .katex .mbin, .katex .mop, +.katex .mopen, .katex .mclose, .katex .mpunct { color: inherit; } +.katex .mfrac .frac-line { border-bottom-color: currentColor; }