thibaud frere commited on
Commit
e0ad823
Β·
1 Parent(s): fef84f3

test playwright

Browse files
.gitignore CHANGED
@@ -19,3 +19,5 @@ node_modules/
19
  *.env
20
  *.cache
21
 
 
 
 
19
  *.env
20
  *.cache
21
 
22
+ # PDF export
23
+ app/public/*.pdf
Dockerfile CHANGED
@@ -1,5 +1,6 @@
1
  # Use an official Node runtime as the base image for building the application
2
- FROM node:20 AS build
 
3
 
4
  # Set the working directory in the container
5
  WORKDIR /app
@@ -16,6 +17,9 @@ COPY app/ .
16
  # Build the application
17
  RUN npm run build
18
 
 
 
 
19
  # Use an official Nginx runtime as the base image for serving the application
20
  FROM nginx:alpine
21
 
 
1
  # Use an official Node runtime as the base image for building the application
2
+ # Build avec Playwright (navigateurs et deps prΓͺts)
3
+ FROM mcr.microsoft.com/playwright:v1.55.0-jammy AS build
4
 
5
  # Set the working directory in the container
6
  WORKDIR /app
 
17
  # Build the application
18
  RUN npm run build
19
 
20
+ # Génère le PDF (thème light, attente complète)
21
+ RUN npm run export:pdf -- --theme=light --wait=full
22
+
23
  # Use an official Nginx runtime as the base image for serving the application
24
  FROM nginx:alpine
25
 
app/.astro/astro/content.d.ts CHANGED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
155
+ };
156
+
157
+ type DataEntryMap = {
158
+ "fragments": Record<string, {
159
+ id: string;
160
+ collection: "fragments";
161
+ data: any;
162
+ }>;
163
+
164
+ };
165
+
166
+ type AnyEntryMap = ContentEntryMap & DataEntryMap;
167
+
168
+ export type ContentConfig = never;
169
+ }
app/astro.config.mjs CHANGED
@@ -20,7 +20,10 @@ export default defineConfig({
20
  dark: 'github-dark'
21
  },
22
  defaultColor: false,
23
- wrap: true
 
 
 
24
  },
25
  remarkPlugins: [
26
  [remarkToc, { heading: 'Table of Contents', maxDepth: 3 }],
 
20
  dark: 'github-dark'
21
  },
22
  defaultColor: false,
23
+ wrap: true,
24
+ langAlias: {
25
+ mdx: 'js'
26
+ }
27
  },
28
  remarkPlugins: [
29
  [remarkToc, { heading: 'Table of Contents', maxDepth: 3 }],
app/package-lock.json CHANGED
Binary files a/app/package-lock.json and b/app/package-lock.json differ
 
app/package.json CHANGED
Binary files a/app/package.json and b/app/package.json differ
 
app/scripts/export-pdf.mjs ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { setTimeout as delay } from 'node:timers/promises';
4
+ import { chromium } from 'playwright';
5
+ import { resolve, basename } from 'node:path';
6
+ import { promises as fs } from 'node:fs';
7
+ import process from 'node:process';
8
+
9
+ async function run(command, args = [], options = {}) {
10
+ return new Promise((resolvePromise, reject) => {
11
+ const child = spawn(command, args, { stdio: 'inherit', shell: false, ...options });
12
+ child.on('error', reject);
13
+ child.on('exit', (code) => {
14
+ if (code === 0) resolvePromise(undefined);
15
+ else reject(new Error(`${command} ${args.join(' ')} exited with code ${code}`));
16
+ });
17
+ });
18
+ }
19
+
20
+ async function waitForServer(url, timeoutMs = 60000) {
21
+ const start = Date.now();
22
+ while (Date.now() - start < timeoutMs) {
23
+ try {
24
+ const res = await fetch(url);
25
+ if (res.ok) return;
26
+ } catch {}
27
+ await delay(500);
28
+ }
29
+ throw new Error(`Le serveur n'a pas dΓ©marrΓ© Γ  temps: ${url}`);
30
+ }
31
+
32
+ function parseArgs(argv) {
33
+ const out = {};
34
+ for (const arg of argv.slice(2)) {
35
+ if (!arg.startsWith('--')) continue;
36
+ const [k, v] = arg.replace(/^--/, '').split('=');
37
+ out[k] = v === undefined ? true : v;
38
+ }
39
+ return out;
40
+ }
41
+
42
+ function slugify(text) {
43
+ return String(text || '')
44
+ .normalize('NFKD')
45
+ .replace(/\p{Diacritic}+/gu, '')
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, '-')
48
+ .replace(/^-+|-+$/g, '')
49
+ .slice(0, 120) || 'article';
50
+ }
51
+
52
+ function parseMargin(margin) {
53
+ if (!margin) return { top: '12mm', right: '12mm', bottom: '16mm', left: '12mm' };
54
+ const parts = String(margin).split(',').map(s => s.trim()).filter(Boolean);
55
+ if (parts.length === 1) {
56
+ return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
57
+ }
58
+ if (parts.length === 2) {
59
+ return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
60
+ }
61
+ if (parts.length === 3) {
62
+ return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
63
+ }
64
+ return { top: parts[0] || '12mm', right: parts[1] || '12mm', bottom: parts[2] || '16mm', left: parts[3] || '12mm' };
65
+ }
66
+
67
+ async function waitForImages(page, timeoutMs = 15000) {
68
+ await page.evaluate(async (timeout) => {
69
+ const deadline = Date.now() + timeout;
70
+ const imgs = Array.from(document.images || []);
71
+ const unloaded = imgs.filter(img => !img.complete || (img.naturalWidth === 0));
72
+ await Promise.race([
73
+ Promise.all(unloaded.map(img => new Promise(res => {
74
+ if (img.complete && img.naturalWidth !== 0) return res(undefined);
75
+ img.addEventListener('load', () => res(undefined), { once: true });
76
+ img.addEventListener('error', () => res(undefined), { once: true });
77
+ }))),
78
+ new Promise(res => setTimeout(res, Math.max(0, deadline - Date.now())))
79
+ ]);
80
+ }, timeoutMs);
81
+ }
82
+
83
+ async function waitForPlotly(page, timeoutMs = 20000) {
84
+ await page.evaluate(async (timeout) => {
85
+ const start = Date.now();
86
+ const hasPlots = () => Array.from(document.querySelectorAll('.js-plotly-plot')).length > 0;
87
+ // Wait until plots exist or timeout
88
+ while (!hasPlots() && (Date.now() - start) < timeout) {
89
+ await new Promise(r => setTimeout(r, 200));
90
+ }
91
+ const deadline = start + timeout;
92
+ // Then wait until each plot contains the main svg
93
+ const allReady = () => Array.from(document.querySelectorAll('.js-plotly-plot')).every(el => el.querySelector('svg.main-svg'));
94
+ while (!allReady() && Date.now() < deadline) {
95
+ await new Promise(r => setTimeout(r, 200));
96
+ }
97
+ }, timeoutMs);
98
+ }
99
+
100
+ async function waitForStableLayout(page, timeoutMs = 5000) {
101
+ const start = Date.now();
102
+ let last = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight);
103
+ let stableCount = 0;
104
+ while ((Date.now() - start) < timeoutMs && stableCount < 3) {
105
+ await page.waitForTimeout(250);
106
+ const now = await page.evaluate(() => document.scrollingElement ? document.scrollingElement.scrollHeight : document.body.scrollHeight);
107
+ if (now === last) stableCount += 1; else { stableCount = 0; last = now; }
108
+ }
109
+ }
110
+
111
+ async function main() {
112
+ const cwd = process.cwd();
113
+ const port = Number(process.env.PREVIEW_PORT || 8080);
114
+ const baseUrl = `http://127.0.0.1:${port}/`;
115
+ const args = parseArgs(process.argv);
116
+ // Par dΓ©faut: light (n'emploie pas de variable d'environnement implicite)
117
+ const theme = (args.theme === 'dark' || args.theme === 'light') ? args.theme : 'light';
118
+ const format = args.format || 'A4';
119
+ const margin = parseMargin(args.margin);
120
+ const wait = (args.wait || 'full'); // 'networkidle' | 'images' | 'plotly' | 'full'
121
+
122
+ // filename can be provided, else computed from page title later
123
+ let outFileBase = (args.filename && String(args.filename).replace(/\.pdf$/i, '')) || 'article';
124
+
125
+ console.log('> Build du site Astro…');
126
+ await run('npm', ['run', 'build']);
127
+
128
+ console.log('> DΓ©marrage du preview AstroοΏ½οΏ½οΏ½');
129
+ const preview = spawn('npm', ['run', 'preview'], { cwd, stdio: 'inherit' });
130
+
131
+ try {
132
+ await waitForServer(baseUrl, 60000);
133
+ console.log('> Serveur prΓͺt, gΓ©nΓ©ration PDF…');
134
+
135
+ const browser = await chromium.launch({ headless: true });
136
+ try {
137
+ const context = await browser.newContext();
138
+ await context.addInitScript((desired) => {
139
+ try {
140
+ localStorage.setItem('theme', desired);
141
+ // Appliquer immédiatement le thème pour éviter les flashes
142
+ if (document && document.documentElement) {
143
+ document.documentElement.dataset.theme = desired;
144
+ }
145
+ } catch {}
146
+ }, theme);
147
+ const page = await context.newPage();
148
+ await page.goto(baseUrl, { waitUntil: 'networkidle', timeout: 60000 });
149
+ // Compute slug from title if needed
150
+ if (!args.filename) {
151
+ const title = await page.evaluate(() => {
152
+ const h1 = document.querySelector('h1.hero-title');
153
+ const t = h1 ? h1.textContent : document.title;
154
+ return (t || '').replace(/\s+/g, ' ').trim();
155
+ });
156
+ outFileBase = slugify(title);
157
+ }
158
+
159
+ // Wait for render readiness
160
+ if (wait === 'images' || wait === 'full') {
161
+ await waitForImages(page);
162
+ }
163
+ if (wait === 'plotly' || wait === 'full') {
164
+ await waitForPlotly(page);
165
+ }
166
+ if (wait === 'full') {
167
+ await waitForStableLayout(page);
168
+ }
169
+ await page.emulateMedia({ media: 'print' });
170
+ const outPath = resolve(cwd, 'dist', `${outFileBase}.pdf`);
171
+ await page.pdf({
172
+ path: outPath,
173
+ format,
174
+ printBackground: true,
175
+ margin
176
+ });
177
+ console.log(`βœ… PDF gΓ©nΓ©rΓ©: ${outPath}`);
178
+
179
+ // Copie de compatibilitΓ© dans dist/article.pdf (pour serveurs Nginx qui ne servent que dist)
180
+ const distCompatPath = resolve(cwd, 'dist', 'article.pdf');
181
+ try {
182
+ if (basename(outPath) !== 'article.pdf') {
183
+ await fs.copyFile(outPath, distCompatPath);
184
+ console.log(`βœ… PDF copiΓ© (compat dist): ${distCompatPath}`);
185
+ }
186
+ } catch (e) {
187
+ console.warn('Impossible de copier le PDF compat vers dist/article.pdf:', e?.message || e);
188
+ }
189
+
190
+ // Copie aussi dans public sous 2 noms: slug.pdf et article.pdf (compat)
191
+ const publicSlugPath = resolve(cwd, 'public', `${outFileBase}.pdf`);
192
+ const publicCompatPath = resolve(cwd, 'public', 'article.pdf');
193
+ try {
194
+ await fs.mkdir(resolve(cwd, 'public'), { recursive: true });
195
+ await fs.copyFile(outPath, publicSlugPath);
196
+ await fs.copyFile(outPath, publicCompatPath);
197
+ console.log(`βœ… PDF copiΓ© dans: ${publicSlugPath}`);
198
+ console.log(`βœ… PDF copiΓ© (compat): ${publicCompatPath}`);
199
+ } catch (e) {
200
+ console.warn('Impossible de copier le PDF vers public/:', e?.message || e);
201
+ }
202
+ } finally {
203
+ await browser.close();
204
+ }
205
+ } finally {
206
+ // Tenter un arrΓͺt propre
207
+ preview.kill('SIGINT');
208
+ }
209
+ }
210
+
211
+ main().catch((err) => {
212
+ console.error(err);
213
+ process.exit(1);
214
+ });
215
+
216
+
app/src/components/Footer.astro CHANGED
@@ -10,10 +10,10 @@ const { citationText, bibtex } = Astro.props as Props;
10
  <section class="citation-block">
11
  <h3>Citation</h3>
12
  <p>For attribution in academic contexts, please cite this work as</p>
13
- <textarea readonly class="citation-text">{citationText}</textarea>
14
 
15
- <h4>BibTeX citation</h4>
16
- <textarea readonly class="citation-bibtex">{bibtex}</textarea>
17
  </section>
18
  <section class="references-block">
19
  <slot />
@@ -21,17 +21,6 @@ const { citationText, bibtex } = Astro.props as Props;
21
  </div>
22
  </footer>
23
 
24
- <style>
25
- .distill-footer { margin-top: 40px; border-top: 1px solid var(--border-color); }
26
- .footer-inner { max-width: 680px; margin: 0 auto; padding: 24px 16px; }
27
- .citation-block h3 { margin: 0 0 8px; }
28
- .citation-block h4 { margin: 16px 0 8px; font-size: 14px; text-transform: uppercase; color: var(--muted-color); }
29
- .citation-text, .citation-bibtex { width: 100%; min-height: 44px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--surface-bg); padding: 8px; resize: none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; color: var(--text-color); white-space: pre-wrap; overflow-y: hidden; line-height: 1.4; }
30
- .references-block h3 { margin: 24px 0 8px; }
31
- .references-block .footnotes { margin-top: 8px; }
32
- .references-block .bibliography { margin-top: 8px; }
33
- </style>
34
-
35
 
36
  <script is:inline>
37
  (() => {
@@ -80,22 +69,11 @@ const { citationText, bibtex } = Astro.props as Props;
80
  return true;
81
  };
82
 
83
- const autoResizeTextareas = () => {
84
- const areas = footer.querySelectorAll('.citation-text, .citation-bibtex');
85
- areas.forEach((ta) => {
86
- ta.style.height = 'auto';
87
- const min = 44;
88
- const next = Math.max(ta.scrollHeight, min);
89
- ta.style.height = next + 'px';
90
- });
91
- };
92
-
93
  const run = () => {
94
  const referencesEl = findFirstOutsideFooter(['#references', '.references', '.bibliography']);
95
  const footnotesEl = findFirstOutsideFooter(['.footnotes']);
96
  const movedRefs = moveIntoFooter(referencesEl, 'References');
97
  const movedNotes = moveIntoFooter(footnotesEl, 'Footnotes');
98
- autoResizeTextareas();
99
  return movedRefs || movedNotes;
100
  };
101
 
@@ -111,15 +89,7 @@ const { citationText, bibtex } = Astro.props as Props;
111
  }
112
 
113
  // Resize on window changes (e.g., fonts, layout)
114
- window.addEventListener('resize', () => {
115
- // throttle via rAF
116
- let raf = null;
117
- if (raf) cancelAnimationFrame(raf);
118
- raf = requestAnimationFrame(() => {
119
- autoResizeTextareas();
120
- raf = null;
121
- });
122
- }, { passive: true });
123
  })();
124
  </script>
125
 
 
10
  <section class="citation-block">
11
  <h3>Citation</h3>
12
  <p>For attribution in academic contexts, please cite this work as</p>
13
+ <pre class="citation short">{citationText}</pre>
14
 
15
+ <p>BibTeX citation</p>
16
+ <pre class="citation long">{bibtex}</pre>
17
  </section>
18
  <section class="references-block">
19
  <slot />
 
21
  </div>
22
  </footer>
23
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  <script is:inline>
26
  (() => {
 
69
  return true;
70
  };
71
 
 
 
 
 
 
 
 
 
 
 
72
  const run = () => {
73
  const referencesEl = findFirstOutsideFooter(['#references', '.references', '.bibliography']);
74
  const footnotesEl = findFirstOutsideFooter(['.footnotes']);
75
  const movedRefs = moveIntoFooter(referencesEl, 'References');
76
  const movedNotes = moveIntoFooter(footnotesEl, 'Footnotes');
 
77
  return movedRefs || movedNotes;
78
  };
79
 
 
89
  }
90
 
91
  // Resize on window changes (e.g., fonts, layout)
92
+ // No textarea auto-resize needed for <pre> blocks
 
 
 
 
 
 
 
 
93
  })();
94
  </script>
95
 
app/src/components/HtmlFragment.astro CHANGED
@@ -3,7 +3,7 @@ interface Props { src: string }
3
  const { src } = Astro.props as Props;
4
 
5
  // Charge tous les fragments .html sous src/fragments/** en tant que string (dev & build)
6
- const fragments = import.meta.glob('../fragments/**/*.html', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
7
 
8
  function resolveFragment(requested: string): string | null {
9
  // Autorise "banner.html" ou "fragments/banner.html"
 
3
  const { src } = Astro.props as Props;
4
 
5
  // Charge tous les fragments .html sous src/fragments/** en tant que string (dev & build)
6
+ const fragments = import.meta.glob('../content/fragments/**/*.html', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
7
 
8
  function resolveFragment(requested: string): string | null {
9
  // Autorise "banner.html" ou "fragments/banner.html"
app/src/components/Meta.astro CHANGED
@@ -27,6 +27,31 @@ const { title, authors = [], affiliation, published } = Astro.props as Props;
27
  <p>{published}</p>
28
  </div>
29
  )}
 
 
 
 
30
  </div>
31
  </header>
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  <p>{published}</p>
28
  </div>
29
  )}
30
+ <div class="meta-container-cell">
31
+ <h3>PDF</h3>
32
+ <p><button id="download-pdf-btn" type="button">Download PDF</button></p>
33
+ </div>
34
  </div>
35
  </header>
36
 
37
+ <script>
38
+ // Attache un gestionnaire pour dΓ©clencher un tΓ©lΓ©chargement programmatique
39
+ (() => {
40
+ const ready = () => {
41
+ const btn = document.getElementById('download-pdf-btn');
42
+ if (!btn) return;
43
+ btn.addEventListener('click', () => {
44
+ const a = document.createElement('a');
45
+ a.href = '/article.pdf';
46
+ a.setAttribute('download', 'article.pdf');
47
+ document.body.appendChild(a);
48
+ a.click();
49
+ a.remove();
50
+ });
51
+ };
52
+ if (document.readyState === 'loading') {
53
+ document.addEventListener('DOMContentLoaded', ready, { once: true });
54
+ } else { ready(); }
55
+ })();
56
+ </script>
57
+
app/src/{pages β†’ content}/article.mdx RENAMED
@@ -1,5 +1,6 @@
1
  ---
2
- title: "The science template:\nCraft Beautiful Blogs"
 
3
  description: "A modern, MDX-first research article template with math, citations, and interactive figures."
4
  authors:
5
  - "John Doe"
@@ -23,11 +24,11 @@ import Aside from "../components/Aside.astro";
23
  import visualPoster from "../assets/images/visual-vocabulary-poster.png";
24
 
25
  <Aside>
26
- Welcome to this single-page research article template built with **Astro** and **MDX**.
27
  It’s designed to help you write clear, modern, and **interactive** technical articles with **minimal setup**.
28
  Whether you cover machine learning, data science, physics, or software topics, this template keeps the authoring flow simple while offering robust features out of the box.
29
  <Fragment slot="aside">
30
- Reading time: 10–15 minutes.
31
  </Fragment>
32
  In this guide, you’ll learn how to install the template,
33
  write content (math, citations, images, code, asides, interactive fragments),
@@ -37,23 +38,27 @@ import visualPoster from "../assets/images/visual-vocabulary-poster.png";
37
  </Fragment>
38
  </Aside>
39
 
40
- Ce template est heavely inspired by distill.
41
 
42
  ## Features
43
 
44
  <div className="tag-list">
45
  <span className="tag">Markdown based</span>
46
  <span className="tag">KaTeX math</span>
 
47
  <span className="tag">Citations & footnotes</span>
48
  <span className="tag">Automatic build</span>
49
  <span className="tag">Table of content</span>
50
  <span className="tag">Dark theme</span>
51
  <span className="tag">HTML fragments</span>
52
  <span className="tag">Plotly ready</span>
 
53
  <span className="tag">SEO Friendly</span>
54
- <span className="tag">Lightweight bundle(\<500ko)</span>
55
  <span className="tag">Aside notes</span>
 
56
  <span className="tag">Medium like zoomable images</span>
 
57
  </div>
58
 
59
  ## Getting Started
@@ -93,22 +98,14 @@ Track binaries (e.g., `.png`, `.wav`) with Git LFS to keep the repository lean.
93
 
94
  ## Writing Your Content
95
 
96
- ### MDX
97
-
98
- Author your content in MDX for a pleasant, productive writing flow that combines familiar Markdown with reusable components when needed.
99
- Below are minimal examples of the core elements.
100
-
101
 
102
- Don't forget to enable syntax highlighting for MDX to make your editing experience even smoother!
103
 
104
- ```bash
105
- # For VS Code but alternatively, use the Extensions marketplace UI and search for "MDX" by unifiedjs.
106
- code --install-extension unifiedjs.vscode-mdx
107
- ```
108
 
109
-
110
-
111
- The initial skeleton of this article looks like this:
112
 
113
  ```mdx
114
  {/* HEADER */}
@@ -129,35 +126,17 @@ ogImage: "https://example.com/your-og-image.png"
129
 
130
  {/* IMPORTS */}
131
  import { Image } from 'astro:assets';
132
- import Aside from "../components/Aside.astro";
133
- import HtmlFragment from "../components/HtmlFragment.astro";
134
- import placeholder from "../assets/images/placeholder.png";
135
- import audioDemo from "../assets/audio/audio-example.wav";
136
 
137
  {/* CONTENT */}
138
- <Aside>
139
- Welcome to this single-page research article template built with Astro and MDX.
140
- It’s designed to help you write clear, modern, and interactive technical articles with minimal setup.
141
- Whether you cover machine learning, data science, physics, or software topics, this template keeps the authoring flow simple while offering robust features out of the box.
142
- <Fragment slot="aside">
143
- Reading time: 10–15 minutes.
144
- </Fragment>
145
- In this guide, you’ll learn how to install the template,
146
- write content (math, citations, images, code, asides, interactive fragments),
147
- customize styles and behavior, and follow a few best practices for publishing.
148
- <Fragment slot="aside">
149
- If you have questions or remarks open a discussion on the <a href="https://huggingface.co/spaces/lvwerra/distill-blog-template/discussions?status=open&type=discussion">Community tab</a>!
150
- </Fragment>
151
- </Aside>
152
 
 
153
 
 
154
  ```
155
 
156
- It is composed of three parts:
157
 
158
- - **Header**: which contains the title and the article's metadata.
159
- - **Imports**: the imports of the different modules / images used in the article
160
- - **Content**: the content of the article
161
 
162
  **Available blocs**:
163
 
@@ -175,18 +154,21 @@ It is composed of three parts:
175
 
176
  ### Math
177
 
178
- Inline example: $x^2 + y^2 = z^2$.
 
 
 
 
 
 
179
 
180
- Block example:
181
 
182
  $$
183
  x^2 + y^2 = z^2
184
  $$
185
 
186
  ```mdx
187
- Inline example: $x^2 + y^2 = z^2$
188
-
189
- Block example:
190
  $$
191
  \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
192
  $$
@@ -198,7 +180,11 @@ Responsive images automatically generate an optimized `srcset` and `sizes` so th
198
 
199
  **Optional:** Zoomable (Medium-like lightbox): add `data-zoomable` to opt-in. Only images with this attribute will open full-screen on click.
200
 
201
- **Optional:** Figcaption and credits
 
 
 
 
202
 
203
  <figure>
204
  <Image
@@ -208,7 +194,7 @@ Responsive images automatically generate an optimized `srcset` and `sizes` so th
208
  />
209
  <figcaption>
210
  Optimized image with a descriptive caption.
211
- <span className="image-credit">CrΓ©dit: Photo by <a href="https://example.com">Author</a></span>
212
  </figcaption>
213
  </figure>
214
 
@@ -297,7 +283,7 @@ import Aside from '../components/Aside.astro'
297
 
298
  Use these helpers to expand content beyond the main column when needed. They will always be centered and displayed above every other content.
299
 
300
- **Wide example**
301
 
302
  <Wide>
303
  <div className="demo-wide">demo wide</div>
@@ -311,7 +297,7 @@ import Wide from '../components/Wide.astro'
311
  </Wide>
312
  ```
313
 
314
- **Full-bleed example**
315
 
316
  <FullBleed>
317
  <div className="demo-full-bleed">demo full-bleed</div>
@@ -359,7 +345,7 @@ import audioDemo from '../assets/audio/audio-example.wav'
359
  ### Embeds
360
 
361
 
362
- #### HtmlFragments
363
 
364
  The main purpose of the ```HtmlFragment``` component is to embed a plotly or d3 chart in your article. Libraries are already imported in the template.
365
 
@@ -371,7 +357,6 @@ The main purpose of the ```HtmlFragment``` component is to embed a plotly or d3
371
  import HtmlFragment from '../components/HtmlFragment.astro'
372
 
373
  <HtmlFragment src="line.html" />
374
-
375
  ```
376
 
377
  #### Iframes
@@ -402,21 +387,36 @@ Finally, if you want to include code from GitHub you can use emgithub.com and, f
402
 
403
  ## Best Practices
404
 
405
- - Keep sections short and focused.
406
- - Prefer vector or optimized images; use `astro:assets` for responsive delivery.
407
- - Annotate figures and code minimally but clearly.
408
- - Use math sparingly; explain notation on first use.
 
 
 
 
 
 
409
 
410
- ### Choosing the right chart
 
 
 
 
 
 
411
 
412
  Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from analytical task to chart types.
413
 
414
  <figure>
415
- <Image src={visualPoster} alt="Visual Vocabulary: choosing the right chart by task" data-zoomable />
 
 
416
  <figcaption>
417
  Visual Vocabulary: a handy reference to select chart types by purpose (comparison, distribution, part-to-whole, correlation, and more).
 
418
  </figcaption>
419
- </figure>
420
 
421
 
422
  ## Conclusions
 
1
  ---
2
+ title: "From Idea to Interactive:\n A Modern Template for Scientific Writing
3
+ "
4
  description: "A modern, MDX-first research article template with math, citations, and interactive figures."
5
  authors:
6
  - "John Doe"
 
24
  import visualPoster from "../assets/images/visual-vocabulary-poster.png";
25
 
26
  <Aside>
27
+ Welcome to this single-page research article template built with **Markdown**.
28
  It’s designed to help you write clear, modern, and **interactive** technical articles with **minimal setup**.
29
  Whether you cover machine learning, data science, physics, or software topics, this template keeps the authoring flow simple while offering robust features out of the box.
30
  <Fragment slot="aside">
31
+ Reading time: 20–25 minutes.
32
  </Fragment>
33
  In this guide, you’ll learn how to install the template,
34
  write content (math, citations, images, code, asides, interactive fragments),
 
38
  </Fragment>
39
  </Aside>
40
 
41
+ This template is inspired by [**Distill**](https://distill.pub); we aim to preserve the best of it while modernizing the stack. Their work is highly inspiring.
42
 
43
  ## Features
44
 
45
  <div className="tag-list">
46
  <span className="tag">Markdown based</span>
47
  <span className="tag">KaTeX math</span>
48
+ <span className="tag">Syntax highlighting</span>
49
  <span className="tag">Citations & footnotes</span>
50
  <span className="tag">Automatic build</span>
51
  <span className="tag">Table of content</span>
52
  <span className="tag">Dark theme</span>
53
  <span className="tag">HTML fragments</span>
54
  <span className="tag">Plotly ready</span>
55
+ <span className="tag">D3.js ready</span>
56
  <span className="tag">SEO Friendly</span>
57
+ <span className="tag">Lightweight bundle</span>
58
  <span className="tag">Aside notes</span>
59
+ <span className="tag">Responsive images</span>
60
  <span className="tag">Medium like zoomable images</span>
61
+ <span className="tag">PDF export</span>
62
  </div>
63
 
64
  ## Getting Started
 
98
 
99
  ## Writing Your Content
100
 
101
+ ### Introduction
 
 
 
 
102
 
103
+ Your article lives in two places:
104
 
105
+ - `app/src/content/` β€” where you can find the article.mdx and bibliography.bib.
106
+ - `app/src/assets/` β€” images, audio, and other static assets. (handled by git lfs)
 
 
107
 
108
+ The initial skeleton of an article looks like this:
 
 
109
 
110
  ```mdx
111
  {/* HEADER */}
 
126
 
127
  {/* IMPORTS */}
128
  import { Image } from 'astro:assets';
129
+ import placeholder from '../assets/images/placeholder.jpg';
 
 
 
130
 
131
  {/* CONTENT */}
132
+ # Hello, world
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ This is a short paragraph written in Markdown. Below is an example image:
135
 
136
+ <Image src={placeholder} alt="Example image" />
137
  ```
138
 
 
139
 
 
 
 
140
 
141
  **Available blocs**:
142
 
 
154
 
155
  ### Math
156
 
157
+ **Inline**
158
+
159
+ $x^2 + y^2 = z^2$.
160
+
161
+ ```mdx
162
+ $x^2 + y^2 = z^2$
163
+ ```
164
 
165
+ **Block**
166
 
167
  $$
168
  x^2 + y^2 = z^2
169
  $$
170
 
171
  ```mdx
 
 
 
172
  $$
173
  \mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V
174
  $$
 
180
 
181
  **Optional:** Zoomable (Medium-like lightbox): add `data-zoomable` to opt-in. Only images with this attribute will open full-screen on click.
182
 
183
+ **Optional:** Lazy loading: add `loading="lazy"` to opt-in.
184
+
185
+ **Optional:** Figcaption and credits: add a `figcaption` element with a `span` containing the credit.
186
+
187
+
188
 
189
  <figure>
190
  <Image
 
194
  />
195
  <figcaption>
196
  Optimized image with a descriptive caption.
197
+ <span className="image-credit">Credit: Photo by <a href="https://example.com">Author</a></span>
198
  </figcaption>
199
  </figure>
200
 
 
283
 
284
  Use these helpers to expand content beyond the main column when needed. They will always be centered and displayed above every other content.
285
 
286
+ #### Wide example
287
 
288
  <Wide>
289
  <div className="demo-wide">demo wide</div>
 
297
  </Wide>
298
  ```
299
 
300
+ #### Full-bleed example
301
 
302
  <FullBleed>
303
  <div className="demo-full-bleed">demo full-bleed</div>
 
345
  ### Embeds
346
 
347
 
348
+ #### Html Fragments
349
 
350
  The main purpose of the ```HtmlFragment``` component is to embed a plotly or d3 chart in your article. Libraries are already imported in the template.
351
 
 
357
  import HtmlFragment from '../components/HtmlFragment.astro'
358
 
359
  <HtmlFragment src="line.html" />
 
360
  ```
361
 
362
  #### Iframes
 
387
 
388
  ## Best Practices
389
 
390
+ ### Short sections
391
+ 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.
392
+
393
+ ### Clear, minimal annotations
394
+ Favor **concise captions** and callouts that clarify what to look at and why it matters. In code, **highlight just the lines** that carry the idea; avoid verbose commentary. **Precision beats volume**.
395
+
396
+ ### Explain math notation
397
+ **Introduce symbols and variables** the first time they appear, and prefer **well‑known identities** over custom shorthand. When formulas carry the message, add one sentence of **plain‑language interpretation** right after.
398
+
399
+ ### Use the right color scale
400
 
401
+ Choosing colors well is critical: a **palette** encodes **meaning** (categories, magnitudes, oppositions), preserves **readability** and **accessibility** (**sufficient contrast**, **color‑vision safety**), and ensures **perceptually smooth transitions**. The three families below illustrate when to use **categorical**, **sequential**, or **diverging** colors and how they evolve from the same **reference hue**.
402
+
403
+ <div className="">
404
+ <HtmlFragment src="palettes.html" />
405
+ </div>
406
+
407
+ ### Use the right chart
408
 
409
  Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from analytical task to chart types.
410
 
411
  <figure>
412
+ <a href="https://ft-interactive.github.io/visual-vocabulary/" target="_blank" rel="noopener noreferrer">
413
+ <Image src={visualPoster} alt="Visual Vocabulary: choosing the right chart by task" />
414
+ </a>
415
  <figcaption>
416
  Visual Vocabulary: a handy reference to select chart types by purpose (comparison, distribution, part-to-whole, correlation, and more).
417
+ β€” <a href="https://ft-interactive.github.io/visual-vocabulary/" target="_blank" rel="noopener noreferrer">Website</a>
418
  </figcaption>
419
+ </figure>
420
 
421
 
422
  ## Conclusions
app/src/content/fragments/banner.html ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-galaxy" style="width:100%;margin:10px 0;"></div>
2
+ <script>
3
+ (() => {
4
+ const ensureD3 = (cb) => {
5
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
6
+ let s = document.getElementById('d3-cdn-script');
7
+ if (!s) {
8
+ s = document.createElement('script');
9
+ s.id = 'd3-cdn-script';
10
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
11
+ document.head.appendChild(s);
12
+ }
13
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
14
+ s.addEventListener('load', onReady, { once: true });
15
+ if (window.d3) onReady();
16
+ };
17
+
18
+ const bootstrap = () => {
19
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
20
+ const container = (mount && mount.querySelector && mount.querySelector('.d3-galaxy')) || document.querySelector('.d3-galaxy');
21
+ if (!container) return;
22
+ if (container.dataset) {
23
+ if (container.dataset.mounted === 'true') return;
24
+ container.dataset.mounted = 'true';
25
+ }
26
+ // Scene params (match previous Plotly ranges)
27
+ const cx = 1.5, cy = 0.5;
28
+ const a = 1.3, b = 0.45;
29
+ const numPoints = 3000;
30
+ const numArms = 3;
31
+ const numTurns = 2.1;
32
+ const angleJitter = 0.12;
33
+ const posNoise = 0.015;
34
+
35
+ // Generate spiral + bulge
36
+ const twoPi = Math.PI * 2;
37
+ const t = Float64Array.from({ length: numPoints }, () => Math.random() * (twoPi * numTurns));
38
+ const armIndices = Int16Array.from({ length: numPoints }, () => Math.floor(Math.random() * numArms));
39
+ const armOffsets = Float64Array.from(armIndices, (k) => k * (twoPi / numArms));
40
+ const theta = Float64Array.from(t, (tv, i) => tv + armOffsets[i] + d3.randomNormal.source(Math.random)(0, angleJitter)());
41
+ const rNorm = Float64Array.from(t, (tv) => Math.pow(tv / (twoPi * numTurns), 0.9));
42
+ const noiseScale = (rn) => posNoise * (0.8 + 0.6 * rn);
43
+ const noiseX = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
44
+ const noiseY = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
45
+
46
+ const xSpiral = Float64Array.from(theta, (th, i) => cx + a * rNorm[i] * Math.cos(th) + noiseX[i]);
47
+ const ySpiral = Float64Array.from(theta, (th, i) => cy + b * rNorm[i] * Math.sin(th) + noiseY[i]);
48
+
49
+ const bulgePoints = Math.floor(0.18 * numPoints);
50
+ const phiB = Float64Array.from({ length: bulgePoints }, () => twoPi * Math.random());
51
+ const rB = Float64Array.from({ length: bulgePoints }, () => Math.pow(Math.random(), 2.2) * 0.22);
52
+ const noiseXB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
53
+ const noiseYB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
54
+ const xBulge = Float64Array.from(phiB, (ph, i) => cx + a * rB[i] * Math.cos(ph) + noiseXB[i]);
55
+ const yBulge = Float64Array.from(phiB, (ph, i) => cy + b * rB[i] * Math.sin(ph) + noiseYB[i]);
56
+
57
+ // Concatenate
58
+ const X = Array.from(xSpiral).concat(Array.from(xBulge));
59
+ const Y = Array.from(ySpiral).concat(Array.from(yBulge));
60
+ const lenSpiral = xSpiral.length;
61
+
62
+ const zSpiral = Array.from(rNorm, (rn) => 1 - rn);
63
+ const maxRB = rB && rB.length ? (window.d3 && d3.max ? d3.max(rB) : Math.max.apply(null, Array.from(rB))) : 1;
64
+ const zBulge = Array.from(rB, (rb) => 1 - (maxRB ? rb / maxRB : 0));
65
+ const Zraw = zSpiral.concat(zBulge);
66
+ const sizesPx = Zraw.map((z) => (z + 1) * 5); // 5..10 px (diameter)
67
+
68
+ // Labels (same categories as Python version)
69
+ const labelOf = (i) => {
70
+ const z = Zraw[i];
71
+ if (z < 0.25) return 'smol dot';
72
+ if (z < 0.5) return 'ok-ish dot';
73
+ if (z < 0.75) return 'a dot';
74
+ return 'biiig dot';
75
+ };
76
+
77
+ // Sort by size ascending for z-index: small first, big last
78
+ const idx = d3.range(X.length).sort((i, j) => sizesPx[i] - sizesPx[j]);
79
+
80
+ // Colors: piecewise gradient [0 -> 0.5 -> 1]
81
+ const c0 = d3.rgb(78, 165, 183); // rgb(78, 165, 183)
82
+ const c1 = d3.rgb(206, 192, 250); // rgb(206, 192, 250)
83
+ const c2 = d3.rgb(232, 137, 171); // rgb(232, 137, 171)
84
+ const interp01 = d3.interpolateRgb(c0, c1);
85
+ const interp12 = d3.interpolateRgb(c1, c2);
86
+ const colorFor = (v) => {
87
+ const t = Math.max(0, Math.min(1, v));
88
+ return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);
89
+ };
90
+
91
+ // Create SVG
92
+ const svg = d3.select(container).append('svg')
93
+ .attr('width', '100%')
94
+ .style('display', 'block');
95
+
96
+ const render = () => {
97
+ const width = container.clientWidth || 800;
98
+ const height = Math.max(260, Math.round(width / 3)); // keep ~3:1, min height
99
+ svg.attr('width', width).attr('height', height);
100
+
101
+ const xScale = d3.scaleLinear().domain([0, 3]).range([0, width]);
102
+ const yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]);
103
+
104
+ // Subtle stroke color depending on theme
105
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
106
+ const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
107
+
108
+ // Background rect using gradient
109
+ const bg = svg.selectAll('rect.d3-bg').data([0]);
110
+ bg.join('rect')
111
+ .attr('class', 'd3-bg')
112
+ .attr('x', 0)
113
+ .attr('y', 0)
114
+ .attr('width', width)
115
+ .attr('height', height)
116
+ .attr('fill', 'url(#spaceBg)');
117
+
118
+ // Group with blend mode so points softly accumulate light
119
+ const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points').style('mix-blend-mode', 'screen');
120
+
121
+ // Ensure container can host an absolute tooltip
122
+ container.style.position = container.style.position || 'relative';
123
+ let tip = container.querySelector('.d3-tooltip');
124
+ let tipInner;
125
+ if (!tip) {
126
+ tip = document.createElement('div');
127
+ tip.className = 'd3-tooltip';
128
+ Object.assign(tip.style, {
129
+ position: 'absolute',
130
+ top: '0px',
131
+ left: '0px',
132
+ transform: 'translate(-9999px, -9999px)',
133
+ pointerEvents: 'none',
134
+ padding: '8px 10px',
135
+ borderRadius: '8px',
136
+ fontSize: '12px',
137
+ lineHeight: '1.35',
138
+ border: '1px solid var(--border-color)',
139
+ background: 'var(--surface-bg)',
140
+ color: 'var(--text-color)',
141
+ boxShadow: '0 4px 24px rgba(0,0,0,.18)',
142
+ opacity: '0',
143
+ transition: 'opacity .12s ease'
144
+ });
145
+ tipInner = document.createElement('div');
146
+ tipInner.className = 'd3-tooltip__inner';
147
+ tipInner.style.textAlign = 'left';
148
+ tip.appendChild(tipInner);
149
+ container.appendChild(tip);
150
+ } else {
151
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
152
+ }
153
+
154
+ // Final filter: remove small dots very close to the galaxy center (after placement)
155
+ const centerHoleRadius = 0.08; // elliptical radius threshold
156
+ const smallSizeThreshold = 7.5; // same notion as Python size cut
157
+ const rTotal = idx.map((i) => Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2));
158
+ const idxFiltered = idx.filter((i, k) => !(rTotal[k] <= centerHoleRadius && sizesPx[i] < smallSizeThreshold));
159
+
160
+ const sel = g.selectAll('circle').data(idxFiltered, (i) => i);
161
+ sel.join(
162
+ (enter) => enter.append('circle')
163
+ .attr('cx', (i) => xScale(X[i]))
164
+ .attr('cy', (i) => yScale(Y[i]))
165
+ .attr('r', (i) => sizesPx[i] / 2)
166
+ .attr('fill', (i) => colorFor(Zraw[i]))
167
+ .attr('fill-opacity', 0.9)
168
+ .attr('stroke', strokeColor)
169
+ .attr('stroke-width', 0.4)
170
+ .on('mouseenter', function(ev, i) {
171
+ d3.select(this).raise()
172
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
173
+ .attr('stroke-width', 1.2);
174
+ const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
175
+ const type = i < lenSpiral ? 'spiral' : 'bulge';
176
+ const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
177
+ tipInner.innerHTML = `<div><strong>${labelOf(i)}</strong></div>` +
178
+ `<div><strong>Type</strong> ${type}${arm ? ` (arm ${arm})` : ''}</div>` +
179
+ `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>` +
180
+ `<div><strong>X</strong> ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
181
+ `<div><strong>r</strong> ${r.toFixed(3)} Β· <strong>z</strong> ${Zraw[i].toFixed(3)}</div>`;
182
+ tip.style.opacity = '1';
183
+ })
184
+ .on('mousemove', (ev, i) => {
185
+ const [mx, my] = d3.pointer(ev, container);
186
+ const offsetX = 10, offsetY = 12;
187
+ tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
188
+ })
189
+ .on('mouseleave', function() {
190
+ tip.style.opacity = '0';
191
+ tip.style.transform = 'translate(-9999px, -9999px)';
192
+ d3.select(this).attr('stroke', strokeColor).attr('stroke-width', 0.4);
193
+ }),
194
+ (update) => update
195
+ .attr('cx', (i) => xScale(X[i]))
196
+ .attr('cy', (i) => yScale(Y[i]))
197
+ .attr('r', (i) => sizesPx[i] / 2)
198
+ .attr('fill', (i) => colorFor(Zraw[i]))
199
+ .attr('fill-opacity', 0.9)
200
+ .attr('stroke', strokeColor)
201
+ .attr('stroke-width', 0.4)
202
+ .on('mouseenter', function(ev, i) {
203
+ d3.select(this).raise()
204
+ .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
205
+ .attr('stroke-width', 1.2);
206
+ const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
207
+ const type = i < lenSpiral ? 'spiral' : 'bulge';
208
+ const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
209
+ tipInner.innerHTML = `<div><strong>${labelOf(i)}</strong></div>` +
210
+ `<div><strong>Type</strong> ${type}${arm ? ` (arm ${arm})` : ''}</div>` +
211
+ `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>` +
212
+ `<div><strong>X</strong> ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
213
+ `<div><strong>r</strong> ${r.toFixed(3)} Β· <strong>z</strong> ${Zraw[i].toFixed(3)}</div>`;
214
+ tip.style.opacity = '1';
215
+ })
216
+ .on('mousemove', (ev, i) => {
217
+ const [mx, my] = d3.pointer(ev, container);
218
+ const offsetX = 10, offsetY = 12;
219
+ tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
220
+ })
221
+ .on('mouseleave', function() {
222
+ tip.style.opacity = '0';
223
+ tip.style.transform = 'translate(-9999px, -9999px)';
224
+ d3.select(this).attr('stroke', strokeColor).attr('stroke-width', 0.4);
225
+ })
226
+ );
227
+ };
228
+
229
+ // First render + resize
230
+ if (window.ResizeObserver) {
231
+ const ro = new ResizeObserver(() => render());
232
+ ro.observe(container);
233
+ } else {
234
+ window.addEventListener('resize', render);
235
+ }
236
+ render();
237
+ };
238
+
239
+ if (document.readyState === 'loading') {
240
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
241
+ } else { ensureD3(bootstrap); }
242
+ })();
243
+ </script>
244
+
app/src/{fragments β†’ content/fragments}/bar.html RENAMED
File without changes
app/src/{fragments β†’ content/fragments}/heatmap.html RENAMED
File without changes
app/src/{fragments β†’ content/fragments}/line.html RENAMED
File without changes
app/src/content/fragments/palettes.html ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="palettes" style="width:100%; margin: 10px 0;">
2
+ <style>
3
+ .palettes .palettes__grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
4
+ @media (max-width: 820px) { .palettes .palettes__grid { grid-template-columns: 1fr; } }
5
+ .palettes .palette-card { position: relative; }
6
+ .palettes .palette-card__header { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-top-left-radius: 10px; border-top-right-radius: 10px; background: transparent; }
7
+ .palettes .palette-card__badge { position: relative; width: 44px; height: 44px; border-radius: 50%; margin: 14px auto 6px; background: transparent; border: none; box-shadow: none; }
8
+ .palettes .palette-card__badge .badge-hole { position:absolute; top:50%; left:50%; width: 26px; height: 26px; transform: translate(-50%, -50%); border-radius: 50%; background: var(--surface-bg); border: none; box-shadow: none; }
9
+ .palettes .palette-card__badge .badge-marker { position:absolute; width:6px; height:6px; border-radius:50%; border: none; box-shadow: none; }
10
+ .palettes .palette-card__swatches { display: flex; gap: 0; margin-top: 8px; }
11
+ .palettes .palette-card__swatches .sw { flex: 1 1 0; height: 28px; min-width: 0; border: none; }
12
+ .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 6px; border-bottom-left-radius: 6px; }
13
+ .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 6px; border-bottom-right-radius: 6px; }
14
+ .palettes .palette-card__footer { margin-top: auto; display: flex; flex-direction: column; gap: 8px; }
15
+ .palettes .copy-btn { width: 100%; margin: 0; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); font-size: 12px; cursor: pointer; }
16
+ .palettes .palettes__meta { display: flex; align-items: center; gap: 10px; justify-content: space-between; }
17
+ .palettes .current-color { display: flex; align-items: center; gap: 10px; }
18
+ .palettes .current-swatch { width: 20px; height: 20px; border-radius: 50%; border: 1px solid var(--border-color); }
19
+ .palettes .current-text { display: flex; flex-direction: column; line-height: 1.1; }
20
+ .palettes .current-name { font-size: 14px; font-weight: 700; color: var(--text-color); }
21
+ .palettes .current-hex { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; }
22
+ .palettes .cvd-select { padding: 4px 8px; font-size: 12px; border-radius: 6px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); }
23
+ </style>
24
+ <div class="palettes__controls" style="display:flex; flex-direction:column; gap:10px; margin-bottom:24px;">
25
+ <div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
26
+ <div style="font-weight:700;">Pick a hue</div>
27
+ <div class="hue-value" style="font-variant-numeric: tabular-nums; color: var(--muted-color);">H 220Β°</div>
28
+ </div>
29
+ <div class="hue-slider" style="position:relative; height:18px; border-radius:10px; border:1px solid var(--border-color); background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); cursor: ew-resize; touch-action: none;">
30
+ <div class="hue-knob" style="position:absolute; top:50%; left:61.1%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; box-shadow:0 0 0 1px rgba(0,0,0,.2), 0 2px 8px rgba(0,0,0,.25); transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2;"></div>
31
+ </div>
32
+ <div class="palettes__meta">
33
+ <div class="current-color">
34
+ <div class="current-swatch" aria-label="Current color" title="Current color"></div>
35
+ <div class="current-text">
36
+ <div class="current-name">β€”</div>
37
+ <div class="current-hex">β€”</div>
38
+ </div>
39
+ </div>
40
+ <label style="display:flex; align-items:center; gap:8px;">
41
+ <span style="font-size:12px; color: var(--muted-color);">Color blindness preview</span>
42
+ <select class="cvd-select" title="Choose a color‑blindness simulation">
43
+ <option value="none">None</option>
44
+ <option value="protanopia">Protanopia (<2%)</option>
45
+ <option value="deuteranopia">Deuteranopia (<2%)</option>
46
+ <option value="tritanopia">Tritanopia (<0.1%)</option>
47
+ <option value="achromatopsia">Monochromacy (<0.1%)</option>
48
+ </select>
49
+ </label>
50
+ </div>
51
+ </div>
52
+ <div class="palettes__grid"></div>
53
+ </div>
54
+ <script>
55
+ (() => {
56
+ const ensureLibs = (cb) => {
57
+ const loadScript = (id, src, onload, onerror) => {
58
+ let s = document.getElementById(id);
59
+ if (s) { return onload && onload(); }
60
+ s = document.createElement('script');
61
+ s.id = id;
62
+ s.src = src;
63
+ s.async = true;
64
+ if (onload) s.addEventListener('load', onload, { once: true });
65
+ if (onerror) s.addEventListener('error', onerror, { once: true });
66
+ document.head.appendChild(s);
67
+ };
68
+ const ensureD3 = (next) => {
69
+ if (window.d3) return next();
70
+ loadScript('d3-cdn', 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js', next, () => {
71
+ loadScript('d3-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js', next);
72
+ });
73
+ };
74
+ const ensureChroma = (next) => {
75
+ if (window.chroma) return next();
76
+ loadScript('chroma-cdn', 'https://unpkg.com/[email protected]/dist/chroma.min.js', next, () => {
77
+ loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', next);
78
+ });
79
+ };
80
+ const ensureNtc = (next) => {
81
+ if (window.ntc && typeof window.ntc.name === 'function') return next();
82
+ loadScript('ntc-cdn', 'https://unpkg.com/[email protected]/build/ntc.js', next, () => next());
83
+ };
84
+ ensureD3(() => ensureChroma(() => ensureNtc(cb)));
85
+ };
86
+
87
+ const bootstrap = () => {
88
+ console.log('[palettes] bootstrap start');
89
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
90
+ const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
91
+ if (!root || root.dataset.mounted) return; root.dataset.mounted = 'true';
92
+
93
+ const grid = root.querySelector('.palettes__grid');
94
+ const slider = root.querySelector('.hue-slider');
95
+ const knob = root.querySelector('.hue-knob');
96
+ const hueValue = root.querySelector('.hue-value');
97
+ const currentSwatch = root.querySelector('.current-swatch');
98
+ const currentName = root.querySelector('.current-name');
99
+ const currentHex = root.querySelector('.current-hex');
100
+ const simSelect = root.querySelector('.cvd-select');
101
+ console.log('[palettes] elements', { root: !!root, grid: !!grid, slider: !!slider, knob: !!knob, hueValue: !!hueValue });
102
+
103
+ // Cards data with full descriptions
104
+ const cards = [
105
+ { key: 'categorical', title: 'Categorical', desc: 'Categorical colors help users map non-numeric meaning to objects in a visualization. These are designed to be visually distinct from one another. Maximum of six.', generator: (base) => {
106
+ // GΓ©nΓ©ration en LCH pour des couleurs perceptuellement distinctes
107
+ const baseH = chroma(base).get('hsl.h');
108
+ const L = 70; // luminositΓ© confortable
109
+ const C = 80; // chroma modΓ©rΓ© pour rester dans le gamut
110
+ return Array.from({ length: 6 }, (_, i) => chroma.lch(L, C, (baseH + i * 60) % 360).hex());
111
+ } },
112
+ { key: 'sequential', title: 'Sequential', desc: 'Sequential colors have numeric meaning. These are a gradation of colors that go from light to dark. They are used in a heatmap context.', generator: (base) => {
113
+ const c = chroma(base).saturate(0.3);
114
+ return chroma
115
+ .scale([c.brighten(2), c, c.darken(2)])
116
+ .mode('lab')
117
+ .correctLightness(true)
118
+ .colors(6);
119
+ } },
120
+ { key: 'diverging', title: 'Diverging', desc: 'Diverging colors also have numeric meaning. They’re useful when dealing with negative values or ranges that have two extremes with a baseline in the middle.', generator: (base) => {
121
+ const c = chroma(base);
122
+ const baseH = c.get('hsl.h');
123
+ const compH = (baseH + 180) % 360;
124
+ const left = chroma.hsl(compH, 0.75, 0.55);
125
+ const right = chroma.hsl(baseH, 0.75, 0.55);
126
+ const center = '#ffffff';
127
+ // Construire deux rampes symΓ©triques qui se rejoignent (sans dupliquer le neutre)
128
+ const leftRamp = chroma.scale([left, center]).mode('lch').correctLightness(true).colors(4);
129
+ const rightRamp = chroma.scale([center, right]).mode('lch').correctLightness(true).colors(4);
130
+ return [leftRamp[0], leftRamp[1], leftRamp[2], rightRamp[1], rightRamp[2], rightRamp[3]];
131
+ } }
132
+ ];
133
+
134
+ // Render cards scaffolding
135
+ const cardSel = d3.select(grid).selectAll('.palette-card').data(cards, d => d.key);
136
+ const enter = cardSel.enter().append('div').attr('class', 'palette-card')
137
+ .style('border', '1px solid var(--border-color)')
138
+ .style('border-radius', '10px')
139
+ .style('background', 'var(--surface-bg)')
140
+ .style('padding', '16px 14px 12px')
141
+ .style('display', 'flex')
142
+ .style('flex-direction', 'column')
143
+ .style('gap', '10px')
144
+ .style('min-height', '240px');
145
+
146
+ enter.append('div').attr('class', 'palette-card__header');
147
+
148
+ enter.append('div').attr('class', 'palette-card__badge');
149
+
150
+ enter.append('div').attr('class', 'palette-card__title')
151
+ .style('text-align', 'center')
152
+ .style('font-weight', '800')
153
+ .style('font-size', '28px')
154
+ .text(d => d.title);
155
+
156
+ enter.append('div').attr('class', 'palette-card__desc')
157
+ .style('text-align', 'center')
158
+ .style('color', 'var(--muted-color)')
159
+ .style('line-height', '1.6')
160
+ .style('font-size', '15px')
161
+ .text(d => d.desc);
162
+
163
+ const footer = enter.append('div').attr('class', 'palette-card__footer');
164
+ footer.append('div').attr('class', 'palette-card__swatches');
165
+ footer.append('button').attr('class', 'copy-btn').text('Copy palette');
166
+
167
+ // Rendering
168
+ const renderPalettes = (h) => {
169
+ console.log('[palettes] renderPalettes', h);
170
+ const base = chroma.hsl(h, 0.75, 0.55);
171
+ const uniformText = (bg) => (chroma(bg).luminance() > 0.45 ? '#111' : '#fff');
172
+
173
+ // Update current swatch + name
174
+ if (currentSwatch) currentSwatch.style.background = base.hex();
175
+ const getName = (hex) => {
176
+ // Prefer large color-name list if available
177
+ const list = (window.__colorNames && window.__colorNames.length) ? window.__colorNames : null;
178
+ if (list && window.chroma) {
179
+ let bestName = null; let best = Infinity;
180
+ for (let i = 0; i < list.length; i++) {
181
+ const item = list[i];
182
+ const d = (chroma.deltaE ? chroma.deltaE(hex, item.hex) : chroma.distance(hex, item.hex, 'lab'));
183
+ if (d < best) { best = d; bestName = item.name; }
184
+ }
185
+ if (bestName) return bestName;
186
+ }
187
+ // Fallback to ntc.js
188
+ try { if (window.ntc && window.ntc.name) { const res = window.ntc.name(hex); return res && res[1]; } } catch {}
189
+ // Hue-based coarse fallback
190
+ const hh = chroma(hex).get('hsl.h') || 0;
191
+ const labels = ['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta'];
192
+ const idx = Math.round(((hh % 360) / 360) * (labels.length - 1));
193
+ return labels[idx];
194
+ };
195
+ const hexStr = base.hex().toUpperCase();
196
+ if (currentName) currentName.textContent = getName(hexStr);
197
+ if (currentHex) currentHex.textContent = hexStr;
198
+ d3.select(root).selectAll('.palette-card').each(function(d) {
199
+ const palette = d.generator(base);
200
+ console.log('[palettes] palette', d.key, palette.length, palette);
201
+ const sw = d3.select(this).select('.palette-card__swatches');
202
+ const data = palette.slice(0, 6);
203
+ const s = sw.selectAll('.sw').data(data, (c, i) => i);
204
+ const sEnter = s.enter().append('div').attr('class', 'sw');
205
+ sEnter.merge(s)
206
+ .style('background', c => c)
207
+ .text('');
208
+ s.exit().remove();
209
+
210
+ // Hook up copy button (keeps palette visible)
211
+ const btn = d3.select(this).select('.copy-btn');
212
+ btn.on('click', async () => {
213
+ const json = JSON.stringify(data, null, 2);
214
+ try {
215
+ await navigator.clipboard.writeText(json);
216
+ const old = btn.text(); btn.text('Copied!'); setTimeout(() => btn.text(old), 900);
217
+ } catch {
218
+ window.prompt('Copy palette', json);
219
+ }
220
+ });
221
+
222
+ // Update header gradient to reflect palette
223
+ const header = d3.select(this).select('.palette-card__header');
224
+ const grad = `linear-gradient(90deg, ${data.join(',')})`;
225
+ header.style('background', grad);
226
+
227
+ // Update circular badge
228
+ const badge = d3.select(this).select('.palette-card__badge');
229
+ if (d.key === 'categorical') {
230
+ // Donut full hue circle for categorical
231
+ const hueCircle = 'conic-gradient(#f00 0%, #ff0 16.6%, #0f0 33.3%, #0ff 50%, #00f 66.6%, #f0f 83.3%, #f00 100%)';
232
+ badge.style('background', hueCircle);
233
+ // Clear previous markers
234
+ badge.selectAll('.badge-marker').remove();
235
+ // Place markers at palette hues
236
+ const radius = 21; // outer circle radius
237
+ const center = 22; // half of badge size (44)
238
+ data.forEach((hex) => {
239
+ const h = chroma(hex).get('hsl.h') || 0;
240
+ const angle = (h * Math.PI) / 180; // radians
241
+ const r = radius - 3; // adjust for 6px marker size
242
+ const x = center + r * Math.cos(angle - Math.PI / 2);
243
+ const y = center + r * Math.sin(angle - Math.PI / 2);
244
+ const mk = document.createElement('div');
245
+ mk.className = 'badge-marker';
246
+ mk.style.left = `${Math.round(x - 3)}px`;
247
+ mk.style.top = `${Math.round(y - 3)}px`;
248
+ mk.style.background = "rgba(255,255,255,0.9)";
249
+ badge.node().appendChild(mk);
250
+ });
251
+ // Inner hole to make it a donut
252
+ if (!badge.select('.badge-hole').node()) badge.append('div').attr('class', 'badge-hole');
253
+ } else {
254
+ // Linear gradient left->right for sequential/diverging
255
+ const linear = `linear-gradient(90deg, ${data.join(',')})`;
256
+ badge.style('background', linear);
257
+ badge.selectAll('.badge-marker').remove();
258
+ badge.selectAll('.badge-hole').remove();
259
+ }
260
+ });
261
+ };
262
+
263
+ // Hue slider behavior
264
+ let hue = 220; // initial
265
+ const setHue = (h) => { hue = (h + 360) % 360; const pct = hue / 360 * 100; if (knob) knob.style.left = pct + '%'; if (hueValue) hueValue.textContent = `H ${Math.round(hue)}Β°`; console.log('[palettes] setHue', hue, pct); renderPalettes(hue); };
266
+ const getHueFromEvent = (ev) => { const rect = slider.getBoundingClientRect(); const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX; const x = clientX - rect.left; const t = Math.max(0, Math.min(1, x / rect.width)); const h = t * 360; console.log('[palettes] getHueFromEvent', { clientX, left: rect.left, width: rect.width, t, h }); return h; };
267
+ const onDown = (ev) => { console.log('[palettes] onDown', ev.type); ev.preventDefault(); setHue(getHueFromEvent(ev)); const move = (e) => { e.preventDefault && e.preventDefault(); setHue(getHueFromEvent(e)); }; const up = () => { console.log('[palettes] onUp'); window.removeEventListener('mousemove', move); window.removeEventListener('touchmove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up); }; window.addEventListener('mousemove', move, { passive: false }); window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('mouseup', up, { once: true }); window.addEventListener('touchend', up, { once: true }); };
268
+ if (slider) { slider.addEventListener('mousedown', onDown); slider.addEventListener('touchstart', onDown, { passive: false }); console.log('[palettes] listeners attached'); } else { console.warn('[palettes] slider not found'); }
269
+
270
+ // Color-vision simulation filters (SVG)
271
+ const injectFilters = () => {
272
+ if (document.getElementById('cvd-filters')) return;
273
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
274
+ svg.setAttribute('id', 'cvd-filters');
275
+ svg.setAttribute('width', '0'); svg.setAttribute('height', '0'); svg.style.position = 'absolute';
276
+ svg.innerHTML = `
277
+ <filter id="cvd-protanopia"><feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"/></filter>
278
+ <filter id="cvd-deuteranopia"><feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"/></filter>
279
+ <filter id="cvd-tritanopia"><feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"/></filter>
280
+ <filter id="cvd-achromatopsia"><feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"/></filter>
281
+ `;
282
+ document.body.appendChild(svg);
283
+ };
284
+ injectFilters();
285
+
286
+ const applySimulation = (mode) => {
287
+ if (!grid) return;
288
+ if (!mode || mode === 'none') { grid.style.filter = 'none'; return; }
289
+ grid.style.filter = `url(#cvd-${mode})`;
290
+ };
291
+ if (simSelect) simSelect.addEventListener('change', () => applySimulation(simSelect.value));
292
+
293
+ // Load extended color-name dataset, then initial render
294
+ const loadColorNames = () => {
295
+ if (window.__colorNames) return Promise.resolve(window.__colorNames);
296
+ const url = 'https://unpkg.com/[email protected]/dist/colornames.json';
297
+ return fetch(url).then(r => r.json()).then(arr => { window.__colorNames = arr; return arr; }).catch(() => []);
298
+ };
299
+ console.log('[palettes] initial render');
300
+ loadColorNames().finally(() => setHue(hue));
301
+ applySimulation('none');
302
+
303
+ // Fixed 3 columns layout
304
+ grid.style.gridTemplateColumns = '1fr 1fr 1fr';
305
+ };
306
+
307
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureLibs(bootstrap), { once: true });
308
+ else ensureLibs(bootstrap);
309
+ })();
310
+ </script>
311
+
app/src/fragments/banner.html DELETED
The diff for this file is too large to render. See raw diff
 
app/src/pages/index.astro CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- import Article, { frontmatter as articleFM } from './article.mdx';
3
  import Meta from '../components/Meta.astro';
4
  import HtmlFragment from '../components/HtmlFragment.astro';
5
  import Footer from '../components/Footer.astro';
@@ -67,6 +67,7 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
67
 
68
 
69
  <script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
 
70
  </head>
71
  <body>
72
  <ThemeToggle />
@@ -87,6 +88,10 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
87
  <div class="title">Table of Contents</div>
88
  <div id="toc-placeholder"></div>
89
  </aside>
 
 
 
 
90
  <main>
91
  <Article />
92
  <style is:inline>
@@ -193,7 +198,8 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
193
  // Build TOC from article headings (h2/h3/h4) and render into the sticky aside
194
  const buildTOC = () => {
195
  const holder = document.getElementById('toc-placeholder');
196
- if (!holder || holder.children.length) return;
 
197
  const articleRoot = document.querySelector('section.content-grid main');
198
  if (!articleRoot) return;
199
  const headings = articleRoot.querySelectorAll('h2, h3, h4');
@@ -219,24 +225,40 @@ const bibtex = `@misc{${bibKey},\n title={${titleFlat}},\n author={${authorsBi
219
  ulStack[ulStack.length-1].appendChild(li);
220
  });
221
 
222
- holder.appendChild(nav);
 
223
 
224
  // active link on scroll
225
- const links = holder.querySelectorAll('a');
 
 
 
226
  const onScroll = () => {
227
  for (let i = headings.length - 1; i >= 0; i--) {
228
  const top = headings[i].getBoundingClientRect().top;
229
  if (top - 60 <= 0) {
230
  links.forEach(l => l.classList.remove('active'));
231
  const id = '#' + headings[i].id;
232
- const active = Array.from(links).find(l => l.getAttribute('href') === id);
233
- active && active.classList.add('active');
234
  break;
235
  }
236
  }
237
  };
238
  window.addEventListener('scroll', onScroll);
239
  onScroll();
 
 
 
 
 
 
 
 
 
 
 
 
240
  };
241
 
242
  if (document.readyState === 'loading') {
 
1
  ---
2
+ import Article, { frontmatter as articleFM } from '../content/article.mdx';
3
  import Meta from '../components/Meta.astro';
4
  import HtmlFragment from '../components/HtmlFragment.astro';
5
  import Footer from '../components/Footer.astro';
 
67
 
68
 
69
  <script src="https://cdn.plot.ly/plotly-3.0.0.min.js" charset="utf-8"></script>
70
+ <script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
71
  </head>
72
  <body>
73
  <ThemeToggle />
 
88
  <div class="title">Table of Contents</div>
89
  <div id="toc-placeholder"></div>
90
  </aside>
91
+ <details class="toc-mobile">
92
+ <summary>Table of Contents</summary>
93
+ <div id="toc-mobile-placeholder"></div>
94
+ </details>
95
  <main>
96
  <Article />
97
  <style is:inline>
 
198
  // Build TOC from article headings (h2/h3/h4) and render into the sticky aside
199
  const buildTOC = () => {
200
  const holder = document.getElementById('toc-placeholder');
201
+ const holderMobile = document.getElementById('toc-mobile-placeholder');
202
+ if ((holder && holder.children.length) && (holderMobile && holderMobile.children.length)) return;
203
  const articleRoot = document.querySelector('section.content-grid main');
204
  if (!articleRoot) return;
205
  const headings = articleRoot.querySelectorAll('h2, h3, h4');
 
225
  ulStack[ulStack.length-1].appendChild(li);
226
  });
227
 
228
+ if (holder) holder.appendChild(nav);
229
+ if (holderMobile) holderMobile.appendChild(nav.cloneNode(true));
230
 
231
  // active link on scroll
232
+ const links = [
233
+ ...(holder ? holder.querySelectorAll('a') : []),
234
+ ...(holderMobile ? holderMobile.querySelectorAll('a') : [])
235
+ ];
236
  const onScroll = () => {
237
  for (let i = headings.length - 1; i >= 0; i--) {
238
  const top = headings[i].getBoundingClientRect().top;
239
  if (top - 60 <= 0) {
240
  links.forEach(l => l.classList.remove('active'));
241
  const id = '#' + headings[i].id;
242
+ const actives = Array.from(links).filter(l => l.getAttribute('href') === id);
243
+ actives.forEach(a => a.classList.add('active'));
244
  break;
245
  }
246
  }
247
  };
248
  window.addEventListener('scroll', onScroll);
249
  onScroll();
250
+
251
+ // Close mobile accordion when a link inside it is clicked
252
+ if (holderMobile) {
253
+ const details = holderMobile.closest('details');
254
+ holderMobile.addEventListener('click', (ev) => {
255
+ const target = ev.target as Element | null;
256
+ const anchor = target && 'closest' in target ? (target as Element).closest('a') : null;
257
+ if (anchor instanceof HTMLAnchorElement && details && (details as HTMLDetailsElement).open) {
258
+ (details as HTMLDetailsElement).open = false;
259
+ }
260
+ });
261
+ }
262
  };
263
 
264
  if (document.readyState === 'loading') {
app/src/styles/_base.scss CHANGED
@@ -4,7 +4,7 @@
4
  html { box-sizing: border-box; }
5
  *, *::before, *::after { box-sizing: inherit; }
6
  body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, Apple Color Emoji, Segoe UI Emoji; color: var(--text-color); }
7
-
8
  /* Avoid constraining <main> inside grid; scope container sizing elsewhere if needed */
9
  /* main { max-width: 980px; margin: 24px auto; padding: 16px; } */
10
 
@@ -68,25 +68,6 @@ html { font-size: 14px; line-height: 1.6; }
68
 
69
  .content-grid main pre:not(.astro-code) { background: var(--code-bg); border: 1px solid var(--border-color); border-radius: 6px; padding: var(--spacing-3); font-size: 14px; overflow: auto; }
70
 
71
- /* Sync Shiki variables with current theme */
72
- /* Standard wrapper look for code blocks */
73
- .astro-code { border: 1px solid var(--border-color); border-radius: 6px; padding: var(--spacing-3); padding-left: calc(var(--spacing-3) + 6px); font-size: 14px; }
74
-
75
- html[data-theme='light'] .astro-code { background-color: var(--code-bg); }
76
-
77
- html[data-theme='dark'] .astro-code { background-color: var(--shiki-dark-bg); }
78
-
79
- /* Apply token color from per-span vars exposed by Shiki dual themes */
80
- html[data-theme='light'] .astro-code span { color: var(--shiki-light) !important; }
81
- html[data-theme='dark'] .astro-code span { color: var(--shiki-dark) !important; }
82
-
83
- /* Token color remapping using Shiki CSS variables on the wrapper */
84
- /* Optionnel: booster le contraste light */
85
- html[data-theme='light'] .astro-code {
86
- --shiki-foreground: #24292f;
87
- --shiki-background: #ffffff;
88
- }
89
-
90
  /* Rely on Shiki's own token spans; no class remap */
91
  .content-grid main code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
92
  /* Placeholder block (discreet centered text) */
@@ -157,14 +138,14 @@ html[data-theme='light'] .astro-code {
157
  .content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
158
 
159
 
160
- .code-block {
161
- background: var(--code-bg);
162
- border: 1px solid var(--border-color);
163
- border-radius: 6px;
164
- padding: var(--spacing-3);
165
- font-size: 14px;
166
- overflow: auto;
167
- }
168
 
169
  // ============================================================================
170
  // Media / Figures
@@ -203,3 +184,67 @@ figure { margin: 12px 0; }
203
  figcaption { text-align: center; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
204
  .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
205
  .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  html { box-sizing: border-box; }
5
  *, *::before, *::after { box-sizing: inherit; }
6
  body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, Apple Color Emoji, Segoe UI Emoji; color: var(--text-color); }
7
+ audio { display: block; }
8
  /* Avoid constraining <main> inside grid; scope container sizing elsewhere if needed */
9
  /* main { max-width: 980px; margin: 24px auto; padding: 16px; } */
10
 
 
68
 
69
  .content-grid main pre:not(.astro-code) { background: var(--code-bg); border: 1px solid var(--border-color); border-radius: 6px; padding: var(--spacing-3); font-size: 14px; overflow: auto; }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  /* Rely on Shiki's own token spans; no class remap */
72
  .content-grid main code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
73
  /* Placeholder block (discreet centered text) */
 
138
  .content-grid main hr { border: none; border-bottom: 1px solid var(--border-color); margin: var(--spacing-5) 0; }
139
 
140
 
141
+ // .code-block {
142
+ // background: rgba(120, 120, 120, 0.5);
143
+ // border: 1px solid var(--border-color);
144
+ // border-radius: 6px;
145
+ // padding: var(--spacing-3);
146
+ // font-size: 14px;
147
+ // overflow: auto;
148
+ // }
149
 
150
  // ============================================================================
151
  // Media / Figures
 
184
  figcaption { text-align: center; font-size: 0.9rem; color: var(--muted-color); margin-top: 6px; }
185
  .image-credit { display: block; margin-top: 4px; font-size: 12px; color: var(--muted-color); }
186
  .image-credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
187
+
188
+ // ============================================================================
189
+ // Buttons (minimal, Γ©purΓ©)
190
+ // ============================================================================
191
+ .meta .meta-container-cell button {
192
+ appearance: none;
193
+ background: var(--surface-bg);
194
+ color: var(--text-color);
195
+ border: 1px solid var(--border-color);
196
+ border-radius: 6px;
197
+ padding: 8px 12px;
198
+ font-size: 14px;
199
+ line-height: 1;
200
+ cursor: pointer;
201
+ transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease, transform .02s ease;
202
+ }
203
+ .meta .meta-container-cell button:hover {
204
+ background: var(--code-bg);
205
+ }
206
+ .meta .meta-container-cell button:active {
207
+ transform: translateY(1px);
208
+ }
209
+ .meta .meta-container-cell button:focus-visible {
210
+ outline: none;
211
+ box-shadow: 0 0 0 2px var(--link-underline);
212
+ }
213
+ .meta .meta-container-cell button:disabled {
214
+ opacity: .6;
215
+ cursor: not-allowed;
216
+ }
217
+
218
+ // ============================================================================
219
+ // Print styles
220
+ // =========================================================================
221
+ @media print {
222
+ html, body { background: #fff; }
223
+ /* Marges gΓ©rΓ©es par Playwright; Γ©viter marges globales supplΓ©mentaires */
224
+ body { margin: 0; }
225
+
226
+ /* Conserver bannière (hero), masquer éléments d'UI non nécessaires */
227
+ #theme-toggle { display: none !important; }
228
+
229
+ /* Liens: enlever soulignement au survol */
230
+ .content-grid main a { text-decoration: none; border-bottom: 1px solid rgba(0,0,0,.2); }
231
+
232
+ /* Eviter coupures dans des blocs complexes */
233
+ .content-grid main pre,
234
+ .content-grid main blockquote,
235
+ .content-grid main table,
236
+ .content-grid main figure { break-inside: avoid; page-break-inside: avoid; }
237
+
238
+ /* Sauts de page doux autour des titres principaux */
239
+ .content-grid main h2 { page-break-before: auto; page-break-after: avoid; break-after: avoid-page; }
240
+
241
+ /* Petites tailles d’icΓ΄nes inutiles Γ  l’impression */
242
+ .code-lang-chip { display: none !important; }
243
+
244
+ /* Ajuster couleurs plus contrastΓ©es Γ  l’impression */
245
+ :root {
246
+ --border-color: rgba(0,0,0,.2);
247
+ --link-underline: rgba(0,0,0,.3);
248
+ --link-underline-hover: rgba(0,0,0,.4);
249
+ }
250
+ }
app/src/styles/_layout.scss CHANGED
@@ -20,6 +20,18 @@ main > nav:first-of-type { display: none; }
20
  .toc nav a:hover { text-decoration: underline solid var(--muted-color); }
21
  .toc nav a.active { text-decoration: underline; }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  // Right aside (notes)
24
  .right-aside { position: sticky; top: 24px; }
25
  .right-aside .aside-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; margin-bottom: 10px; font-size: 0.9rem; color: var(--text-color); }
@@ -27,7 +39,8 @@ main > nav:first-of-type { display: none; }
27
  // Responsive – collapse to single column
28
  @media (max-width: 1100px) {
29
  .content-grid { grid-template-columns: 1fr; }
30
- .toc { position: static; }
 
31
  .right-aside { display: none; }
32
  main > nav:first-of-type { display: block; }
33
  }
 
20
  .toc nav a:hover { text-decoration: underline solid var(--muted-color); }
21
  .toc nav a.active { text-decoration: underline; }
22
 
23
+ // Mobile TOC accordion
24
+ .toc-mobile { display: none; margin: 8px 0 16px; }
25
+ .toc-mobile > summary { cursor: pointer; list-style: none; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface-bg); color: var(--text-color); font-weight: 600; }
26
+ .toc-mobile[open] > summary { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
27
+ .toc-mobile nav { border-left: none; padding: 10px 12px; font-size: 14px; border: 1px solid var(--border-color); border-top: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; background: var(--surface-bg); }
28
+ .toc-mobile nav ul { margin: 0 0 6px; padding-left: 1em; }
29
+ .toc-mobile nav li { list-style: none; margin: .25em 0; }
30
+ .toc-mobile nav a { color: var(--text-color); text-decoration: none; border-bottom: none; }
31
+ .toc-mobile nav > ul > li > a { font-weight: 700; }
32
+ .toc-mobile nav a:hover { text-decoration: underline solid var(--muted-color); }
33
+ .toc-mobile nav a.active { text-decoration: underline; }
34
+
35
  // Right aside (notes)
36
  .right-aside { position: sticky; top: 24px; }
37
  .right-aside .aside-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; margin-bottom: 10px; font-size: 0.9rem; color: var(--text-color); }
 
39
  // Responsive – collapse to single column
40
  @media (max-width: 1100px) {
41
  .content-grid { grid-template-columns: 1fr; }
42
+ .toc { position: static; display: none; }
43
+ .toc-mobile { display: block; }
44
  .right-aside { display: none; }
45
  main > nav:first-of-type { display: block; }
46
  }
app/src/styles/components/_code.scss ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ /* Sync Shiki variables with current theme */
3
+ /* Standard wrapper look for code blocks */
4
+ .astro-code { border: 1px solid var(--border-color); border-radius: 6px; padding: var(--spacing-3); padding-left: calc(var(--spacing-3) + 6px); font-size: 14px; }
5
+
6
+ /* Prevent code blocks from breaking layout on small screens */
7
+ .astro-code { overflow-x: auto; width: 100%; max-width: 100%; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
8
+ section.content-grid pre { overflow-x: auto; width: 100%; max-width: 100%; box-sizing: border-box; -webkit-overflow-scrolling: touch; }
9
+ section.content-grid pre code { display: inline-block; min-width: 100%; }
10
+
11
+ /* Wrap longues lignes en mobile pour Γ©viter l'overflow (URLs, etc.) */
12
+ @media (max-width: 700px) {
13
+ .astro-code,
14
+ section.content-grid pre { white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; }
15
+ section.content-grid pre code { white-space: pre-wrap; display: block; min-width: 0; }
16
+ }
17
+
18
+ html[data-theme='light'] .astro-code { background-color: var(--code-bg); }
19
+
20
+ html[data-theme='dark'] .astro-code { background-color: var(--shiki-dark-bg); }
21
+
22
+ /* Apply token color from per-span vars exposed by Shiki dual themes */
23
+ html[data-theme='light'] .astro-code span { color: var(--shiki-light) !important; }
24
+ html[data-theme='dark'] .astro-code span { color: var(--shiki-dark) !important; }
25
+
26
+ /* Token color remapping using Shiki CSS variables on the wrapper */
27
+ /* Optionnel: booster le contraste light */
28
+ html[data-theme='light'] .astro-code {
29
+ --shiki-foreground: #24292f;
30
+ --shiki-background: #ffffff;
31
+ }
app/src/styles/components/_footer.scss CHANGED
@@ -1,8 +1,54 @@
1
- .distill-footer { margin-top: 40px; border-top: 1px solid var(--border-color); }
2
- .footer-inner { max-width: 680px; margin: 0 auto; padding: 24px 16px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  .citation-block h3 { margin: 0 0 8px; }
4
  .citation-block h4 { margin: 16px 0 8px; font-size: 14px; text-transform: uppercase; color: var(--muted-color); }
5
- .citation-text, .citation-bibtex { width: 100%; min-height: 44px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--surface-bg); padding: 8px; resize: none; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 13px; color: var(--text-color); white-space: pre-wrap; overflow-y: hidden; line-height: 1.4; }
6
- .references-block h3 { margin: 24px 0 8px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  .references-block .footnotes { margin-top: 8px; }
8
  .references-block .bibliography { margin-top: 8px; }
 
 
 
 
 
 
 
 
 
 
 
1
+ .distill-footer { contain: layout style; font-size: 0.8em; line-height: 1.7em; margin-top: 60px; margin-bottom: 0; border-top: 1px solid rgba(0, 0, 0, 0.1); color: rgba(0, 0, 0, 0.5); }
2
+ .footer-inner { max-width: 1280px; margin: 0 auto; padding: 60px 16px 48px; display: grid; grid-template-columns: 220px minmax(0, 680px) 260px; gap: 32px; align-items: start; }
3
+
4
+ // Utiliser la grille parent (3 colonnes comme .content-grid)
5
+ .citation-block,
6
+ .references-block { display: contents; }
7
+ .citation-block > h3,
8
+ .references-block > h3 { grid-column: 1; font-size: 15px; margin: 0; }
9
+ .citation-block > :not(h3),
10
+ .references-block > :not(h3) { grid-column: 2; }
11
+ .citation-block > h3 + *,
12
+ .references-block > h3 + * { margin-top: 1em; }
13
+ @media (max-width: 1100px) {
14
+ .footer-inner { grid-template-columns: 1fr; gap: 16px; }
15
+ .footer-inner > h3 { grid-column: auto; margin-top: 16px; }
16
+ }
17
  .citation-block h3 { margin: 0 0 8px; }
18
  .citation-block h4 { margin: 16px 0 8px; font-size: 14px; text-transform: uppercase; color: var(--muted-color); }
19
+
20
+ // Distill-like appendix citation styling
21
+ .citation {
22
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
23
+ font-size: 11px;
24
+ line-height: 15px;
25
+ border-left: 1px solid rgba(0, 0, 0, 0.1);
26
+ padding-left: 18px;
27
+ border: 1px solid rgba(0,0,0,0.1);
28
+ background: rgba(0, 0, 0, 0.02);
29
+ padding: 10px 18px;
30
+ border-radius: 3px;
31
+ color: rgba(150, 150, 150, 1);
32
+ overflow: hidden;
33
+ margin-top: -12px;
34
+ white-space: pre-wrap;
35
+ word-wrap: break-word;
36
+ }
37
+
38
+ .citation a { color: rgba(0, 0, 0, 0.6); text-decoration: underline; }
39
+
40
+ .citation.short { margin-top: -4px; }
41
+
42
+ .references-block h3 { margin: 0; }
43
  .references-block .footnotes { margin-top: 8px; }
44
  .references-block .bibliography { margin-top: 8px; }
45
+
46
+ // Distill-like list styling for references/footnotes
47
+ .references-block ol { padding: 0 0 0 15px; }
48
+ @media (min-width: 768px) { .references-block ol { padding: 0 0 0 30px; margin-left: -30px; } }
49
+ .references-block li { margin-bottom: 1em; }
50
+ .references-block a { color: rgba(0, 0, 0, 0.6); }
51
+
52
+ @media (max-width: 1100px) {
53
+ .footer-inner { display: block; padding: 40px 16px; }
54
+ }
app/src/styles/global.scss CHANGED
@@ -2,6 +2,7 @@
2
  @use "./base" as *;
3
  @use "./layout" as *;
4
  @use "./components/footer" as *;
 
5
 
6
  /* Dark-mode form tweak */
7
  [data-theme="dark"] .plotly_input_container > select { background-color: #1a1f27; border-color: var(--border-color); color: var(--text-color); }
@@ -10,9 +11,9 @@
10
  [data-theme="dark"] .right-aside .aside-card { background: #12151b; border-color: rgba(255,255,255,.15); }
11
  [data-theme="dark"] .content-grid main pre { background: #12151b; border-color: rgba(255,255,255,.15); }
12
  [data-theme="dark"] .toc nav { border-left-color: rgba(255,255,255,.15); }
13
- [data-theme="dark"] .distill-footer { border-top-color: rgba(255,255,255,.15); }
14
- [data-theme="dark"] .citation-text,
15
- [data-theme="dark"] .citation-bibtex { background: #12151b; border-color: rgba(255,255,255,.15); color: var(--text-color); }
16
 
17
  /* Opt-in zoomable images */
18
  img[data-zoomable] { cursor: zoom-in; }
@@ -68,6 +69,7 @@ img[data-zoomable] { cursor: zoom-in; }
68
  // Plotly – fragments & controls
69
  // ============================================================================
70
  .plot-card { background: var(--code-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; margin: 8px 0; }
 
71
  .plotly-graph-div { width: 100% !important; min-height: 320px; }
72
  @media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
73
  [id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
 
2
  @use "./base" as *;
3
  @use "./layout" as *;
4
  @use "./components/footer" as *;
5
+ @use "./components/code" as *;
6
 
7
  /* Dark-mode form tweak */
8
  [data-theme="dark"] .plotly_input_container > select { background-color: #1a1f27; border-color: var(--border-color); color: var(--text-color); }
 
11
  [data-theme="dark"] .right-aside .aside-card { background: #12151b; border-color: rgba(255,255,255,.15); }
12
  [data-theme="dark"] .content-grid main pre { background: #12151b; border-color: rgba(255,255,255,.15); }
13
  [data-theme="dark"] .toc nav { border-left-color: rgba(255,255,255,.15); }
14
+ [data-theme="dark"] .distill-footer { border-top-color: rgba(255,255,255,.15); color: rgba(200,200,200,.8); }
15
+ [data-theme="dark"] .citation { background: rgba(255,255,255,0.04); border-color: rgba(255,255,255,.15); color: rgba(200,200,200,1); }
16
+ [data-theme="dark"] .citation a { color: rgba(255,255,255,0.75); }
17
 
18
  /* Opt-in zoomable images */
19
  img[data-zoomable] { cursor: zoom-in; }
 
69
  // Plotly – fragments & controls
70
  // ============================================================================
71
  .plot-card { background: var(--code-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; margin: 8px 0; }
72
+ .plot-card svg text { fill: var(--text-color) !important; }
73
  .plotly-graph-div { width: 100% !important; min-height: 320px; }
74
  @media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
75
  [id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
fragments/d3js/banner.html ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-galaxy" style="width:100%;margin:10px 0;"></div>
2
+ <script>
3
+ (() => {
4
+ const ensureD3 = (cb) => {
5
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
6
+ let s = document.getElementById('d3-cdn-script');
7
+ if (!s) {
8
+ s = document.createElement('script');
9
+ s.id = 'd3-cdn-script';
10
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
11
+ document.head.appendChild(s);
12
+ }
13
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
14
+ s.addEventListener('load', onReady, { once: true });
15
+ if (window.d3) onReady();
16
+ };
17
+
18
+ const bootstrap = () => {
19
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
20
+ const container = (mount && mount.querySelector && mount.querySelector('.d3-galaxy')) || document.querySelector('.d3-galaxy');
21
+ if (!container) return;
22
+ if (container.dataset) {
23
+ if (container.dataset.mounted === 'true') return;
24
+ container.dataset.mounted = 'true';
25
+ }
26
+ // Scene params (match previous Plotly ranges)
27
+ const cx = 1.5, cy = 0.5;
28
+ const a = 1.3, b = 0.45;
29
+ const numPoints = 3000;
30
+ const numArms = 3;
31
+ const numTurns = 2.1;
32
+ const angleJitter = 0.12;
33
+ const posNoise = 0.015;
34
+
35
+ // Generate spiral + bulge
36
+ const twoPi = Math.PI * 2;
37
+ const t = Float64Array.from({ length: numPoints }, () => Math.random() * (twoPi * numTurns));
38
+ const armIndices = Int16Array.from({ length: numPoints }, () => Math.floor(Math.random() * numArms));
39
+ const armOffsets = Float64Array.from(armIndices, (k) => k * (twoPi / numArms));
40
+ const theta = Float64Array.from(t, (tv, i) => tv + armOffsets[i] + d3.randomNormal.source(Math.random)(0, angleJitter)());
41
+ const rNorm = Float64Array.from(t, (tv) => Math.pow(tv / (twoPi * numTurns), 0.9));
42
+ const noiseScale = (rn) => posNoise * (0.8 + 0.6 * rn);
43
+ const noiseX = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
44
+ const noiseY = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
45
+
46
+ const xSpiral = Float64Array.from(theta, (th, i) => cx + a * rNorm[i] * Math.cos(th) + noiseX[i]);
47
+ const ySpiral = Float64Array.from(theta, (th, i) => cy + b * rNorm[i] * Math.sin(th) + noiseY[i]);
48
+
49
+ const bulgePoints = Math.floor(0.18 * numPoints);
50
+ const phiB = Float64Array.from({ length: bulgePoints }, () => twoPi * Math.random());
51
+ const rB = Float64Array.from({ length: bulgePoints }, () => Math.pow(Math.random(), 2.2) * 0.22);
52
+ const noiseXB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
53
+ const noiseYB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
54
+ const xBulge = Float64Array.from(phiB, (ph, i) => cx + a * rB[i] * Math.cos(ph) + noiseXB[i]);
55
+ const yBulge = Float64Array.from(phiB, (ph, i) => cy + b * rB[i] * Math.sin(ph) + noiseYB[i]);
56
+
57
+ // Concatenate
58
+ const X = Array.from(xSpiral).concat(Array.from(xBulge));
59
+ const Y = Array.from(ySpiral).concat(Array.from(yBulge));
60
+ const lenSpiral = xSpiral.length;
61
+
62
+ const zSpiral = Array.from(rNorm, (rn) => 1 - rn);
63
+ const maxRB = rB && rB.length ? (window.d3 && d3.max ? d3.max(rB) : Math.max.apply(null, Array.from(rB))) : 1;
64
+ const zBulge = Array.from(rB, (rb) => 1 - (maxRB ? rb / maxRB : 0));
65
+ const Zraw = zSpiral.concat(zBulge);
66
+ const sizesPx = Zraw.map((z) => (z + 1) * 5); // 5..10 px (diameter)
67
+
68
+ // Labels (same categories as Python version)
69
+ const labelOf = (i) => {
70
+ const z = Zraw[i];
71
+ if (z < 0.25) return 'smol dot';
72
+ if (z < 0.5) return 'ok-ish dot';
73
+ if (z < 0.75) return 'a dot';
74
+ return 'biiig dot';
75
+ };
76
+
77
+ // Sort by size ascending for z-index: small first, big last
78
+ const idx = d3.range(X.length).sort((i, j) => sizesPx[i] - sizesPx[j]);
79
+
80
+ // Colors: piecewise gradient [0 -> 0.5 -> 1]
81
+ const c0 = d3.rgb(78, 165, 183); // rgb(78, 165, 183)
82
+ const c1 = d3.rgb(206, 192, 250); // rgb(206, 192, 250)
83
+ const c2 = d3.rgb(232, 137, 171); // rgb(232, 137, 171)
84
+ const interp01 = d3.interpolateRgb(c0, c1);
85
+ const interp12 = d3.interpolateRgb(c1, c2);
86
+ const colorFor = (v) => {
87
+ const t = Math.max(0, Math.min(1, v));
88
+ return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);
89
+ };
90
+
91
+ // Create SVG
92
+ const svg = d3.select(container).append('svg')
93
+ .attr('width', '100%')
94
+ .style('display', 'block');
95
+
96
+ // Subtle background gradient
97
+ const defs = svg.append('defs');
98
+ const grad = defs.append('radialGradient')
99
+ .attr('id', 'spaceBg')
100
+ .attr('cx', '50%')
101
+ .attr('cy', '50%')
102
+ .attr('r', '65%');
103
+ grad.append('stop').attr('offset', '0%').attr('stop-color', 'rgba(255,255,255,0.06)');
104
+ grad.append('stop').attr('offset', '60%').attr('stop-color', 'rgba(160,120,200,0.05)');
105
+ grad.append('stop').attr('offset', '100%').attr('stop-color', 'rgba(0,0,0,0)');
106
+
107
+ const render = () => {
108
+ const width = container.clientWidth || 800;
109
+ const height = Math.max(260, Math.round(width / 3)); // keep ~3:1, min height
110
+ svg.attr('width', width).attr('height', height);
111
+
112
+ const xScale = d3.scaleLinear().domain([0, 3]).range([0, width]);
113
+ const yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]);
114
+
115
+ // Subtle stroke color depending on theme
116
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
117
+ const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
118
+
119
+ // Background rect using gradient
120
+ const bg = svg.selectAll('rect.d3-bg').data([0]);
121
+ bg.join('rect')
122
+ .attr('class', 'd3-bg')
123
+ .attr('x', 0)
124
+ .attr('y', 0)
125
+ .attr('width', width)
126
+ .attr('height', height)
127
+ .attr('fill', 'url(#spaceBg)');
128
+
129
+ // Group with blend mode so points softly accumulate light
130
+ const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points').style('mix-blend-mode', 'screen');
131
+
132
+ // Ensure container can host an absolute tooltip
133
+ container.style.position = container.style.position || 'relative';
134
+ let tip = container.querySelector('.d3-tooltip');
135
+ if (!tip) {
136
+ tip = document.createElement('div');
137
+ tip.className = 'd3-tooltip';
138
+ Object.assign(tip.style, {
139
+ position: 'absolute',
140
+ top: '0px',
141
+ left: '0px',
142
+ transform: 'translate(-9999px, -9999px)',
143
+ pointerEvents: 'none',
144
+ padding: '6px 8px',
145
+ borderRadius: '6px',
146
+ fontSize: '12px',
147
+ lineHeight: '1.3',
148
+ border: '1px solid var(--border-color)',
149
+ background: 'var(--surface-bg)',
150
+ color: 'var(--text-color)',
151
+ boxShadow: '0 2px 10px rgba(0,0,0,.15)',
152
+ opacity: '0',
153
+ transition: 'opacity .12s ease'
154
+ });
155
+ container.appendChild(tip);
156
+ }
157
+
158
+ // Final filter: remove small dots very close to the galaxy center (after placement)
159
+ const centerHoleRadius = 0.08; // elliptical radius threshold
160
+ const smallSizeThreshold = 7.5; // same notion as Python size cut
161
+ const rTotal = idx.map((i) => Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2));
162
+ const idxFiltered = idx.filter((i, k) => !(rTotal[k] <= centerHoleRadius && sizesPx[i] < smallSizeThreshold));
163
+
164
+ const sel = g.selectAll('circle').data(idxFiltered, (i) => i);
165
+ sel.join(
166
+ (enter) => enter.append('circle')
167
+ .attr('cx', (i) => xScale(X[i]))
168
+ .attr('cy', (i) => yScale(Y[i]))
169
+ .attr('r', (i) => sizesPx[i] / 2)
170
+ .attr('fill', (i) => colorFor(Zraw[i]))
171
+ .attr('fill-opacity', 0.9)
172
+ .attr('stroke', strokeColor)
173
+ .attr('stroke-width', 0.4)
174
+ .on('mouseenter', (ev, i) => {
175
+ const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
176
+ const type = i < lenSpiral ? 'spiral' : 'bulge';
177
+ const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
178
+ tip.innerHTML = `<div><strong>${labelOf(i)}</strong></div>` +
179
+ `<div>Type: ${type}${arm ? ` (arm ${arm})` : ''}</div>` +
180
+ `<div>Size: ${sizesPx[i].toFixed(1)} px</div>` +
181
+ `<div>X: ${X[i].toFixed(2)} Β· Y: ${Y[i].toFixed(2)}</div>` +
182
+ `<div>r: ${r.toFixed(3)} Β· z: ${Zraw[i].toFixed(3)}</div>`;
183
+ tip.style.opacity = '1';
184
+ })
185
+ .on('mousemove', (ev, i) => {
186
+ const [mx, my] = d3.pointer(ev, container);
187
+ const offsetX = 10, offsetY = 12;
188
+ tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
189
+ })
190
+ .on('mouseleave', () => {
191
+ tip.style.opacity = '0';
192
+ tip.style.transform = 'translate(-9999px, -9999px)';
193
+ }),
194
+ (update) => update
195
+ .attr('cx', (i) => xScale(X[i]))
196
+ .attr('cy', (i) => yScale(Y[i]))
197
+ .attr('r', (i) => sizesPx[i] / 2)
198
+ .attr('fill', (i) => colorFor(Zraw[i]))
199
+ .attr('fill-opacity', 0.9)
200
+ .attr('stroke', strokeColor)
201
+ .attr('stroke-width', 0.4)
202
+ .on('mouseenter', (ev, i) => {
203
+ const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
204
+ const type = i < lenSpiral ? 'spiral' : 'bulge';
205
+ const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
206
+ tip.innerHTML = `<div><strong>${labelOf(i)}</strong></div>` +
207
+ `<div>Type: ${type}${arm ? ` (arm ${arm})` : ''}</div>` +
208
+ `<div>Size: ${sizesPx[i].toFixed(1)} px</div>` +
209
+ `<div>X: ${X[i].toFixed(2)} Β· Y: ${Y[i].toFixed(2)}</div>` +
210
+ `<div>r: ${r.toFixed(3)} Β· z: ${Zraw[i].toFixed(3)}</div>`;
211
+ tip.style.opacity = '1';
212
+ })
213
+ .on('mousemove', (ev, i) => {
214
+ const [mx, my] = d3.pointer(ev, container);
215
+ const offsetX = 10, offsetY = 12;
216
+ tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
217
+ })
218
+ .on('mouseleave', () => {
219
+ tip.style.opacity = '0';
220
+ tip.style.transform = 'translate(-9999px, -9999px)';
221
+ })
222
+ );
223
+ };
224
+
225
+ // First render + resize
226
+ if (window.ResizeObserver) {
227
+ const ro = new ResizeObserver(() => render());
228
+ ro.observe(container);
229
+ } else {
230
+ window.addEventListener('resize', render);
231
+ }
232
+ render();
233
+ };
234
+
235
+ if (document.readyState === 'loading') {
236
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
237
+ } else { ensureD3(bootstrap); }
238
+ })();
239
+ </script>
240
+
{python/fragments β†’ fragments/plotly}/banner.py RENAMED
@@ -52,19 +52,7 @@ z_raw = np.concatenate([z_spiral, z_bulge])
52
  # Tailles: conserver l'Γ©chelle 5..10 pour cohΓ©rence
53
  sizes = (z_raw + 1) * 5
54
 
55
- # Filtrer les petits points proches du centre (esthΓ©tique du bulbe)
56
- # - on calcule le rayon elliptique normalisΓ©
57
- # - on retire les points de petite taille situés trop près du centre
58
- central_radius_cut = 0.18
59
- min_size_center = 7.5
60
- r_total = np.sqrt(((x - cx) / a) ** 2 + ((y - cy) / b) ** 2)
61
- mask = ~((r_total <= central_radius_cut) & (sizes < min_size_center))
62
-
63
- # Appliquer le masque
64
- x = x[mask]
65
- y = y[mask]
66
- z_raw = z_raw[mask]
67
- sizes = sizes[mask]
68
 
69
  df = pd.DataFrame({
70
  "x": x,
@@ -85,6 +73,9 @@ def get_label(z):
85
  # Labels basΓ©s sur l'intensitΓ© centrale
86
  df["label"] = pd.Series(z_raw).apply(get_label)
87
 
 
 
 
88
  fig = go.Figure()
89
 
90
  fig.add_trace(go.Scattergl(
 
52
  # Tailles: conserver l'Γ©chelle 5..10 pour cohΓ©rence
53
  sizes = (z_raw + 1) * 5
54
 
55
+ # Suppression du filtre intermΓ©diaire: on garde tous les points posΓ©s, on filtrera Γ  la toute fin
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  df = pd.DataFrame({
58
  "x": x,
 
73
  # Labels basΓ©s sur l'intensitΓ© centrale
74
  df["label"] = pd.Series(z_raw).apply(get_label)
75
 
76
+ # Ordonnancement pour le rendu: petits d'abord, gros ensuite (au-dessus)
77
+ df = df.sort_values(by="z", ascending=True).reset_index(drop=True)
78
+
79
  fig = go.Figure()
80
 
81
  fig.add_trace(go.Scattergl(
{python/fragments β†’ fragments/plotly}/bar.py RENAMED
File without changes
{python/fragments β†’ fragments/plotly}/heatmap.py RENAMED
File without changes
{python/fragments β†’ fragments/plotly}/line.py RENAMED
File without changes
{python β†’ fragments/plotly}/poetry.lock RENAMED
File without changes
{python β†’ fragments/plotly}/pyproject.toml RENAMED
File without changes
python/convert.py DELETED
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env python3
2
- import markdown
3
- from pathlib import Path
4
- import sys
5
-
6
- def convert_md_to_html(filepath):
7
- input_path = Path(filepath)
8
- output_path = input_path.with_suffix('.html')
9
-
10
- try:
11
- with open(input_path, 'r', encoding='utf-8') as md_file:
12
- text = md_file.read()
13
- html = markdown.markdown(text)
14
-
15
- with open(output_path, 'w', encoding='utf-8', errors='xmlcharrefreplace') as html_file:
16
- html_file.write(html)
17
-
18
- print(f"Converted {input_path} -> {output_path}")
19
-
20
- except FileNotFoundError:
21
- print(f"Error: Could not find file {input_path}")
22
- sys.exit(1)
23
- except Exception as e:
24
- print(f"Error converting file: {e}")
25
- sys.exit(1)
26
-
27
- if __name__ == '__main__':
28
- if len(sys.argv) != 2:
29
- print("Usage: python convert.py FILEPATH.md")
30
- sys.exit(1)
31
-
32
- convert_md_to_html(sys.argv[1])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
python/convert_to_md.py DELETED
@@ -1,110 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- HTML to Markdown Converter
4
-
5
- This script converts HTML files to Markdown format.
6
- Usage: python html_to_md.py input.html [output.md]
7
- If no output file is specified, it will use the input filename with .md extension.
8
- """
9
-
10
- import sys
11
- import os
12
- import argparse
13
- import html2text
14
- import requests
15
- from urllib.parse import urlparse
16
-
17
- def is_url(path):
18
- """Check if the given path is a URL."""
19
- parsed = urlparse(path)
20
- return parsed.scheme != '' and parsed.netloc != ''
21
-
22
- def convert_html_to_markdown(html_content, **options):
23
- """Convert HTML content to Markdown."""
24
- converter = html2text.HTML2Text()
25
-
26
- # Configure converter options
27
- converter.ignore_links = options.get('ignore_links', False)
28
- converter.ignore_images = options.get('ignore_images', False)
29
- converter.ignore_tables = options.get('ignore_tables', False)
30
- converter.body_width = options.get('body_width', 0) # 0 means no wrapping
31
- converter.unicode_snob = options.get('unicode_snob', True) # Use Unicode instead of ASCII
32
- converter.wrap_links = options.get('wrap_links', False)
33
- converter.inline_links = options.get('inline_links', True)
34
-
35
- # Convert HTML to Markdown
36
- return converter.handle(html_content)
37
-
38
- def main():
39
- parser = argparse.ArgumentParser(description='Convert HTML to Markdown')
40
- parser.add_argument('input', help='Input HTML file or URL')
41
- parser.add_argument('output', nargs='?', help='Output Markdown file (optional)')
42
- parser.add_argument('--ignore-links', action='store_true', help='Ignore links in the HTML')
43
- parser.add_argument('--ignore-images', action='store_true', help='Ignore images in the HTML')
44
- parser.add_argument('--ignore-tables', action='store_true', help='Ignore tables in the HTML')
45
- parser.add_argument('--body-width', type=int, default=0, help='Wrap text at this width (0 for no wrapping)')
46
- parser.add_argument('--unicode', action='store_true', help='Use Unicode characters instead of ASCII approximations')
47
- parser.add_argument('--wrap-links', action='store_true', help='Wrap links in angle brackets')
48
- parser.add_argument('--reference-links', action='store_true', help='Use reference style links instead of inline links')
49
-
50
- args = parser.parse_args()
51
-
52
- # Determine input
53
- if is_url(args.input):
54
- try:
55
- response = requests.get(args.input)
56
- response.raise_for_status()
57
- html_content = response.text
58
- except requests.exceptions.RequestException as e:
59
- print(f"Error fetching URL: {e}", file=sys.stderr)
60
- return 1
61
- else:
62
- try:
63
- with open(args.input, 'r', encoding='utf-8') as f:
64
- html_content = f.read()
65
- except IOError as e:
66
- print(f"Error reading file: {e}", file=sys.stderr)
67
- return 1
68
-
69
- # Configure conversion options
70
- options = {
71
- 'ignore_links': args.ignore_links,
72
- 'ignore_images': args.ignore_images,
73
- 'ignore_tables': args.ignore_tables,
74
- 'body_width': args.body_width,
75
- 'unicode_snob': args.unicode,
76
- 'wrap_links': args.wrap_links,
77
- 'inline_links': not args.reference_links,
78
- }
79
-
80
- # Convert HTML to Markdown
81
- markdown_content = convert_html_to_markdown(html_content, **options)
82
-
83
- # Determine output
84
- if args.output:
85
- output_file = args.output
86
- else:
87
- if is_url(args.input):
88
- # Generate a filename from the URL
89
- url_parts = urlparse(args.input)
90
- base_name = os.path.basename(url_parts.path) or 'index'
91
- if not base_name.endswith('.html'):
92
- base_name += '.html'
93
- output_file = os.path.splitext(base_name)[0] + '.md'
94
- else:
95
- # Generate a filename from the input file
96
- output_file = os.path.splitext(args.input)[0] + '.md'
97
-
98
- # Write output
99
- try:
100
- with open(output_file, 'w', encoding='utf-8') as f:
101
- f.write(markdown_content)
102
- print(f"Conversion successful! Output saved to: {output_file}")
103
- except IOError as e:
104
- print(f"Error writing file: {e}", file=sys.stderr)
105
- return 1
106
-
107
- return 0
108
-
109
- if __name__ == "__main__":
110
- sys.exit(main())