|
|
--- |
|
|
import HtmlEmbed from "../components/HtmlEmbed.astro"; |
|
|
import Seo from "../components/Seo.astro"; |
|
|
import ThemeToggle from "../components/ThemeToggle.astro"; |
|
|
import { loadEmbedsFromMDX } from "../utils/extract-embeds.mjs"; |
|
|
import * as ArticleMod from "../content/article.mdx"; |
|
|
import "katex/dist/katex.min.css"; |
|
|
import "../styles/global.css"; |
|
|
|
|
|
|
|
|
const articleFM = (ArticleMod as any).frontmatter ?? {}; |
|
|
const stripHtml = (text: string) => String(text || "").replace(/<[^>]*>/g, ""); |
|
|
const articleTitle = stripHtml(articleFM?.title ?? "Article") |
|
|
.replace(/\\n/g, " ") |
|
|
.replace(/\n/g, " ") |
|
|
.replace(/\s+/g, " ") |
|
|
.trim(); |
|
|
const articleDesc = articleFM?.description ?? ""; |
|
|
|
|
|
|
|
|
const pageTitle = articleTitle; |
|
|
const pageDesc = articleDesc; |
|
|
|
|
|
|
|
|
const contentEmbeds = loadEmbedsFromMDX(); |
|
|
|
|
|
|
|
|
const bannerEmbed = { |
|
|
src: "banner.html", |
|
|
wide: true, |
|
|
frameless: false, |
|
|
skipGallery: false, |
|
|
title: undefined, |
|
|
desc: undefined, |
|
|
data: undefined, |
|
|
config: undefined, |
|
|
}; |
|
|
|
|
|
|
|
|
const allEmbeds = [bannerEmbed, ...contentEmbeds]; |
|
|
|
|
|
|
|
|
--- |
|
|
|
|
|
<html lang="en" data-theme="light"> |
|
|
<head> |
|
|
<meta charset="utf-8" /> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
|
<Seo title={pageTitle} description={pageDesc} /> |
|
|
<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/[email protected]/dist/medium-zoom.min.js" |
|
|
></script> |
|
|
<script |
|
|
src="https://unpkg.com/masonry-layout@4/dist/masonry.pkgd.min.js" |
|
|
></script> |
|
|
<script src="/src/scripts/mermaid-zoom.js"></script> |
|
|
</head> |
|
|
<body class="dataviz-page"> |
|
|
<ThemeToggle /> |
|
|
|
|
|
<main class="dataviz-content"> |
|
|
<header class="dataviz-header"> |
|
|
<a href="/" class="back-arrow" aria-label="Back to article"> |
|
|
<svg |
|
|
width="24" |
|
|
height="24" |
|
|
viewBox="0 0 24 24" |
|
|
fill="none" |
|
|
stroke="currentColor" |
|
|
stroke-width="2" |
|
|
stroke-linecap="round" |
|
|
stroke-linejoin="round" |
|
|
> |
|
|
<line x1="19" y1="12" x2="5" y2="12"></line> |
|
|
<polyline points="12 19 5 12 12 5"></polyline> |
|
|
</svg> |
|
|
</a> |
|
|
<div class="dataviz-header-content"> |
|
|
<h1 class="dataviz-title">{pageTitle}</h1> |
|
|
<p class="dataviz-desc"> |
|
|
All <strong>{allEmbeds.length}</strong> interactive visualizations |
|
|
from this article |
|
|
</p> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<div class="dataviz-grid-wrapper"> |
|
|
<div class="loading-spinner"> |
|
|
<svg class="spinner" viewBox="0 0 50 50"> |
|
|
<circle |
|
|
class="path" |
|
|
cx="25" |
|
|
cy="25" |
|
|
r="20" |
|
|
fill="none" |
|
|
stroke-width="4"></circle> |
|
|
</svg> |
|
|
</div> |
|
|
|
|
|
{ |
|
|
allEmbeds.length > 0 ? ( |
|
|
<section |
|
|
class="dataviz-grid" |
|
|
style="opacity: 0; visibility: hidden;" |
|
|
> |
|
|
{allEmbeds |
|
|
.filter((embed) => !embed.skipGallery) |
|
|
.map((embed) => ( |
|
|
<div |
|
|
class={`dataviz-card${embed.wide ? " dataviz-card--wide" : ""}`} |
|
|
> |
|
|
<HtmlEmbed |
|
|
src={embed.src} |
|
|
title={embed.title} |
|
|
desc={embed.desc} |
|
|
frameless={false} |
|
|
data={embed.data} |
|
|
config={embed.config} |
|
|
/> |
|
|
</div> |
|
|
))} |
|
|
</section> |
|
|
) : ( |
|
|
<div style="text-align: center; padding: 60px 20px;"> |
|
|
<p style="font-size: 1.2rem; color: var(--muted-color);"> |
|
|
No embeds found in the content. |
|
|
</p> |
|
|
</div> |
|
|
) |
|
|
} |
|
|
</div> |
|
|
</main> |
|
|
|
|
|
<script> |
|
|
|
|
|
|
|
|
let masonryInstance = null; |
|
|
let relayoutScheduled = false; |
|
|
let contentShown = false; |
|
|
|
|
|
function showContent() { |
|
|
if (contentShown) return; |
|
|
contentShown = true; |
|
|
|
|
|
const spinner = document.querySelector(".loading-spinner"); |
|
|
const grid = document.querySelector(".dataviz-grid"); |
|
|
|
|
|
if (spinner) { |
|
|
spinner.style.opacity = "0"; |
|
|
setTimeout(() => { |
|
|
spinner.style.display = "none"; |
|
|
}, 300); |
|
|
} |
|
|
|
|
|
if (grid) { |
|
|
grid.style.visibility = "visible"; |
|
|
grid.style.opacity = "1"; |
|
|
grid.style.transition = "opacity 0.3s ease"; |
|
|
} |
|
|
|
|
|
console.log("✨ Content displayed"); |
|
|
} |
|
|
|
|
|
function scheduleRelayout() { |
|
|
if (!relayoutScheduled && masonryInstance) { |
|
|
relayoutScheduled = true; |
|
|
requestAnimationFrame(() => { |
|
|
if (masonryInstance) { |
|
|
masonryInstance.reloadItems(); |
|
|
masonryInstance.layout(); |
|
|
} |
|
|
relayoutScheduled = false; |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
function initializeMasonry() { |
|
|
const grid = document.querySelector(".dataviz-grid"); |
|
|
if (!grid || grid.children.length === 0) return; |
|
|
|
|
|
|
|
|
if (typeof Masonry === "undefined") { |
|
|
setTimeout(initializeMasonry, 50); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const allCards = Array.from( |
|
|
grid.querySelectorAll(".dataviz-card"), |
|
|
); |
|
|
const allEmbeds = allCards.map((card) => |
|
|
card.querySelector(".html-embed"), |
|
|
); |
|
|
const loadedCount = allEmbeds.filter( |
|
|
(embed) => |
|
|
embed && embed.classList.contains("html-embed--loaded"), |
|
|
).length; |
|
|
|
|
|
|
|
|
if (!masonryInstance) { |
|
|
const startTime = window.masonryWaitStart || Date.now(); |
|
|
window.masonryWaitStart = startTime; |
|
|
const elapsed = Date.now() - startTime; |
|
|
const loadPercent = loadedCount / allEmbeds.length; |
|
|
|
|
|
if (loadPercent < 0.6 && elapsed < 1500) { |
|
|
console.log( |
|
|
`⏳ Waiting: ${loadedCount}/${allEmbeds.length} embeds loaded (${Math.round(loadPercent * 100)}%)`, |
|
|
); |
|
|
setTimeout(initializeMasonry, 150); |
|
|
return; |
|
|
} |
|
|
} |
|
|
|
|
|
if (masonryInstance) { |
|
|
scheduleRelayout(); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const getColumnWidth = () => { |
|
|
const gridWidth = grid.offsetWidth; |
|
|
const gutterSize = 16; |
|
|
const cols = |
|
|
window.innerWidth <= 600 |
|
|
? 1 |
|
|
: window.innerWidth <= 1199 |
|
|
? 2 |
|
|
: 3; |
|
|
return (gridWidth - gutterSize * (cols - 1)) / cols; |
|
|
}; |
|
|
|
|
|
|
|
|
masonryInstance = new Masonry(grid, { |
|
|
itemSelector: ".dataviz-card", |
|
|
columnWidth: getColumnWidth(), |
|
|
gutter: 16, |
|
|
percentPosition: false, |
|
|
horizontalOrder: false, |
|
|
fitWidth: false, |
|
|
transitionDuration: "0.3s", |
|
|
initLayout: true, |
|
|
stagger: 30, |
|
|
originLeft: true, |
|
|
originTop: true, |
|
|
resize: true, |
|
|
isResizeBound: true, |
|
|
}); |
|
|
|
|
|
console.log( |
|
|
`✅ Masonry initialized (${loadedCount}/${allEmbeds.length} embeds loaded)`, |
|
|
); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
showContent(); |
|
|
}, 100); |
|
|
|
|
|
|
|
|
const resizeObserver = new ResizeObserver((entries) => { |
|
|
scheduleRelayout(); |
|
|
}); |
|
|
|
|
|
|
|
|
allCards.forEach((card) => { |
|
|
resizeObserver.observe(card); |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(scheduleRelayout, 200); |
|
|
setTimeout(scheduleRelayout, 600); |
|
|
setTimeout(scheduleRelayout, 1200); |
|
|
setTimeout(scheduleRelayout, 2000); |
|
|
|
|
|
|
|
|
let resizeTimeout; |
|
|
const resizeHandler = () => { |
|
|
clearTimeout(resizeTimeout); |
|
|
resizeTimeout = setTimeout(() => { |
|
|
if (masonryInstance) { |
|
|
masonryInstance.destroy(); |
|
|
masonryInstance = null; |
|
|
resizeObserver.disconnect(); |
|
|
window.masonryWaitStart = null; |
|
|
initializeMasonry(); |
|
|
} |
|
|
}, 200); |
|
|
}; |
|
|
|
|
|
window.removeEventListener("resize", resizeHandler); |
|
|
window.addEventListener("resize", resizeHandler); |
|
|
} |
|
|
|
|
|
|
|
|
function initializeZoom() { |
|
|
const zoomableImages = document.querySelectorAll( |
|
|
'img[data-zoomable="1"]', |
|
|
); |
|
|
if ((window as any).mediumZoom && zoomableImages.length > 0) { |
|
|
zoomableImages.forEach((img) => { |
|
|
if (!img.classList.contains("medium-zoom-image")) { |
|
|
try { |
|
|
(window as any).mediumZoom(img, { |
|
|
background: "rgba(0,0,0,.85)", |
|
|
margin: 24, |
|
|
scrollOffset: 0, |
|
|
}); |
|
|
} catch (error) { |
|
|
console.error( |
|
|
"Error initializing zoom:", |
|
|
error, |
|
|
); |
|
|
} |
|
|
} |
|
|
}); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (document.readyState === "loading") { |
|
|
document.addEventListener("DOMContentLoaded", () => { |
|
|
initializeMasonry(); |
|
|
initializeZoom(); |
|
|
}); |
|
|
} else { |
|
|
initializeMasonry(); |
|
|
initializeZoom(); |
|
|
} |
|
|
</script> |
|
|
|
|
|
<style is:global> |
|
|
|
|
|
.dataviz-grid-wrapper { |
|
|
position: relative; |
|
|
min-height: 150px; |
|
|
} |
|
|
|
|
|
|
|
|
.loading-spinner { |
|
|
position: absolute; |
|
|
top: 50%; |
|
|
left: 50%; |
|
|
transform: translate(-50%, -50%); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
transition: opacity 0.3s ease; |
|
|
pointer-events: none; |
|
|
z-index: 10; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 28px; |
|
|
height: 28px; |
|
|
opacity: 0.3; |
|
|
animation: rotate 2s linear infinite; |
|
|
} |
|
|
|
|
|
.spinner .path { |
|
|
stroke: var(--muted-color); |
|
|
stroke-linecap: round; |
|
|
animation: dash 1.5s ease-in-out infinite; |
|
|
} |
|
|
|
|
|
@keyframes rotate { |
|
|
100% { |
|
|
transform: rotate(360deg); |
|
|
} |
|
|
} |
|
|
|
|
|
@keyframes dash { |
|
|
0% { |
|
|
stroke-dasharray: 1, 150; |
|
|
stroke-dashoffset: 0; |
|
|
} |
|
|
50% { |
|
|
stroke-dasharray: 90, 150; |
|
|
stroke-dashoffset: -35; |
|
|
} |
|
|
100% { |
|
|
stroke-dasharray: 90, 150; |
|
|
stroke-dashoffset: -124; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-page #theme-toggle { |
|
|
left: auto; |
|
|
right: var(--spacing-3); |
|
|
} |
|
|
|
|
|
.dataviz-content { |
|
|
width: 100%; |
|
|
padding: 20px; |
|
|
} |
|
|
|
|
|
.dataviz-header { |
|
|
display: flex; |
|
|
align-items: flex-start; |
|
|
gap: 16px; |
|
|
margin-bottom: 0; |
|
|
padding-top: 0px; |
|
|
padding-bottom: 16px; |
|
|
padding-left: 0; |
|
|
max-width: 100%; |
|
|
} |
|
|
|
|
|
.back-arrow { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
color: var(--primary-color); |
|
|
text-decoration: none; |
|
|
flex-shrink: 0; |
|
|
transition: opacity 0.2s ease; |
|
|
margin-top: 2px; |
|
|
} |
|
|
|
|
|
.back-arrow:hover { |
|
|
opacity: 0.7; |
|
|
} |
|
|
|
|
|
.dataviz-header-content { |
|
|
flex: 1; |
|
|
max-width: 750px; |
|
|
} |
|
|
|
|
|
.dataviz-title { |
|
|
font-size: 1.35rem; |
|
|
font-weight: 700; |
|
|
margin: 0 0 6px 0; |
|
|
color: var(--text-color); |
|
|
line-height: 1.2; |
|
|
} |
|
|
|
|
|
.dataviz-desc { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
font-size: 0.8125rem; |
|
|
line-height: 1.4; |
|
|
color: var(--muted-color); |
|
|
opacity: 0.8; |
|
|
} |
|
|
|
|
|
.dataviz-desc strong { |
|
|
color: var(--text-color); |
|
|
font-weight: 600; |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.dataviz-header { |
|
|
gap: 12px; |
|
|
} |
|
|
|
|
|
.dataviz-title { |
|
|
font-size: 1.2rem; |
|
|
} |
|
|
|
|
|
.dataviz-desc { |
|
|
font-size: 0.8rem; |
|
|
} |
|
|
|
|
|
.back-arrow svg { |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
} |
|
|
} |
|
|
|
|
|
.dataviz-grid { |
|
|
width: 100%; |
|
|
margin: 0 auto; |
|
|
padding: 0; |
|
|
padding-top: 20px; |
|
|
} |
|
|
|
|
|
.dataviz-card { |
|
|
width: calc((100% - 32px) / 3); |
|
|
margin-bottom: 16px; |
|
|
box-sizing: border-box; |
|
|
min-height: 300px; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed { |
|
|
margin-bottom: 0 !important; |
|
|
min-height: 250px; |
|
|
} |
|
|
|
|
|
.dataviz-page .dataviz-card--wide .html-embed { |
|
|
min-height: 350px; |
|
|
} |
|
|
|
|
|
.dataviz-card--wide { |
|
|
width: calc((100% - 32px) / 3 * 2 + 16px) !important; |
|
|
} |
|
|
|
|
|
@media (max-width: 1199px) { |
|
|
.dataviz-card { |
|
|
width: calc((100% - 16px) / 2); |
|
|
} |
|
|
|
|
|
.dataviz-card--wide { |
|
|
width: 100% !important; |
|
|
} |
|
|
} |
|
|
|
|
|
@media (max-width: 600px) { |
|
|
.dataviz-card { |
|
|
width: 100% !important; |
|
|
} |
|
|
|
|
|
.dataviz-card--wide { |
|
|
width: 100% !important; |
|
|
} |
|
|
} |
|
|
|
|
|
.dataviz-card h2 { |
|
|
margin-top: 0 !important; |
|
|
} |
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed { |
|
|
margin: 0 !important; |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
min-width: 0 !important; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed__card { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
min-width: 0 !important; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed__card { |
|
|
order: 0; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed__title { |
|
|
order: 1; |
|
|
font-size: 0.8rem; |
|
|
font-weight: 500; |
|
|
margin-bottom: 3px; |
|
|
margin-top: 10px; |
|
|
padding-bottom: 0; |
|
|
padding-top: 0; |
|
|
color: var(--text-color); |
|
|
opacity: 0.85; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-page .dataviz-card .html-embed__desc { |
|
|
order: 2; |
|
|
font-size: 0.75rem; |
|
|
line-height: 1.35; |
|
|
margin-top: 0; |
|
|
padding-top: 0; |
|
|
opacity: 0.7; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-card :global(svg) { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
height: auto !important; |
|
|
display: block; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
.dataviz-card :global(.plotly-graph-div) { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
height: auto !important; |
|
|
display: block; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-card :global(.d3-benchmark), |
|
|
.dataviz-card :global(.d3-line), |
|
|
.dataviz-card :global(.d3-pie), |
|
|
.dataviz-card :global(.d3-matrix), |
|
|
.dataviz-card :global(.d3-equation-editor), |
|
|
.dataviz-card :global(.d3-neural), |
|
|
.dataviz-card :global([class^="d3-"]) { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
min-width: 0 !important; |
|
|
display: block; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-card :global(.chart-card) { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
min-width: 0 !important; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
|
|
|
.dataviz-card :global(div[id]) { |
|
|
width: 100% !important; |
|
|
max-width: 100% !important; |
|
|
min-width: 0 !important; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
|
|
|
</style> |
|
|
</body> |
|
|
</html> |
|
|
|