Spaces:
Running
Running
thibaud frere
commited on
Commit
·
c1d1666
1
Parent(s):
8e480d5
update
Browse files- .gitignore +2 -1
- app/.astro/astro/content.d.ts +0 -206
- app/astro.config.mjs +9 -0
- app/public/scripts/color-palettes.js +217 -0
- app/src/components/HtmlEmbed.astro +10 -5
- app/src/components/Note.astro +14 -2
- app/src/components/ResponsiveImage.astro +143 -14
- app/src/components/TableOfContent.astro +230 -0
- app/src/content/article.mdx +3 -71
- app/src/content/assets/images/placeholder.png +2 -2
- app/src/content/chapters/available-blocks.mdx +40 -18
- app/src/content/chapters/best-pratices.mdx +1 -1
- app/src/content/chapters/getting-started.mdx +22 -3
- app/src/content/chapters/introduction.mdx +58 -0
- app/src/content/chapters/writing-your-content.mdx +42 -4
- app/src/content/embeds/d3-line-example.html +14 -14
- app/src/content/embeds/palettes.html +40 -130
- app/src/content/embeds/vibe-code-d3-embeds-directives.md +212 -0
- app/src/env.d.ts +9 -2
- app/src/pages/index.astro +20 -302
- app/src/styles/_variables.css +3 -0
.gitignore
CHANGED
|
@@ -22,4 +22,5 @@ node_modules/
|
|
| 22 |
# PDF export
|
| 23 |
app/public/*.pdf
|
| 24 |
app/public/*.png
|
| 25 |
-
app/public/*.jpg
|
|
|
|
|
|
| 22 |
# PDF export
|
| 23 |
app/public/*.pdf
|
| 24 |
app/public/*.png
|
| 25 |
+
app/public/*.jpg
|
| 26 |
+
app/public/data/**/*
|
app/.astro/astro/content.d.ts
CHANGED
|
@@ -1,206 +0,0 @@
|
|
| 1 |
-
declare module 'astro:content' {
|
| 2 |
-
interface Render {
|
| 3 |
-
'.mdx': Promise<{
|
| 4 |
-
Content: import('astro').MarkdownInstance<{}>['Content'];
|
| 5 |
-
headings: import('astro').MarkdownHeading[];
|
| 6 |
-
remarkPluginFrontmatter: Record<string, any>;
|
| 7 |
-
components: import('astro').MDXInstance<{}>['components'];
|
| 8 |
-
}>;
|
| 9 |
-
}
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
declare module 'astro:content' {
|
| 13 |
-
interface RenderResult {
|
| 14 |
-
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
| 15 |
-
headings: import('astro').MarkdownHeading[];
|
| 16 |
-
remarkPluginFrontmatter: Record<string, any>;
|
| 17 |
-
}
|
| 18 |
-
interface Render {
|
| 19 |
-
'.md': Promise<RenderResult>;
|
| 20 |
-
}
|
| 21 |
-
|
| 22 |
-
export interface RenderedContent {
|
| 23 |
-
html: string;
|
| 24 |
-
metadata?: {
|
| 25 |
-
imagePaths: Array<string>;
|
| 26 |
-
[key: string]: unknown;
|
| 27 |
-
};
|
| 28 |
-
}
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
declare module 'astro:content' {
|
| 32 |
-
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
| 33 |
-
|
| 34 |
-
export type CollectionKey = keyof AnyEntryMap;
|
| 35 |
-
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
|
| 36 |
-
|
| 37 |
-
export type ContentCollectionKey = keyof ContentEntryMap;
|
| 38 |
-
export type DataCollectionKey = keyof DataEntryMap;
|
| 39 |
-
|
| 40 |
-
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
| 41 |
-
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
|
| 42 |
-
ContentEntryMap[C]
|
| 43 |
-
>['slug'];
|
| 44 |
-
|
| 45 |
-
/** @deprecated Use `getEntry` instead. */
|
| 46 |
-
export function getEntryBySlug<
|
| 47 |
-
C extends keyof ContentEntryMap,
|
| 48 |
-
E extends ValidContentEntrySlug<C> | (string & {}),
|
| 49 |
-
>(
|
| 50 |
-
collection: C,
|
| 51 |
-
// Note that this has to accept a regular string too, for SSR
|
| 52 |
-
entrySlug: E,
|
| 53 |
-
): E extends ValidContentEntrySlug<C>
|
| 54 |
-
? Promise<CollectionEntry<C>>
|
| 55 |
-
: Promise<CollectionEntry<C> | undefined>;
|
| 56 |
-
|
| 57 |
-
/** @deprecated Use `getEntry` instead. */
|
| 58 |
-
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
|
| 59 |
-
collection: C,
|
| 60 |
-
entryId: E,
|
| 61 |
-
): Promise<CollectionEntry<C>>;
|
| 62 |
-
|
| 63 |
-
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
|
| 64 |
-
collection: C,
|
| 65 |
-
filter?: (entry: CollectionEntry<C>) => entry is E,
|
| 66 |
-
): Promise<E[]>;
|
| 67 |
-
export function getCollection<C extends keyof AnyEntryMap>(
|
| 68 |
-
collection: C,
|
| 69 |
-
filter?: (entry: CollectionEntry<C>) => unknown,
|
| 70 |
-
): Promise<CollectionEntry<C>[]>;
|
| 71 |
-
|
| 72 |
-
export function getEntry<
|
| 73 |
-
C extends keyof ContentEntryMap,
|
| 74 |
-
E extends ValidContentEntrySlug<C> | (string & {}),
|
| 75 |
-
>(entry: {
|
| 76 |
-
collection: C;
|
| 77 |
-
slug: E;
|
| 78 |
-
}): E extends ValidContentEntrySlug<C>
|
| 79 |
-
? Promise<CollectionEntry<C>>
|
| 80 |
-
: Promise<CollectionEntry<C> | undefined>;
|
| 81 |
-
export function getEntry<
|
| 82 |
-
C extends keyof DataEntryMap,
|
| 83 |
-
E extends keyof DataEntryMap[C] | (string & {}),
|
| 84 |
-
>(entry: {
|
| 85 |
-
collection: C;
|
| 86 |
-
id: E;
|
| 87 |
-
}): E extends keyof DataEntryMap[C]
|
| 88 |
-
? Promise<DataEntryMap[C][E]>
|
| 89 |
-
: Promise<CollectionEntry<C> | undefined>;
|
| 90 |
-
export function getEntry<
|
| 91 |
-
C extends keyof ContentEntryMap,
|
| 92 |
-
E extends ValidContentEntrySlug<C> | (string & {}),
|
| 93 |
-
>(
|
| 94 |
-
collection: C,
|
| 95 |
-
slug: E,
|
| 96 |
-
): E extends ValidContentEntrySlug<C>
|
| 97 |
-
? Promise<CollectionEntry<C>>
|
| 98 |
-
: Promise<CollectionEntry<C> | undefined>;
|
| 99 |
-
export function getEntry<
|
| 100 |
-
C extends keyof DataEntryMap,
|
| 101 |
-
E extends keyof DataEntryMap[C] | (string & {}),
|
| 102 |
-
>(
|
| 103 |
-
collection: C,
|
| 104 |
-
id: E,
|
| 105 |
-
): E extends keyof DataEntryMap[C]
|
| 106 |
-
? Promise<DataEntryMap[C][E]>
|
| 107 |
-
: Promise<CollectionEntry<C> | undefined>;
|
| 108 |
-
|
| 109 |
-
/** Resolve an array of entry references from the same collection */
|
| 110 |
-
export function getEntries<C extends keyof ContentEntryMap>(
|
| 111 |
-
entries: {
|
| 112 |
-
collection: C;
|
| 113 |
-
slug: ValidContentEntrySlug<C>;
|
| 114 |
-
}[],
|
| 115 |
-
): Promise<CollectionEntry<C>[]>;
|
| 116 |
-
export function getEntries<C extends keyof DataEntryMap>(
|
| 117 |
-
entries: {
|
| 118 |
-
collection: C;
|
| 119 |
-
id: keyof DataEntryMap[C];
|
| 120 |
-
}[],
|
| 121 |
-
): Promise<CollectionEntry<C>[]>;
|
| 122 |
-
|
| 123 |
-
export function render<C extends keyof AnyEntryMap>(
|
| 124 |
-
entry: AnyEntryMap[C][string],
|
| 125 |
-
): Promise<RenderResult>;
|
| 126 |
-
|
| 127 |
-
export function reference<C extends keyof AnyEntryMap>(
|
| 128 |
-
collection: C,
|
| 129 |
-
): import('astro/zod').ZodEffects<
|
| 130 |
-
import('astro/zod').ZodString,
|
| 131 |
-
C extends keyof ContentEntryMap
|
| 132 |
-
? {
|
| 133 |
-
collection: C;
|
| 134 |
-
slug: ValidContentEntrySlug<C>;
|
| 135 |
-
}
|
| 136 |
-
: {
|
| 137 |
-
collection: C;
|
| 138 |
-
id: keyof DataEntryMap[C];
|
| 139 |
-
}
|
| 140 |
-
>;
|
| 141 |
-
// Allow generic `string` to avoid excessive type errors in the config
|
| 142 |
-
// if `dev` is not running to update as you edit.
|
| 143 |
-
// Invalid collection names will be caught at build time.
|
| 144 |
-
export function reference<C extends string>(
|
| 145 |
-
collection: C,
|
| 146 |
-
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
|
| 147 |
-
|
| 148 |
-
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
| 149 |
-
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
|
| 150 |
-
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
| 151 |
-
>;
|
| 152 |
-
|
| 153 |
-
type ContentEntryMap = {
|
| 154 |
-
"chapters": {
|
| 155 |
-
"available-blocks.mdx": {
|
| 156 |
-
id: "available-blocks.mdx";
|
| 157 |
-
slug: "available-blocks";
|
| 158 |
-
body: string;
|
| 159 |
-
collection: "chapters";
|
| 160 |
-
data: any
|
| 161 |
-
} & { render(): Render[".mdx"] };
|
| 162 |
-
"best-pratices.mdx": {
|
| 163 |
-
id: "best-pratices.mdx";
|
| 164 |
-
slug: "best-pratices";
|
| 165 |
-
body: string;
|
| 166 |
-
collection: "chapters";
|
| 167 |
-
data: any
|
| 168 |
-
} & { render(): Render[".mdx"] };
|
| 169 |
-
"getting-started.mdx": {
|
| 170 |
-
id: "getting-started.mdx";
|
| 171 |
-
slug: "getting-started";
|
| 172 |
-
body: string;
|
| 173 |
-
collection: "chapters";
|
| 174 |
-
data: any
|
| 175 |
-
} & { render(): Render[".mdx"] };
|
| 176 |
-
"writing-your-content.mdx": {
|
| 177 |
-
id: "writing-your-content.mdx";
|
| 178 |
-
slug: "writing-your-content";
|
| 179 |
-
body: string;
|
| 180 |
-
collection: "chapters";
|
| 181 |
-
data: any
|
| 182 |
-
} & { render(): Render[".mdx"] };
|
| 183 |
-
};
|
| 184 |
-
|
| 185 |
-
};
|
| 186 |
-
|
| 187 |
-
type DataEntryMap = {
|
| 188 |
-
"assets": {
|
| 189 |
-
"data/mnist-variant-model": {
|
| 190 |
-
id: "data/mnist-variant-model";
|
| 191 |
-
collection: "assets";
|
| 192 |
-
data: any
|
| 193 |
-
};
|
| 194 |
-
};
|
| 195 |
-
"embeds": Record<string, {
|
| 196 |
-
id: string;
|
| 197 |
-
collection: "embeds";
|
| 198 |
-
data: any;
|
| 199 |
-
}>;
|
| 200 |
-
|
| 201 |
-
};
|
| 202 |
-
|
| 203 |
-
type AnyEntryMap = ContentEntryMap & DataEntryMap;
|
| 204 |
-
|
| 205 |
-
export type ContentConfig = never;
|
| 206 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/astro.config.mjs
CHANGED
|
@@ -151,6 +151,15 @@ export default defineConfig({
|
|
| 151 |
devToolbar: {
|
| 152 |
enabled: false
|
| 153 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
markdown: {
|
| 155 |
shikiConfig: {
|
| 156 |
themes: {
|
|
|
|
| 151 |
devToolbar: {
|
| 152 |
enabled: false
|
| 153 |
},
|
| 154 |
+
// Expose a public flag to control TOC auto-collapse behavior at runtime
|
| 155 |
+
// This can be overridden via environment variable PUBLIC_TOC_AUTO_COLLAPSE
|
| 156 |
+
// (Vite define provides a default fallback when env is not set.)
|
| 157 |
+
vite: {
|
| 158 |
+
define: {
|
| 159 |
+
'import.meta.env.PUBLIC_TOC_AUTO_COLLAPSE': JSON.stringify(false),
|
| 160 |
+
'import.meta.env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE': JSON.stringify(false)
|
| 161 |
+
}
|
| 162 |
+
},
|
| 163 |
markdown: {
|
| 164 |
shikiConfig: {
|
| 165 |
themes: {
|
app/public/scripts/color-palettes.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Global color palettes generator and watcher
|
| 2 |
+
// - Observes CSS variable --primary-color and theme changes
|
| 3 |
+
// - Generates categorical, sequential, and diverging palettes (OKLCH/OKLab)
|
| 4 |
+
// - Exposes results as CSS variables on :root
|
| 5 |
+
// - Supports variable color counts per palette via CSS vars
|
| 6 |
+
// - Dispatches a 'palettes:updated' CustomEvent after each update
|
| 7 |
+
|
| 8 |
+
(() => {
|
| 9 |
+
const MODE = { cssRoot: document.documentElement };
|
| 10 |
+
|
| 11 |
+
const getCssVar = (name) => {
|
| 12 |
+
try { return getComputedStyle(MODE.cssRoot).getPropertyValue(name).trim(); } catch { return ''; }
|
| 13 |
+
};
|
| 14 |
+
const getIntFromCssVar = (name, fallback) => {
|
| 15 |
+
const raw = getCssVar(name);
|
| 16 |
+
if (!raw) return fallback;
|
| 17 |
+
const v = parseInt(String(raw), 10);
|
| 18 |
+
if (Number.isNaN(v)) return fallback;
|
| 19 |
+
return v;
|
| 20 |
+
};
|
| 21 |
+
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
| 22 |
+
|
| 23 |
+
// Color math (OKLab/OKLCH)
|
| 24 |
+
const srgbToLinear = (u) => (u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4));
|
| 25 |
+
const linearToSrgb = (u) => (u <= 0.0031308 ? 12.92 * u : 1.055 * Math.pow(Math.max(0, u), 1 / 2.4) - 0.055);
|
| 26 |
+
const rgbToOklab = (r, g, b) => {
|
| 27 |
+
const rl = srgbToLinear(r), gl = srgbToLinear(g), bl = srgbToLinear(b);
|
| 28 |
+
const l = Math.cbrt(0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl);
|
| 29 |
+
const m = Math.cbrt(0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl);
|
| 30 |
+
const s = Math.cbrt(0.0883024619 * rl + 0.2817188376 * gl + 0.6299787005 * bl);
|
| 31 |
+
const L = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s;
|
| 32 |
+
const a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s;
|
| 33 |
+
const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s;
|
| 34 |
+
return { L, a, b: b2 };
|
| 35 |
+
};
|
| 36 |
+
const oklabToRgb = (L, a, b) => {
|
| 37 |
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
| 38 |
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
| 39 |
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
| 40 |
+
const l = l_ * l_ * l_;
|
| 41 |
+
const m = m_ * m_ * m_;
|
| 42 |
+
const s = s_ * s_ * s_;
|
| 43 |
+
const r = linearToSrgb(+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
|
| 44 |
+
const g = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
|
| 45 |
+
const b3 = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s);
|
| 46 |
+
return { r, g, b: b3 };
|
| 47 |
+
};
|
| 48 |
+
const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
|
| 49 |
+
const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a*a + b*b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
|
| 50 |
+
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
| 51 |
+
const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
|
| 52 |
+
const toHex = ({ r, g, b }) => { const R = Math.round(clamp01(r)*255), G = Math.round(clamp01(g)*255), B = Math.round(clamp01(b)*255); const h = (n) => n.toString(16).padStart(2,'0'); return `#${h(R)}${h(G)}${h(B)}`.toUpperCase(); };
|
| 53 |
+
const oklchToHexSafe = (L, C, h) => { let c = C; for (let i=0;i<12;i++){ const { a, b } = oklchToOklab(L,c,h); const rgb = oklabToRgb(L,a,b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c-0.02);} return toHex(oklabToRgb(L,0,0)); };
|
| 54 |
+
const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1])/255, g: Number(m[2])/255, b: Number(m[3])/255 }; } catch { return null; } };
|
| 55 |
+
|
| 56 |
+
const getPrimaryHex = () => {
|
| 57 |
+
const css = getCssVar('--primary-color');
|
| 58 |
+
if (!css) return '#E889AB';
|
| 59 |
+
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(css)) return css.toUpperCase();
|
| 60 |
+
const rgb = parseCssColorToRgb(css);
|
| 61 |
+
if (rgb) return toHex(rgb);
|
| 62 |
+
return '#E889AB';
|
| 63 |
+
};
|
| 64 |
+
const getCounts = () => {
|
| 65 |
+
const Fallback = 6;
|
| 66 |
+
const globalCount = clamp(getIntFromCssVar('--palette-count', Fallback), 1, 12);
|
| 67 |
+
return {
|
| 68 |
+
categorical: clamp(getIntFromCssVar('--palette-categorical-count', globalCount), 1, 12),
|
| 69 |
+
sequential: clamp(getIntFromCssVar('--palette-sequential-count', globalCount), 1, 12),
|
| 70 |
+
diverging: clamp(getIntFromCssVar('--palette-diverging-count', globalCount), 1, 12),
|
| 71 |
+
};
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
const generators = {
|
| 75 |
+
categorical: (baseHex, count) => {
|
| 76 |
+
const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
|
| 77 |
+
const { r, g, b } = parseHex(baseHex);
|
| 78 |
+
const { L, a, b: bb } = rgbToOklab(r,g,b);
|
| 79 |
+
const { C, h } = oklabToOklch(L,a,bb);
|
| 80 |
+
const L0 = Math.min(0.85, Math.max(0.4, L));
|
| 81 |
+
const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
|
| 82 |
+
const total = Math.max(1, Math.min(12, count || 6));
|
| 83 |
+
const hueStep = 360 / total;
|
| 84 |
+
const results = [];
|
| 85 |
+
for (let i=0;i<total;i++) { const hDeg = (h + i*hueStep) % 360; const lVar = ((i % 3) - 1) * 0.04; results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg)); }
|
| 86 |
+
return results;
|
| 87 |
+
},
|
| 88 |
+
sequential: (baseHex, count) => {
|
| 89 |
+
const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
|
| 90 |
+
const { r, g, b } = parseHex(baseHex);
|
| 91 |
+
const { L, a, b: bb } = rgbToOklab(r,g,b);
|
| 92 |
+
const { C, h } = oklabToOklch(L,a,bb);
|
| 93 |
+
const total = Math.max(1, Math.min(12, count || 6));
|
| 94 |
+
const startL = Math.max(0.25, L - 0.18);
|
| 95 |
+
const endL = Math.min(0.92, L + 0.18);
|
| 96 |
+
const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
|
| 97 |
+
const out = [];
|
| 98 |
+
for (let i=0;i<total;i++) { const t = total===1 ? 0 : i/(total-1); const lNow = startL*(1-t)+endL*t; const cNow = cBase*(0.85 + 0.15*(1 - Math.abs(0.5 - t)*2)); out.push(oklchToHexSafe(lNow, cNow, h)); }
|
| 99 |
+
return out;
|
| 100 |
+
},
|
| 101 |
+
diverging: (baseHex, count) => {
|
| 102 |
+
const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
|
| 103 |
+
const { r, g, b } = parseHex(baseHex);
|
| 104 |
+
const baseLab = rgbToOklab(r,g,b);
|
| 105 |
+
const baseLch = oklabToOklch(baseLab.L, baseLab.a, baseLab.b);
|
| 106 |
+
const total = Math.max(1, Math.min(12, count || 6));
|
| 107 |
+
|
| 108 |
+
// Left endpoint: EXACT primary color (no darkening)
|
| 109 |
+
const leftLab = baseLab;
|
| 110 |
+
// Right endpoint: complement with same L and similar C (clamped safe)
|
| 111 |
+
const compH = (baseLch.h + 180) % 360;
|
| 112 |
+
const cSafe = Math.min(0.35, Math.max(0.08, baseLch.C));
|
| 113 |
+
const rightLab = oklchToOklab(baseLab.L, cSafe, compH);
|
| 114 |
+
const whiteLab = { L: 0.98, a: 0, b: 0 }; // center near‑white
|
| 115 |
+
|
| 116 |
+
const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
|
| 117 |
+
const lerp = (a, b, t) => a + (b - a) * t;
|
| 118 |
+
const lerpOKLabHex = (A, B, t) => hexFromOKLab(lerp(A.L, B.L, t), lerp(A.a, B.a, t), lerp(A.b, B.b, t));
|
| 119 |
+
|
| 120 |
+
const out = [];
|
| 121 |
+
if (total % 2 === 1) {
|
| 122 |
+
const nSide = (total - 1) >> 1; // items on each side
|
| 123 |
+
// Left side: include left endpoint exactly at index 0
|
| 124 |
+
for (let i = 0; i < nSide; i++) {
|
| 125 |
+
const t = nSide <= 1 ? 0 : (i / (nSide - 1)); // 0 .. 1
|
| 126 |
+
// Move from leftLab to a value close (but not equal) to white; ensure last before center is lighter
|
| 127 |
+
const tt = t * 0.9; // keep some distance from pure white before center
|
| 128 |
+
out.push(lerpOKLabHex(leftLab, whiteLab, tt));
|
| 129 |
+
}
|
| 130 |
+
// Center
|
| 131 |
+
out.push(hexFromOKLab(whiteLab.L, whiteLab.a, whiteLab.b));
|
| 132 |
+
// Right side: start near white and end EXACTLY at rightLab
|
| 133 |
+
for (let i = 0; i < nSide; i++) {
|
| 134 |
+
const t = nSide <= 1 ? 1 : ((i + 1) / nSide); // (1/n)..1
|
| 135 |
+
const tt = Math.max(0.1, t); // avoid starting at pure white
|
| 136 |
+
out.push(lerpOKLabHex(whiteLab, rightLab, tt));
|
| 137 |
+
}
|
| 138 |
+
// Ensure first and last are exact endpoints
|
| 139 |
+
if (out.length) { out[0] = hexFromOKLab(leftLab.L, leftLab.a, leftLab.b); out[out.length - 1] = hexFromOKLab(rightLab.L, rightLab.a, rightLab.b); }
|
| 140 |
+
} else {
|
| 141 |
+
const nSide = total >> 1;
|
| 142 |
+
// Left half including left endpoint, approaching white but not reaching it
|
| 143 |
+
for (let i = 0; i < nSide; i++) {
|
| 144 |
+
const t = nSide <= 1 ? 0 : (i / (nSide - 1)); // 0 .. 1
|
| 145 |
+
const tt = t * 0.9;
|
| 146 |
+
out.push(lerpOKLabHex(leftLab, whiteLab, tt));
|
| 147 |
+
}
|
| 148 |
+
// Right half: mirror from near white to exact right endpoint
|
| 149 |
+
for (let i = 0; i < nSide; i++) {
|
| 150 |
+
const t = nSide <= 1 ? 1 : ((i + 1) / nSide); // (1/n)..1
|
| 151 |
+
const tt = Math.max(0.1, t);
|
| 152 |
+
out.push(lerpOKLabHex(whiteLab, rightLab, tt));
|
| 153 |
+
}
|
| 154 |
+
if (out.length) { out[0] = hexFromOKLab(leftLab.L, leftLab.a, leftLab.b); out[out.length - 1] = hexFromOKLab(rightLab.L, rightLab.a, rightLab.b); }
|
| 155 |
+
}
|
| 156 |
+
return out;
|
| 157 |
+
}
|
| 158 |
+
};
|
| 159 |
+
|
| 160 |
+
const setCssVar = (name, value) => { try { MODE.cssRoot.style.setProperty(name, value); } catch {} };
|
| 161 |
+
const removeCssVar = (name) => { try { MODE.cssRoot.style.removeProperty(name); } catch {} };
|
| 162 |
+
|
| 163 |
+
let lastSignature = '';
|
| 164 |
+
let lastCounts = { categorical: 0, sequential: 0, diverging: 0 };
|
| 165 |
+
|
| 166 |
+
const updatePalettes = () => {
|
| 167 |
+
const primary = getPrimaryHex();
|
| 168 |
+
const counts = getCounts();
|
| 169 |
+
const signature = `${primary}|${counts.categorical}|${counts.sequential}|${counts.diverging}`;
|
| 170 |
+
if (signature === lastSignature) return;
|
| 171 |
+
|
| 172 |
+
const out = {};
|
| 173 |
+
out.categorical = generators.categorical(primary, counts.categorical);
|
| 174 |
+
out.sequential = generators.sequential(primary, counts.sequential);
|
| 175 |
+
out.diverging = generators.diverging(primary, counts.diverging);
|
| 176 |
+
|
| 177 |
+
setCssVar('--primary-hex', primary);
|
| 178 |
+
setCssVar('--palette-categorical-count-current', String(out.categorical.length));
|
| 179 |
+
setCssVar('--palette-sequential-count-current', String(out.sequential.length));
|
| 180 |
+
setCssVar('--palette-diverging-count-current', String(out.diverging.length));
|
| 181 |
+
|
| 182 |
+
const applyList = (key, list, prevCount) => {
|
| 183 |
+
for (let i=0;i<list.length;i++) setCssVar(`--palette-${key}-${i+1}`, list[i]);
|
| 184 |
+
for (let i=list.length;i<prevCount;i++) removeCssVar(`--palette-${key}-${i+1}`);
|
| 185 |
+
setCssVar(`--palette-${key}-json`, JSON.stringify(list));
|
| 186 |
+
};
|
| 187 |
+
applyList('categorical', out.categorical, lastCounts.categorical);
|
| 188 |
+
applyList('sequential', out.sequential, lastCounts.sequential);
|
| 189 |
+
applyList('diverging', out.diverging, lastCounts.diverging);
|
| 190 |
+
|
| 191 |
+
lastCounts = { categorical: out.categorical.length, sequential: out.sequential.length, diverging: out.diverging.length };
|
| 192 |
+
lastSignature = signature;
|
| 193 |
+
|
| 194 |
+
try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary, counts, palettes: out } })); } catch {}
|
| 195 |
+
};
|
| 196 |
+
|
| 197 |
+
const bootstrap = () => {
|
| 198 |
+
updatePalettes();
|
| 199 |
+
const mo = new MutationObserver(() => updatePalettes());
|
| 200 |
+
mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
|
| 201 |
+
setInterval(updatePalettes, 400);
|
| 202 |
+
window.ColorPalettes = {
|
| 203 |
+
refresh: updatePalettes,
|
| 204 |
+
getColors: (key) => {
|
| 205 |
+
const count = Number(getCssVar(`--palette-${key}-count-current`)) || 0;
|
| 206 |
+
const arr = [];
|
| 207 |
+
for (let i=0;i<count;i++) arr.push(getCssVar(`--palette-${key}-${i+1}`));
|
| 208 |
+
return arr.filter(Boolean);
|
| 209 |
+
}
|
| 210 |
+
};
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
| 214 |
+
else bootstrap();
|
| 215 |
+
})();
|
| 216 |
+
|
| 217 |
+
|
app/src/components/HtmlEmbed.astro
CHANGED
|
@@ -31,6 +31,12 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
|
|
| 31 |
<div><!-- Fragment not found: {src} --></div>
|
| 32 |
) }
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
<script>
|
| 35 |
// Re-execute <script> tags inside the injected fragment (innerHTML doesn't run scripts)
|
| 36 |
const scriptEl = document.currentScript;
|
|
@@ -45,7 +51,7 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
|
|
| 45 |
old.dataset.executed = 'true';
|
| 46 |
if (old.src) {
|
| 47 |
const s = document.createElement('script');
|
| 48 |
-
Array.from(old.attributes).forEach(
|
| 49 |
document.body.appendChild(s);
|
| 50 |
} else {
|
| 51 |
try {
|
|
@@ -57,10 +63,9 @@ const mountId = `frag-${Math.random().toString(36).slice(2)}`;
|
|
| 57 |
}
|
| 58 |
});
|
| 59 |
};
|
| 60 |
-
//
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
else window.addEventListener('load', execute, { once: true });
|
| 64 |
</script>
|
| 65 |
|
| 66 |
<style>
|
|
|
|
| 31 |
<div><!-- Fragment not found: {src} --></div>
|
| 32 |
) }
|
| 33 |
|
| 34 |
+
<script type="module" is:inline>
|
| 35 |
+
// Ensure global color palettes generator is loaded once per page
|
| 36 |
+
import '../scripts/color-palettes.js';
|
| 37 |
+
export {};
|
| 38 |
+
</script>
|
| 39 |
+
|
| 40 |
<script>
|
| 41 |
// Re-execute <script> tags inside the injected fragment (innerHTML doesn't run scripts)
|
| 42 |
const scriptEl = document.currentScript;
|
|
|
|
| 51 |
old.dataset.executed = 'true';
|
| 52 |
if (old.src) {
|
| 53 |
const s = document.createElement('script');
|
| 54 |
+
Array.from(old.attributes).forEach(attr => s.setAttribute(attr.name, attr.value));
|
| 55 |
document.body.appendChild(s);
|
| 56 |
} else {
|
| 57 |
try {
|
|
|
|
| 63 |
}
|
| 64 |
});
|
| 65 |
};
|
| 66 |
+
// Execute after DOM is parsed (ensures deferred module scripts are executed first)
|
| 67 |
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', execute, { once: true });
|
| 68 |
+
else execute();
|
|
|
|
| 69 |
</script>
|
| 70 |
|
| 71 |
<style>
|
app/src/components/Note.astro
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
|
| 5 |
---
|
| 6 |
<div class={wrapperClass} {...props}>
|
|
@@ -21,5 +27,11 @@ const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title)
|
|
| 21 |
.note__content { color: var(--text-color); font-size: 0.95rem; }
|
| 22 |
/* Ensure the very last slotted element has no bottom margin */
|
| 23 |
.note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</style>
|
| 25 |
|
|
|
|
| 1 |
---
|
| 2 |
+
interface Props {
|
| 3 |
+
title?: string;
|
| 4 |
+
emoji?: string;
|
| 5 |
+
class?: string;
|
| 6 |
+
variant?: 'neutral' | 'info' | 'success' | 'danger';
|
| 7 |
+
}
|
| 8 |
+
const { title, emoji, class: className, variant = 'neutral', ...props } = Astro.props as Props;
|
| 9 |
+
const wrapperClass = ["note", `note--${variant}`, className].filter(Boolean).join(" ");
|
| 10 |
const hasHeader = (emoji && String(emoji).length > 0) || (title && String(title).length > 0);
|
| 11 |
---
|
| 12 |
<div class={wrapperClass} {...props}>
|
|
|
|
| 27 |
.note__content { color: var(--text-color); font-size: 0.95rem; }
|
| 28 |
/* Ensure the very last slotted element has no bottom margin */
|
| 29 |
.note .note__content > :global(*:last-child) { margin-bottom: 0 !important; }
|
| 30 |
+
|
| 31 |
+
/* Variants */
|
| 32 |
+
.note.note--neutral { border-left-color: var(--border-color); background: var(--surface-bg); }
|
| 33 |
+
.note.note--info { border-left-color: #f39c12; background: color-mix(in oklab, #f39c12 10%, var(--surface-bg)); }
|
| 34 |
+
.note.note--success { border-left-color: #2ecc71; background: color-mix(in oklab, #2ecc71 8%, var(--surface-bg)); }
|
| 35 |
+
.note.note--danger { border-left-color: #e74c3c; background: color-mix(in oklab, #e74c3c 8%, var(--surface-bg)); }
|
| 36 |
</style>
|
| 37 |
|
app/src/components/ResponsiveImage.astro
CHANGED
|
@@ -11,28 +11,157 @@ interface Props {
|
|
| 11 |
caption?: string;
|
| 12 |
/** Optional class to apply on the <figure> wrapper when caption is used */
|
| 13 |
figureClass?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
/** Any additional attributes should be forwarded to the underlying <Image> */
|
| 15 |
[key: string]: any;
|
| 16 |
}
|
| 17 |
|
| 18 |
-
const { caption, figureClass, ...imgProps } = Astro.props as Props;
|
| 19 |
const hasCaptionSlot = Astro.slots.has('caption');
|
| 20 |
const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0);
|
|
|
|
|
|
|
|
|
|
| 21 |
---
|
| 22 |
-
|
| 23 |
-
{hasCaption ? (
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
) : (
|
| 30 |
-
|
| 31 |
)}
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
|
|
|
| 11 |
caption?: string;
|
| 12 |
/** Optional class to apply on the <figure> wrapper when caption is used */
|
| 13 |
figureClass?: string;
|
| 14 |
+
/** Enable medium-zoom behavior on this image */
|
| 15 |
+
zoomable?: boolean;
|
| 16 |
+
/** Show a download button overlay and enable download flow */
|
| 17 |
+
downloadable?: boolean;
|
| 18 |
+
/** Optional explicit file name to use on download */
|
| 19 |
+
downloadName?: string;
|
| 20 |
+
/** Optional explicit source URL to download instead of currentSrc */
|
| 21 |
+
downloadSrc?: string;
|
| 22 |
/** Any additional attributes should be forwarded to the underlying <Image> */
|
| 23 |
[key: string]: any;
|
| 24 |
}
|
| 25 |
|
| 26 |
+
const { caption, figureClass, zoomable, downloadable, downloadName, downloadSrc, ...imgProps } = Astro.props as Props;
|
| 27 |
const hasCaptionSlot = Astro.slots.has('caption');
|
| 28 |
const hasCaption = hasCaptionSlot || (typeof caption === 'string' && caption.length > 0);
|
| 29 |
+
const uid = `ri_${Math.random().toString(36).slice(2)}`;
|
| 30 |
+
const dataZoomable = (zoomable === true || (imgProps as any)['data-zoomable']) ? '1' : undefined;
|
| 31 |
+
const dataDownloadable = (downloadable === true || (imgProps as any)['data-downloadable']) ? '1' : undefined;
|
| 32 |
---
|
| 33 |
+
<div class="ri-root" data-ri-root={uid}>
|
| 34 |
+
{hasCaption ? (
|
| 35 |
+
<figure class={(figureClass || '') + (dataDownloadable ? ' has-dl-btn' : '')}>
|
| 36 |
+
{dataDownloadable ? (
|
| 37 |
+
<span class="img-dl-wrap">
|
| 38 |
+
<Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
|
| 39 |
+
<button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
|
| 40 |
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>
|
| 41 |
+
</button>
|
| 42 |
+
</span>
|
| 43 |
) : (
|
| 44 |
+
<Image {...imgProps} data-zoomable={dataZoomable} />
|
| 45 |
)}
|
| 46 |
+
<figcaption>
|
| 47 |
+
{hasCaptionSlot ? (
|
| 48 |
+
<slot name="caption" />
|
| 49 |
+
) : (
|
| 50 |
+
caption && <span set:html={caption} />
|
| 51 |
+
)}
|
| 52 |
+
</figcaption>
|
| 53 |
+
</figure>
|
| 54 |
+
) : (
|
| 55 |
+
dataDownloadable ? (
|
| 56 |
+
<span class="img-dl-wrap">
|
| 57 |
+
<Image {...imgProps} data-zoomable={dataZoomable} data-downloadable={dataDownloadable} data-download-name={downloadName} data-download-src={downloadSrc} />
|
| 58 |
+
<button type="button" class="button button--ghost img-dl-btn" aria-label="Download image" title={downloadName ? `Download ${downloadName}` : 'Download image'}>
|
| 59 |
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>
|
| 60 |
+
</button>
|
| 61 |
+
</span>
|
| 62 |
+
) : (
|
| 63 |
+
<Image {...imgProps} data-zoomable={dataZoomable} />
|
| 64 |
+
)
|
| 65 |
+
)}
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<script is:inline>
|
| 69 |
+
(() => {
|
| 70 |
+
const scriptEl = document.currentScript;
|
| 71 |
+
const root = scriptEl ? scriptEl.previousElementSibling : null;
|
| 72 |
+
if (!root) return;
|
| 73 |
+
const img = (root.tagName === 'IMG' ? root : (root.querySelector ? root.querySelector('img') : null));
|
| 74 |
+
if (!img) return;
|
| 75 |
+
|
| 76 |
+
// medium-zoom integration scoped to this image only
|
| 77 |
+
const ensureMediumZoomReady = (cb) => {
|
| 78 |
+
// @ts-ignore
|
| 79 |
+
if (window.mediumZoom) return cb();
|
| 80 |
+
const retry = () => {
|
| 81 |
+
// @ts-ignore
|
| 82 |
+
if (window.mediumZoom) cb(); else setTimeout(retry, 30);
|
| 83 |
+
};
|
| 84 |
+
retry();
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
const initZoomIfNeeded = () => {
|
| 88 |
+
if (img.getAttribute('data-zoomable') !== '1') return;
|
| 89 |
+
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 90 |
+
const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
|
| 91 |
+
ensureMediumZoomReady(() => {
|
| 92 |
+
// @ts-ignore
|
| 93 |
+
const instance = window.mediumZoom ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 }) : null;
|
| 94 |
+
if (!instance) return;
|
| 95 |
+
let onScrollLike;
|
| 96 |
+
const attachCloseOnScroll = () => {
|
| 97 |
+
if (onScrollLike) return;
|
| 98 |
+
onScrollLike = () => { try { instance.close && instance.close(); } catch {} };
|
| 99 |
+
window.addEventListener('wheel', onScrollLike, { passive: true });
|
| 100 |
+
window.addEventListener('touchmove', onScrollLike, { passive: true });
|
| 101 |
+
window.addEventListener('scroll', onScrollLike, { passive: true });
|
| 102 |
+
};
|
| 103 |
+
const detachCloseOnScroll = () => {
|
| 104 |
+
if (!onScrollLike) return;
|
| 105 |
+
window.removeEventListener('wheel', onScrollLike);
|
| 106 |
+
window.removeEventListener('touchmove', onScrollLike);
|
| 107 |
+
window.removeEventListener('scroll', onScrollLike);
|
| 108 |
+
onScrollLike = null;
|
| 109 |
+
};
|
| 110 |
+
try { instance.on && instance.on('open', attachCloseOnScroll); } catch {}
|
| 111 |
+
try { instance.on && instance.on('close', detachCloseOnScroll); } catch {}
|
| 112 |
+
const themeObserver = new MutationObserver(() => {
|
| 113 |
+
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 114 |
+
try { instance.update && instance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' }); } catch {}
|
| 115 |
+
});
|
| 116 |
+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
| 117 |
+
});
|
| 118 |
+
};
|
| 119 |
+
|
| 120 |
+
// Download button handler
|
| 121 |
+
const dlBtn = (root.querySelector ? root.querySelector('.img-dl-btn') : null);
|
| 122 |
+
if (dlBtn) {
|
| 123 |
+
dlBtn.addEventListener('click', async (ev) => {
|
| 124 |
+
try {
|
| 125 |
+
ev.preventDefault();
|
| 126 |
+
ev.stopPropagation();
|
| 127 |
+
const pickHrefAndName = () => {
|
| 128 |
+
const current = img.currentSrc || img.src || '';
|
| 129 |
+
let href = img.getAttribute('data-download-src') || current;
|
| 130 |
+
const deriveName = () => {
|
| 131 |
+
try {
|
| 132 |
+
const u = new URL(current, location.href);
|
| 133 |
+
const rawHref = u.searchParams.get('href');
|
| 134 |
+
const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname;
|
| 135 |
+
const last = String(candidate).split('/').pop() || '';
|
| 136 |
+
const base = last.split('?')[0].split('#')[0];
|
| 137 |
+
const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i);
|
| 138 |
+
if (m && m[1]) return m[1];
|
| 139 |
+
return base || 'image';
|
| 140 |
+
} catch { return 'image'; }
|
| 141 |
+
};
|
| 142 |
+
const name = img.getAttribute('data-download-name') || deriveName();
|
| 143 |
+
return { href, name };
|
| 144 |
+
};
|
| 145 |
+
const picked = pickHrefAndName();
|
| 146 |
+
const res = await fetch(picked.href, { credentials: 'same-origin' });
|
| 147 |
+
const blob = await res.blob();
|
| 148 |
+
const objectUrl = URL.createObjectURL(blob);
|
| 149 |
+
const tmp = document.createElement('a');
|
| 150 |
+
tmp.href = objectUrl;
|
| 151 |
+
tmp.download = picked.name || 'image';
|
| 152 |
+
tmp.target = '_self';
|
| 153 |
+
tmp.rel = 'noopener';
|
| 154 |
+
tmp.style.display = 'none';
|
| 155 |
+
document.body.appendChild(tmp);
|
| 156 |
+
tmp.click();
|
| 157 |
+
setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000);
|
| 158 |
+
} catch {}
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
if (document.readyState === 'complete') initZoomIfNeeded();
|
| 163 |
+
else window.addEventListener('load', initZoomIfNeeded, { once: true });
|
| 164 |
+
})();
|
| 165 |
+
</script>
|
| 166 |
|
| 167 |
|
app/src/components/TableOfContent.astro
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
export interface Props { tableOfContentAutoCollapse?: boolean }
|
| 3 |
+
const { tableOfContentAutoCollapse = false } = Astro.props as Props;
|
| 4 |
+
---
|
| 5 |
+
<aside class="toc" data-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
|
| 6 |
+
<div class="title">Table of Contents</div>
|
| 7 |
+
<div id="article-toc-placeholder"></div>
|
| 8 |
+
|
| 9 |
+
</aside>
|
| 10 |
+
<details class="toc-mobile">
|
| 11 |
+
<summary>Table of Contents</summary>
|
| 12 |
+
<div id="article-toc-mobile-placeholder"></div>
|
| 13 |
+
</details>
|
| 14 |
+
|
| 15 |
+
<script is:inline>
|
| 16 |
+
// Build TOC from article headings (h2/h3/h4) and render into the sticky aside
|
| 17 |
+
const buildTOC = () => {
|
| 18 |
+
const holder = document.getElementById('article-toc-placeholder');
|
| 19 |
+
const holderMobile = document.getElementById('article-toc-mobile-placeholder');
|
| 20 |
+
// Always rebuild TOC to avoid stale entries
|
| 21 |
+
if (holder) holder.innerHTML = '';
|
| 22 |
+
if (holderMobile) holderMobile.innerHTML = '';
|
| 23 |
+
const articleRoot = document.querySelector('section.content-grid main');
|
| 24 |
+
if (!articleRoot) return;
|
| 25 |
+
const headings = articleRoot.querySelectorAll('h2, h3, h4');
|
| 26 |
+
if (!headings.length) return;
|
| 27 |
+
|
| 28 |
+
// Filter out headings that should not appear in TOC
|
| 29 |
+
const normalize = (s) => String(s || '')
|
| 30 |
+
.toLowerCase()
|
| 31 |
+
.replace(/[^a-z0-9]+/g, ' ')
|
| 32 |
+
.trim();
|
| 33 |
+
const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
|
| 34 |
+
const shouldSkip = (h) => {
|
| 35 |
+
const t = h.textContent || '';
|
| 36 |
+
const id = String(h.id || '');
|
| 37 |
+
const slug = normalize(t).replace(/\s+/g, '_');
|
| 38 |
+
if (isTocLabel(t)) return true;
|
| 39 |
+
if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
|
| 40 |
+
if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
|
| 41 |
+
return false;
|
| 42 |
+
};
|
| 43 |
+
const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
|
| 44 |
+
if (!headingsArr.length) return;
|
| 45 |
+
|
| 46 |
+
// Ensure unique ids for headings (deduplicate duplicates)
|
| 47 |
+
const usedIds = new Set();
|
| 48 |
+
const slugify = (s) => String(s || '')
|
| 49 |
+
.toLowerCase()
|
| 50 |
+
.trim()
|
| 51 |
+
.replace(/\s+/g, '_')
|
| 52 |
+
.replace(/[^a-z0-9_\-]/g, '');
|
| 53 |
+
headingsArr.forEach((h) => {
|
| 54 |
+
let id = (h.id || '').trim();
|
| 55 |
+
if (!id) {
|
| 56 |
+
const base = slugify(h.textContent || '');
|
| 57 |
+
id = base || 'section';
|
| 58 |
+
}
|
| 59 |
+
let candidate = id;
|
| 60 |
+
let n = 2;
|
| 61 |
+
while (usedIds.has(candidate)) {
|
| 62 |
+
candidate = `${id}-${n++}`;
|
| 63 |
+
}
|
| 64 |
+
if (h.id !== candidate) h.id = candidate;
|
| 65 |
+
usedIds.add(candidate);
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
const nav = document.createElement('nav');
|
| 69 |
+
let ulStack = [document.createElement('ul')];
|
| 70 |
+
nav.appendChild(ulStack[0]);
|
| 71 |
+
|
| 72 |
+
const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
|
| 73 |
+
let prev = 2;
|
| 74 |
+
let h2Count = -1;
|
| 75 |
+
const h2List = headingsArr.filter(h => h.tagName === 'H2');
|
| 76 |
+
headingsArr.forEach((h) => {
|
| 77 |
+
const lvl = levelOf(h.tagName);
|
| 78 |
+
// adjust depth
|
| 79 |
+
while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
|
| 80 |
+
while (lvl < prev) { ulStack.pop(); prev--; }
|
| 81 |
+
const li = document.createElement('li');
|
| 82 |
+
const a = document.createElement('a');
|
| 83 |
+
a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';
|
| 84 |
+
li.appendChild(a);
|
| 85 |
+
if (lvl === 2) {
|
| 86 |
+
h2Count += 1;
|
| 87 |
+
li.setAttribute('data-h2-idx', String(h2Count));
|
| 88 |
+
}
|
| 89 |
+
ulStack[ulStack.length-1].appendChild(li);
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
if (holder) holder.appendChild(nav);
|
| 93 |
+
const navClone = nav.cloneNode(true);
|
| 94 |
+
if (holderMobile) holderMobile.appendChild(navClone);
|
| 95 |
+
|
| 96 |
+
// active link on scroll
|
| 97 |
+
const links = [
|
| 98 |
+
...(holder ? holder.querySelectorAll('a') : []),
|
| 99 |
+
...(holderMobile ? holderMobile.querySelectorAll('a') : [])
|
| 100 |
+
];
|
| 101 |
+
const autoCollapse = (document.querySelector('aside.toc')?.getAttribute('data-auto-collapse') === '1');
|
| 102 |
+
|
| 103 |
+
// Inject styles for collapsible & animation
|
| 104 |
+
const ensureStyles = () => {
|
| 105 |
+
if (document.getElementById('toc-collapse-style')) return;
|
| 106 |
+
const style = document.createElement('style');
|
| 107 |
+
style.id = 'toc-collapse-style';
|
| 108 |
+
style.textContent = `
|
| 109 |
+
aside.toc nav.toc-collapsible > ul > li > ul,
|
| 110 |
+
details.toc-mobile nav.toc-collapsible > ul > li > ul { overflow: hidden; transition: height 200ms ease; }
|
| 111 |
+
aside.toc nav.toc-collapsible > ul > li.collapsed > ul,
|
| 112 |
+
details.toc-mobile nav.toc-collapsible > ul > li.collapsed > ul { display: block; }
|
| 113 |
+
`;
|
| 114 |
+
document.head.appendChild(style);
|
| 115 |
+
};
|
| 116 |
+
ensureStyles();
|
| 117 |
+
|
| 118 |
+
const getTopLevelItems = () => {
|
| 119 |
+
const sideNav = holder ? holder.querySelector('nav') : null;
|
| 120 |
+
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
|
| 121 |
+
const q = (navEl) => navEl ? Array.from(navEl.querySelectorAll(':scope > ul > li[data-h2-idx]')) : [];
|
| 122 |
+
return { sideNav, mobileNav, sideTop: q(sideNav), mobileTop: q(mobileNav) };
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
const setNavCollapsible = () => {
|
| 126 |
+
const sideNav = holder ? holder.querySelector('nav') : null;
|
| 127 |
+
const mobileNav = holderMobile ? holderMobile.querySelector('nav') : null;
|
| 128 |
+
if (sideNav) sideNav.classList.add('toc-collapsible');
|
| 129 |
+
if (mobileNav) mobileNav.classList.add('toc-collapsible');
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const measure = (el) => {
|
| 133 |
+
if (!el) return 0;
|
| 134 |
+
// Temporarily set height to auto to measure scrollHeight reliably
|
| 135 |
+
const prev = el.style.height;
|
| 136 |
+
el.style.height = 'auto';
|
| 137 |
+
const h = el.scrollHeight;
|
| 138 |
+
el.style.height = prev || '';
|
| 139 |
+
return h;
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
const animateTo = (el, target) => {
|
| 143 |
+
if (!el) return;
|
| 144 |
+
const current = parseFloat(getComputedStyle(el).height) || 0;
|
| 145 |
+
if (Math.abs(current - target) < 1) {
|
| 146 |
+
el.style.height = target ? 'auto' : '0px';
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
el.style.height = current + 'px';
|
| 150 |
+
// Force reflow
|
| 151 |
+
void el.offsetHeight;
|
| 152 |
+
el.style.height = target + 'px';
|
| 153 |
+
const onEnd = (e) => {
|
| 154 |
+
if (e.propertyName !== 'height') return;
|
| 155 |
+
el.removeEventListener('transitionend', onEnd);
|
| 156 |
+
if (target > 0) el.style.height = 'auto';
|
| 157 |
+
};
|
| 158 |
+
el.addEventListener('transitionend', onEnd);
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
let prevActiveIdx = -1;
|
| 162 |
+
const setCollapsedState = (activeIdx) => {
|
| 163 |
+
if (!autoCollapse) return;
|
| 164 |
+
if (activeIdx == null || activeIdx < 0) activeIdx = 0;
|
| 165 |
+
const { sideTop, mobileTop } = getTopLevelItems();
|
| 166 |
+
const update = (items) => items.forEach((li) => {
|
| 167 |
+
const idx = Number(li.getAttribute('data-h2-idx') || '-1');
|
| 168 |
+
const sub = li.querySelector(':scope > ul');
|
| 169 |
+
if (!sub) return;
|
| 170 |
+
if (idx === activeIdx) {
|
| 171 |
+
li.classList.remove('collapsed');
|
| 172 |
+
const target = measure(sub);
|
| 173 |
+
animateTo(sub, target);
|
| 174 |
+
} else {
|
| 175 |
+
li.classList.add('collapsed');
|
| 176 |
+
animateTo(sub, 0);
|
| 177 |
+
}
|
| 178 |
+
});
|
| 179 |
+
update(sideTop);
|
| 180 |
+
update(mobileTop);
|
| 181 |
+
setNavCollapsible();
|
| 182 |
+
prevActiveIdx = activeIdx;
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
const onScroll = () => {
|
| 186 |
+
// active link highlight
|
| 187 |
+
let activeIdx = -1;
|
| 188 |
+
for (let i = headingsArr.length - 1; i >= 0; i--) {
|
| 189 |
+
const top = headingsArr[i].getBoundingClientRect().top;
|
| 190 |
+
if (top - 60 <= 0) {
|
| 191 |
+
links.forEach(l => l.classList.remove('active'));
|
| 192 |
+
const id = '#' + headingsArr[i].id;
|
| 193 |
+
const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
|
| 194 |
+
actives.forEach(a => a.classList.add('active'));
|
| 195 |
+
if (headingsArr[i].tagName === 'H2') {
|
| 196 |
+
activeIdx = h2List.indexOf(headingsArr[i]);
|
| 197 |
+
} else {
|
| 198 |
+
for (let j = i; j >= 0; j--) {
|
| 199 |
+
if (headingsArr[j].tagName === 'H2') { activeIdx = h2List.indexOf(headingsArr[j]); break; }
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
break;
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
if (activeIdx !== prevActiveIdx) setCollapsedState(activeIdx);
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
window.addEventListener('scroll', onScroll);
|
| 209 |
+
// Initialize state
|
| 210 |
+
onScroll();
|
| 211 |
+
|
| 212 |
+
// Close mobile accordion when a link inside it is clicked
|
| 213 |
+
if (holderMobile) {
|
| 214 |
+
const details = holderMobile.closest('details');
|
| 215 |
+
holderMobile.addEventListener('click', (ev) => {
|
| 216 |
+
const target = ev.target;
|
| 217 |
+
const anchor = target && 'closest' in target ? target.closest('a') : null;
|
| 218 |
+
if (anchor instanceof HTMLAnchorElement && details && details.open) {
|
| 219 |
+
details.open = false;
|
| 220 |
+
}
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
if (document.readyState === 'loading') {
|
| 226 |
+
document.addEventListener('DOMContentLoaded', buildTOC, { once: true });
|
| 227 |
+
} else { buildTOC(); }
|
| 228 |
+
</script>
|
| 229 |
+
|
| 230 |
+
|
app/src/content/article.mdx
CHANGED
|
@@ -10,84 +10,16 @@ published: "Sep. 01, 2025"
|
|
| 10 |
tags:
|
| 11 |
- research
|
| 12 |
- template
|
| 13 |
-
|
| 14 |
---
|
| 15 |
|
| 16 |
-
import
|
| 17 |
-
import Wide from "../components/Wide.astro";
|
| 18 |
-
import FullWidth from "../components/FullWidth.astro";
|
| 19 |
-
import { Image } from 'astro:assets';
|
| 20 |
-
import placeholder from "./assets/images/placeholder.png";
|
| 21 |
-
import audioDemo from "./assets/audio/audio-example.wav";
|
| 22 |
-
import Sidenote from "../components/Sidenote.astro";
|
| 23 |
-
import visualPoster from "./assets/images/visual-vocabulary-poster.png";
|
| 24 |
-
|
| 25 |
import BestPractices from "./chapters/best-pratices.mdx";
|
| 26 |
import WritingYourContent from "./chapters/writing-your-content.mdx";
|
| 27 |
import AvailableBlocks from "./chapters/available-blocks.mdx";
|
| 28 |
import GettingStarted from "./chapters/getting-started.mdx";
|
| 29 |
|
| 30 |
-
<
|
| 31 |
-
Welcome to this **single‑page** research article template. It helps you publish **clear**, **modern**, and **interactive** technical writing with **minimal setup**. Grounded in up to date good practices in web dev, it favors interactive explanations, clear notation, and inspectable examples over static snapshots.
|
| 32 |
-
|
| 33 |
-
It offers a **ready‑to‑publish**, **all‑in‑one** workflow so you can focus on ideas rather than infrastructure.
|
| 34 |
-
<Fragment slot="aside">
|
| 35 |
-
Reading time: 20–25 minutes.
|
| 36 |
-
</Fragment>
|
| 37 |
-
</Sidenote>
|
| 38 |
-
|
| 39 |
-
#### Features
|
| 40 |
-
|
| 41 |
-
<Sidenote>
|
| 42 |
-
<div className="tag-list">
|
| 43 |
-
<span className="tag">Markdown based</span>
|
| 44 |
-
<span className="tag">KaTeX math</span>
|
| 45 |
-
<span className="tag">Syntax highlighting</span>
|
| 46 |
-
<span className="tag">Citations & footnotes</span>
|
| 47 |
-
<span className="tag">Automatic build</span>
|
| 48 |
-
<span className="tag">Table of contents</span>
|
| 49 |
-
<span className="tag">Dark theme</span>
|
| 50 |
-
<span className="tag">HTML Embeds</span>
|
| 51 |
-
<span className="tag">Plotly ready</span>
|
| 52 |
-
<span className="tag">D3.js ready</span>
|
| 53 |
-
<span className="tag">SEO Friendly</span>
|
| 54 |
-
<span className="tag">Mermaid diagrams</span>
|
| 55 |
-
<span className="tag">Lightweight bundle</span>
|
| 56 |
-
<span className="tag">Mobile friendly</span>
|
| 57 |
-
<span className="tag">Optimized images</span>
|
| 58 |
-
<span className="tag">Automatic PDF export</span>
|
| 59 |
-
<span className="tag">Dataviz color palettes</span>
|
| 60 |
-
<span className="tag">Embed gradio apps</span>
|
| 61 |
-
</div>
|
| 62 |
-
<Fragment slot="aside">
|
| 63 |
-
If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
|
| 64 |
-
</Fragment>
|
| 65 |
-
</Sidenote>
|
| 66 |
-
|
| 67 |
-
## Introduction
|
| 68 |
-
The web enables what static PDFs can’t: **interactive diagrams**, **progressive notation**, and **exploratory views** that reveal how ideas behave. This template makes **interactive artifacts**—figures, math, code and **inspectable experiments**—first‑class alongside prose, so readers **build intuition** rather than skim results.
|
| 69 |
-
|
| 70 |
-
If you write technical blogs or research notes, you’ll get a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**. **Start below and iterate quickly.**
|
| 71 |
-
|
| 72 |
-
This project is heavily inspired by [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship.
|
| 73 |
-
|
| 74 |
-
### Notable examples of excellent scientific articles
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
<Sidenote>
|
| 78 |
-
A short, curated list of well‑designed and often interactive work:
|
| 79 |
-
|
| 80 |
-
- [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
|
| 81 |
-
- [Activation Atlas](https://distill.pub/2019/activation-atlas/)
|
| 82 |
-
- [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
|
| 83 |
-
- [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
|
| 84 |
-
|
| 85 |
-
<Fragment slot="aside">
|
| 86 |
-
I'm always excited to discover more great examples—please share your favorites in the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
|
| 87 |
-
</Fragment>
|
| 88 |
-
</Sidenote>
|
| 89 |
-
|
| 90 |
-
|
| 91 |
|
| 92 |
<GettingStarted />
|
| 93 |
|
|
|
|
| 10 |
tags:
|
| 11 |
- research
|
| 12 |
- template
|
| 13 |
+
tocAutoCollapse: true
|
| 14 |
---
|
| 15 |
|
| 16 |
+
import Introduction from "./chapters/introduction.mdx";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
import BestPractices from "./chapters/best-pratices.mdx";
|
| 18 |
import WritingYourContent from "./chapters/writing-your-content.mdx";
|
| 19 |
import AvailableBlocks from "./chapters/available-blocks.mdx";
|
| 20 |
import GettingStarted from "./chapters/getting-started.mdx";
|
| 21 |
|
| 22 |
+
<Introduction />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
<GettingStarted />
|
| 25 |
|
app/src/content/assets/images/placeholder.png
CHANGED
|
Git LFS Details
|
|
Git LFS Details
|
app/src/content/chapters/available-blocks.mdx
CHANGED
|
@@ -111,19 +111,19 @@ Here are a few variations using the same bibliography:
|
|
| 111 |
|
| 112 |
| Prop | Required | Description
|
| 113 |
|------------------------|----------|-------------------------------------------------------
|
| 114 |
-
| `
|
| 115 |
-
| `
|
| 116 |
| `loading="lazy"` | No | Lazy loads the image.
|
| 117 |
| `caption` | No | Adds a caption and credit.
|
| 118 |
|
| 119 |
|
| 120 |
<ResponsiveImage
|
| 121 |
src={placeholder}
|
| 122 |
-
|
| 123 |
-
|
| 124 |
layout="fixed"
|
| 125 |
-
alt="
|
| 126 |
-
caption={'
|
| 127 |
/>
|
| 128 |
|
| 129 |
<small className="muted">Example</small>
|
|
@@ -136,8 +136,8 @@ import myImage from './assets/images/placeholder.jpg'
|
|
| 136 |
<ResponsiveImage
|
| 137 |
src={myImage}
|
| 138 |
layout="fixed"
|
| 139 |
-
|
| 140 |
-
|
| 141 |
loading="lazy"
|
| 142 |
alt="Example with caption and credit"
|
| 143 |
caption={'Optimized image with a descriptive caption. <span class="image-credit">Credit: Photo by <a href="https://example.com">Author</a></span>'}
|
|
@@ -282,15 +282,25 @@ greet("Astro");
|
|
| 282 |
|
| 283 |
Small contextual callout for tips, caveats, or emphasis.
|
| 284 |
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
| 289 |
|
| 290 |
-
<Note title="Heads‑up" emoji="💡">
|
| 291 |
Use notes to surface context without breaking reading flow.
|
| 292 |
</Note>
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
<Note>
|
| 295 |
Plain note without header. Useful for short clarifications.
|
| 296 |
</Note>
|
|
@@ -299,10 +309,18 @@ Props (optional)
|
|
| 299 |
```mdx
|
| 300 |
import Note from '../../components/Note.astro'
|
| 301 |
|
| 302 |
-
<Note title="Heads‑up" emoji="💡">
|
| 303 |
Use notes to surface context without breaking reading flow.
|
| 304 |
</Note>
|
| 305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
<Note>
|
| 307 |
Plain note without header. Useful for short clarifications.
|
| 308 |
</Note>
|
|
@@ -369,18 +387,22 @@ import HtmlEmbed from '../components/HtmlEmbed.astro'
|
|
| 369 |
|
| 370 |
#### Data
|
| 371 |
|
| 372 |
-
If you need to link your HTML embeds to data files
|
| 373 |
As long as your files are there, they will be served from the **`public/data`** folder.
|
| 374 |
You can fetch them with this address: **`[domain]/data/your-data.ext`**
|
| 375 |
|
| 376 |
<Note>⚠️ <b>Be careful</b>, unlike images, <b>data files are not optimized</b> by Astro. You need to optimize them manually.</Note>
|
| 377 |
|
| 378 |
-
####
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
|
| 380 |
-
Here are some real world examples to inspire you.
|
| 381 |
|
| 382 |
<HtmlEmbed src="d3-line.html" title="Training curves by metric" desc="Interactive time series across runs. Choose a metric; hover for values." />
|
| 383 |
-
<HtmlEmbed src="d3-line-example.html" title="Training curves by metric" desc="Interactive time series across runs. Choose a metric; hover for values." />
|
| 384 |
---
|
| 385 |
<HtmlEmbed src="d3-bar.html" title="D3 Memory usage with recomputation" desc={`Memory usage with recomputation — <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}/>
|
| 386 |
---
|
|
|
|
| 111 |
|
| 112 |
| Prop | Required | Description
|
| 113 |
|------------------------|----------|-------------------------------------------------------
|
| 114 |
+
| `zoomable` | No | Adds a zoomable lightbox (Medium-like).
|
| 115 |
+
| `downloadable` | No | Adds a download button to fetch the image file.
|
| 116 |
| `loading="lazy"` | No | Lazy loads the image.
|
| 117 |
| `caption` | No | Adds a caption and credit.
|
| 118 |
|
| 119 |
|
| 120 |
<ResponsiveImage
|
| 121 |
src={placeholder}
|
| 122 |
+
zoomable
|
| 123 |
+
downloadable
|
| 124 |
layout="fixed"
|
| 125 |
+
alt="A placeholder image alt text"
|
| 126 |
+
caption={'A placeholder image description <span class="image-credit">From the <a target="_blank" href="https://commons.wikimedia.org/wiki/File:RCA_Indian_Head_Test_Pattern.svg">RCA Indian Head Test Pattern</a></span>'}
|
| 127 |
/>
|
| 128 |
|
| 129 |
<small className="muted">Example</small>
|
|
|
|
| 136 |
<ResponsiveImage
|
| 137 |
src={myImage}
|
| 138 |
layout="fixed"
|
| 139 |
+
zoomable
|
| 140 |
+
downloadable
|
| 141 |
loading="lazy"
|
| 142 |
alt="Example with caption and credit"
|
| 143 |
caption={'Optimized image with a descriptive caption. <span class="image-credit">Credit: Photo by <a href="https://example.com">Author</a></span>'}
|
|
|
|
| 282 |
|
| 283 |
Small contextual callout for tips, caveats, or emphasis.
|
| 284 |
|
| 285 |
+
| Prop | Required | Type | Description |
|
| 286 |
+
|----------|----------|------------------------------|-------------------------------------|
|
| 287 |
+
| `title` | No | string | Short title displayed in header |
|
| 288 |
+
| `emoji` | No | string | Emoji displayed before the title |
|
| 289 |
+
| `class` | No | string | Extra classes for custom styling |
|
| 290 |
+
| `variant`| No | 'info' | 'success' | 'danger' | Visual intent of the note |
|
| 291 |
|
| 292 |
+
<Note title="Heads‑up" emoji="💡" variant="info">
|
| 293 |
Use notes to surface context without breaking reading flow.
|
| 294 |
</Note>
|
| 295 |
|
| 296 |
+
<Note variant="success">
|
| 297 |
+
Operation completed successfully.
|
| 298 |
+
</Note>
|
| 299 |
+
|
| 300 |
+
<Note variant="danger">
|
| 301 |
+
Be careful: this action cannot be undone.
|
| 302 |
+
</Note>
|
| 303 |
+
|
| 304 |
<Note>
|
| 305 |
Plain note without header. Useful for short clarifications.
|
| 306 |
</Note>
|
|
|
|
| 309 |
```mdx
|
| 310 |
import Note from '../../components/Note.astro'
|
| 311 |
|
| 312 |
+
<Note title="Heads‑up" emoji="💡" variant="info">
|
| 313 |
Use notes to surface context without breaking reading flow.
|
| 314 |
</Note>
|
| 315 |
|
| 316 |
+
<Note variant="success">
|
| 317 |
+
Operation completed successfully.
|
| 318 |
+
</Note>
|
| 319 |
+
|
| 320 |
+
<Note variant="danger">
|
| 321 |
+
Be careful: this action cannot be undone.
|
| 322 |
+
</Note>
|
| 323 |
+
|
| 324 |
<Note>
|
| 325 |
Plain note without header. Useful for short clarifications.
|
| 326 |
</Note>
|
|
|
|
| 387 |
|
| 388 |
#### Data
|
| 389 |
|
| 390 |
+
If you need to link your **HTML embeds** to **data files**, there is an **`assets/data`** folder for this.
|
| 391 |
As long as your files are there, they will be served from the **`public/data`** folder.
|
| 392 |
You can fetch them with this address: **`[domain]/data/your-data.ext`**
|
| 393 |
|
| 394 |
<Note>⚠️ <b>Be careful</b>, unlike images, <b>data files are not optimized</b> by Astro. You need to optimize them manually.</Note>
|
| 395 |
|
| 396 |
+
#### Vibe coding charts
|
| 397 |
+
|
| 398 |
+
If you are from the research field, it can be difficult to use **D3.js** charts instead of **Plotly**.
|
| 399 |
+
Happily, **LLM's** are here to help you! In the `app/src/content/embeds` folder, there is a `vibe-code-d3-embeds-directives.md` file that you can use to vibe code **D3.js** charts.
|
| 400 |
+
|
| 401 |
+
Inside this file, you will find every directives you need to code your own **HtmlEmbed** proof **D3.js** chart.
|
| 402 |
|
| 403 |
+
Here are some **real world examples** that were vibe coded to inspire you.
|
| 404 |
|
| 405 |
<HtmlEmbed src="d3-line.html" title="Training curves by metric" desc="Interactive time series across runs. Choose a metric; hover for values." />
|
|
|
|
| 406 |
---
|
| 407 |
<HtmlEmbed src="d3-bar.html" title="D3 Memory usage with recomputation" desc={`Memory usage with recomputation — <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}/>
|
| 408 |
---
|
app/src/content/chapters/best-pratices.mdx
CHANGED
|
@@ -4,7 +4,7 @@ import Note from '../../components/Note.astro';
|
|
| 4 |
import ResponsiveImage from '../../components/ResponsiveImage.astro';
|
| 5 |
|
| 6 |
|
| 7 |
-
##
|
| 8 |
|
| 9 |
### Short sections
|
| 10 |
Break content into **small, purpose‑driven sections**. Each section should answer a **single question** or support one idea. This improves **scanability**, helps readers navigate with the TOC, and makes later edits safer.
|
|
|
|
| 4 |
import ResponsiveImage from '../../components/ResponsiveImage.astro';
|
| 5 |
|
| 6 |
|
| 7 |
+
## Writing best practices
|
| 8 |
|
| 9 |
### Short sections
|
| 10 |
Break content into **small, purpose‑driven sections**. Each section should answer a **single question** or support one idea. This improves **scanability**, helps readers navigate with the TOC, and makes later edits safer.
|
app/src/content/chapters/getting-started.mdx
CHANGED
|
@@ -14,11 +14,26 @@ The recommended way is to **duplicate this Space** on **Hugging Face** rather th
|
|
| 14 |
git clone git@hf.co:spaces/<your-username>/<your-space>
|
| 15 |
cd <your-space>
|
| 16 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
```bash
|
| 20 |
git lfs install
|
| 21 |
git lfs pull
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
cd app
|
| 23 |
npm install
|
| 24 |
```
|
|
@@ -28,12 +43,16 @@ npm install
|
|
| 28 |
</Sidenote>
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
| 31 |
### Development
|
| 32 |
|
| 33 |
```bash
|
| 34 |
npm run dev
|
| 35 |
```
|
| 36 |
|
|
|
|
|
|
|
| 37 |
### Build
|
| 38 |
|
| 39 |
```bash
|
|
@@ -43,7 +62,7 @@ npm run build
|
|
| 43 |
|
| 44 |
### Deploy
|
| 45 |
|
| 46 |
-
|
| 47 |
```bash
|
| 48 |
# Make edits locally, then:
|
| 49 |
git add .
|
|
@@ -55,5 +74,5 @@ git push
|
|
| 55 |
Serving the `dist/` directory on any static host is enough to deliver the site.
|
| 56 |
</Note>
|
| 57 |
<Note title="Note">
|
| 58 |
-
A [slugified-title].pdf and thumb.jpg are also generated at build time.<br/>You can find them in the public folder.
|
| 59 |
</Note>
|
|
|
|
| 14 |
git clone git@hf.co:spaces/<your-username>/<your-space>
|
| 15 |
cd <your-space>
|
| 16 |
```
|
| 17 |
+
<br/>
|
| 18 |
+
4. Use **Node.js 20 or newer**. <br/>To manage versions, consider using **nvm**
|
| 19 |
+
- macOS/Linux: see [nvm-sh](https://github.com/nvm-sh/nvm)
|
| 20 |
+
- Windows: see [nvm-windows](https://github.com/coreybutler/nvm-windows)
|
| 21 |
|
| 22 |
+
```bash
|
| 23 |
+
nvm install 20
|
| 24 |
+
nvm use 20
|
| 25 |
+
node -v
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
5. Install lfs and pull files from the repository.
|
| 29 |
```bash
|
| 30 |
git lfs install
|
| 31 |
git lfs pull
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
6. Install dependencies.
|
| 35 |
+
<Sidenote>
|
| 36 |
+
```bash
|
| 37 |
cd app
|
| 38 |
npm install
|
| 39 |
```
|
|
|
|
| 43 |
</Sidenote>
|
| 44 |
|
| 45 |
|
| 46 |
+
<br/>And that's it!
|
| 47 |
+
|
| 48 |
### Development
|
| 49 |
|
| 50 |
```bash
|
| 51 |
npm run dev
|
| 52 |
```
|
| 53 |
|
| 54 |
+
Once started, the dev server is available at `http://localhost:4321`.
|
| 55 |
+
|
| 56 |
### Build
|
| 57 |
|
| 58 |
```bash
|
|
|
|
| 62 |
|
| 63 |
### Deploy
|
| 64 |
|
| 65 |
+
**Every push** automatically triggers a **build** and **deploy** on Spaces.
|
| 66 |
```bash
|
| 67 |
# Make edits locally, then:
|
| 68 |
git add .
|
|
|
|
| 74 |
Serving the `dist/` directory on any static host is enough to deliver the site.
|
| 75 |
</Note>
|
| 76 |
<Note title="Note">
|
| 77 |
+
A [slugified-title].pdf and thumb.jpg are also generated at build time.<br/>You can find them in the public folder and point to them at `[domain]/public/thumb.jpg`.
|
| 78 |
</Note>
|
app/src/content/chapters/introduction.mdx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import Sidenote from "../../components/Sidenote.astro";
|
| 2 |
+
|
| 3 |
+
<Sidenote>
|
| 4 |
+
Welcome to this **single‑page** research article template. It helps you publish **clear**, **modern**, and **interactive** technical writing with **minimal setup**. Grounded in up to date good practices in web dev, it favors interactive explanations, clear notation, and inspectable examples over static snapshots.
|
| 5 |
+
|
| 6 |
+
It offers a **ready‑to‑publish**, **all‑in‑one** workflow so you can focus on ideas rather than infrastructure.
|
| 7 |
+
<Fragment slot="aside">
|
| 8 |
+
Reading time: 20–25 minutes.
|
| 9 |
+
</Fragment>
|
| 10 |
+
</Sidenote>
|
| 11 |
+
|
| 12 |
+
#### Features
|
| 13 |
+
|
| 14 |
+
<Sidenote>
|
| 15 |
+
<div className="tag-list">
|
| 16 |
+
<span className="tag">Markdown based</span>
|
| 17 |
+
<span className="tag">KaTeX math</span>
|
| 18 |
+
<span className="tag">Syntax highlighting</span>
|
| 19 |
+
<span className="tag">Citations & footnotes</span>
|
| 20 |
+
<span className="tag">Automatic build</span>
|
| 21 |
+
<span className="tag">Table of contents</span>
|
| 22 |
+
<span className="tag">Dark theme</span>
|
| 23 |
+
<span className="tag">HTML Embeds</span>
|
| 24 |
+
<span className="tag">Plotly ready</span>
|
| 25 |
+
<span className="tag">D3.js ready</span>
|
| 26 |
+
<span className="tag">SEO Friendly</span>
|
| 27 |
+
<span className="tag">Mermaid diagrams</span>
|
| 28 |
+
<span className="tag">Lightweight bundle</span>
|
| 29 |
+
<span className="tag">Mobile friendly</span>
|
| 30 |
+
<span className="tag">Optimized images</span>
|
| 31 |
+
<span className="tag">Automatic PDF export</span>
|
| 32 |
+
<span className="tag">Dataviz color palettes</span>
|
| 33 |
+
<span className="tag">Embed gradio apps</span>
|
| 34 |
+
</div>
|
| 35 |
+
<Fragment slot="aside">
|
| 36 |
+
If you have questions, remarks or suggestions, open a discussion on the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
|
| 37 |
+
</Fragment>
|
| 38 |
+
</Sidenote>
|
| 39 |
+
|
| 40 |
+
## Introduction
|
| 41 |
+
The web enables what static PDFs can’t: **interactive diagrams**, **progressive notation**, and **exploratory views** that reveal how ideas behave. This template makes **interactive artifacts**—figures, math, code and **inspectable experiments**—first‑class alongside prose, so readers **build intuition** rather than skim results.
|
| 42 |
+
|
| 43 |
+
If you write technical blogs or research notes, you’ll benefit from a **ready‑to‑publish workflow** with clear notation, optimized media, and **automatic builds**.
|
| 44 |
+
|
| 45 |
+
This project draws strong inspiration from [**Distill**](https://distill.pub) (2016–2021), which championed clear, web‑native scholarship and set a high standard for interactive scientific communication.
|
| 46 |
+
|
| 47 |
+
<Sidenote>
|
| 48 |
+
To give you a sense of what inspired this template, here is a short, curated list of well‑designed and often interactive works from Distill:
|
| 49 |
+
|
| 50 |
+
- [Growing Neural Cellular Automata](https://distill.pub/2020/growing-ca/)
|
| 51 |
+
- [Activation Atlas](https://distill.pub/2019/activation-atlas/)
|
| 52 |
+
- [Handwriting with a Neural Network](https://distill.pub/2016/handwriting/)
|
| 53 |
+
- [The Building Blocks of Interpretability](https://distill.pub/2018/building-blocks/)
|
| 54 |
+
|
| 55 |
+
<Fragment slot="aside">
|
| 56 |
+
I'm always excited to discover more great examples—please share your favorites in the <a href="https://huggingface.co/spaces/tfrere/research-blog-template/discussions?status=open&type=discussion">Community tab</a>!
|
| 57 |
+
</Fragment>
|
| 58 |
+
</Sidenote>
|
app/src/content/chapters/writing-your-content.mdx
CHANGED
|
@@ -3,6 +3,7 @@ import { Image } from 'astro:assets';
|
|
| 3 |
import placeholder from '../assets/images/placeholder.png';
|
| 4 |
import Sidenote from '../../components/Sidenote.astro';
|
| 5 |
import Wide from '../../components/Wide.astro';
|
|
|
|
| 6 |
import FullWidth from '../../components/FullWidth.astro';
|
| 7 |
import HtmlEmbed from '../../components/HtmlEmbed.astro';
|
| 8 |
import audioDemo from '../assets/audio/audio-example.wav';
|
|
@@ -15,12 +16,12 @@ Your **article** lives in **one place**
|
|
| 15 |
|
| 16 |
Everything is **self-contained** under `app/src/content/`:
|
| 17 |
- **MDX**: `article.mdx` and [**optional chapters**](#chapters) in `chapters/`
|
| 18 |
-
- **Assets**: `assets/`
|
| 19 |
-
- **Embeds**: `embed/` [**HtmlEmbed**](#htmlembed) for
|
| 20 |
|
| 21 |
The `article.mdx` file is the **main entry point** of your article.
|
| 22 |
|
| 23 |
-
<small className="muted">
|
| 24 |
```mdx
|
| 25 |
{/* HEADER */}
|
| 26 |
---
|
|
@@ -38,6 +39,8 @@ tags:
|
|
| 38 |
- template
|
| 39 |
{/* Optional override of the default Open Graph image */}
|
| 40 |
ogImage: "https://override-example.com/your-og-image.png"
|
|
|
|
|
|
|
| 41 |
---
|
| 42 |
|
| 43 |
{/* IMPORTS */}
|
|
@@ -129,6 +132,42 @@ Here is a suggestion of **color palettes** for your **data visualizations** that
|
|
| 129 |
Color should rarely be the only channel of meaning.
|
| 130 |
Always pair it with text, icons, shape or position. The simulation helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies.
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
### Placement
|
| 133 |
|
| 134 |
Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
|
|
@@ -152,7 +191,6 @@ import Sidenote from '../components/Sidenote.astro'
|
|
| 152 |
</Sidenote>
|
| 153 |
```
|
| 154 |
|
| 155 |
-
Use these helpers to expand content beyond the main column when needed. They will always be centered and displayed above every other content.
|
| 156 |
|
| 157 |
#### Wide example
|
| 158 |
|
|
|
|
| 3 |
import placeholder from '../assets/images/placeholder.png';
|
| 4 |
import Sidenote from '../../components/Sidenote.astro';
|
| 5 |
import Wide from '../../components/Wide.astro';
|
| 6 |
+
import Note from '../../components/Note.astro';
|
| 7 |
import FullWidth from '../../components/FullWidth.astro';
|
| 8 |
import HtmlEmbed from '../../components/HtmlEmbed.astro';
|
| 9 |
import audioDemo from '../assets/audio/audio-example.wav';
|
|
|
|
| 16 |
|
| 17 |
Everything is **self-contained** under `app/src/content/`:
|
| 18 |
- **MDX**: `article.mdx` and [**optional chapters**](#chapters) in `chapters/`
|
| 19 |
+
- **Assets**: `assets/` images, audios, datas, etc. (tracked via **Git LFS**)
|
| 20 |
+
- **Embeds**: `embed/` [**HtmlEmbed**](#htmlembed) for Plotly/D3, etc.
|
| 21 |
|
| 22 |
The `article.mdx` file is the **main entry point** of your article.
|
| 23 |
|
| 24 |
+
<small className="muted">app/src/content/article.mdx</small>
|
| 25 |
```mdx
|
| 26 |
{/* HEADER */}
|
| 27 |
---
|
|
|
|
| 39 |
- template
|
| 40 |
{/* Optional override of the default Open Graph image */}
|
| 41 |
ogImage: "https://override-example.com/your-og-image.png"
|
| 42 |
+
{/* By default the table of contents is not collapsing */}
|
| 43 |
+
tocAutoCollapse: true
|
| 44 |
---
|
| 45 |
|
| 46 |
{/* IMPORTS */}
|
|
|
|
| 132 |
Color should rarely be the only channel of meaning.
|
| 133 |
Always pair it with text, icons, shape or position. The simulation helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies.
|
| 134 |
|
| 135 |
+
#### Using the palettes
|
| 136 |
+
|
| 137 |
+
##### CSS
|
| 138 |
+
Use the generated **CSS variables** to style elements.
|
| 139 |
+
|
| 140 |
+
```css
|
| 141 |
+
/* Usage */
|
| 142 |
+
.series-a { color: var(--palette-categorical-1); }
|
| 143 |
+
.series-b { color: var(--palette-categorical-2); }
|
| 144 |
+
.heatmap-low { background: var(--palette-sequential-1); }
|
| 145 |
+
.heatmap-high { background: var(--palette-sequential-6); }
|
| 146 |
+
.neg { color: var(--palette-diverging-1); } /* left = primary */
|
| 147 |
+
.pos { color: var(--palette-diverging-6); } /* right = complement */
|
| 148 |
+
|
| 149 |
+
/* Override size */
|
| 150 |
+
:root { --palette-count: 8; } /* global */
|
| 151 |
+
:root { --palette-diverging-count: 7; } /* per palette */
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
<Note variant="info">⚠️ **CSS overrides** automatically trigger a **palette regeneration** via JS observers; no manual call is needed.</Note>
|
| 155 |
+
|
| 156 |
+
##### JS
|
| 157 |
+
Fetch arrays of colors via **`window.ColorPalettes.getColors(type)`**. To change sizes **at runtime**, set CSS vars and call **`window.ColorPalettes.refresh()`**.
|
| 158 |
+
|
| 159 |
+
```js
|
| 160 |
+
// Usage
|
| 161 |
+
const cat = window.ColorPalettes.getColors('categorical');
|
| 162 |
+
const seq = window.ColorPalettes.getColors('sequential');
|
| 163 |
+
const div = window.ColorPalettes.getColors('diverging');
|
| 164 |
+
|
| 165 |
+
// Override size (runtime)
|
| 166 |
+
document.documentElement.style.setProperty('--palette-count', '8');
|
| 167 |
+
document.documentElement.style.setProperty('--palette-diverging-count', '7');
|
| 168 |
+
window.ColorPalettes.refresh();
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
### Placement
|
| 172 |
|
| 173 |
Use these helpers when you need to step outside the main content flow: **Sidenotes** for contextual side notes, **Wide** to extend beyond the main column, and **Full-width** for full-width, immersive sections.
|
|
|
|
| 191 |
</Sidenote>
|
| 192 |
```
|
| 193 |
|
|
|
|
| 194 |
|
| 195 |
#### Wide example
|
| 196 |
|
app/src/content/embeds/d3-line-example.html
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
<div class="d3-line" style="width:100%;margin:10px 0;"></div>
|
| 2 |
<style>
|
| 3 |
-
.d3-line .d3-line__controls select {
|
| 4 |
font-size: 12px;
|
| 5 |
padding: 8px 28px 8px 10px;
|
| 6 |
border: 1px solid var(--border-color);
|
|
@@ -17,21 +17,21 @@
|
|
| 17 |
cursor: pointer;
|
| 18 |
transition: border-color .15s ease, box-shadow .15s ease;
|
| 19 |
}
|
| 20 |
-
[data-theme="dark"] .d3-line .d3-line__controls select {
|
| 21 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 22 |
}
|
| 23 |
-
.d3-line .d3-line__controls select:hover {
|
| 24 |
border-color: var(--primary-color);
|
| 25 |
}
|
| 26 |
-
.d3-line .d3-line__controls select:focus {
|
| 27 |
border-color: var(--primary-color);
|
| 28 |
box-shadow: 0 0 0 3px rgba(232,137,171,.25);
|
| 29 |
outline: none;
|
| 30 |
}
|
| 31 |
-
.d3-line .d3-line__controls label { gap: 8px; }
|
| 32 |
|
| 33 |
/* Range slider themed with --primary-color */
|
| 34 |
-
.d3-line .d3-line__controls input[type="range"] {
|
| 35 |
-webkit-appearance: none;
|
| 36 |
appearance: none;
|
| 37 |
width: 100%;
|
|
@@ -40,12 +40,12 @@
|
|
| 40 |
background: var(--border-color);
|
| 41 |
outline: none;
|
| 42 |
}
|
| 43 |
-
.d3-line .d3-line__controls input[type="range"]::-webkit-slider-runnable-track {
|
| 44 |
height: 6px;
|
| 45 |
background: transparent;
|
| 46 |
border-radius: 999px;
|
| 47 |
}
|
| 48 |
-
.d3-line .d3-line__controls input[type="range"]::-webkit-slider-thumb {
|
| 49 |
-webkit-appearance: none;
|
| 50 |
appearance: none;
|
| 51 |
width: 16px;
|
|
@@ -56,12 +56,12 @@
|
|
| 56 |
margin-top: -5px;
|
| 57 |
cursor: pointer;
|
| 58 |
}
|
| 59 |
-
.d3-line .d3-line__controls input[type="range"]::-moz-range-track {
|
| 60 |
height: 6px;
|
| 61 |
background: transparent;
|
| 62 |
border-radius: 999px;
|
| 63 |
}
|
| 64 |
-
.d3-line .d3-line__controls input[type="range"]::-moz-range-thumb {
|
| 65 |
width: 16px;
|
| 66 |
height: 16px;
|
| 67 |
border-radius: 50%;
|
|
@@ -70,7 +70,7 @@
|
|
| 70 |
cursor: pointer;
|
| 71 |
}
|
| 72 |
/* Improved line color via CSS */
|
| 73 |
-
.d3-line .lines path.improved { stroke: var(--primary-color); }
|
| 74 |
</style>
|
| 75 |
<script>
|
| 76 |
(() => {
|
|
@@ -94,7 +94,7 @@
|
|
| 94 |
const getLocalPrev = () => {
|
| 95 |
if (!scriptEl) return null;
|
| 96 |
let el = scriptEl.previousElementSibling;
|
| 97 |
-
while (el && !(el.classList && el.classList.contains('d3-line'))) {
|
| 98 |
el = el.previousElementSibling;
|
| 99 |
}
|
| 100 |
return el || null;
|
|
@@ -103,7 +103,7 @@
|
|
| 103 |
const localTarget = getLocalPrev();
|
| 104 |
const targets = localTarget
|
| 105 |
? [localTarget]
|
| 106 |
-
: Array.from(document.querySelectorAll('.d3-line'))
|
| 107 |
.filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
|
| 108 |
|
| 109 |
targets.forEach((container) => {
|
|
|
|
| 1 |
+
<div class="d3-line-example" style="width:100%;margin:10px 0;"></div>
|
| 2 |
<style>
|
| 3 |
+
.d3-line-example .d3-line__controls select {
|
| 4 |
font-size: 12px;
|
| 5 |
padding: 8px 28px 8px 10px;
|
| 6 |
border: 1px solid var(--border-color);
|
|
|
|
| 17 |
cursor: pointer;
|
| 18 |
transition: border-color .15s ease, box-shadow .15s ease;
|
| 19 |
}
|
| 20 |
+
[data-theme="dark"] .d3-line-example .d3-line__controls select {
|
| 21 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 22 |
}
|
| 23 |
+
.d3-line-example .d3-line__controls select:hover {
|
| 24 |
border-color: var(--primary-color);
|
| 25 |
}
|
| 26 |
+
.d3-line-example .d3-line__controls select:focus {
|
| 27 |
border-color: var(--primary-color);
|
| 28 |
box-shadow: 0 0 0 3px rgba(232,137,171,.25);
|
| 29 |
outline: none;
|
| 30 |
}
|
| 31 |
+
.d3-line-example .d3-line__controls label { gap: 8px; }
|
| 32 |
|
| 33 |
/* Range slider themed with --primary-color */
|
| 34 |
+
.d3-line-example .d3-line__controls input[type="range"] {
|
| 35 |
-webkit-appearance: none;
|
| 36 |
appearance: none;
|
| 37 |
width: 100%;
|
|
|
|
| 40 |
background: var(--border-color);
|
| 41 |
outline: none;
|
| 42 |
}
|
| 43 |
+
.d3-line-example .d3-line__controls input[type="range"]::-webkit-slider-runnable-track {
|
| 44 |
height: 6px;
|
| 45 |
background: transparent;
|
| 46 |
border-radius: 999px;
|
| 47 |
}
|
| 48 |
+
.d3-line-example .d3-line__controls input[type="range"]::-webkit-slider-thumb {
|
| 49 |
-webkit-appearance: none;
|
| 50 |
appearance: none;
|
| 51 |
width: 16px;
|
|
|
|
| 56 |
margin-top: -5px;
|
| 57 |
cursor: pointer;
|
| 58 |
}
|
| 59 |
+
.d3-line-example .d3-line__controls input[type="range"]::-moz-range-track {
|
| 60 |
height: 6px;
|
| 61 |
background: transparent;
|
| 62 |
border-radius: 999px;
|
| 63 |
}
|
| 64 |
+
.d3-line-example .d3-line__controls input[type="range"]::-moz-range-thumb {
|
| 65 |
width: 16px;
|
| 66 |
height: 16px;
|
| 67 |
border-radius: 50%;
|
|
|
|
| 70 |
cursor: pointer;
|
| 71 |
}
|
| 72 |
/* Improved line color via CSS */
|
| 73 |
+
.d3-line-example .lines path.improved { stroke: var(--primary-color); }
|
| 74 |
</style>
|
| 75 |
<script>
|
| 76 |
(() => {
|
|
|
|
| 94 |
const getLocalPrev = () => {
|
| 95 |
if (!scriptEl) return null;
|
| 96 |
let el = scriptEl.previousElementSibling;
|
| 97 |
+
while (el && !(el.classList && el.classList.contains('d3-line-example'))) {
|
| 98 |
el = el.previousElementSibling;
|
| 99 |
}
|
| 100 |
return el || null;
|
|
|
|
| 103 |
const localTarget = getLocalPrev();
|
| 104 |
const targets = localTarget
|
| 105 |
? [localTarget]
|
| 106 |
+
: Array.from(document.querySelectorAll('.d3-line-example'))
|
| 107 |
.filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
|
| 108 |
|
| 109 |
targets.forEach((container) => {
|
app/src/content/embeds/palettes.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
.palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 0; border: 1px solid var(--border-color); }
|
| 9 |
.palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
|
| 10 |
.palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
|
| 11 |
-
.palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right:
|
| 12 |
.palettes .palette-card__content__info { display: flex; flex-direction: column; }
|
| 13 |
.palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
|
| 14 |
.palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
|
|
@@ -97,128 +97,24 @@
|
|
| 97 |
</div>
|
| 98 |
<script>
|
| 99 |
(() => {
|
| 100 |
-
const loadScript = (id, src, onload, onerror) => {
|
| 101 |
-
let s = document.getElementById(id);
|
| 102 |
-
if (s) { return onload && onload(); }
|
| 103 |
-
s = document.createElement('script'); s.id = id; s.src = src; s.async = true;
|
| 104 |
-
if (onload) s.addEventListener('load', onload, { once: true });
|
| 105 |
-
if (onerror) s.addEventListener('error', onerror, { once: true });
|
| 106 |
-
document.head.appendChild(s);
|
| 107 |
-
};
|
| 108 |
-
const ensureChroma = (next) => {
|
| 109 |
-
if (window.chroma) return next();
|
| 110 |
-
const tryReady = () => { if (window.chroma) next(); else setTimeout(tryReady, 25); };
|
| 111 |
-
const existing = document.getElementById('chroma-cdn') || document.getElementById('chroma-cdn-fallback');
|
| 112 |
-
if (existing) { tryReady(); return; }
|
| 113 |
-
loadScript('chroma-cdn', 'https://unpkg.com/chroma-js@2.4.2/dist/chroma.min.js', tryReady, () => {
|
| 114 |
-
loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', tryReady);
|
| 115 |
-
});
|
| 116 |
-
};
|
| 117 |
-
|
| 118 |
const cards = [
|
| 119 |
-
{ key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. The more you have the more likely they are to look similar.'
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
const baseH = base.get('hsl.h') || 0;
|
| 123 |
-
const L0 = Math.max(40, Math.min(85, lc[0] || 70));
|
| 124 |
-
const C0 = Math.max(45, Math.min(75, lc[1] || 70));
|
| 125 |
-
const MIN_DELTA = 18; // minimal distance in Lab
|
| 126 |
-
|
| 127 |
-
const seen = new Set();
|
| 128 |
-
const results = [];
|
| 129 |
-
|
| 130 |
-
const makeSafe = (h, L, C) => {
|
| 131 |
-
let c = C;
|
| 132 |
-
let col = chroma.lch(L, c, h);
|
| 133 |
-
let guard = 0;
|
| 134 |
-
while (col.clipped && typeof col.clipped === 'function' && col.clipped() && c > 30 && guard < 8) {
|
| 135 |
-
c -= 5;
|
| 136 |
-
col = chroma.lch(L, c, h);
|
| 137 |
-
guard++;
|
| 138 |
-
}
|
| 139 |
-
return col;
|
| 140 |
-
};
|
| 141 |
-
|
| 142 |
-
const isFarEnough = (hex) => results.every(prev => chroma.distance(hex, prev, 'lab') >= MIN_DELTA);
|
| 143 |
-
const pushHex = (col) => {
|
| 144 |
-
const hex = col.hex();
|
| 145 |
-
if (!seen.has(hex.toLowerCase())) { results.push(hex); seen.add(hex.toLowerCase()); }
|
| 146 |
-
};
|
| 147 |
-
|
| 148 |
-
// Base first
|
| 149 |
-
pushHex(base);
|
| 150 |
-
|
| 151 |
-
const total = Math.max(6, Math.min(10, count || 6));
|
| 152 |
-
const hueStep = 360 / total;
|
| 153 |
-
const hueOffsets = [0, 18, -18, 36, -36, 54, -54, 72, -72];
|
| 154 |
-
const lVariants = [L0, Math.max(40, L0 - 6), Math.min(85, L0 + 6)];
|
| 155 |
-
|
| 156 |
-
for (let idx = 1; results.length < total && idx < total + 12; idx++) {
|
| 157 |
-
let stepHue = (baseH + idx * hueStep) % 360;
|
| 158 |
-
let accepted = false;
|
| 159 |
-
for (let li = 0; li < lVariants.length && !accepted; li++) {
|
| 160 |
-
for (let oi = 0; oi < hueOffsets.length && !accepted; oi++) {
|
| 161 |
-
const h = (stepHue + hueOffsets[oi] + 360) % 360;
|
| 162 |
-
const col = makeSafe(h, lVariants[li], C0);
|
| 163 |
-
const hex = col.hex();
|
| 164 |
-
if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
|
| 165 |
-
pushHex(col);
|
| 166 |
-
accepted = true;
|
| 167 |
-
}
|
| 168 |
-
}
|
| 169 |
-
}
|
| 170 |
-
if (!accepted) {
|
| 171 |
-
// Reduce chroma if necessary
|
| 172 |
-
let cTry = C0 - 10;
|
| 173 |
-
let trials = 0;
|
| 174 |
-
while (!accepted && cTry >= 30 && trials < 6) {
|
| 175 |
-
const col = makeSafe(stepHue, L0, cTry);
|
| 176 |
-
const hex = col.hex();
|
| 177 |
-
if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
|
| 178 |
-
pushHex(col);
|
| 179 |
-
accepted = true;
|
| 180 |
-
break;
|
| 181 |
-
}
|
| 182 |
-
cTry -= 5;
|
| 183 |
-
trials++;
|
| 184 |
-
}
|
| 185 |
-
if (!accepted) {
|
| 186 |
-
let bestHex = null; let bestMin = -1;
|
| 187 |
-
hueOffsets.forEach(off => {
|
| 188 |
-
const hh = (stepHue + off + 360) % 360;
|
| 189 |
-
const cand = makeSafe(hh, L0, C0).hex();
|
| 190 |
-
const minD = results.reduce((m, prev) => Math.min(m, chroma.distance(cand, prev, 'lab')), Infinity);
|
| 191 |
-
if (minD > bestMin && !seen.has(cand.toLowerCase())) { bestMin = minD; bestHex = cand; }
|
| 192 |
-
});
|
| 193 |
-
if (bestHex) { seen.add(bestHex.toLowerCase()); results.push(bestHex); }
|
| 194 |
-
}
|
| 195 |
-
}
|
| 196 |
-
}
|
| 197 |
-
|
| 198 |
-
return results.slice(0, total);
|
| 199 |
-
}},
|
| 200 |
-
{ key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.', generator: (baseHex, count) => {
|
| 201 |
-
const total = Math.max(6, Math.min(10, count || 6));
|
| 202 |
-
const c = chroma(baseHex).saturate(0.3);
|
| 203 |
-
return chroma.scale([c.darken(2), c, c.brighten(2)]).mode('lab').correctLightness(true).colors(total);
|
| 204 |
-
}},
|
| 205 |
-
{ key: 'diverging', title: 'Diverging', desc: 'Opposing extremes via <strong>base → white → complement</strong>; smooth contrast around a neutral midpoint.', generator: (baseHex, count) => {
|
| 206 |
-
const total = Math.max(6, Math.min(10, count || 6));
|
| 207 |
-
const comp = chroma(baseHex).set('hsl.h', '+180');
|
| 208 |
-
const bez = chroma.bezier([baseHex, '#ffffff', comp]);
|
| 209 |
-
return bez.scale().padding(-0.15).colors(total);
|
| 210 |
-
}}
|
| 211 |
];
|
| 212 |
|
| 213 |
-
const
|
| 214 |
-
try { return getComputedStyle(document.documentElement).getPropertyValue(
|
| 215 |
};
|
| 216 |
|
| 217 |
-
const
|
| 218 |
-
const
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
| 222 |
};
|
| 223 |
|
| 224 |
const render = () => {
|
|
@@ -227,13 +123,8 @@
|
|
| 227 |
if (!root) return;
|
| 228 |
const grid = root.querySelector('.palettes__grid');
|
| 229 |
if (!grid) return;
|
| 230 |
-
grid.innerHTML = '';
|
| 231 |
-
const css = getCssPrimary();
|
| 232 |
-
const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
|
| 233 |
-
const count = getDesiredCount();
|
| 234 |
-
|
| 235 |
const html = cards.map((c) => {
|
| 236 |
-
const colors = c.
|
| 237 |
const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
|
| 238 |
return `
|
| 239 |
<div class="palette-card" data-colors="${colors.join(',')}">
|
|
@@ -252,7 +143,7 @@
|
|
| 252 |
`;
|
| 253 |
}).join('');
|
| 254 |
grid.innerHTML = html;
|
| 255 |
-
|
| 256 |
|
| 257 |
const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
|
| 258 |
const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
|
|
@@ -281,9 +172,19 @@
|
|
| 281 |
const input = document.getElementById('color-count');
|
| 282 |
const out = document.getElementById('color-count-out');
|
| 283 |
if (!input) return;
|
| 284 |
-
const
|
| 285 |
-
|
| 286 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
};
|
| 288 |
|
| 289 |
let copyDelegationSetup = false;
|
|
@@ -317,12 +218,21 @@
|
|
| 317 |
setupCountControl();
|
| 318 |
render();
|
| 319 |
setupCopyDelegation();
|
|
|
|
|
|
|
| 320 |
const mo = new MutationObserver(() => render());
|
| 321 |
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
};
|
| 323 |
|
| 324 |
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded',
|
| 325 |
-
else
|
| 326 |
})();
|
| 327 |
</script>
|
| 328 |
|
|
|
|
| 8 |
.palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 0; border: 1px solid var(--border-color); }
|
| 9 |
.palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
|
| 10 |
.palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
|
| 11 |
+
.palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 0; }
|
| 12 |
.palettes .palette-card__content__info { display: flex; flex-direction: column; }
|
| 13 |
.palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
|
| 14 |
.palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
|
|
|
|
| 97 |
</div>
|
| 98 |
<script>
|
| 99 |
(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
const cards = [
|
| 101 |
+
{ key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. The more you have the more likely they are to look similar.' },
|
| 102 |
+
{ key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
|
| 103 |
+
{ key: 'diverging', title: 'Diverging', desc: 'Opposing extremes via <strong>base → white → complement</strong>; smooth contrast around a neutral midpoint.' }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
];
|
| 105 |
|
| 106 |
+
const getCssVar = (name) => {
|
| 107 |
+
try { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } catch { return ''; }
|
| 108 |
};
|
| 109 |
|
| 110 |
+
const getPaletteColors = (key) => {
|
| 111 |
+
const count = Number(getCssVar(`--palette-${key}-count-current`)) || Number(getCssVar('--palette-count')) || 6;
|
| 112 |
+
const colors = [];
|
| 113 |
+
for (let i = 1; i <= count; i++) {
|
| 114 |
+
const v = getCssVar(`--palette-${key}-${i}`);
|
| 115 |
+
if (v) colors.push(v);
|
| 116 |
+
}
|
| 117 |
+
return colors;
|
| 118 |
};
|
| 119 |
|
| 120 |
const render = () => {
|
|
|
|
| 123 |
if (!root) return;
|
| 124 |
const grid = root.querySelector('.palettes__grid');
|
| 125 |
if (!grid) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
const html = cards.map((c) => {
|
| 127 |
+
const colors = getPaletteColors(c.key);
|
| 128 |
const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
|
| 129 |
return `
|
| 130 |
<div class="palette-card" data-colors="${colors.join(',')}">
|
|
|
|
| 143 |
`;
|
| 144 |
}).join('');
|
| 145 |
grid.innerHTML = html;
|
| 146 |
+
};
|
| 147 |
|
| 148 |
const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
|
| 149 |
const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
|
|
|
|
| 172 |
const input = document.getElementById('color-count');
|
| 173 |
const out = document.getElementById('color-count-out');
|
| 174 |
if (!input) return;
|
| 175 |
+
const read = () => {
|
| 176 |
+
const v = Number(getCssVar('--palette-count')) || 6;
|
| 177 |
+
return Math.max(6, Math.min(10, v));
|
| 178 |
+
};
|
| 179 |
+
const write = (v) => {
|
| 180 |
+
document.documentElement.style.setProperty('--palette-count', String(v));
|
| 181 |
+
if (window.ColorPalettes && typeof window.ColorPalettes.refresh === 'function') window.ColorPalettes.refresh();
|
| 182 |
+
};
|
| 183 |
+
const syncOut = () => { if (out) out.textContent = String(read()); };
|
| 184 |
+
const syncInput = () => { try { input.value = String(read()); } catch {} };
|
| 185 |
+
syncInput(); syncOut();
|
| 186 |
+
input.addEventListener('input', () => { write(input.value); syncOut(); });
|
| 187 |
+
document.addEventListener('palettes:updated', () => { syncInput(); syncOut(); render(); });
|
| 188 |
};
|
| 189 |
|
| 190 |
let copyDelegationSetup = false;
|
|
|
|
| 218 |
setupCountControl();
|
| 219 |
render();
|
| 220 |
setupCopyDelegation();
|
| 221 |
+
// Re-render when global palettes update and when :root style changes
|
| 222 |
+
document.addEventListener('palettes:updated', render);
|
| 223 |
const mo = new MutationObserver(() => render());
|
| 224 |
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
|
| 225 |
+
|
| 226 |
+
// Fallback: if global script loads after this fragment and we missed the event
|
| 227 |
+
const waitForPalettes = () => {
|
| 228 |
+
if (getCssVar('--palette-categorical-1')) { render(); return; }
|
| 229 |
+
setTimeout(waitForPalettes, 50);
|
| 230 |
+
};
|
| 231 |
+
waitForPalettes();
|
| 232 |
};
|
| 233 |
|
| 234 |
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
| 235 |
+
else bootstrap();
|
| 236 |
})();
|
| 237 |
</script>
|
| 238 |
|
app/src/content/embeds/vibe-code-d3-embeds-directives.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Embed Chart Authoring Guidelines
|
| 2 |
+
|
| 3 |
+
Authoring rules for creating a new interactive chart as a single self-contained `.html` file under `src/content/embeds/`. These conventions are derived from `d3-bar.html`, `d3-comparison.html`, `d3-neural.html`, `d3-line.html`, and `d3-pie.html`.
|
| 4 |
+
|
| 5 |
+
### 1) File, naming, and structure
|
| 6 |
+
- Name files with a clear prefix and purpose: `d3-<type>.html` (e.g., `d3-scatter.html`).
|
| 7 |
+
- Wrap everything in a single `<div class="<root-class>">`, a `<style>` block scoped to that root class, and a `<script>` IIFE.
|
| 8 |
+
- Do not leak globals; do not attach anything to `window`.
|
| 9 |
+
- Use a unique, descriptive root class (e.g., `.d3-scatter`).
|
| 10 |
+
|
| 11 |
+
Minimal skeleton:
|
| 12 |
+
```html
|
| 13 |
+
<div class="d3-yourchart" style="width:100%;margin:10px 0;"></div>
|
| 14 |
+
<style>
|
| 15 |
+
.d3-yourchart {/* all styles scoped to the root */}
|
| 16 |
+
</style>
|
| 17 |
+
<script>
|
| 18 |
+
(() => {
|
| 19 |
+
// Optional dependency loader (e.g., D3)
|
| 20 |
+
const ensureD3 = (cb) => {
|
| 21 |
+
if (window.d3 && typeof window.d3.select === 'function') return cb();
|
| 22 |
+
let s = document.getElementById('d3-cdn-script');
|
| 23 |
+
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
|
| 24 |
+
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
|
| 25 |
+
s.addEventListener('load', onReady, { once: true });
|
| 26 |
+
if (window.d3) onReady();
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const bootstrap = () => {
|
| 30 |
+
const scriptEl = document.currentScript;
|
| 31 |
+
// Prefer the closest previous sibling with the root class
|
| 32 |
+
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 33 |
+
if (!(container && container.classList && container.classList.contains('d3-yourchart'))) {
|
| 34 |
+
// Fallback: pick the last unmounted instance in the page
|
| 35 |
+
const candidates = Array.from(document.querySelectorAll('.d3-yourchart'))
|
| 36 |
+
.filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
|
| 37 |
+
container = candidates[candidates.length - 1] || null;
|
| 38 |
+
}
|
| 39 |
+
if (!container) return;
|
| 40 |
+
if (container.dataset) {
|
| 41 |
+
if (container.dataset.mounted === 'true') return;
|
| 42 |
+
container.dataset.mounted = 'true';
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Tooltip (optional)
|
| 46 |
+
container.style.position = container.style.position || 'relative';
|
| 47 |
+
let tip = container.querySelector('.d3-tooltip'); let tipInner;
|
| 48 |
+
if (!tip) {
|
| 49 |
+
tip = document.createElement('div'); tip.className = 'd3-tooltip';
|
| 50 |
+
Object.assign(tip.style, { position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none', padding:'8px 10px', borderRadius:'8px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)', background:'var(--surface-bg)', color:'var(--text-color)', boxShadow:'0 4px 24px rgba(0,0,0,.18)', opacity:'0', transition:'opacity .12s ease' });
|
| 51 |
+
tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip);
|
| 52 |
+
} else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
|
| 53 |
+
|
| 54 |
+
// SVG scaffolding (if using D3)
|
| 55 |
+
const svg = d3.select(container).append('svg').attr('width','100%').style('display','block');
|
| 56 |
+
const gRoot = svg.append('g');
|
| 57 |
+
|
| 58 |
+
// State & layout
|
| 59 |
+
let width = 800, height = 360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
|
| 60 |
+
function updateSize(){
|
| 61 |
+
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 62 |
+
width = container.clientWidth || 800;
|
| 63 |
+
height = Math.max(260, Math.round(width / 3));
|
| 64 |
+
svg.attr('width', width).attr('height', height);
|
| 65 |
+
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 66 |
+
return { innerWidth: width - margin.left - margin.right, innerHeight: height - margin.top - margin.bottom, isDark };
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function render(){
|
| 70 |
+
const { innerWidth, innerHeight } = updateSize();
|
| 71 |
+
// ... draw/update your chart here using data joins
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// Initial render + resize handling
|
| 75 |
+
render();
|
| 76 |
+
const rerender = () => render();
|
| 77 |
+
if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); }
|
| 78 |
+
else { window.addEventListener('resize', rerender); }
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); }
|
| 82 |
+
else { ensureD3(bootstrap); }
|
| 83 |
+
})();
|
| 84 |
+
</script>
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### 2) Mounting and re-entrancy
|
| 88 |
+
- Select the closest previous sibling with the root class; fallback to the last unmounted matching element in the document.
|
| 89 |
+
- Gate with `data-mounted` to avoid double-initialization when the fragment re-runs.
|
| 90 |
+
- Assume the chart can appear multiple times on the same page.
|
| 91 |
+
|
| 92 |
+
### 3) Styling and theming
|
| 93 |
+
- Scope all rules under the root class; do not style `body`, `svg` globally.
|
| 94 |
+
- Use CSS variables for theme alignment: `--primary-color`, `--text-color`, `--muted-color`, `--surface-bg`, `--border-color`.
|
| 95 |
+
- For dark mode–aware strokes/ticks, either:
|
| 96 |
+
- Read `document.documentElement.getAttribute('data-theme') === 'dark'`, or
|
| 97 |
+
- Prefer CSS-only where possible.
|
| 98 |
+
- Keep backgrounds light and borders subtle; the outer card frame is handled by `HtmlEmbed.astro`.
|
| 99 |
+
|
| 100 |
+
### 4) Controls (labels, selects, sliders)
|
| 101 |
+
- Compose controls as plain HTML elements appended inside the root container.
|
| 102 |
+
- Style selects like in `d3-line.html`/`d3-bar.html` for consistency (rounded 8px, custom caret via data-URI, focus ring).
|
| 103 |
+
- Use `<label>` wrapping the input for accessibility; set concise text (e.g., "Metric", "Model Size").
|
| 104 |
+
|
| 105 |
+
### 5) Tooltip pattern
|
| 106 |
+
- Create a single `.d3-tooltip` absolutely positioned inside the container.
|
| 107 |
+
- Show on hover, hide on leave; position using `d3.pointer(event, container)` plus a small offset.
|
| 108 |
+
- Keep content in a `.d3-tooltip__inner` node; avoid large inner HTML.
|
| 109 |
+
|
| 110 |
+
### 6) Data loading
|
| 111 |
+
- Prefer public assets first, then fall back to content assets:
|
| 112 |
+
- Example CSV paths: `/data/<file>.csv`, then `./assets/data/<file>.csv`, `../assets/data/<file>.csv`, etc.
|
| 113 |
+
- Implement `fetchFirstAvailable(paths)`; try in order with `cache:'no-cache'`; handle errors gracefully with a red `<pre>` message.
|
| 114 |
+
- For images or JSON models, mirror the same approach (see `d3-comparison.html`, `d3-neural.html`).
|
| 115 |
+
|
| 116 |
+
### 7) Responsiveness and layout
|
| 117 |
+
- Compute `width = container.clientWidth`, and a height derived from width (e.g., `width / 3`), with a sensible minimum height.
|
| 118 |
+
- Maintain a `margin` object and derive `innerWidth/innerHeight` for plots.
|
| 119 |
+
- Use a `ResizeObserver` on the container; fallback to `window.resize`.
|
| 120 |
+
- Recompute scales/axes/grid on every render.
|
| 121 |
+
|
| 122 |
+
### 8) Legends and labels
|
| 123 |
+
- Use `foreignObject` + inline HTML to render compact legends that wrap nicely (see `d3-line.html`, `d3-pie.html`).
|
| 124 |
+
- For axes, remove and re-append groups each render (simple and predictable), or update in place if needed.
|
| 125 |
+
- Always add axis labels when applicable (e.g., `Step`, `Value`).
|
| 126 |
+
|
| 127 |
+
### 9) Accessibility
|
| 128 |
+
- Provide `alt` attributes on `<img>` (see `d3-comparison.html`).
|
| 129 |
+
- Provide `aria-label` on interactive buttons (e.g., the erase button in `d3-neural.html`).
|
| 130 |
+
- Ensure focus-visible styles for interactive controls; avoid relying on color alone to encode meaning.
|
| 131 |
+
|
| 132 |
+
### 10) Performance and updates
|
| 133 |
+
- Use D3 data joins (`.data().join()` or explicit enter/merge/exit) and keep transitions short (≤200ms).
|
| 134 |
+
- Recompute only what is necessary on each render; avoid repeated DOM clears if not needed.
|
| 135 |
+
- Debounce or gate expensive computations, especially on `mousemove`.
|
| 136 |
+
|
| 137 |
+
### 11) External dependencies
|
| 138 |
+
- Load D3 (and optional TFJS) via CDN only once using an element id (e.g., `d3-cdn-script`, `tfjs-cdn-script`).
|
| 139 |
+
- After `.load`, verify the expected API (e.g., `window.d3.select`).
|
| 140 |
+
- Prefer pure D3 and built-ins; do not introduce new runtime dependencies unless necessary.
|
| 141 |
+
|
| 142 |
+
### 12) Error handling and fallbacks
|
| 143 |
+
- Fail gracefully: append a small `<pre>` with a readable message inside the container.
|
| 144 |
+
- For optional models (e.g., TFJS), attempt multiple URLs and fall back to a heuristic if load fails.
|
| 145 |
+
|
| 146 |
+
### 13) Printing
|
| 147 |
+
- Favor vector (`svg`) or simple shapes; avoid large bitmap backgrounds.
|
| 148 |
+
- Let `HtmlEmbed.astro` handle most print constraints; ensure the chart scales with width 100% and auto height.
|
| 149 |
+
|
| 150 |
+
### 14) Conventions checklist (before committing)
|
| 151 |
+
- Root class is unique and matches file name (`d3-<type>`).
|
| 152 |
+
- No globals added; script wrapped in an IIFE.
|
| 153 |
+
- `data-mounded` guard is present to avoid double-mount.
|
| 154 |
+
- Uses CSS variables for colors; dark-mode friendly.
|
| 155 |
+
- Responsive: recomputes layout on resize; uses `ResizeObserver`.
|
| 156 |
+
- Controls are accessible and consistently styled.
|
| 157 |
+
- Tooltip is present (if hover/inspect is required).
|
| 158 |
+
- Data loading includes public-path-first strategy and graceful error.
|
| 159 |
+
- Axes/labels/legends are legible at small widths.
|
| 160 |
+
- Code is easy to skim: clear naming, early returns, short functions.
|
| 161 |
+
|
| 162 |
+
### 15) Example: small bar chart (structure only)
|
| 163 |
+
```html
|
| 164 |
+
<div class="d3-mini-bar" style="width:100%;margin:10px 0;"></div>
|
| 165 |
+
<style>
|
| 166 |
+
.d3-mini-bar .bar { stroke: none; }
|
| 167 |
+
</style>
|
| 168 |
+
<script>
|
| 169 |
+
(() => {
|
| 170 |
+
const ensureD3 = (cb) => {
|
| 171 |
+
if (window.d3 && d3.select) return cb();
|
| 172 |
+
let s = document.getElementById('d3-cdn-script');
|
| 173 |
+
if (!s) { s = document.createElement('script'); s.id='d3-cdn-script'; s.src='https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
|
| 174 |
+
const onReady = () => { if (window.d3 && d3.select) cb(); };
|
| 175 |
+
s.addEventListener('load', onReady, { once:true }); if (window.d3) onReady();
|
| 176 |
+
};
|
| 177 |
+
const bootstrap = () => {
|
| 178 |
+
const scriptEl = document.currentScript;
|
| 179 |
+
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 180 |
+
if (!(container && container.classList && container.classList.contains('d3-mini-bar'))){
|
| 181 |
+
const cs = Array.from(document.querySelectorAll('.d3-mini-bar')).filter(el => !(el.dataset && el.dataset.mounted==='true'));
|
| 182 |
+
container = cs[cs.length-1] || null;
|
| 183 |
+
}
|
| 184 |
+
if (!container) return;
|
| 185 |
+
if (container.dataset){ if (container.dataset.mounted==='true') return; container.dataset.mounted='true'; }
|
| 186 |
+
|
| 187 |
+
const svg = d3.select(container).append('svg').attr('width','100%').style('display','block');
|
| 188 |
+
const g = svg.append('g');
|
| 189 |
+
let width=800,height=280; const margin={top:16,right:16,bottom:40,left:40};
|
| 190 |
+
const x=d3.scaleBand().padding(0.2), y=d3.scaleLinear();
|
| 191 |
+
const data=[{k:'A',v:3},{k:'B',v:7},{k:'C',v:5}];
|
| 192 |
+
|
| 193 |
+
function render(){
|
| 194 |
+
width = container.clientWidth || 800; height = Math.max(220, Math.round(width/3.2));
|
| 195 |
+
svg.attr('width', width).attr('height', height);
|
| 196 |
+
g.attr('transform',`translate(${margin.left},${margin.top})`);
|
| 197 |
+
const iw=width-margin.left-margin.right, ih=height-margin.top-margin.bottom;
|
| 198 |
+
x.domain(data.map(d=>d.k)).range([0,iw]); y.domain([0, d3.max(data,d=>d.v)||1]).range([ih,0]).nice();
|
| 199 |
+
const bars=g.selectAll('rect.bar').data(data);
|
| 200 |
+
bars.join('rect').attr('class','bar').attr('x',d=>x(d.k)).attr('y',d=>y(d.v)).attr('width',x.bandwidth()).attr('height',d=>Math.max(0.5, ih - y(d.v))).attr('fill','var(--primary-color)');
|
| 201 |
+
g.selectAll('.x').data([0]).join('g').attr('class','x').attr('transform',`translate(0,${ih})`).call(d3.axisBottom(x));
|
| 202 |
+
g.selectAll('.y').data([0]).join('g').attr('class','y').call(d3.axisLeft(y).ticks(5));
|
| 203 |
+
}
|
| 204 |
+
render();
|
| 205 |
+
const ro = window.ResizeObserver ? new ResizeObserver(() => render()) : null; if (ro) ro.observe(container); else window.addEventListener('resize', render);
|
| 206 |
+
};
|
| 207 |
+
if (document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once:true }); } else { ensureD3(bootstrap); }
|
| 208 |
+
})();
|
| 209 |
+
</script>
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
|
app/src/env.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
| 1 |
/// <reference path="../.astro/types.d.ts" />
|
| 2 |
/// <reference types="astro/client" />
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
|
|
|
| 1 |
/// <reference path="../.astro/types.d.ts" />
|
| 2 |
/// <reference types="astro/client" />
|
| 3 |
+
/// <reference types="vite/client" />
|
| 4 |
|
| 5 |
+
interface ImportMetaEnv {
|
| 6 |
+
readonly PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE?: string | boolean;
|
| 7 |
+
// Back-compat
|
| 8 |
+
readonly PUBLIC_TOC_AUTO_COLLAPSE?: string | boolean;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface ImportMeta {
|
| 12 |
+
readonly env: ImportMetaEnv;
|
| 13 |
}
|
app/src/pages/index.astro
CHANGED
|
@@ -4,6 +4,7 @@ import Hero from '../components/Hero.astro';
|
|
| 4 |
import Footer from '../components/Footer.astro';
|
| 5 |
import ThemeToggle from '../components/ThemeToggle.astro';
|
| 6 |
import Seo from '../components/Seo.astro';
|
|
|
|
| 7 |
// Default OG image served from public/
|
| 8 |
const ogDefaultUrl = '/thumb.jpg';
|
| 9 |
import 'katex/dist/katex.min.css';
|
|
@@ -50,8 +51,21 @@ const keyAuthor = (authors[0] || 'article').split(/\s+/).slice(-1)[0].toLowerCas
|
|
| 50 |
const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
|
| 51 |
const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
|
| 52 |
const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}}` : ''}\n}`;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
---
|
| 54 |
-
<html lang="en" data-theme="light">
|
| 55 |
<head>
|
| 56 |
<meta charset="utf-8" />
|
| 57 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -66,25 +80,20 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
|
|
| 66 |
} catch {}
|
| 67 |
})();
|
| 68 |
</script>
|
|
|
|
| 69 |
|
| 70 |
<!-- TO MANAGE PROPERLY -->
|
| 71 |
<script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
|
| 72 |
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
| 73 |
-
<script
|
|
|
|
| 74 |
</head>
|
| 75 |
<body>
|
| 76 |
<ThemeToggle />
|
| 77 |
<Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={articleFM?.authors} affiliation={articleFM?.affiliation} published={articleFM?.published} />
|
| 78 |
|
| 79 |
<section class="content-grid">
|
| 80 |
-
<
|
| 81 |
-
<div class="title">Table of Contents</div>
|
| 82 |
-
<div id="article-toc-placeholder"></div>
|
| 83 |
-
</aside>
|
| 84 |
-
<details class="toc-mobile">
|
| 85 |
-
<summary>Table of Contents</summary>
|
| 86 |
-
<div id="article-toc-mobile-placeholder"></div>
|
| 87 |
-
</details>
|
| 88 |
<main>
|
| 89 |
<Article />
|
| 90 |
<style is:inline>
|
|
@@ -96,185 +105,6 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
|
|
| 96 |
|
| 97 |
<Footer citationText={citationText} bibtex={bibtex} />
|
| 98 |
|
| 99 |
-
<!-- Medium-like image zoom (lightbox) -->
|
| 100 |
-
<script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script>
|
| 101 |
-
<script>
|
| 102 |
-
// Initialize zoom on img[data-zoomable]; wait for script & content; close on scroll like Medium
|
| 103 |
-
(() => {
|
| 104 |
-
/** @type {any} */
|
| 105 |
-
let zoomInstance = null;
|
| 106 |
-
|
| 107 |
-
/** @param {() => void} cb */
|
| 108 |
-
const ensureMediumZoomReady = (cb) => {
|
| 109 |
-
// @ts-ignore mediumZoom injected globally by external script
|
| 110 |
-
if (window.mediumZoom) return cb();
|
| 111 |
-
// @ts-ignore mediumZoom injected globally by external script
|
| 112 |
-
const retry = () => (window.mediumZoom ? cb() : setTimeout(retry, 30));
|
| 113 |
-
retry();
|
| 114 |
-
};
|
| 115 |
-
|
| 116 |
-
/** @returns {HTMLElement[]} */
|
| 117 |
-
const collectTargets = () => Array.from(document.querySelectorAll('section.content-grid main img[data-zoomable]'));
|
| 118 |
-
|
| 119 |
-
const initOrUpdateZoom = () => {
|
| 120 |
-
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 121 |
-
const background = isDark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)';
|
| 122 |
-
const targets = collectTargets();
|
| 123 |
-
if (!targets.length) return;
|
| 124 |
-
|
| 125 |
-
if (!zoomInstance) {
|
| 126 |
-
// @ts-ignore medium-zoom injected globally by external script
|
| 127 |
-
zoomInstance = window.mediumZoom(targets, { background, margin: 24, scrollOffset: 0 });
|
| 128 |
-
|
| 129 |
-
let onScrollLike;
|
| 130 |
-
const attachCloseOnScroll = () => {
|
| 131 |
-
if (onScrollLike) return;
|
| 132 |
-
// @ts-ignore medium-zoom instance has close()
|
| 133 |
-
onScrollLike = () => { zoomInstance && zoomInstance.close(); };
|
| 134 |
-
window.addEventListener('wheel', onScrollLike, { passive: true });
|
| 135 |
-
window.addEventListener('touchmove', onScrollLike, { passive: true });
|
| 136 |
-
window.addEventListener('scroll', onScrollLike, { passive: true });
|
| 137 |
-
};
|
| 138 |
-
const detachCloseOnScroll = () => {
|
| 139 |
-
if (!onScrollLike) return;
|
| 140 |
-
window.removeEventListener('wheel', onScrollLike);
|
| 141 |
-
window.removeEventListener('touchmove', onScrollLike);
|
| 142 |
-
window.removeEventListener('scroll', onScrollLike);
|
| 143 |
-
onScrollLike = null;
|
| 144 |
-
};
|
| 145 |
-
// @ts-ignore medium-zoom instance has on()
|
| 146 |
-
zoomInstance.on('open', attachCloseOnScroll);
|
| 147 |
-
// @ts-ignore medium-zoom instance has on()
|
| 148 |
-
zoomInstance.on('close', detachCloseOnScroll);
|
| 149 |
-
|
| 150 |
-
const themeObserver = new MutationObserver(() => {
|
| 151 |
-
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 152 |
-
// @ts-ignore medium-zoom instance has update()
|
| 153 |
-
zoomInstance && zoomInstance.update({ background: dark ? 'rgba(0,0,0,.9)' : 'rgba(0,0,0,.85)' });
|
| 154 |
-
});
|
| 155 |
-
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
| 156 |
-
} else {
|
| 157 |
-
// @ts-ignore medium-zoom instance has attach()/update()
|
| 158 |
-
zoomInstance.attach(targets);
|
| 159 |
-
// @ts-ignore medium-zoom instance has update()
|
| 160 |
-
zoomInstance.update({ background });
|
| 161 |
-
}
|
| 162 |
-
};
|
| 163 |
-
|
| 164 |
-
const bootstrap = () => ensureMediumZoomReady(() => {
|
| 165 |
-
initOrUpdateZoom();
|
| 166 |
-
setTimeout(initOrUpdateZoom, 0);
|
| 167 |
-
const main = document.querySelector('section.content-grid main');
|
| 168 |
-
if (main) {
|
| 169 |
-
const mo = new MutationObserver(() => initOrUpdateZoom());
|
| 170 |
-
mo.observe(main, { childList: true, subtree: true });
|
| 171 |
-
}
|
| 172 |
-
});
|
| 173 |
-
|
| 174 |
-
if (document.readyState === 'complete') bootstrap();
|
| 175 |
-
else window.addEventListener('load', bootstrap, { once: true });
|
| 176 |
-
})();
|
| 177 |
-
</script>
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
<script>
|
| 181 |
-
// Add a small download button to img[data-downloadable]
|
| 182 |
-
(() => {
|
| 183 |
-
const SELECTOR = 'section.content-grid main img[data-downloadable]';
|
| 184 |
-
|
| 185 |
-
/**
|
| 186 |
-
* @param {HTMLImageElement} img
|
| 187 |
-
*/
|
| 188 |
-
const injectDownloadButton = (img) => {
|
| 189 |
-
if (!img || img.dataset.__dlInjected) return;
|
| 190 |
-
const parentFigure = img.closest('figure');
|
| 191 |
-
const parent = img.parentElement;
|
| 192 |
-
if (!parent) return;
|
| 193 |
-
img.dataset.__dlInjected = '1';
|
| 194 |
-
|
| 195 |
-
// Wrap the image in a positioned inline-block so the button is on the image
|
| 196 |
-
const wrapper = document.createElement('span');
|
| 197 |
-
wrapper.className = 'img-dl-wrap';
|
| 198 |
-
parent.insertBefore(wrapper, img);
|
| 199 |
-
wrapper.appendChild(img);
|
| 200 |
-
if (parentFigure && !parentFigure.classList.contains('has-dl-btn')) {
|
| 201 |
-
parentFigure.classList.add('has-dl-btn');
|
| 202 |
-
}
|
| 203 |
-
|
| 204 |
-
// Determine download href and filename
|
| 205 |
-
const pickHrefAndName = () => {
|
| 206 |
-
const current = img.currentSrc || img.src || '';
|
| 207 |
-
let href = img.getAttribute('data-download-src') || current;
|
| 208 |
-
// Derive filename from the original source when possible
|
| 209 |
-
const deriveName = () => {
|
| 210 |
-
try {
|
| 211 |
-
const u = new URL(current, location.href);
|
| 212 |
-
// Prefer original href param if provided by Astro image service
|
| 213 |
-
const rawHref = u.searchParams.get('href');
|
| 214 |
-
const candidate = rawHref ? decodeURIComponent(rawHref) : u.pathname;
|
| 215 |
-
const last = String(candidate).split('/').pop() || '';
|
| 216 |
-
// Strip query/hash and any appended transform suffixes after extension
|
| 217 |
-
const base = last.split('?')[0].split('#')[0];
|
| 218 |
-
const m = base.match(/^(.+?\.(?:png|jpe?g|webp|avif|gif|svg))(?:[._-].*)?$/i);
|
| 219 |
-
if (m && m[1]) return m[1];
|
| 220 |
-
// If extension missing, fallback to base as-is
|
| 221 |
-
return base || 'image';
|
| 222 |
-
} catch {
|
| 223 |
-
return 'image';
|
| 224 |
-
}
|
| 225 |
-
};
|
| 226 |
-
const name = img.getAttribute('data-download-name') || deriveName();
|
| 227 |
-
return { href, name };
|
| 228 |
-
};
|
| 229 |
-
|
| 230 |
-
const { href, name } = pickHrefAndName();
|
| 231 |
-
const a = document.createElement('a');
|
| 232 |
-
a.className = 'button button--ghost img-dl-btn';
|
| 233 |
-
a.href = href;
|
| 234 |
-
if (name) a.download = name;
|
| 235 |
-
a.setAttribute('aria-label', 'Download image');
|
| 236 |
-
a.setAttribute('title', name ? `Download ${name}` : 'Download image');
|
| 237 |
-
a.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 16c-.26 0-.52-.11-.71-.29l-5-5a1 1 0 0 1 1.42-1.42L11 12.59V4a1 1 0 1 1 2 0v8.59l3.29-3.3a1 1 0 1 1 1.42 1.42l-5 5c-.19.18-.45.29-.71.29zM5 20a1 1 0 1 1 0-2h14a1 1 0 1 1 0 2H5z"/></svg>';
|
| 238 |
-
|
| 239 |
-
// Ensure href/name match currentSrc right before navigation
|
| 240 |
-
a.addEventListener('click', async (ev) => {
|
| 241 |
-
try {
|
| 242 |
-
ev.preventDefault();
|
| 243 |
-
const picked = pickHrefAndName();
|
| 244 |
-
const res = await fetch(picked.href, { credentials: 'same-origin' });
|
| 245 |
-
const blob = await res.blob();
|
| 246 |
-
const objectUrl = URL.createObjectURL(blob);
|
| 247 |
-
const tmp = document.createElement('a');
|
| 248 |
-
tmp.href = objectUrl;
|
| 249 |
-
tmp.download = picked.name || 'image';
|
| 250 |
-
document.body.appendChild(tmp);
|
| 251 |
-
tmp.click();
|
| 252 |
-
setTimeout(() => { URL.revokeObjectURL(objectUrl); tmp.remove(); }, 1000);
|
| 253 |
-
} catch {
|
| 254 |
-
// Fallback to native behavior if fetch fails
|
| 255 |
-
}
|
| 256 |
-
});
|
| 257 |
-
|
| 258 |
-
// Append inside wrapper so positioning is relative to the image box
|
| 259 |
-
wrapper.appendChild(a);
|
| 260 |
-
};
|
| 261 |
-
|
| 262 |
-
const scan = () => {
|
| 263 |
-
document.querySelectorAll(SELECTOR).forEach((el) => injectDownloadButton(el));
|
| 264 |
-
};
|
| 265 |
-
|
| 266 |
-
const bootstrap = () => {
|
| 267 |
-
scan();
|
| 268 |
-
const main = document.querySelector('section.content-grid main');
|
| 269 |
-
if (!main) return;
|
| 270 |
-
const mo = new MutationObserver(() => scan());
|
| 271 |
-
mo.observe(main, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
|
| 272 |
-
};
|
| 273 |
-
|
| 274 |
-
if (document.readyState === 'complete') bootstrap();
|
| 275 |
-
else window.addEventListener('load', bootstrap, { once: true });
|
| 276 |
-
})();
|
| 277 |
-
</script>
|
| 278 |
|
| 279 |
<script>
|
| 280 |
// Open external links in a new tab; keep internal anchors in-page
|
|
@@ -320,119 +150,7 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
|
|
| 320 |
});
|
| 321 |
</script>
|
| 322 |
|
| 323 |
-
|
| 324 |
-
// Build TOC from article headings (h2/h3/h4) and render into the sticky aside
|
| 325 |
-
const buildTOC = () => {
|
| 326 |
-
const holder = document.getElementById('article-toc-placeholder');
|
| 327 |
-
const holderMobile = document.getElementById('article-toc-mobile-placeholder');
|
| 328 |
-
// Always rebuild TOC to avoid stale entries
|
| 329 |
-
if (holder) holder.innerHTML = '';
|
| 330 |
-
if (holderMobile) holderMobile.innerHTML = '';
|
| 331 |
-
const articleRoot = document.querySelector('section.content-grid main');
|
| 332 |
-
if (!articleRoot) return;
|
| 333 |
-
const headings = articleRoot.querySelectorAll('h2, h3, h4');
|
| 334 |
-
if (!headings.length) return;
|
| 335 |
-
|
| 336 |
-
// Filter out headings that should not appear in TOC
|
| 337 |
-
const normalize = (s) => String(s || '')
|
| 338 |
-
.toLowerCase()
|
| 339 |
-
.replace(/[^a-z0-9]+/g, ' ')
|
| 340 |
-
.trim();
|
| 341 |
-
const isTocLabel = (s) => /^(table\s+of\s+contents?)$|^toc$/i.test(String(s || '').replace(/[^a-zA-Z0-9]+/g, ' ').trim());
|
| 342 |
-
const shouldSkip = (h) => {
|
| 343 |
-
const t = h.textContent || '';
|
| 344 |
-
const id = String(h.id || '');
|
| 345 |
-
const slug = normalize(t).replace(/\s+/g, '_');
|
| 346 |
-
if (isTocLabel(t)) return true;
|
| 347 |
-
if (isTocLabel(id.replace(/[_-]+/g, ' '))) return true;
|
| 348 |
-
if (isTocLabel(slug.replace(/[_-]+/g, ' '))) return true;
|
| 349 |
-
return false;
|
| 350 |
-
};
|
| 351 |
-
const headingsArr = Array.from(headings).filter(h => !shouldSkip(h));
|
| 352 |
-
if (!headingsArr.length) return;
|
| 353 |
-
|
| 354 |
-
// Ensure unique ids for headings (deduplicate duplicates)
|
| 355 |
-
const usedIds = new Set<string>();
|
| 356 |
-
const slugify = (s: string) => String(s || '')
|
| 357 |
-
.toLowerCase()
|
| 358 |
-
.trim()
|
| 359 |
-
.replace(/\s+/g, '_')
|
| 360 |
-
.replace(/[^a-z0-9_\-]/g, '');
|
| 361 |
-
headingsArr.forEach((h) => {
|
| 362 |
-
let id = (h.id || '').trim();
|
| 363 |
-
if (!id) {
|
| 364 |
-
const base = slugify(h.textContent || '');
|
| 365 |
-
id = base || 'section';
|
| 366 |
-
}
|
| 367 |
-
let candidate = id;
|
| 368 |
-
let n = 2;
|
| 369 |
-
while (usedIds.has(candidate)) {
|
| 370 |
-
candidate = `${id}-${n++}`;
|
| 371 |
-
}
|
| 372 |
-
if (h.id !== candidate) h.id = candidate;
|
| 373 |
-
usedIds.add(candidate);
|
| 374 |
-
});
|
| 375 |
-
|
| 376 |
-
const nav = document.createElement('nav');
|
| 377 |
-
let ulStack = [document.createElement('ul')];
|
| 378 |
-
nav.appendChild(ulStack[0]);
|
| 379 |
-
|
| 380 |
-
const levelOf = (tag) => tag === 'H2' ? 2 : tag === 'H3' ? 3 : 4;
|
| 381 |
-
let prev = 2;
|
| 382 |
-
headingsArr.forEach((h) => {
|
| 383 |
-
const lvl = levelOf(h.tagName);
|
| 384 |
-
// adjust depth
|
| 385 |
-
while (lvl > prev) { const ul = document.createElement('ul'); ulStack[ulStack.length-1].lastElementChild?.appendChild(ul); ulStack.push(ul); prev++; }
|
| 386 |
-
while (lvl < prev) { ulStack.pop(); prev--; }
|
| 387 |
-
const li = document.createElement('li');
|
| 388 |
-
const a = document.createElement('a');
|
| 389 |
-
a.href = '#' + h.id; a.textContent = h.textContent; a.target = '_self';
|
| 390 |
-
li.appendChild(a);
|
| 391 |
-
ulStack[ulStack.length-1].appendChild(li);
|
| 392 |
-
});
|
| 393 |
-
|
| 394 |
-
if (holder) holder.appendChild(nav);
|
| 395 |
-
if (holderMobile) holderMobile.appendChild(nav.cloneNode(true));
|
| 396 |
-
|
| 397 |
-
// active link on scroll
|
| 398 |
-
const links = [
|
| 399 |
-
...(holder ? holder.querySelectorAll('a') : []),
|
| 400 |
-
...(holderMobile ? holderMobile.querySelectorAll('a') : [])
|
| 401 |
-
];
|
| 402 |
-
const onScroll = () => {
|
| 403 |
-
for (let i = headingsArr.length - 1; i >= 0; i--) {
|
| 404 |
-
const top = headingsArr[i].getBoundingClientRect().top;
|
| 405 |
-
if (top - 60 <= 0) {
|
| 406 |
-
links.forEach(l => l.classList.remove('active'));
|
| 407 |
-
const id = '#' + headingsArr[i].id;
|
| 408 |
-
const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
|
| 409 |
-
actives.forEach(a => a.classList.add('active'));
|
| 410 |
-
break;
|
| 411 |
-
}
|
| 412 |
-
}
|
| 413 |
-
};
|
| 414 |
-
window.addEventListener('scroll', onScroll);
|
| 415 |
-
onScroll();
|
| 416 |
-
|
| 417 |
-
// Close mobile accordion when a link inside it is clicked
|
| 418 |
-
if (holderMobile) {
|
| 419 |
-
const details = holderMobile.closest('details');
|
| 420 |
-
holderMobile.addEventListener('click', (ev) => {
|
| 421 |
-
const target = ev.target as Element | null;
|
| 422 |
-
const anchor = target && 'closest' in target ? (target as Element).closest('a') : null;
|
| 423 |
-
if (anchor instanceof HTMLAnchorElement && details && (details as HTMLDetailsElement).open) {
|
| 424 |
-
(details as HTMLDetailsElement).open = false;
|
| 425 |
-
}
|
| 426 |
-
});
|
| 427 |
-
}
|
| 428 |
-
};
|
| 429 |
-
|
| 430 |
-
if (document.readyState === 'loading') {
|
| 431 |
-
document.addEventListener('DOMContentLoaded', buildTOC, { once: true });
|
| 432 |
-
} else { buildTOC(); }
|
| 433 |
-
</script>
|
| 434 |
-
|
| 435 |
-
<!-- Removed JS fallback for language chips; labels handled by CSS/Shiki -->
|
| 436 |
</body>
|
| 437 |
</html>
|
| 438 |
|
|
|
|
| 4 |
import Footer from '../components/Footer.astro';
|
| 5 |
import ThemeToggle from '../components/ThemeToggle.astro';
|
| 6 |
import Seo from '../components/Seo.astro';
|
| 7 |
+
import TableOfContent from '../components/TableOfContent.astro';
|
| 8 |
// Default OG image served from public/
|
| 9 |
const ogDefaultUrl = '/thumb.jpg';
|
| 10 |
import 'katex/dist/katex.min.css';
|
|
|
|
| 51 |
const keyTitle = titleFlat.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 24);
|
| 52 |
const bibKey = `${keyAuthor}${year ?? ''}_${keyTitle}`;
|
| 53 |
const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBib}},\n ${year ? `year={${year}}` : ''}\n}`;
|
| 54 |
+
// Determine if TOC auto-collapse is enabled via (priority):
|
| 55 |
+
// 1) article frontmatter: tableOfContentAutoCollapse (fallback: tocAutoCollapse)
|
| 56 |
+
// 2) public env flag: PUBLIC_TOC_AUTO_COLLAPSE
|
| 57 |
+
// Falls back to 'false' when not defined (string or boolean)
|
| 58 |
+
const __env = (import.meta as any)?.env || {};
|
| 59 |
+
const envCollapse = (
|
| 60 |
+
String((__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE ?? __env.PUBLIC_TOC_AUTO_COLLAPSE ?? 'false')).toLowerCase() === 'true'
|
| 61 |
+
|| (__env.PUBLIC_TABLE_OF_CONTENT_AUTO_COLLAPSE === true)
|
| 62 |
+
|| (__env.PUBLIC_TOC_AUTO_COLLAPSE === true)
|
| 63 |
+
);
|
| 64 |
+
const tableOfContentAutoCollapse = Boolean(
|
| 65 |
+
(articleFM as any)?.tableOfContentAutoCollapse ?? (articleFM as any)?.tocAutoCollapse ?? envCollapse
|
| 66 |
+
);
|
| 67 |
---
|
| 68 |
+
<html lang="en" data-theme="light" data-toc-auto-collapse={tableOfContentAutoCollapse ? '1' : '0'}>
|
| 69 |
<head>
|
| 70 |
<meta charset="utf-8" />
|
| 71 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
|
|
| 80 |
} catch {}
|
| 81 |
})();
|
| 82 |
</script>
|
| 83 |
+
<script is:inline src="/scripts/color-palettes.js"></script>
|
| 84 |
|
| 85 |
<!-- TO MANAGE PROPERLY -->
|
| 86 |
<script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
|
| 87 |
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
| 88 |
+
<script src="https://cdn.jsdelivr.net/npm/medium-zoom@1.1.0/dist/medium-zoom.min.js"></script>
|
| 89 |
+
|
| 90 |
</head>
|
| 91 |
<body>
|
| 92 |
<ThemeToggle />
|
| 93 |
<Hero title={docTitleHtml} titleRaw={docTitle} description={subtitle} authors={articleFM?.authors} affiliation={articleFM?.affiliation} published={articleFM?.published} />
|
| 94 |
|
| 95 |
<section class="content-grid">
|
| 96 |
+
<TableOfContent tableOfContentAutoCollapse={tableOfContentAutoCollapse} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
<main>
|
| 98 |
<Article />
|
| 99 |
<style is:inline>
|
|
|
|
| 105 |
|
| 106 |
<Footer citationText={citationText} bibtex={bibtex} />
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
<script>
|
| 110 |
// Open external links in a new tab; keep internal anchors in-page
|
|
|
|
| 150 |
});
|
| 151 |
</script>
|
| 152 |
|
| 153 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
</body>
|
| 155 |
</html>
|
| 156 |
|
app/src/styles/_variables.css
CHANGED
|
@@ -34,6 +34,9 @@
|
|
| 34 |
--spacing-9: 64px;
|
| 35 |
--spacing-10: 72px;
|
| 36 |
|
|
|
|
|
|
|
|
|
|
| 37 |
/* Z-index scale */
|
| 38 |
--z-base: 0; /* background/base */
|
| 39 |
--z-content: 1; /* regular content */
|
|
|
|
| 34 |
--spacing-9: 64px;
|
| 35 |
--spacing-10: 72px;
|
| 36 |
|
| 37 |
+
/* Palettes config */
|
| 38 |
+
--palette-count: 6; /* default number of colors per palette */
|
| 39 |
+
|
| 40 |
/* Z-index scale */
|
| 41 |
--z-base: 0; /* background/base */
|
| 42 |
--z-content: 1; /* regular content */
|