| --- |
| import HtmlEmbed from "../components/HtmlEmbed.astro"; |
| import Image from "../components/Image.astro"; |
| import Wide from "../components/Wide.astro"; |
| import Stack from "../components/Stack.astro"; |
| import Seo from "../components/Seo.astro"; |
| import ThemeToggle from "../components/ThemeToggle.astro"; |
| import { loadAllVisualsFromMDX } from "../utils/extract-embeds.mjs"; |
| import * as ArticleMod from "../content/article.mdx"; |
| import "katex/dist/katex.min.css"; |
| import "../styles/global.css"; |
|
|
| |
| |
| const imageModules = (import.meta as any).glob('../content/assets/image/**/*.{png,jpg,jpeg,gif,webp,svg}', { eager: true }); |
|
|
| |
| const imageMap = new Map(); |
| for (const [path, module] of Object.entries(imageModules)) { |
| |
| const filename = path.split('/').pop(); |
| if (filename) { |
| imageMap.set(filename, (module as any).default); |
| |
| const nameWithoutExt = filename.replace(/\.[^.]+$/, ''); |
| imageMap.set(nameWithoutExt, (module as any).default); |
| } |
| } |
|
|
| |
| function findImage(nameOrFilename: string) { |
| if (!nameOrFilename) return null; |
| |
| |
| if (imageMap.has(nameOrFilename)) { |
| return imageMap.get(nameOrFilename); |
| } |
| |
| |
| const withoutExt = nameOrFilename.replace(/\.[^.]+$/, ''); |
| if (imageMap.has(withoutExt)) { |
| return imageMap.get(withoutExt); |
| } |
| |
| |
| const normalizedVar = nameOrFilename.toLowerCase().replace(/[-_]/g, ''); |
| for (const [filename, img] of imageMap.entries()) { |
| const normalizedFilename = filename.toLowerCase().replace(/[-_]/g, ''); |
| if (normalizedFilename.includes(normalizedVar) || normalizedVar.includes(normalizedFilename.replace(/\.[^.]+$/, ''))) { |
| return img; |
| } |
| } |
| |
| |
| const varParts = nameOrFilename.split(/[-_]/); |
| for (const [filename, img] of imageMap.entries()) { |
| const matchCount = varParts.filter(part => |
| part.length > 3 && filename.toLowerCase().includes(part.toLowerCase()) |
| ).length; |
| if (matchCount >= 3) { |
| return img; |
| } |
| } |
| |
| return null; |
| } |
|
|
| |
| const articleFM = (ArticleMod as any).frontmatter ?? {}; |
| |
| const articleTitle = String(articleFM?.title ?? "Article") |
| .replace(/<(?!br\s*\/?)[^>]+>/gi, "") |
| .replace(/\\n/g, "<br>") |
| .trim(); |
| const articleDesc = articleFM?.description ?? ""; |
|
|
| |
| const pageTitle = `Visualizations - ${articleTitle}`; |
| const pageDesc = articleDesc; |
|
|
| |
| const allVisuals = loadAllVisualsFromMDX(); |
|
|
| |
| const visuals = allVisuals.filter((item: any) => !item.skipGallery); |
| const embedCount = visuals.filter((v: any) => v.type === 'embed').length + 1; |
| const imageCount = visuals.filter((v: any) => v.type === 'image' || v.type === 'stack').length; |
| const tableCount = visuals.filter((v: any) => v.type === 'table').length; |
|
|
| |
| function getDisplayName(item: any): string | null { |
| if (item.type === 'embed') { |
| |
| if (item.title) return item.title; |
| const filename = item.src?.replace(/\.html$/, '').replace(/^.*\//, '') || ''; |
| |
| return filename |
| .split(/[-_]/) |
| .map((word: string) => word.charAt(0).toUpperCase() + word.slice(1)) |
| .join(' '); |
| } else if (item.type === 'image') { |
| |
| if (item.alt && item.alt !== 'Image') return item.alt; |
| if (item.caption) return item.caption; |
| return null; |
| } else if (item.type === 'stack') { |
| |
| const imgs = item.images || []; |
| const firstCaption = imgs.find((i: any) => i.caption)?.caption; |
| const firstAlt = imgs.find((i: any) => i.alt && i.alt !== 'Image')?.alt; |
| return firstCaption || firstAlt || `Stack (${imgs.length} images)`; |
| } else if (item.type === 'table') { |
| |
| return null; |
| } |
| return null; |
| } |
|
|
| |
| function getTypeLabel(type: string): string { |
| switch (type) { |
| case 'embed': return 'Chart'; |
| |
| case 'image': return 'Image'; |
| case 'stack': return 'Stack'; |
| case 'table': return 'Table'; |
| default: return 'Visual'; |
| } |
| } |
|
|
| |
| function generateSlug(item: any): string | null { |
| |
| if (item.type === 'embed' && item.src) { |
| const filename = item.src.replace(/\.html$/, '').replace(/^.*\//, ''); |
| return filename |
| .toLowerCase() |
| .replace(/[^a-z0-9]+/g, '-') |
| .replace(/^-+|-+$/g, '') |
| .substring(0, 40); |
| } |
| |
| return null; |
| } |
|
|
| |
| const usedSlugs = new Set<string>(); |
| let displayIndex = 0; |
| const visualsWithMeta = visuals.map((item: any) => { |
| const displayName = getDisplayName(item); |
| let slug = generateSlug(item); |
| |
| |
| if (slug && usedSlugs.has(slug)) { |
| let counter = 2; |
| while (usedSlugs.has(`${slug}-${counter}`)) { |
| counter++; |
| } |
| slug = `${slug}-${counter}`; |
| } |
| if (slug) usedSlugs.add(slug); |
| |
| |
| if (item.type !== 'banner') { |
| displayIndex++; |
| } |
| |
| return { |
| ...item, |
| displayName, |
| anchorId: slug, |
| index: displayIndex |
| }; |
| }); |
| --- |
|
|
| <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/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script> |
| <script src="/src/scripts/mermaid-zoom.js"></script> |
| </head> |
| <body class="dataviz-page"> |
| <ThemeToggle /> |
| |
| <section class="content-grid dataviz-layout"> |
| <main> |
| <header class="dataviz-header"> |
| <a href="/" class="back-link">← Back to article</a> |
| |
| <div class="header-content"> |
| <p class="header-label">Visual assets from</p> |
| <h1 class="article-title" set:html={articleTitle} /> |
| </div> |
| |
| <div class="header-stats"> |
| {embedCount > 0 && ( |
| <div class="stat-card"> |
| <span class="stat-number">{embedCount}</span> |
| <span class="stat-label">Charts</span> |
| </div> |
| )} |
| {imageCount > 0 && ( |
| <div class="stat-card"> |
| <span class="stat-number">{imageCount}</span> |
| <span class="stat-label">Images</span> |
| </div> |
| )} |
| {tableCount > 0 && ( |
| <div class="stat-card"> |
| <span class="stat-number">{tableCount}</span> |
| <span class="stat-label">Tables</span> |
| </div> |
| )} |
| </div> |
| </header> |
| |
| <div class="dataviz-banner"> |
| <Wide> |
| <HtmlEmbed src="banner.html" frameless /> |
| </Wide> |
| </div> |
| |
| <div class="dataviz-list"> |
| {visualsWithMeta.filter((item: any) => item.type !== 'banner').map((item: any) => { |
| const isWide = item.wide || item.fullWidth; |
| |
| return ( |
| <article class={`dataviz-item ${isWide ? 'dataviz-item--wide' : ''}`} data-type={item.type} id={item.anchorId || undefined}> |
| |
| {!item.hideCaption && ( |
| <header class="item-header"> |
| <span class="header-number">#{item.index}</span> |
| <span class="header-type">{getTypeLabel(item.type)}</span> |
| {item.displayName && ( |
| <h2 class="header-title">{item.displayName}</h2> |
| )} |
| {(item.desc || item.caption) && ( |
| <p class="header-desc" set:html={item.desc || item.caption} /> |
| )} |
| {item.anchorId && ( |
| <a href={`/#${item.anchorId}`} class="header-link"> |
| View in article → |
| </a> |
| )} |
| </header> |
| )} |
| |
| <div class="item-content"> |
| {(item.type === 'embed' || item.type === 'banner') && !isWide && ( |
| <HtmlEmbed |
| src={item.src} |
| frameless={item.frameless} |
| data={item.data} |
| config={item.config} |
| /> |
| )} |
| |
| {(item.type === 'embed' || item.type === 'banner') && isWide && ( |
| <Wide> |
| <HtmlEmbed |
| src={item.src} |
| frameless={item.frameless} |
| data={item.data} |
| config={item.config} |
| /> |
| </Wide> |
| )} |
| |
| {item.type === 'image' && (() => { |
| // Prefer resolvedFilename (from MDX imports), fallback to variable name |
| const imgSrc = findImage(item.resolvedFilename || item.src); |
| return imgSrc ? ( |
| <Image |
| src={imgSrc} |
| alt={item.alt || 'Image'} |
| caption={item.caption} |
| /> |
| ) : ( |
| <div class="image-not-found"> |
| <span class="image-placeholder">Image</span> |
| <code class="image-ref">{item.src}</code> |
| </div> |
| ); |
| })()} |
| |
| {item.type === 'stack' && ( |
| <Stack layout={item.layout || '2-column'} gap={item.gap || 'medium'}> |
| {(item.images || []).map((img: any) => { |
| const imgSrc = findImage(img.resolvedFilename || img.src); |
| return imgSrc ? ( |
| <Image |
| src={imgSrc} |
| alt={img.alt || 'Image'} |
| caption={img.caption} |
| /> |
| ) : ( |
| <div class="image-not-found"> |
| <span class="image-placeholder">Image</span> |
| <code class="image-ref">{img.src}</code> |
| </div> |
| ); |
| })} |
| </Stack> |
| )} |
| |
| {item.type === 'table' && ( |
| <div class="table-scroll"> |
| <table> |
| <thead> |
| <tr> |
| {item.headers.map((header: string) => ( |
| <th set:html={header} /> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {item.rows.map((row: string[]) => ( |
| <tr> |
| {row.map((cell: string) => ( |
| <td set:html={cell} /> |
| ))} |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </div> |
| )} |
| </div> |
| </article> |
| ); |
| })} |
| </div> |
| </main> |
| </section> |
| |
| <script> |
| |
| function initializeZoom() { |
| const zoomableImages = document.querySelectorAll('img[data-zoomable="true"]'); |
| 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", initializeZoom); |
| } else { |
| initializeZoom(); |
| } |
| window.addEventListener("load", () => setTimeout(initializeZoom, 100)); |
| </script> |
| |
| |
| <style is:global> |
| |
| .dataviz-layout { |
| padding-top: 40px; |
| padding-bottom: 80px; |
| } |
| |
| .dataviz-layout main { |
| grid-column: 2; |
| } |
| |
| .dataviz-banner { |
| margin-bottom: 0; |
| } |
| .dataviz-banner .wide { |
| width: min(1100px, 100vw - var(--content-padding-x) * 4); |
| margin-left: 50%; |
| transform: translateX(-50%); |
| padding: 0; |
| background: transparent; |
| } |
| .dataviz-banner .html-embed { |
| margin: 0; |
| } |
| |
| |
| .dataviz-header { |
| margin-bottom: 16px; |
| text-align: center; |
| } |
| |
| .back-link { |
| color: var(--muted-color); |
| text-decoration: none; |
| font-size: 0.8125rem; |
| display: inline-block; |
| margin-bottom: 20px; |
| transition: color 0.15s; |
| } |
| |
| .back-link:hover { |
| color: var(--text-color); |
| } |
| |
| .header-content { |
| margin-bottom: 20px; |
| } |
| |
| .header-label { |
| font-size: 0.6875rem; |
| color: var(--muted-color); |
| margin: 0 0 8px 0; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| font-weight: 600; |
| } |
| |
| .article-title { |
| font-size: clamp(28px, 4vw, 48px); |
| font-weight: 800; |
| margin: 0; |
| color: var(--text-color); |
| line-height: 1.1; |
| letter-spacing: -0.02em; |
| } |
| |
| .header-stats { |
| display: flex; |
| justify-content: center; |
| gap: 32px; |
| flex-wrap: wrap; |
| } |
| |
| .stat-card { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 2px; |
| } |
| |
| .stat-number { |
| font-size: 1.25rem; |
| font-weight: 700; |
| color: var(--text-color); |
| font-variant-numeric: tabular-nums; |
| } |
| |
| .stat-label { |
| font-size: 0.6875rem; |
| color: var(--muted-color); |
| text-transform: uppercase; |
| letter-spacing: 0.03em; |
| } |
| |
| |
| .dataviz-list { |
| display: flex; |
| flex-direction: column; |
| gap: 56px; |
| } |
| |
| |
| .dataviz-item { |
| position: relative; |
| scroll-margin-top: 24px; |
| } |
| |
| .dataviz-item .html-embed { |
| margin: 0 !important; |
| } |
| |
| .dataviz-item .wide { |
| |
| width: min(1100px, 100vw - var(--content-padding-x) * 4); |
| margin-left: 50%; |
| transform: translateX(-50%); |
| |
| padding: 0 !important; |
| background: transparent !important; |
| border-radius: 0 !important; |
| -webkit-mask: none !important; |
| mask: none !important; |
| } |
| |
| .dataviz-item .wide .html-embed { |
| margin: 0 !important; |
| } |
| |
| |
| .item-header { |
| margin-bottom: 40px; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 2px; |
| text-align: center; |
| } |
| |
| .header-number { |
| font-size: 0.6875rem; |
| font-weight: 500; |
| color: var(--muted-color); |
| opacity: 0.5; |
| } |
| |
| .header-type { |
| font-size: 0.625rem; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.1em; |
| color: var(--text-color); |
| } |
| |
| .header-title { |
| font-size: 0.875rem; |
| font-weight: 500; |
| margin: 2px 0 0 0 !important; |
| padding: 0 !important; |
| color: var(--text-color); |
| line-height: 1.3; |
| border: none !important; |
| text-decoration: none !important; |
| } |
| |
| .header-title::after { |
| display: none !important; |
| } |
| |
| .header-desc { |
| font-size: 0.8125rem; |
| color: var(--muted-color); |
| margin: 4px 0 0 0; |
| max-width: 600px; |
| line-height: 1.4; |
| } |
| |
| .header-link { |
| margin-top: 4px; |
| font-size: 0.6875rem; |
| color: var(--muted-color); |
| text-decoration: none; |
| opacity: 0.7; |
| transition: opacity 0.15s; |
| } |
| |
| .header-link:hover { |
| opacity: 1; |
| color: var(--primary-color); |
| } |
| |
| |
| .image-not-found { |
| background: var(--neutral-50); |
| border: 1px dashed var(--border-color); |
| border-radius: 8px; |
| padding: 32px 24px; |
| text-align: center; |
| } |
| |
| [data-theme="dark"] .image-not-found { |
| background: var(--neutral-900); |
| } |
| |
| .image-placeholder { |
| font-size: 0.8125rem; |
| color: var(--muted-color); |
| display: block; |
| margin-bottom: 4px; |
| } |
| |
| .image-ref { |
| font-size: 0.6875rem; |
| color: var(--muted-color); |
| opacity: 0.6; |
| } |
| |
| |
| |
| @media (max-width: 768px) { |
| .dataviz-list { |
| gap: 40px; |
| } |
| } |
| </style> |
| </body> |
| </html> |
|
|