Spaces:
Running
Running
thibaud frere
commited on
Commit
Β·
e0ad823
1
Parent(s):
fef84f3
test playwright
Browse files- .gitignore +2 -0
- Dockerfile +5 -1
- app/.astro/astro/content.d.ts +169 -0
- app/astro.config.mjs +4 -1
- app/package-lock.json +0 -0
- app/package.json +0 -0
- app/scripts/export-pdf.mjs +216 -0
- app/src/components/Footer.astro +4 -34
- app/src/components/HtmlFragment.astro +1 -1
- app/src/components/Meta.astro +25 -0
- app/src/{pages β content}/article.mdx +58 -58
- app/src/content/fragments/banner.html +244 -0
- app/src/{fragments β content/fragments}/bar.html +0 -0
- app/src/{fragments β content/fragments}/heatmap.html +0 -0
- app/src/{fragments β content/fragments}/line.html +0 -0
- app/src/content/fragments/palettes.html +311 -0
- app/src/fragments/banner.html +0 -0
- app/src/pages/index.astro +28 -6
- app/src/styles/_base.scss +73 -28
- app/src/styles/_layout.scss +14 -1
- app/src/styles/components/_code.scss +31 -0
- app/src/styles/components/_footer.scss +50 -4
- app/src/styles/global.scss +5 -3
- fragments/d3js/banner.html +240 -0
- {python/fragments β fragments/plotly}/banner.py +4 -13
- {python/fragments β fragments/plotly}/bar.py +0 -0
- {python/fragments β fragments/plotly}/heatmap.py +0 -0
- {python/fragments β fragments/plotly}/line.py +0 -0
- {python β fragments/plotly}/poetry.lock +0 -0
- {python β fragments/plotly}/pyproject.toml +0 -0
- python/convert.py +0 -32
- python/convert_to_md.py +0 -110
.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 |
-
|
|
|
|
| 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 |
-
<
|
| 14 |
|
| 15 |
-
<
|
| 16 |
-
<
|
| 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 |
-
|
| 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: "
|
|
|
|
| 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 **
|
| 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:
|
| 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 |
-
|
| 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
|
| 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 |
-
###
|
| 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 |
-
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 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
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
Block
|
| 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:**
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
####
|
| 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 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
| 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 |
-
|
| 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 '
|
| 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 |
-
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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
|
| 233 |
-
|
| 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 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 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:
|
| 2 |
-
.footer-inner { max-width:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 15 |
-
[data-theme="dark"] .citation
|
| 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 |
-
#
|
| 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())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|