|
|
--- |
|
|
import * as ArticleMod from "../content/article.mdx"; |
|
|
|
|
|
import Hero from "../components/Hero.astro"; |
|
|
import Footer from "../components/Footer.astro"; |
|
|
import ThemeToggle from "../components/ThemeToggle.astro"; |
|
|
import Seo from "../components/Seo.astro"; |
|
|
import TableOfContents from "../components/TableOfContents.astro"; |
|
|
|
|
|
const ogDefaultUrl = "/thumb.auto.jpg"; |
|
|
import "katex/dist/katex.min.css"; |
|
|
import "../styles/global.css"; |
|
|
const articleFM = (ArticleMod as any).frontmatter ?? {}; |
|
|
const Article = (ArticleMod as any).default; |
|
|
const docTitle = articleFM?.title ?? "Untitled article"; |
|
|
|
|
|
const docTitleHtml = (articleFM?.title ?? "Untitled article") |
|
|
.replace(/\\n/g, "<br/>") |
|
|
.replace(/\n/g, "<br/>"); |
|
|
const subtitle = articleFM?.subtitle ?? ""; |
|
|
const description = articleFM?.description ?? ""; |
|
|
|
|
|
const rawAuthors = (articleFM as any)?.authors ?? []; |
|
|
type Affiliation = { id: number; name: string; url?: string }; |
|
|
type Author = { name: string; url?: string; affiliationIndices?: number[] }; |
|
|
|
|
|
|
|
|
const rawAffils = |
|
|
(articleFM as any)?.affiliations ?? (articleFM as any)?.affiliation ?? []; |
|
|
const normalizedAffiliations: Affiliation[] = (() => { |
|
|
const seen: Map<string, number> = new Map(); |
|
|
const list: Affiliation[] = []; |
|
|
const pushUnique = (name: string, url?: string) => { |
|
|
const key = `${String(name).trim()}|${url ? String(url).trim() : ""}`; |
|
|
if (seen.has(key)) return seen.get(key)!; |
|
|
const id = list.length + 1; |
|
|
list.push({ |
|
|
id, |
|
|
name: String(name).trim(), |
|
|
url: url ? String(url) : undefined, |
|
|
}); |
|
|
seen.set(key, id); |
|
|
return id; |
|
|
}; |
|
|
const input = Array.isArray(rawAffils) |
|
|
? rawAffils |
|
|
: rawAffils |
|
|
? [rawAffils] |
|
|
: []; |
|
|
for (const a of input) { |
|
|
if (typeof a === "string") { |
|
|
pushUnique(a); |
|
|
} else if (a && typeof a === "object") { |
|
|
const name = a.name ?? a.label ?? a.text ?? a.affiliation ?? ""; |
|
|
if (!String(name).trim()) continue; |
|
|
const url = a.url || a.link; |
|
|
|
|
|
pushUnique(String(name), url ? String(url) : undefined); |
|
|
} |
|
|
} |
|
|
return list; |
|
|
})(); |
|
|
|
|
|
|
|
|
const ensureAffiliation = (val: any): number | undefined => { |
|
|
if (val == null) return undefined; |
|
|
if (typeof val === "number" && Number.isFinite(val) && val > 0) { |
|
|
return Math.floor(val); |
|
|
} |
|
|
const name = |
|
|
typeof val === "string" |
|
|
? val |
|
|
: (val?.name ?? val?.label ?? val?.text ?? val?.affiliation); |
|
|
if (!name || !String(name).trim()) return undefined; |
|
|
const existing = normalizedAffiliations.find( |
|
|
(a) => a.name === String(name).trim(), |
|
|
); |
|
|
if (existing) return existing.id; |
|
|
const id = normalizedAffiliations.length + 1; |
|
|
normalizedAffiliations.push({ |
|
|
id, |
|
|
name: String(name).trim(), |
|
|
url: val?.url || val?.link, |
|
|
}); |
|
|
return id; |
|
|
}; |
|
|
|
|
|
|
|
|
const normalizedAuthors: Author[] = ( |
|
|
Array.isArray(rawAuthors) ? rawAuthors : [] |
|
|
) |
|
|
.map((a: any) => { |
|
|
if (typeof a === "string") { |
|
|
return { name: a } as Author; |
|
|
} |
|
|
const name = String(a?.name || "").trim(); |
|
|
const url = a?.url || a?.link; |
|
|
let indices: number[] | undefined = undefined; |
|
|
const raw = a?.affiliations ?? a?.affiliation ?? a?.affils; |
|
|
if (raw != null) { |
|
|
const entries = Array.isArray(raw) ? raw : [raw]; |
|
|
const ids = entries |
|
|
.map(ensureAffiliation) |
|
|
.filter((x): x is number => typeof x === "number"); |
|
|
const unique = Array.from(new Set(ids)).sort((x, y) => x - y); |
|
|
if (unique.length) indices = unique; |
|
|
} |
|
|
return { name, url, affiliationIndices: indices } as Author; |
|
|
}) |
|
|
.filter((a: Author) => a.name && a.name.trim().length > 0); |
|
|
const authorNames: string[] = normalizedAuthors.map((a) => a.name); |
|
|
const published = articleFM?.published ?? undefined; |
|
|
const tags = articleFM?.tags ?? []; |
|
|
|
|
|
const fmOg = articleFM?.seoThumbImage as string | undefined; |
|
|
const imageAbs: string = |
|
|
fmOg && fmOg.startsWith("http") |
|
|
? fmOg |
|
|
: Astro.site |
|
|
? new URL(fmOg ?? ogDefaultUrl, Astro.site).toString() |
|
|
: (fmOg ?? ogDefaultUrl); |
|
|
|
|
|
|
|
|
const stripHtml = (text: string) => String(text || "").replace(/<[^>]*>/g, ""); |
|
|
const rawTitle = articleFM?.title ?? "Untitled article"; |
|
|
const titleFlat = stripHtml(String(rawTitle)) |
|
|
.replace(/\\n/g, " ") |
|
|
.replace(/\n/g, " ") |
|
|
.replace(/\s+/g, " ") |
|
|
.trim(); |
|
|
const extractYear = (val: string | undefined): number | undefined => { |
|
|
if (!val) return undefined; |
|
|
const d = new Date(val); |
|
|
if (!Number.isNaN(d.getTime())) return d.getFullYear(); |
|
|
const m = String(val).match(/(19|20)\d{2}/); |
|
|
return m ? Number(m[0]) : undefined; |
|
|
}; |
|
|
|
|
|
const year = extractYear(published); |
|
|
const citationAuthorsText = authorNames.join(", "); |
|
|
const citationText = `${citationAuthorsText}${year ? ` (${year})` : ""}. "${titleFlat}".`; |
|
|
|
|
|
const authorsBib = authorNames.join(" and "); |
|
|
const keyAuthor = (authorNames[0] || "article") |
|
|
.split(/\s+/) |
|
|
.slice(-1)[0] |
|
|
.toLowerCase(); |
|
|
const keyTitle = titleFlat |
|
|
.toLowerCase() |
|
|
.replace(/[^a-z0-9]+/g, "_") |
|
|
.replace(/^_|_$/g, "") |
|
|
.slice(0, 24); |
|
|
const bibKey = `${keyAuthor}${year ?? ""}_${keyTitle}`; |
|
|
const doi = (ArticleMod as any)?.frontmatter?.doi |
|
|
? String((ArticleMod as any).frontmatter.doi) |
|
|
: undefined; |
|
|
const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}},\n ` : ""}${doi ? `doi={${doi}}` : ""}\n}`; |
|
|
const envCollapse = false; |
|
|
const tableOfContentAutoCollapse = Boolean( |
|
|
(articleFM as any)?.tableOfContentAutoCollapse ?? |
|
|
(articleFM as any)?.tableOfContentsAutoCollapse ?? |
|
|
envCollapse, |
|
|
); |
|
|
|
|
|
const licence = |
|
|
(articleFM as any)?.licence ?? |
|
|
(articleFM as any)?.license ?? |
|
|
(articleFM as any)?.licenseNote; |
|
|
|
|
|
const acknowledgements = (articleFM as any)?.acknowledgements; |
|
|
|
|
|
--- |
|
|
|
|
|
<html |
|
|
lang="en" |
|
|
data-theme="light" |
|
|
data-toc-auto-collapse={tableOfContentAutoCollapse ? "1" : "0"} |
|
|
> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
|
<Seo |
|
|
title={docTitle} |
|
|
description={description} |
|
|
authors={authorNames} |
|
|
published={published} |
|
|
tags={tags} |
|
|
image={imageAbs} |
|
|
/> |
|
|
<script is:inline> |
|
|
(() => { |
|
|
try { |
|
|
const saved = localStorage.getItem("theme"); |
|
|
const prefersDark = |
|
|
window.matchMedia && |
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches; |
|
|
const theme = saved || (prefersDark ? "dark" : "light"); |
|
|
document.documentElement.setAttribute("data-theme", theme); |
|
|
} catch {} |
|
|
})(); |
|
|
</script> |
|
|
<script type="module" src="/scripts/color-palettes.js"></script> |
|
|
|
|
|
|
|
|
<script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8" |
|
|
></script> |
|
|
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script> |
|
|
<script |
|
|
src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js" |
|
|
></script> |
|
|
<script> |
|
|
|
|
|
|
|
|
function initializeZoom() { |
|
|
const zoomableImages = document.querySelectorAll( |
|
|
'img[data-zoomable="1"]', |
|
|
); |
|
|
|
|
|
if (window.mediumZoom && zoomableImages.length > 0) { |
|
|
zoomableImages.forEach((img, index) => { |
|
|
|
|
|
if (!img.classList.contains("medium-zoom-image")) { |
|
|
try { |
|
|
const instance = window.mediumZoom(img, { |
|
|
background: "rgba(0,0,0,.85)", |
|
|
margin: 24, |
|
|
scrollOffset: 0, |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error( |
|
|
`Global script: Error initializing zoom for image ${index}:`, |
|
|
error, |
|
|
); |
|
|
} |
|
|
} else { |
|
|
console.log(`Global script: Image ${index} already has zoom`); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
console.log( |
|
|
"Global script: mediumZoom not available or no images found", |
|
|
); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (document.readyState === "loading") { |
|
|
document.addEventListener("DOMContentLoaded", initializeZoom); |
|
|
} else { |
|
|
initializeZoom(); |
|
|
} |
|
|
|
|
|
|
|
|
window.addEventListener("load", () => { |
|
|
setTimeout(initializeZoom, 100); |
|
|
}); |
|
|
</script> |
|
|
</head> |
|
|
<body> |
|
|
<ThemeToggle /> |
|
|
<Hero |
|
|
title={docTitleHtml} |
|
|
titleRaw={docTitle} |
|
|
description={subtitle} |
|
|
authors={normalizedAuthors as any} |
|
|
affiliations={normalizedAffiliations as any} |
|
|
affiliation={articleFM?.affiliation} |
|
|
published={articleFM?.published} |
|
|
doi={doi} |
|
|
/> |
|
|
|
|
|
<section class="content-grid"> |
|
|
<TableOfContents |
|
|
tableOfContentAutoCollapse={tableOfContentAutoCollapse} |
|
|
/> |
|
|
<main> |
|
|
<Article /> |
|
|
</main> |
|
|
</section> |
|
|
|
|
|
<Footer |
|
|
citationText={citationText} |
|
|
bibtex={bibtex} |
|
|
licence={licence} |
|
|
doi={doi} |
|
|
acknowledgements={acknowledgements} |
|
|
/> |
|
|
|
|
|
<script> |
|
|
|
|
|
const setExternalTargets = () => { |
|
|
const isExternal = (href) => { |
|
|
try { |
|
|
const u = new URL(href, location.href); |
|
|
return u.origin !== location.origin; |
|
|
} catch { |
|
|
return false; |
|
|
} |
|
|
}; |
|
|
document.querySelectorAll("a[href]").forEach((a) => { |
|
|
const href = a.getAttribute("href"); |
|
|
if (!href) return; |
|
|
if (isExternal(href)) { |
|
|
a.setAttribute("target", "_blank"); |
|
|
a.setAttribute("rel", "noopener noreferrer"); |
|
|
} else { |
|
|
a.removeAttribute("target"); |
|
|
} |
|
|
}); |
|
|
}; |
|
|
if (document.readyState === "loading") { |
|
|
document.addEventListener("DOMContentLoaded", setExternalTargets, { |
|
|
once: true, |
|
|
}); |
|
|
} else { |
|
|
setExternalTargets(); |
|
|
} |
|
|
</script> |
|
|
|
|
|
<script> |
|
|
|
|
|
document.addEventListener("click", async (e) => { |
|
|
const target = e.target instanceof Element ? e.target : null; |
|
|
const btn = target ? target.closest(".code-copy") : null; |
|
|
if (!btn) return; |
|
|
const card = btn.closest(".code-card"); |
|
|
const pre = card && card.querySelector("pre"); |
|
|
if (!pre) return; |
|
|
const text = pre.textContent || ""; |
|
|
try { |
|
|
await navigator.clipboard.writeText(text.trim()); |
|
|
const old = btn.innerHTML; |
|
|
btn.innerHTML = |
|
|
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; |
|
|
setTimeout(() => (btn.innerHTML = old), 1200); |
|
|
} catch { |
|
|
btn.textContent = "Error"; |
|
|
setTimeout(() => (btn.textContent = "Copy"), 1200); |
|
|
} |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|