thibaud frere commited on
Commit
1ee6ce7
Β·
1 Parent(s): 91b0400
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. .gitignore +2 -1
  2. .temp-template-sync +0 -1
  3. app/.astro/astro/content.d.ts +284 -0
  4. app/package-lock.json +0 -0
  5. app/package.json +0 -0
  6. app/public/scripts/color-palettes.js +82 -44
  7. app/scripts/latex-to-mdx/README.md +4 -4
  8. app/scripts/latex-to-mdx/input/sections/05_foundation_models.tex.temp +0 -224
  9. app/scripts/latex-to-mdx/input/sections/test.md +0 -488
  10. app/scripts/latex-to-mdx/mdx-converter.mjs +25 -25
  11. app/scripts/latex-to-mdx/post-processor.mjs +1 -1
  12. app/scripts/sync-template.mjs +67 -14
  13. app/src/components/ColorPicker.astro +0 -118
  14. app/src/components/{ResponsiveImage.astro β†’ Figure.astro} +9 -20
  15. app/src/components/{MultiImage.astro β†’ MultiFigure.astro} +10 -10
  16. app/src/components/Palettes.astro +0 -170
  17. app/src/components/Sidenote.astro +8 -8
  18. app/src/components/demo/ColorPicker.astro +633 -0
  19. app/src/components/demo/Palettes.astro +596 -0
  20. app/src/components/trackio/Trackio.svelte +500 -259
  21. app/src/components/{TrackioWrapper.astro β†’ trackio/TrackioWrapper.astro} +219 -147
  22. app/src/components/trackio/core/adaptive-sampler.js +9 -9
  23. app/src/content/assets/data/data.json +3 -0
  24. .devcontainer/devcontainer.json β†’ app/src/content/assets/data/font-sprite-mapping.json +2 -2
  25. app/src/content/assets/data/font-sprite.svg +0 -0
  26. app/src/content/assets/data/font_manifest.json +3 -0
  27. app/src/content/assets/data/typography_data.json +3 -0
  28. app/src/content/assets/sprites/font-sprite.svg +0 -0
  29. app/src/content/chapters/demo/best-pratices.mdx +2 -2
  30. app/src/content/chapters/demo/components.mdx +9 -9
  31. app/src/content/chapters/demo/debug-components.mdx +1 -1
  32. app/src/content/chapters/demo/introduction.mdx +3 -2
  33. app/src/content/chapters/demo/markdown.mdx +2 -2
  34. app/src/content/chapters/demo/vibe-coding-charts.mdx +74 -25
  35. app/src/content/chapters/demo/writing-your-content.mdx +5 -4
  36. app/src/content/chapters/your-first-chapter.mdx +2 -0
  37. app/src/content/embeds/arxiv/arxiv.html +566 -0
  38. app/src/content/embeds/arxiv/fetch_arxiv_api.py +270 -0
  39. app/src/content/embeds/arxiv/generate_umap.py +329 -0
  40. app/src/content/embeds/banner.html +235 -244
  41. app/src/content/embeds/d3-bar.html +224 -82
  42. app/src/content/embeds/d3-equation-editor.html +677 -0
  43. app/src/content/embeds/d3-line-quad.html +272 -134
  44. app/src/content/embeds/d3-matrix.html +56 -47
  45. app/src/content/embeds/d3-neural-network.html +427 -329
  46. app/src/content/embeds/d3-pie-quad.html +8 -8
  47. app/src/content/embeds/d3-scatter.html +1 -1
  48. app/src/content/embeds/d3-umap-typography.html +804 -0
  49. app/src/content/embeds/demo/color-picker.html +0 -226
  50. app/src/content/embeds/demo/palettes.html +0 -219
.gitignore CHANGED
@@ -19,7 +19,8 @@ node_modules/
19
  *.env
20
  *.cache
21
 
22
- app/scripts/latex-converter/output/
 
23
 
24
  # PDF export
25
  app/public/*.pdf
 
19
  *.env
20
  *.cache
21
 
22
+ app/scripts/latex-to-mdx/output/
23
+ app/src/content/embeds/typography/generated
24
 
25
  # PDF export
26
  app/public/*.pdf
.temp-template-sync DELETED
@@ -1 +0,0 @@
1
- Subproject commit ead50ba9028719475151d26696248ef6fe996b70
 
 
app/.astro/astro/content.d.ts CHANGED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ declare module 'astro:content' {
2
+ interface Render {
3
+ '.mdx': Promise<{
4
+ Content: import('astro').MarkdownInstance<{}>['Content'];
5
+ headings: import('astro').MarkdownHeading[];
6
+ remarkPluginFrontmatter: Record<string, any>;
7
+ components: import('astro').MDXInstance<{}>['components'];
8
+ }>;
9
+ }
10
+ }
11
+
12
+ declare module 'astro:content' {
13
+ interface RenderResult {
14
+ Content: import('astro/runtime/server/index.js').AstroComponentFactory;
15
+ headings: import('astro').MarkdownHeading[];
16
+ remarkPluginFrontmatter: Record<string, any>;
17
+ }
18
+ interface Render {
19
+ '.md': Promise<RenderResult>;
20
+ }
21
+
22
+ export interface RenderedContent {
23
+ html: string;
24
+ metadata?: {
25
+ imagePaths: Array<string>;
26
+ [key: string]: unknown;
27
+ };
28
+ }
29
+ }
30
+
31
+ declare module 'astro:content' {
32
+ type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
33
+
34
+ export type CollectionKey = keyof AnyEntryMap;
35
+ export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
36
+
37
+ export type ContentCollectionKey = keyof ContentEntryMap;
38
+ export type DataCollectionKey = keyof DataEntryMap;
39
+
40
+ type AllValuesOf<T> = T extends any ? T[keyof T] : never;
41
+ type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
42
+ ContentEntryMap[C]
43
+ >['slug'];
44
+
45
+ /** @deprecated Use `getEntry` instead. */
46
+ export function getEntryBySlug<
47
+ C extends keyof ContentEntryMap,
48
+ E extends ValidContentEntrySlug<C> | (string & {}),
49
+ >(
50
+ collection: C,
51
+ // Note that this has to accept a regular string too, for SSR
52
+ entrySlug: E,
53
+ ): E extends ValidContentEntrySlug<C>
54
+ ? Promise<CollectionEntry<C>>
55
+ : Promise<CollectionEntry<C> | undefined>;
56
+
57
+ /** @deprecated Use `getEntry` instead. */
58
+ export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
59
+ collection: C,
60
+ entryId: E,
61
+ ): Promise<CollectionEntry<C>>;
62
+
63
+ export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
64
+ collection: C,
65
+ filter?: (entry: CollectionEntry<C>) => entry is E,
66
+ ): Promise<E[]>;
67
+ export function getCollection<C extends keyof AnyEntryMap>(
68
+ collection: C,
69
+ filter?: (entry: CollectionEntry<C>) => unknown,
70
+ ): Promise<CollectionEntry<C>[]>;
71
+
72
+ export function getEntry<
73
+ C extends keyof ContentEntryMap,
74
+ E extends ValidContentEntrySlug<C> | (string & {}),
75
+ >(entry: {
76
+ collection: C;
77
+ slug: E;
78
+ }): E extends ValidContentEntrySlug<C>
79
+ ? Promise<CollectionEntry<C>>
80
+ : Promise<CollectionEntry<C> | undefined>;
81
+ export function getEntry<
82
+ C extends keyof DataEntryMap,
83
+ E extends keyof DataEntryMap[C] | (string & {}),
84
+ >(entry: {
85
+ collection: C;
86
+ id: E;
87
+ }): E extends keyof DataEntryMap[C]
88
+ ? Promise<DataEntryMap[C][E]>
89
+ : Promise<CollectionEntry<C> | undefined>;
90
+ export function getEntry<
91
+ C extends keyof ContentEntryMap,
92
+ E extends ValidContentEntrySlug<C> | (string & {}),
93
+ >(
94
+ collection: C,
95
+ slug: E,
96
+ ): E extends ValidContentEntrySlug<C>
97
+ ? Promise<CollectionEntry<C>>
98
+ : Promise<CollectionEntry<C> | undefined>;
99
+ export function getEntry<
100
+ C extends keyof DataEntryMap,
101
+ E extends keyof DataEntryMap[C] | (string & {}),
102
+ >(
103
+ collection: C,
104
+ id: E,
105
+ ): E extends keyof DataEntryMap[C]
106
+ ? Promise<DataEntryMap[C][E]>
107
+ : Promise<CollectionEntry<C> | undefined>;
108
+
109
+ /** Resolve an array of entry references from the same collection */
110
+ export function getEntries<C extends keyof ContentEntryMap>(
111
+ entries: {
112
+ collection: C;
113
+ slug: ValidContentEntrySlug<C>;
114
+ }[],
115
+ ): Promise<CollectionEntry<C>[]>;
116
+ export function getEntries<C extends keyof DataEntryMap>(
117
+ entries: {
118
+ collection: C;
119
+ id: keyof DataEntryMap[C];
120
+ }[],
121
+ ): Promise<CollectionEntry<C>[]>;
122
+
123
+ export function render<C extends keyof AnyEntryMap>(
124
+ entry: AnyEntryMap[C][string],
125
+ ): Promise<RenderResult>;
126
+
127
+ export function reference<C extends keyof AnyEntryMap>(
128
+ collection: C,
129
+ ): import('astro/zod').ZodEffects<
130
+ import('astro/zod').ZodString,
131
+ C extends keyof ContentEntryMap
132
+ ? {
133
+ collection: C;
134
+ slug: ValidContentEntrySlug<C>;
135
+ }
136
+ : {
137
+ collection: C;
138
+ id: keyof DataEntryMap[C];
139
+ }
140
+ >;
141
+ // Allow generic `string` to avoid excessive type errors in the config
142
+ // if `dev` is not running to update as you edit.
143
+ // Invalid collection names will be caught at build time.
144
+ export function reference<C extends string>(
145
+ collection: C,
146
+ ): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
147
+
148
+ type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
149
+ type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
150
+ ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
151
+ >;
152
+
153
+ type ContentEntryMap = {
154
+ "chapters": {
155
+ "demo/best-pratices.mdx": {
156
+ id: "demo/best-pratices.mdx";
157
+ slug: "demo/best-pratices";
158
+ body: string;
159
+ collection: "chapters";
160
+ data: any
161
+ } & { render(): Render[".mdx"] };
162
+ "demo/components.mdx": {
163
+ id: "demo/components.mdx";
164
+ slug: "demo/components";
165
+ body: string;
166
+ collection: "chapters";
167
+ data: any
168
+ } & { render(): Render[".mdx"] };
169
+ "demo/debug-components.mdx": {
170
+ id: "demo/debug-components.mdx";
171
+ slug: "demo/debug-components";
172
+ body: string;
173
+ collection: "chapters";
174
+ data: any
175
+ } & { render(): Render[".mdx"] };
176
+ "demo/getting-started.mdx": {
177
+ id: "demo/getting-started.mdx";
178
+ slug: "demo/getting-started";
179
+ body: string;
180
+ collection: "chapters";
181
+ data: any
182
+ } & { render(): Render[".mdx"] };
183
+ "demo/greetings.mdx": {
184
+ id: "demo/greetings.mdx";
185
+ slug: "demo/greetings";
186
+ body: string;
187
+ collection: "chapters";
188
+ data: any
189
+ } & { render(): Render[".mdx"] };
190
+ "demo/introduction.mdx": {
191
+ id: "demo/introduction.mdx";
192
+ slug: "demo/introduction";
193
+ body: string;
194
+ collection: "chapters";
195
+ data: any
196
+ } & { render(): Render[".mdx"] };
197
+ "demo/latex-convertion.mdx": {
198
+ id: "demo/latex-convertion.mdx";
199
+ slug: "demo/latex-convertion";
200
+ body: string;
201
+ collection: "chapters";
202
+ data: any
203
+ } & { render(): Render[".mdx"] };
204
+ "demo/markdown.mdx": {
205
+ id: "demo/markdown.mdx";
206
+ slug: "demo/markdown";
207
+ body: string;
208
+ collection: "chapters";
209
+ data: any
210
+ } & { render(): Render[".mdx"] };
211
+ "demo/vibe-coding-charts.mdx": {
212
+ id: "demo/vibe-coding-charts.mdx";
213
+ slug: "demo/vibe-coding-charts";
214
+ body: string;
215
+ collection: "chapters";
216
+ data: any
217
+ } & { render(): Render[".mdx"] };
218
+ "demo/writing-your-content.mdx": {
219
+ id: "demo/writing-your-content.mdx";
220
+ slug: "demo/writing-your-content";
221
+ body: string;
222
+ collection: "chapters";
223
+ data: any
224
+ } & { render(): Render[".mdx"] };
225
+ "your-first-chapter.mdx": {
226
+ id: "your-first-chapter.mdx";
227
+ slug: "your-first-chapter";
228
+ body: string;
229
+ collection: "chapters";
230
+ data: any
231
+ } & { render(): Render[".mdx"] };
232
+ };
233
+ "embeds": {
234
+ "vibe-code-d3-embeds-directives.md": {
235
+ id: "vibe-code-d3-embeds-directives.md";
236
+ slug: "vibe-code-d3-embeds-directives";
237
+ body: string;
238
+ collection: "embeds";
239
+ data: any
240
+ } & { render(): Render[".md"] };
241
+ };
242
+
243
+ };
244
+
245
+ type DataEntryMap = {
246
+ "assets": {
247
+ "data/data": {
248
+ id: "data/data";
249
+ collection: "assets";
250
+ data: any
251
+ };
252
+ "data/font-sprite-mapping": {
253
+ id: "data/font-sprite-mapping";
254
+ collection: "assets";
255
+ data: any
256
+ };
257
+ "data/font_manifest": {
258
+ id: "data/font_manifest";
259
+ collection: "assets";
260
+ data: any
261
+ };
262
+ "data/llm_benchmarks": {
263
+ id: "data/llm_benchmarks";
264
+ collection: "assets";
265
+ data: any
266
+ };
267
+ "data/mnist-variant-model": {
268
+ id: "data/mnist-variant-model";
269
+ collection: "assets";
270
+ data: any
271
+ };
272
+ "data/typography_data": {
273
+ id: "data/typography_data";
274
+ collection: "assets";
275
+ data: any
276
+ };
277
+ };
278
+
279
+ };
280
+
281
+ type AnyEntryMap = ContentEntryMap & DataEntryMap;
282
+
283
+ export type ContentConfig = never;
284
+ }
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/public/scripts/color-palettes.js CHANGED
@@ -46,63 +46,95 @@
46
  return { r, g, b: b3 };
47
  };
48
  const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
49
- const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a*a + b*b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
50
  const clamp01 = (x) => Math.min(1, Math.max(0, x));
51
  const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
52
- const toHex = ({ r, g, b }) => { const R = Math.round(clamp01(r)*255), G = Math.round(clamp01(g)*255), B = Math.round(clamp01(b)*255); const h = (n) => n.toString(16).padStart(2,'0'); return `#${h(R)}${h(G)}${h(B)}`.toUpperCase(); };
53
- const oklchToHexSafe = (L, C, h) => { let c = C; for (let i=0;i<12;i++){ const { a, b } = oklchToOklab(L,c,h); const rgb = oklabToRgb(L,a,b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c-0.02);} return toHex(oklabToRgb(L,0,0)); };
54
- const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1])/255, g: Number(m[2])/255, b: Number(m[3])/255 }; } catch { return null; } };
 
 
 
 
55
 
56
- const getPrimaryHex = () => {
 
57
  const css = getCssVar('--primary-color');
58
- if (!css) return '#E889AB';
59
- if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(css)) return css.toUpperCase();
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  const rgb = parseCssColorToRgb(css);
61
- if (rgb) return toHex(rgb);
62
- return '#E889AB';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  };
64
  // No count management via CSS anymore; counts are passed directly to the API
65
 
66
  const generators = {
67
- categorical: (baseHex, count) => {
68
- const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
69
- const { r, g, b } = parseHex(baseHex);
70
- const { L, a, b: bb } = rgbToOklab(r,g,b);
71
- const { C, h } = oklabToOklch(L,a,bb);
72
  const L0 = Math.min(0.85, Math.max(0.4, L));
73
  const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
74
  const total = Math.max(1, Math.min(12, count || 8));
75
  const hueStep = 360 / total;
76
  const results = [];
77
- for (let i=0;i<total;i++) { const hDeg = (h + i*hueStep) % 360; const lVar = ((i % 3) - 1) * 0.04; results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg)); }
 
 
 
 
78
  return results;
79
  },
80
- sequential: (baseHex, count) => {
81
- const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
82
- const { r, g, b } = parseHex(baseHex);
83
- const { L, a, b: bb } = rgbToOklab(r,g,b);
84
- const { C, h } = oklabToOklch(L,a,bb);
85
  const total = Math.max(1, Math.min(12, count || 8));
86
  const startL = Math.max(0.25, L - 0.18);
87
  const endL = Math.min(0.92, L + 0.18);
88
  const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
89
  const out = [];
90
- for (let i=0;i<total;i++) { const t = total===1 ? 0 : i/(total-1); const lNow = startL*(1-t)+endL*t; const cNow = cBase*(0.85 + 0.15*(1 - Math.abs(0.5 - t)*2)); out.push(oklchToHexSafe(lNow, cNow, h)); }
 
 
 
 
 
91
  return out;
92
  },
93
- diverging: (baseHex, count) => {
94
- const parseHex = (h) => { const s = h.replace('#',''); const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s; return { r: parseInt(v.slice(0,2),16)/255, g: parseInt(v.slice(2,4),16)/255, b: parseInt(v.slice(4,6),16)/255 }; };
95
- const { r, g, b } = parseHex(baseHex);
96
- const baseLab = rgbToOklab(r,g,b);
97
- const baseLch = oklabToOklch(baseLab.L, baseLab.a, baseLab.b);
98
  const total = Math.max(1, Math.min(12, count || 8));
99
 
100
  // Left endpoint: EXACT primary color (no darkening)
101
- const leftLab = baseLab;
102
  // Right endpoint: complement with same L and similar C (clamped safe)
103
- const compH = (baseLch.h + 180) % 360;
104
- const cSafe = Math.min(0.35, Math.max(0.08, baseLch.C));
105
- const rightLab = oklchToOklab(baseLab.L, cSafe, compH);
106
  const whiteLab = { L: 0.98, a: 0, b: 0 }; // center near‑white
107
 
108
  const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
@@ -152,18 +184,22 @@
152
  let lastSignature = '';
153
 
154
  const updatePalettes = () => {
155
- const primary = getPrimaryHex();
156
- const signature = `${primary}`;
 
157
  if (signature === lastSignature) return;
158
  lastSignature = signature;
159
- try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary } })); } catch {}
160
  };
161
 
162
  const bootstrap = () => {
 
163
  updatePalettes();
 
 
164
  const mo = new MutationObserver(() => updatePalettes());
165
  mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
166
- setInterval(updatePalettes, 400);
167
  // Utility: choose high-contrast (or softened) text style against an arbitrary background color
168
  const pickTextStyleForBackground = (bgCss, opts = {}) => {
169
  const cssRoot = document.documentElement;
@@ -175,13 +211,13 @@
175
  if (!rgb) return null;
176
  return rgb; // already 0..1
177
  };
178
- const mixRgb01 = (a, b, t) => ({ r: a.r*(1-t)+b.r*t, g: a.g*(1-t)+b.g*t, b: a.b*(1-t)+b.b*t });
179
  const relLum = (rgb) => {
180
  const f = (u) => srgbToLinear(u);
181
- return 0.2126*f(rgb.r) + 0.7152*f(rgb.g) + 0.0722*f(rgb.b);
182
  };
183
  const contrast = (fg, bg) => {
184
- const L1 = relLum(fg), L2 = relLum(bg); const a = Math.max(L1,L2), b = Math.min(L1,L2);
185
  return (a + 0.05) / (b + 0.05);
186
  };
187
  try {
@@ -193,7 +229,7 @@
193
  .filter(x => !!x.rgb);
194
  // Pick the max contrast
195
  let best = candidates[0]; let bestCR = contrast(best.rgb, bg);
196
- for (let i=1;i<candidates.length;i++){
197
  const cr = contrast(candidates[i].rgb, bg);
198
  if (cr > bestCR) { best = candidates[i]; bestCR = cr; }
199
  }
@@ -206,7 +242,7 @@
206
  finalRgb = mixRgb01(best.rgb, mutedRgb, blend);
207
  }
208
  const haloStrength = Math.min(1, Math.max(0, Number(opts.haloStrength == null ? 0.5 : opts.haloStrength)));
209
- const stroke = (best.css === '#000' || best.css.toLowerCase() === 'black') ? `rgba(255,255,255,${0.30 + 0.40*haloStrength})` : `rgba(0,0,0,${0.30 + 0.30*haloStrength})`;
210
  return { fill: toHex(finalRgb), stroke, strokeWidth: (opts.haloWidth == null ? 1 : Number(opts.haloWidth)) };
211
  } catch {
212
  return { fill: getCssVar('--text-color') || '#000', stroke: 'var(--transparent-page-contrast)', strokeWidth: 1 };
@@ -214,14 +250,16 @@
214
  };
215
  window.ColorPalettes = {
216
  refresh: updatePalettes,
217
- notify: () => { try { const primary = getPrimaryHex(); document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary } })); } catch {} },
218
  getPrimary: () => getPrimaryHex(),
 
219
  getColors: (key, count = 6) => {
220
- const primary = getPrimaryHex();
 
221
  const total = Math.max(1, Math.min(12, Number(count) || 6));
222
- if (key === 'categorical') return generators.categorical(primary, total);
223
- if (key === 'sequential') return generators.sequential(primary, total);
224
- if (key === 'diverging') return generators.diverging(primary, total);
225
  return [];
226
  },
227
  getTextStyleForBackground: (bgCss, opts) => pickTextStyleForBackground(bgCss, opts || {}),
 
46
  return { r, g, b: b3 };
47
  };
48
  const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
49
+ const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a * a + b * b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
50
  const clamp01 = (x) => Math.min(1, Math.max(0, x));
51
  const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
52
+ const toHex = ({ r, g, b }) => {
53
+ const R = Math.round(clamp01(r) * 255), G = Math.round(clamp01(g) * 255), B = Math.round(clamp01(b) * 255);
54
+ const h = (n) => n.toString(16).padStart(2, '0');
55
+ return `#${h(R)}${h(G)}${h(B)}`.toUpperCase();
56
+ };
57
+ const oklchToHexSafe = (L, C, h) => { let c = C; for (let i = 0; i < 12; i++) { const { a, b } = oklchToOklab(L, c, h); const rgb = oklabToRgb(L, a, b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c - 0.02); } return toHex(oklabToRgb(L, 0, 0)); };
58
+ const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1]) / 255, g: Number(m[2]) / 255, b: Number(m[3]) / 255 }; } catch { return null; } };
59
 
60
+ // Get primary color in OKLCH format to preserve precision
61
+ const getPrimaryOKLCH = () => {
62
  const css = getCssVar('--primary-color');
63
+ if (!css) return null;
64
+
65
+ // For OKLCH colors, return the exact values without conversion
66
+ if (css.includes('oklch')) {
67
+ const oklchMatch = css.match(/oklch\(([^)]+)\)/);
68
+ if (oklchMatch) {
69
+ const values = oklchMatch[1].split(/\s+/).map(v => parseFloat(v.trim()));
70
+ if (values.length >= 3) {
71
+ const [L, C, h] = values;
72
+ return { L, C, h };
73
+ }
74
+ }
75
+ }
76
+
77
+ // For non-OKLCH colors, convert to OKLCH for consistency
78
  const rgb = parseCssColorToRgb(css);
79
+ if (rgb) {
80
+ const { L, a, b } = rgbToOklab(rgb.r, rgb.g, rgb.b);
81
+ const { C, h } = oklabToOklch(L, a, b);
82
+ return { L, C, h };
83
+ }
84
+ return null;
85
+ };
86
+
87
+ // Keep getPrimaryHex for backward compatibility, but now it converts from OKLCH
88
+ const getPrimaryHex = () => {
89
+ const oklch = getPrimaryOKLCH();
90
+ if (!oklch) return null;
91
+
92
+ const { a, b } = oklchToOklab(oklch.L, oklch.C, oklch.h);
93
+ const rgb = oklabToRgb(oklch.L, a, b);
94
+ return toHex(rgb);
95
  };
96
  // No count management via CSS anymore; counts are passed directly to the API
97
 
98
  const generators = {
99
+ categorical: (baseOKLCH, count) => {
100
+ const { L, C, h } = baseOKLCH;
 
 
 
101
  const L0 = Math.min(0.85, Math.max(0.4, L));
102
  const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
103
  const total = Math.max(1, Math.min(12, count || 8));
104
  const hueStep = 360 / total;
105
  const results = [];
106
+ for (let i = 0; i < total; i++) {
107
+ const hDeg = (h + i * hueStep) % 360;
108
+ const lVar = ((i % 3) - 1) * 0.04;
109
+ results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg));
110
+ }
111
  return results;
112
  },
113
+ sequential: (baseOKLCH, count) => {
114
+ const { L, C, h } = baseOKLCH;
 
 
 
115
  const total = Math.max(1, Math.min(12, count || 8));
116
  const startL = Math.max(0.25, L - 0.18);
117
  const endL = Math.min(0.92, L + 0.18);
118
  const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
119
  const out = [];
120
+ for (let i = 0; i < total; i++) {
121
+ const t = total === 1 ? 0 : i / (total - 1);
122
+ const lNow = startL * (1 - t) + endL * t;
123
+ const cNow = cBase * (0.85 + 0.15 * (1 - Math.abs(0.5 - t) * 2));
124
+ out.push(oklchToHexSafe(lNow, cNow, h));
125
+ }
126
  return out;
127
  },
128
+ diverging: (baseOKLCH, count) => {
129
+ const { L, C, h } = baseOKLCH;
 
 
 
130
  const total = Math.max(1, Math.min(12, count || 8));
131
 
132
  // Left endpoint: EXACT primary color (no darkening)
133
+ const leftLab = oklchToOklab(L, C, h);
134
  // Right endpoint: complement with same L and similar C (clamped safe)
135
+ const compH = (h + 180) % 360;
136
+ const cSafe = Math.min(0.35, Math.max(0.08, C));
137
+ const rightLab = oklchToOklab(L, cSafe, compH);
138
  const whiteLab = { L: 0.98, a: 0, b: 0 }; // center near‑white
139
 
140
  const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
 
184
  let lastSignature = '';
185
 
186
  const updatePalettes = () => {
187
+ const primaryOKLCH = getPrimaryOKLCH();
188
+ const primaryHex = getPrimaryHex();
189
+ const signature = `${primaryOKLCH?.L},${primaryOKLCH?.C},${primaryOKLCH?.h}`;
190
  if (signature === lastSignature) return;
191
  lastSignature = signature;
192
+ try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary: primaryHex, primaryOKLCH } })); } catch { }
193
  };
194
 
195
  const bootstrap = () => {
196
+ // Initial setup - only run once on page load
197
  updatePalettes();
198
+
199
+ // Observer will handle all subsequent changes
200
  const mo = new MutationObserver(() => updatePalettes());
201
  mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
202
+
203
  // Utility: choose high-contrast (or softened) text style against an arbitrary background color
204
  const pickTextStyleForBackground = (bgCss, opts = {}) => {
205
  const cssRoot = document.documentElement;
 
211
  if (!rgb) return null;
212
  return rgb; // already 0..1
213
  };
214
+ const mixRgb01 = (a, b, t) => ({ r: a.r * (1 - t) + b.r * t, g: a.g * (1 - t) + b.g * t, b: a.b * (1 - t) + b.b * t });
215
  const relLum = (rgb) => {
216
  const f = (u) => srgbToLinear(u);
217
+ return 0.2126 * f(rgb.r) + 0.7152 * f(rgb.g) + 0.0722 * f(rgb.b);
218
  };
219
  const contrast = (fg, bg) => {
220
+ const L1 = relLum(fg), L2 = relLum(bg); const a = Math.max(L1, L2), b = Math.min(L1, L2);
221
  return (a + 0.05) / (b + 0.05);
222
  };
223
  try {
 
229
  .filter(x => !!x.rgb);
230
  // Pick the max contrast
231
  let best = candidates[0]; let bestCR = contrast(best.rgb, bg);
232
+ for (let i = 1; i < candidates.length; i++) {
233
  const cr = contrast(candidates[i].rgb, bg);
234
  if (cr > bestCR) { best = candidates[i]; bestCR = cr; }
235
  }
 
242
  finalRgb = mixRgb01(best.rgb, mutedRgb, blend);
243
  }
244
  const haloStrength = Math.min(1, Math.max(0, Number(opts.haloStrength == null ? 0.5 : opts.haloStrength)));
245
+ const stroke = (best.css === '#000' || best.css.toLowerCase() === 'black') ? `rgba(255,255,255,${0.30 + 0.40 * haloStrength})` : `rgba(0,0,0,${0.30 + 0.30 * haloStrength})`;
246
  return { fill: toHex(finalRgb), stroke, strokeWidth: (opts.haloWidth == null ? 1 : Number(opts.haloWidth)) };
247
  } catch {
248
  return { fill: getCssVar('--text-color') || '#000', stroke: 'var(--transparent-page-contrast)', strokeWidth: 1 };
 
250
  };
251
  window.ColorPalettes = {
252
  refresh: updatePalettes,
253
+ notify: () => { try { const primaryOKLCH = getPrimaryOKLCH(); const primaryHex = getPrimaryHex(); document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary: primaryHex, primaryOKLCH } })); } catch { } },
254
  getPrimary: () => getPrimaryHex(),
255
+ getPrimaryOKLCH: () => getPrimaryOKLCH(),
256
  getColors: (key, count = 6) => {
257
+ const primaryOKLCH = getPrimaryOKLCH();
258
+ if (!primaryOKLCH) return [];
259
  const total = Math.max(1, Math.min(12, Number(count) || 6));
260
+ if (key === 'categorical') return generators.categorical(primaryOKLCH, total);
261
+ if (key === 'sequential') return generators.sequential(primaryOKLCH, total);
262
+ if (key === 'diverging') return generators.diverging(primaryOKLCH, total);
263
  return [];
264
  },
265
  getTextStyleForBackground: (bgCss, opts) => pickTextStyleForBackground(bgCss, opts || {}),
app/scripts/latex-to-mdx/README.md CHANGED
@@ -49,7 +49,7 @@ latex-to-mdx/
49
  ### 🎨 **Automatic Styling**
50
  - **Highlights**: `\highlight{text}` β†’ `<span class="highlight">text</span>`
51
  - **Auto cleanup**: Removal of numbering `(1)`, `(2)`, etc.
52
- - **Astro components**: Images β†’ `ResponsiveImage` with automatic imports
53
 
54
  ### πŸ”§ **Robust Pipeline**
55
  - **LaTeX preprocessor**: Reference cleanup before Pandoc
@@ -83,7 +83,7 @@ title: "Your Article Title"
83
  description: "Generated from LaTeX"
84
  ---
85
 
86
- import ResponsiveImage from '../components/ResponsiveImage.astro';
87
  import figure1 from '../assets/image/figure1.png';
88
 
89
  ## Section with invisible anchor
@@ -96,7 +96,7 @@ Reference to an interactive [equation](#equation-name).
96
  Equation with KaTeX ID:
97
  $$\htmlId{equation-name}{E = mc^2}$$
98
 
99
- <ResponsiveImage src={figure1} alt="Description" />
100
  ```
101
 
102
  ## βš™οΈ Required Astro Configuration
@@ -141,7 +141,7 @@ export default defineConfig({
141
  - Code snippet injection
142
 
143
  4. **MDX Conversion** (`mdx-converter.mjs`)
144
- - Images transformation β†’ `ResponsiveImage`
145
  - HTML span escaping correction
146
  - Automatic imports generation
147
  - MDX frontmatter
 
49
  ### 🎨 **Automatic Styling**
50
  - **Highlights**: `\highlight{text}` β†’ `<span class="highlight">text</span>`
51
  - **Auto cleanup**: Removal of numbering `(1)`, `(2)`, etc.
52
+ - **Astro components**: Images β†’ `Figure` with automatic imports
53
 
54
  ### πŸ”§ **Robust Pipeline**
55
  - **LaTeX preprocessor**: Reference cleanup before Pandoc
 
83
  description: "Generated from LaTeX"
84
  ---
85
 
86
+ import Figure from '../components/Figure.astro';
87
  import figure1 from '../assets/image/figure1.png';
88
 
89
  ## Section with invisible anchor
 
96
  Equation with KaTeX ID:
97
  $$\htmlId{equation-name}{E = mc^2}$$
98
 
99
+ <Figure src={figure1} alt="Description" />
100
  ```
101
 
102
  ## βš™οΈ Required Astro Configuration
 
141
  - Code snippet injection
142
 
143
  4. **MDX Conversion** (`mdx-converter.mjs`)
144
+ - Images transformation β†’ `Figure`
145
  - HTML span escaping correction
146
  - Automatic imports generation
147
  - MDX frontmatter
app/scripts/latex-to-mdx/input/sections/05_foundation_models.tex.temp DELETED
@@ -1,224 +0,0 @@
1
- \section{Generalist Robot Policies}
2
- \label{sec:learning-foundation}
3
-
4
- \epigraph{\textit{Specialization is for insects}}{Robert A. Heinlein}
5
-
6
- > **TL;DR**
7
- > Openly available large scale datasets and the development of stable, expressive and efficient architecture fostered research on the development of generalist robot policies that can operate across embodiment and tasks.
8
-
9
- The advent of large models trained on internet-scale datasets has drastically influenced fields like Computer Vision (CV) and Natural Language Processing (NLP), shifting the paradigm towards combining (1) an initial, task-agnostic large-scale pre-training stage and a (2) task-specific, adjustment phase.
10
- The pre-training/adaptation paradigm has now largely replaced more classic approaches consisting of task-specific data collection, curation and model training in many subdomains within CV and NLP, motivated by the main drawback of limited scalability for \emph{task-specific approaches}, traditionally labor intensive.
11
- Factors including (1) the advancements in generalist models learned with self-supervision for perception [@oquabDINOv2LearningRobust2024] or semantic understanding [@devlinBERTPretrainingDeep2019] and (2) the popularization collective efforts to aggregate large-scale openly available datasets [@collaborationOpenXEmbodimentRobotic2025,khazatskyDROIDLargeScaleInTheWild2025] are increasingly pushing the field of robot learning towards the pre-train-and-adapt paradigm.
12
- This shift taps into the long-standing challenge of developing generalist robot policies, and holds the premise to surpass traditionally siloed approaches to robotics problems and develop a \emph{foundation robotics model}.
13
- While Section~\ref{sec:learning-bc-single} introduced methods for learning \emph{single-task policies} such as ACT or Diffusion Policy, in this section we present advancements in developing \emph{generalist, multi-task, policies}, capable of performing a wide range of tasks across different environments and embodiments, and guided by unstructured instructions given via natural language.
14
-
15
- ![Fields within ML such as Computer Vision and NLP converged on the development of foundation models, trained on a variety of large scale models and capable to perform multiple downstream tasks (top). Conversely, robotics suffered from limited standardization in terms of the architectures used, and siloed, task specific datasets, incurring in a high degree of fragmentation which traditionally hindered the development of generalist models for robotics in favour of task-specific models (bottom).](assets/image/ch5/ch5-ml-vs-robotics-foundation.png) {#fig-fig:ch5-ml-vs-robotics-foundation}
16
-
17
- *Fields within ML such as Computer Vision and NLP converged on the development of foundation models, trained on a variety of large scale models and capable to perform multiple downstream tasks (top). Conversely, robotics suffered from limited standardization in terms of the architectures used, and siloed, task specific datasets, incurring in a high degree of fragmentation which traditionally hindered the development of generalist models for robotics in favour of task-specific models (bottom).*
18
-
19
- \subsection{Preliminaries: Models and Data}
20
- The remarkable success of foundation models in NLP and CV is predicated on two core principles: architectural innovation and joint data-compute scaling.
21
- The transformer architecture proved instrumental in capturing long-range dependencies in sequential data such as text, and its stability and expressivity made it the \emph{de facto} standard for modern large-scale models trained on internet-scale amounts of data.
22
- In stark contrast with popular NLP [@raffelExploringLimitsTransfer2023] and CV [@ImageNet_VSS09] general-purpose datasets, the field of robotics has historically developed around task-specific datasets which hinders scalability across problems, resulting in a concrete data deficit for general-purpose robot learning.
23
- Unlike the wealth of relatively readily available text and images on the internet, robotics data is intrinsically embodied---datasets collected for a manipulation robot typically differ entirely from locomotion datasets.
24
- Further, datasets consisting of expert demonstrations are (1) intrinsically expensive to collect (2) and notoriously heterogeneous---different human experts may perform the same task optimally yet in very different ways.
25
- In particular, since each expert trajectory is tied to a specific robot platform and the operating conditions of its environment and task, data heterogeneity has long posed a \emph{methodological} challenge for scaling robotics datasets via aggregation.
26
- Beyond this, heterogeneity also raises \emph{conceptual} issues: naively mixing data across embodiments can induce negative transfer, as control strategies developed in isolation for different robot systems in different environments may even conflict when combined.
27
- Thus, the high degree of fragmentation of robotics datasets and tasks has traditionally led to the development of \emph{specialist} policies, trained on small, task-specific datasets, and which excel at their designated task but fail to generalize to new situations (Figure~\ref{fig:ch5-ml-vs-robotics-foundation}).
28
-
29
- ![Early efforts in the development of generalist models for robotics include BC-Zero [@jangBCZZeroShotTask2022], RT-1 [@brohanRT1RoboticsTransformer2023], and RT-2 [@brohanRT2VisionLanguageActionModels2023]: large scale models trained on thousands of demonstrations. The open release of the Open-X [@collaborationOpenXEmbodimentRobotic2025] and DROID datasets [@khazatskyDROIDLargeScaleInTheWild2025] fostered the development of open source models: OpenVLA [@kimOpenVLAOpenSourceVisionLanguageAction2024], \pi_0 [@black$p_0$VisionLanguageActionFlow2024] and SmolVLA [@shukorSmolVLAVisionLanguageActionModel2025].](assets/image/ch5/ch5-generalist-policies-timeline.png) {#fig-fig:ch5-generalist-policies-timeline}
30
-
31
- *Early efforts in the development of generalist models for robotics include BC-Zero [@jangBCZZeroShotTask2022], RT-1 [@brohanRT1RoboticsTransformer2023], and RT-2 [@brohanRT2VisionLanguageActionModels2023]: large scale models trained on thousands of demonstrations. The open release of the Open-X [@collaborationOpenXEmbodimentRobotic2025] and DROID datasets [@khazatskyDROIDLargeScaleInTheWild2025] fostered the development of open source models: OpenVLA [@kimOpenVLAOpenSourceVisionLanguageAction2024], \pi_0 [@black$p_0$VisionLanguageActionFlow2024] and SmolVLA [@shukorSmolVLAVisionLanguageActionModel2025].*
32
-
33
- Motivated by the pursuit of generalist robot policies, the research community started investigating what and how to integrate from other domains within ML.
34
- Figure~\ref{fig:ch5-generalist-policies-timeline} shows a timeline of some of the most popular contributions attempting at developing generalist policies.
35
- Starting from BC-Zero, a latent variable model trained on 25K+ demonstrations, the field has now evolved into \( \pi_0 \), a transformer-based model trained on 10M+ demonstrations and exhibiting strong few-shot capabilities across tasks and embodiments.
36
- For starters, Robotics Transformer 1 (RT-1) [@brohanRT1RoboticsTransformer2023] represented a significant step in the direction of developing a generalist robot policies over prior work including (1) BC-Zero [@jangBCZZeroShotTask2022] and (2) Gato [@reedGeneralistAgent2022], in that~@brohanRT1RoboticsTransformer2023 uses a much larger and diverse set of training tasks compared to both BC-Zero and Gato.
37
- In particular, RT-1 uses a transformer architecture, and is trained on as many as 130k human-recorded trajectories collected over 13 robots in the span on 17 months.
38
- RT-1 learns to process a history of camera images and a natural language instruction, and feeds the resulting sequence of high-dimensional tokens to a transformer, trained using a \emph{classification loss on a discretized actions space} consisting of 6 256 bins, each for each joint of a 6-dof robotic arm.
39
-
40
- Perhaps motivated by the contemporary successes of the transformer architecture in both CV and NLP, the same group of authors investigated using a discrete output space to model---inherently continuous---quantities such as actions, leveraging a (1) more powerful architecture and (2) scaling up the dataset used \citep[RT-2]{brohanRT2VisionLanguageActionModels2023}.
41
- In RT-2,~@brohanRT2VisionLanguageActionModels2023 propose inheriting internet-scale semantic knowledge from large-scale multi-modal datasets to learn a single, \emph{unified model} for robotics control.
42
- Such a model, termed \emph{Vision-Language-Action} (VLA) in the original RT-2 paper, effectively casts robot control as a language modeling problem, and in particular as a Visual Question-Answering (VQ\&A) task, whereby the output token space used to represent \emph{string} tokens is shared with the \emph{8-bits tokens} used to represent the 256 actuation levels of a 6-dof robot joint.
43
- In their work,~@brohanRT2VisionLanguageActionModels2023 propose co-fine-tuning then-leading large-scale VLMs such as PaLIX [@chenPaLIXScalingMultilingual2023] or PaLM-E [@driessPaLMEEmbodiedMultimodal2023] on a mix of web and robotics data, thus complementing VQ\&A training with robotics-specific signal, learning to directly output robot actions in a shared token space for visual and language inputs.
44
- Using large models trained on internet-scale data as backbones for VLAs allows models to tap into the rich semantic knowledge embedded in the VLM's parameters, interpret new commands as well as recognize unseen objects by connecting them to concepts acquired while pre-training.
45
- For instance,~@brohanRT2VisionLanguageActionModels2023 show that while RT-2 has never been explicitly trained to repurpose tools for a hammering task, it can still combine its semantic understanding of images, so that when asked which object between (1) a piece of paper, (2) a pair of headphones or (3) a rock may be used instead of a hammer, it answers correctly, (3).
46
-
47
- Traditionally, research involved not only training the model but also collecting the underlying data, a costly and time-consuming processβ€”for instance, @jangBCZZeroShotTask2022 gathered 25K+ trajectories before training, while RT-1 required 130K+.
48
- In turn, the data used in robot learning research efforts have traditionally proved rather fragmented, tailored to the specific task considered by the specific group of researchers who collected it, ultimately hindering integration.
49
- The Open X-Embodiment project [@collaborationOpenXEmbodimentRobotic2025] was a landmark effort to address the data fragmentation problem, curating the aggregation of 60 \emph{existing} robotics datasets from 22 different robot embodiments and 21 institutions, resulting in a total 1.4M of cross-embodiments, cross-tasks, openly-available trajectories.
50
- Besides the contribution of an aggregate, large scale dataset,~@collaborationOpenXEmbodimentRobotic2025 also demonstrated significant positive transfer \emph{across tasks and embodiments}, showing that a single model trained on multi-embodiment data can outperform specialist models trained on their respective single-embodiment datasets.
51
- The Distributed Robot Interaction Dataset (DROID) [@khazatskyDROIDLargeScaleInTheWild2025] represents another significant step towards addressing the problem of scarse and disaggregated data in robot learning, providing a unique dataset consisting of 75K+ human demonstrations collected in realistic (\emph{in-the-wild}) manipulation settings, providing another cornerstone for building general-purpose robot policies.
52
- Recently, foundational datasets curated through large, centralized efforts, are increasingly complemented by decentralized, community-driven collection of robotics data.
53
- Software libraries as **LeRobot**~have been instrumental in enabling decentralized collection of large amounts of data, providing the infrastructure for researchers and practitioners to easily contribute trajectories from range of embodiments, democratizing data access via distributed collection.
54
-
55
- The success of large, proprietary models like RT-1 and RT-2, highlighted a growing accessibility gap in robotics research, as training and deploying large-scale models requires computational resources simply unattainable for most research institutions.
56
- The OpenVLA project [@kimOpenVLAOpenSourceVisionLanguageAction2024] emerged in direct contrast of closed-source counterparts, as a community-driven effort to create powerful, openly available VLAs.
57
- In particular,~@kimOpenVLAOpenSourceVisionLanguageAction2024 trained OpenVLA by exclusively leveraging openly available data (970K+ from the Open-X dataset), and share training recipes alongside the model weights.
58
- Architecturally, OpenVLA integrates a pre-trained vision encoder to project visual tokens into the embedding space of Llama2-7B [@touvronLlama2Open2023] language model backbone.
59
- The language model backbone is then used to predict \emph{discrete action tokens} over 256 activation levels.
60
-
61
- ![Robot learning is undergoing a paradigmatic shift: centralized data collections (A, left) are increasingly larger, often comprising Ms of demonstrations, and (A, right) decentralized approaches to data collection are also rising as an alternative for large scale data collection. (B) Generalist models are also becoming increasingly smaller and easier to run on limited hardware.](assets/image/ch5/ch5-trends.png) {#fig-fig:ch5-trends}
62
-
63
- *Robot learning is undergoing a paradigmatic shift: centralized data collections (A, left) are increasingly larger, often comprising Ms of demonstrations, and (A, right) decentralized approaches to data collection are also rising as an alternative for large scale data collection. (B) Generalist models are also becoming increasingly smaller and easier to run on limited hardware.*
64
-
65
- Figure~\ref{fig:ch5-trends} illustrates graphically the two most relevant trends in modern robot learning.
66
- As datasets collected via centralized, cross-institutions cooperation of increasing size are made available for the research community, decentralized datasets collected by individual researchers and practitioners have also gained traction recently, closing the gap with academic benchmarks thanks to community-contributed datasets.
67
- Further, models used across tasks and embodiments are also becoming much more compute-efficient, and as a result the models' size has been consistently reducing over time, with consequent gains for autonomous robots in real-world, resource-constrained environments.
68
-
69
- \subsection{Modern VLAs}
70
- Modern recipes to train large scale VLAs extend early efforts to learn foundation models from large amounts of data via BC, introducing significant advancements concerning both architectural and procedural aspects.
71
- From an architectural perspective, modern VLAs such as \pi_0 [@black$p_0$VisionLanguageActionFlow2024] leverage a \emph{unified transformer model} for efficiency of computation, while maintaining specialized sub-components within the model for visual perception and action prediction, enabling cross-task performance via language conditioning.
72
- Crucially, modern VLAs including~@black$p_0$VisionLanguageActionFlow2024[\pi_0] and~@shukorSmolVLAVisionLanguageActionModel2025[SmolVLA] adopt \emph{unified} transformer models employing disjoint set of weights (\emph{experts}) for compute-efficient visual-semantic understanding and robotic control.
73
- Procedurally, modern VLAs complement advanced Vision-Language Model (VLM) backbones with action-specific modules (1) adopting mid-sized \emph{action experts} to model continuous actions distributions \( p (a_{t:t+H_a} \vert o_t) \)---avoiding discrete action tokens entirely---and (2) relying on~\emph{action chunking} \citep[Section~\ref{sec:learning-bc-single}]{zhaoLearningFineGrainedBimanual2023} as a strategy to reduce error compounding when predicting multiple actions learning from inherently non-i.i.d. data, such as demonstration data.
74
-
75
- These architectural and procedural innovations present three benefits.
76
- First, developing architectures that exploit internet-scale pre-trained backbones allows to fully capitalizes on the vast world knowledge and skills state-of-the-art VLMs exhibit, preventig models from needing to learn visual, linguistic and semantic concepts from scratch.
77
- Second, using generative models for continuous action distributions allows to learn rich, multimodal data distributions, a much more likely scenario in the big-data regime typically tackled while developing generalist policies.
78
- Further, introducing two separate components for perception and action planning could enable using Mixture of Experts (MoE) architectures [@fedusReviewSparseExpert2022], more efficient to run and thus resulting in faster inference---a key features for models deployed in real-world scenarios.
79
- This new paradigm has been at the core of some of the most capable generalist policies developed to date, capable to few-shot adapt to novel tasks and to perform highly dexterous manipulation tasks, ranging from end-to-end folding laundry, to bussing tables.
80
-
81
- \subsubsection{VLMs for VLAs}
82
- VLMs are designed to process both visual and textual modalities---most commonly by taking both images and text as input and generating text conditioned on the visual context.
83
- Recent advances in VLMs have been driven by the success of LLMs, with many approaches building upon pretrained LLMs and adopting similar training paradigms to the ones used in language modeling.
84
- Typically, VLMs [@alayracFlamingoVisualLanguage2022,laurenconWhatMattersWhen2024,linVILAPretrainingVisual2024] are constructed by integrating a pretrained vision encoder [@radfordLearningTransferableVisual2021,zhaiSigmoidLossLanguage2023,finiMultimodalAutoregressivePretraining2024] with a pretrained LLM [@grattafioriLlama3Herd2024,jiangMistral7B2023].
85
- Training then proceeds in multiple multimodal stages, beginning with a large-scale pretraining on datasets containing image-text pairs [@LAION-COCO,kakaobrain2022coyo700m] and interleaved vision-language corpora [@OBELICS,MMC4], all followed by a supervised fine-tuning stage on instruction-tuning datasets [@LLaVA-1.5,tong2024cambrian,laurenconWhatMattersWhen2024].
86
- The inherent multimodal nature of VLMs enables them to jointly reason over vision and language.
87
- Pre-training on vast internet-scale datasets allows these models to associate visual patterns with textual descriptions, thereby acquiring a rich semantic understanding of the world---knowledge about objects, their properties, and relationships---without explicit supervision for each concept.
88
- In turn, integrating a VLM as a perception backbone for a VLA allows the complete model to inherit rich world knowledge, sidestepping the need to learn visual and semantic representations from scratch.
89
- In principle, this allows the robot to ground high-level natural language instructions in its visual context, and possibly recognize unseen objects by connecting them to pre-trained concepts absorbed during pre-training, improving on the possibility to generalize to novel scenarios.
90
-
91
- Recently, compute efficiency has also become a central focus in VLM research.
92
- Several works aim to reduce training costs by using smaller, more diverse datasets [@LLaVA-1.5,InstructBLIP,bai2025qwen25vl,zhu2024minigpt,tong2024cambrian], training smaller-scale models [@marafiotiSmolVLMRedefiningSmall2025, moondream,minicmpv2024], or by adapting pretrained unimodal models by tuning only a small subset of parameters [@shukor2023epalm,vallaeys2024improveddepalm,MAPL,FROMAGe,tsimpoukelli2021multimodalfrozen,BLIP-2].
93
- While the majority of VLM research focuses on image and text modalities, recent work has demonstrated that similar techniques can be extended to integrate additional modalities, such as video and audio [@wang2025internvideo2,liu2024kangaroo,zhang2025videollama,kong2024audioflam]---a particularly promising direction of research for robotics applications, where multiple sensor modalities can be integrated effectively.
94
- This trend towards efficiency is paramount for robotics applications, where policies must operate under the stringent constraints of real-world deployment.
95
- Indeed, robots often possess limited on-board computational resources and must react in real-time to dynamic environments.
96
- Smaller and faster VLMs have thus become quintessential for developing responsive autonomous systems, enabling high-frequency control loops by reducing the latency between perception and action.
97
-
98
- \subsection{\( \pi_0 \)}
99
-
100
- \pi_0 [@black$p_0$VisionLanguageActionFlow2024] introduce a VLA consisting of a MoE architecture consisting of (1) a pre-trained VLM backbone (Gemma 2.6B [@teamGemma2Improving2024]) and (2) a dedicated action expert used to generate continuous actions via flow matching.
101
- Images and language are embedded with a late-fusion VLM (PaliGemma), while proprioceptive state and actions chunks are routed to a smaller action expert, initialized from scratch.
102
- The two separate experts communicate via self-attention layers, but maintain disjoint weights to obtain query, key and values matrices at each layer, maintaining specialization while efficiently allocating computation.
103
-
104
- ![The \pi_0 architecture, as in [@black$p_0$\beginalign]
105
- VisionLanguageActionFlow2024. Vision and language tokens are routed to a VLM backbone which is prevented from attending robot proprioperceptive states and action tokens, which are instead routed to a smaller subset of weights within the architecture. The architecture is trained with Flow Matching on 10M+ trajectories from a mixture of closed and openly available datasets.](assets/image/ch5/ch5-pi0.png) {#fig-fig:ch5-pi0}
106
-
107
- *The \pi_0 architecture, as in [@black$p_0$\beginalign]
108
- VisionLanguageActionFlow2024. Vision and language tokens are routed to a VLM backbone which is prevented from attending robot proprioperceptive states and action tokens, which are instead routed to a smaller subset of weights within the architecture. The architecture is trained with Flow Matching on 10M+ trajectories from a mixture of closed and openly available datasets.*
109
-
110
- Concretely, \( \pi_0 \) is a unified transformer with two disjoint sets of weights \( \phi, \theta\).
111
- A larger VLM backbone \( p_\phi \) initialized from Gemma 2.6B processes multiple image frames obtained from multiple cameras points \( [\{ I_t \}_{t=1}^n] \), as well as a language instruction \([\ell_t]\) used to describe the task considered.
112
- Concurrently, a 300M-parameter \emph{action expert} based on a similar transformer architecture is used processes the robot proprioperceptive state \(q_t\) and an action chunk \(a_{t:t+H_a}\) (Figure~\ref{fig:ch5-pi0}).
113
- The different expert networks operate separately in processing the respective inputs and turning them into query, key and value matrices, and only share information between each other via self-attention layers.
114
- The outputs from the VLM backbone are disregarded, while the vector field regressed by the action expert is used to iteratively refine the action process.
115
- In particular, \pi_0 uses a \emph{blockwise causal attention mask} over tokens belonging to three separate blocks: (1) image and language tokens \(\mathcal T_i \) obtained from \([\{ I_t \}_{t=1}^n, \ell_t]\), (2) proprioperceptive tokens \(\mathcal T_q \) obtained from \(q_t\), and (3) the action tokens \( \mathcal T_a \) for items in the chunk \(a^{\tau}_{t:t+H_a}\) at time \( \tau \) in the flow-matching process.
116
- Notably, \emph{within} each block the attention operations are bidirectional, while across blocks, future blocks are masked out.
117
- Formally, this corresponds to using the attention mask
118
- \begin{equation*}
119
- \mathbf{A} =
120
- \bordermatrix{
121
- & \mathcal{T}_i & \mathcal{T}_q & \mathcal{T}_a \cr
122
- \mathcal{T}_i & \mathbf{1} & \mathbf{0} & \mathbf{0} \cr
123
- \mathcal{T}_q & \mathbf{1} & \mathbf{1} & \mathbf{0} \cr
124
- \mathcal{T}_a & \mathbf{1} & \mathbf{1} & \mathbf{1} \cr
125
- },
126
- \quad \mathbf{1}: \text{Bidirectional Attention}, \ \mathbf{0}: \text{Masked Attention}
127
- \end{equation*}
128
- Note how \emph{intra}-block directional attention allows tokens to communicate freely, while \emph{inter}-block communication is mediated by the attention mask \(\mathbf{A} \).
129
- \emph{Blockwise causal masking} effectively prevents the pre-trained perception-language tokens from attending to robotics-tokens, likely out of distribution for VLM backbones traditionally trained on large corpora of internet, non-robotics, data.
130
- Crucially, because communication is obstructed between image-language tokens, proprioperceptive and action tokens, one can cache keys and values across denoising steps at runtime time, incuring in a reduced computational footprint and faster inference.
131
-
132
- In \pi_0, both the VLM backbone and action expert are update using a \emph{flow matching} loss, and in particular are updated minimizing:
133
-
134
- $$
135
-
136
- \mathcal{L}(\phi, \theta) &= \mathbb{E}_{\tau, \epsilon, o_t, a_{t:t+H_a}}\Big[
137
- \big\Vert
138
- v_\theta(\underbrace{\tau a_{t:t+H_a} + (1-\tau) \epsilon}_{\tilde a_{t:t+H_a}},\, o_t,\, \tau)
139
- - (\epsilon - a_{t:t+H_a})
140
- \big\Vert^2
141
- \Big], \label{eq:pi0-loss}
142
-
143
- &\tau \sim \mathrm{Beta}_{[0,s]}(1.5,1), \quad
144
- \epsilon \sim \mathcal{N}(\mathbf{0}, \mathbf{I}), \quad
145
- o_t, a_{t:t+H_a} \sim \mathcal D \notag
146
-
147
- $$
148
-
149
- Where the experts parametrized by the separate weights \( \phi, \theta \) interact with each other via self-attention layers only, so that the action expert \( v_\theta \) internal computations also depend on the VLM backbone's parameters \( \phi \).
150
- Importantly,~@black
151
- \end{align$p_0$VisionLanguageActionFlow2024} minimize~\ref{eq:pi0-loss} over both the multimodal backbone and action expert parameters, thus updating the internal representations of the VLM using BC-specific gradients.
152
- In contrast,~@driessKnowledgeInsulatingVisionLanguageAction2025 later show that failing to insulate the VLM knowledge from the flow matching gradients actually harms performance.
153
- Inference is performed iteratively refining action chunks while numerically forward-integrating the vector field predicted by the action expert,
154
- \begin{equation}
155
- a_{t:t+H_a}^{\tau + \delta} = a_{t:t+H_a}^{\tau } + \delta v_\theta(a_{t:t+H_a}^{\tau }, o_t)
156
- \end{equation}
157
-
158
- Flow matching \citep[Section\ref{sec:ch4-flow-matching}]{lipmanFlowMatchingGenerative2023} can be seen as a continuous time, detetrministic generalization of Diffusion and has proven effective in modeling highly complex multi-modal distributions, including those over images and video.
159
- In turn, its application to large-scale data collections of multiple human behaviors across tasks and embodiments appears rather consequential, particularly considering how it can enable faster inference via a reduced number of denoising steps---as few as 10, in \pi_0.
160
- In particular, the action expert is model as a conditional flow matching model.
161
- Each action token embeds a noisy action \(a_i^{\tau} \in a^\tau_{t:t+H_a}\), alongside a sinusoidal encoding of the \emph{flow process} timestep \(\tau\).
162
- The action expert then leverages full bidirectional attention across the \(H_a\) action tokens provided, as well as attends to previous proprioperceptive and image-language tokens as well.
163
- Interestingly, differently from a standard flow matching pipeline~@lipmanFlowMatchingGenerative2023, \(\tau\) is \emph{not} sampled from a uniform distribution \(\tau \sim \mathcal U([0,1]) \), but rather obtained from \(\tau \sim \textrm{Beta}(1.5,1) \) defined on the \( [0,s], s<1 \) support (Figure~\ref{fig:ch5-pi0-sampling-timesteps}).
164
-
165
- ![Unlike more traditional flow-matching algorithms, \pi_0 uses a modified distribution for the timestep \( \tau \) used during training and inference, favouring earlier timestamps corresponding to noisier chunks.](assets/image/ch5/ch5-pi0-sampling-timesteps.png) {#fig-fig:ch5-pi0-sampling-timesteps}
166
-
167
- Using such Beta distribution emphasizes higher noise levels during training, a choice~@black$p_0$VisionLanguageActionFlow2024 argue allows \pi_0 to focus on learning the mean of the data distribution \( \mathbb E[a_{t:t+H_a} \vert o_t] \) during training, in keeping with~@esserScalingRectifiedFlow2024.
168
- To further optimize performance and reduce inference time,~@black$p_0$VisionLanguageActionFlow2024 propose reducing the support of the timestep distribution to \([0,s], \ s < 1 \), as for any forward-integration step size \( \delta = 1-s \) timesteps above \(s \) are never sampled at inference time.
169
-
170
- Besides adopting a MoE architecture with a VLM backbone initialized from a pre-trained model and trained jointly with an action expert via flow matching, \pi_0 also relies on a unique pre-training corpus mixes open data of 10M+ trajectories, which~@black$p_0$VisionLanguageActionFlow2024 claim to be the largest dataset used in building a foundational model in robotics to date.
171
- The dataset used to train \pi_0---referred to as \( \pi \) dataset---comprises a private, undisclosed portion obtained via teleoperation aggregated to openly available datasets including Open-X and DROID, with \(\approx 9.1\
172
- Open datasets such as DROID and Open-X are complemeneted with expert trajectories with of dexterous demonstrations tasks spanning 7 robot configurations and 68 different tasks.
173
- ~@black$p_0$VisionLanguageActionFlow2024 show that pre-training on the \( \pi \) dataset yields a broadly capable base model, which can be adapted via post-training on narrower high-quality task data, inducing fluent multi-stage behavior while retaining robustness.
174
- In particular,~@black$p_0$VisionLanguageActionFlow2024 report that, across a variety of benchmarks, \pi_0 pretrained on the \( \pi \) dataset and post-trained on extra high-quality data demonstrations \emph{consistently outperform} \pi_0 trained from scratch (i.e., without pretraining on the \( \pi \) dataset), further scoring the relevance of pretraining.
175
- ~@black$p_0$VisionLanguageActionFlow2024 offer an intuition behind this finding: high-quality demonstrations of a given task typically do not contain mistakes, and how human demonstrator may recover from them.
176
- In turn, robot trained on high-quality data exclusively with BC may be incapable to recover from failure.
177
- Conversely, large scale collections of human demonstrations are typically much more diverse (if anything, for their sheer scale), and therefore typically contain rich and diverse information, which may prove suboptimal for any given task when considered in isolation but that proves invaluable in coupling with a small, narrower set of demonstrations.
178
-
179
- Lastly,~@black$p_0$VisionLanguageActionFlow2024 present cross-embodiment experiments where they demonstrate \pi_0's ability to control both mobile and static manipulator robots with varying arm embodiments.
180
- The emergence of cross-embodiment capabilities is largely to be attributed to the presence of large scale cross-embodiment data in the data mixture, handled by \pi_0 defaulting to the maximal configuration size across the \( \pi \) dataset, and zero-padding robots with fewer dof.
181
- In that \pi_0 constantly processes 18 DoFs robots (two 6-DoF arms, two grippers, base, vertical torso), regardless of the kind of robot, and robots with fewer dofs are zero-padded.
182
- \pi_0 also relies on three camera views, and uses masked image slots for training and deployment scenarios with fewer cameras.
183
-
184
- \subsubsection{Code Example: Using \pi_0}
185
- \todo{add code example}
186
-
187
- \subsection{SmolVLA}
188
- VLAs remain in an early stage of development and are not yet as mature or widely adopted as LLMs and VLMs.
189
- Further, much of the impactful VLA progress remains proprietary, with many models sharing only weights while withholding full training details and essential methodological components.
190
- SmolVLA [@shukorSmolVLAVisionLanguageActionModel2025] is an entirely open-source research effort, aiming to democratize the developments of robotics foundation models by open sourcing model, training recipes and data used.
191
-
192
- ![The SmolVLA architecture, as in [@shukorSmolVLAVisionLanguageActionModel2025]. SmolVLA is a compact MoE model trained with flow matching to denoise action chunks. Vision and language tokens are fed to a VLM backbone, and share information with the proprioperceptive and action tokens via the attention mechanism. The attention expert interleaves SA and CA layers for further conditioning on the visual features from the VLM backbone. SmolVLA skips computations and reduces the visual tokens, resulting in 6x less memory usage than \pi_0.](assets/image/ch5/ch5-smolvla.png) {#fig-fig:ch5-smolvla}
193
-
194
- *The SmolVLA architecture, as in [@shukorSmolVLAVisionLanguageActionModel2025]. SmolVLA is a compact MoE model trained with flow matching to denoise action chunks. Vision and language tokens are fed to a VLM backbone, and share information with the proprioperceptive and action tokens via the attention mechanism. The attention expert interleaves SA and CA layers for further conditioning on the visual features from the VLM backbone. SmolVLA skips computations and reduces the visual tokens, resulting in 6x less memory usage than \pi_0.*
195
-
196
- While encouraging efforts like \pi_0 [@black$p_0$VisionLanguageActionFlow2024] demonstrate the feasibility of open VLA systems, they remain (1) large and compute-intensive and (2) dependent on closed datasets collected via centralized efforts on costly robotic platforms, ultimately hindering accessibility.
197
- SmolVLA mitigates both these accessibility issues by (1) prioritizing a compact, compute-efficient VLA design and (2) targeting community-contributed datasets on accessible robotic platforms such as the SO-100 and SO-101 arms.
198
- Similarly to \pi_0, SmolVLA (Figure~\ref{fig:ch5-smolvla}) employs a MoE architecture combining a pretrained VLM backbone with a dedicated action expert, and trains with flow matching.
199
- To ensure efficiency and accessibility, SmolVLA adopts SmolVLM-2 [@marafiotiSmolVLMRedefiningSmall2025] as its VLM backbone, considering SmolVLM-2's reduced size and capability to process multiple image inputs alongside text items.
200
- SmolVLM-2 uses SigLIP [@zhaiSigmoidLossLanguage2023] as vision encoder, producing visual features for a SmolLM2 language decoder [@allalSmolLM2WhenSmol2025].
201
- Further, SmolVLA adopts a smaller action expert consisting of \(\sim\)100M parameters and an interleaved stack of self and cross-attention layers.
202
- To improve efficiency, the action expert adopts a reduced embedding dimension compared to the VLM backbone, resulting in \( d_{v_\theta} = 0.75 d_{\text{VLM}} \).
203
- [@shukorSmolVLAVisionLanguageActionModel2025]'s design choices thus result in a much smaller size model compared to \pi_0, consisting of around 450M parameters versus \pi_0's 3.3B parameters.
204
-
205
- Effectively, SmolVLA consumes multi-view RGB images, a natural-language instruction, and a projected sensorimotor state token as inputs, together with the noised \emph{action chunk} \( \tilde{a_{t:t+H_a}} \) the action expert \( v_\theta \) is trained to denoise.
206
- In particular, robot proprioperceptive states are projected into a shared token space with the VLM to match \( d_{\text{VLM}} \), and successively projected into the expert's token space.
207
- Similarily to \pi_0, SmolVLA adopts separate experts communicating exclusively through self-attention layers, which do not employ the same blockwise causal masking in favour of a simple causal masking, resulting in a lower triangular attention mask.
208
-
209
- In contrast with \pi_0, the action expert interleaves \emph{cross-attention} (CA) and \emph{self-attention} (SA) layers, a choice shown to yield higher success and smoother action chunks in practice.
210
- While in the expert SA layers, tokens are used to obtain queries, keys and values, CA layers use action tokens only as queries, and instead project visual, language and proprioperceptive tokens in a shared action space to obtain keys and values.
211
- Notably, keys and values can be cached as well, resulting in performance gains at inference time.
212
-
213
- SmolVLA trims both token and layer compute.
214
- First, it \emph{reduces visual tokens} via pixel shuffle to a fixed budget of 64 tokens per frame, foregoing tiling used during VLM pretraining for runtime efficiency.
215
- Second, it \emph{skips upper VLM layers}: the action expert consumes features from the first \(N\) decoder layers, with \(N=L/2\) providing a good speed-performance trade-off and effectively halving downstream compute for the larger part of SmolVLA.
216
- Beyond model compactness, SmolVLA also contributes an inference stack that decouples action prediction from execution for responsiveness on modest hardware (Section~\ref{sec:ch4-async-inference}).
217
-
218
- Departing from reliance on proprietary datasets, SmolVLA pretrains exclusively on 450+ \emph{community datasets}, totaling 20K+ trajectories.
219
- Because instructions in community contributed dataset can be noisy or missing, the authors re-annotate tasks with a small off-the-shelf VLM using frames sampled from the dataset, and standardize camera viewpoints by mapping sources to a consistent top/wrist/side ordering.
220
- At inference, similarily to \pi_0, SmolVLA integrates flow over 10 steps, resulting in fast inference.
221
- SmolVLA proves effective across a range of both real-world and simulated environments, rivaling \pi_0 while being close to 40\
222
-
223
- \subsubsection{Code Example: Using SmolVLA}
224
- \todo{add code example}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/scripts/latex-to-mdx/input/sections/test.md DELETED
@@ -1,488 +0,0 @@
1
- # Classical Robotics {#sec:classical}
2
-
3
- ::: epigraph
4
- *Know your enemy* \[\...\]
5
-
6
- Sun Tzu
7
- :::
8
-
9
- ::: tldr
10
- Learning-based approaches to robotics are motivated by the need to (1)
11
- generalize across tasks and embodiments (2) reduce dependency on human
12
- expertise (3) leverage historical trends on the production of data---all
13
- traditionally overlooked by dynamics-based techniques.
14
- :::
15
-
16
- ## Explicit and Implicit Models
17
-
18
- ![Overview of methods to generate motion (clearly non-exhausitve,
19
- seeΒ @bekrisStateRobotMotion2024). The different methods can be grouped
20
- based on whether they explicitly (*dynamics-based*) or implicitly
21
- (*learning-based*) model robot-environment
22
- interactions.](figures/ch2/ch2-approaches.png){#fig:generating-motion-atlas
23
- width="50%"}
24
-
25
- Robotics is concerned with producing artificial motion in the physical
26
- world in useful, reliable and safe fashion. Thus, robotics is an
27
- inherently multi-disciplinar domain: producing autonomous motion in the
28
- physical world requires, to the very least, interfacing different
29
- software (motion planners) and hardware (motion executioners)
30
- components. Further, knowledge of mechanical, electrical, and software
31
- engineering, as well as rigid-body mechanics and control theory have
32
- therefore proven quintessential in robotics since the field first
33
- developed in the 1950s. More recently, Machine Learning (ML) has also
34
- proved effective in robotics, complementing these more traditional
35
- disciplinesΒ [@connellRobotLearning1993]. As a direct consequence of its
36
- multi-disciplinar nature, robotics has developed as a rather wide array
37
- of methods, all concerned with the main purpose of .
38
-
39
- Methods to produce robotics motion range from traditional *explicit*
40
- models---[^1] methods, leveraging precise descriptions of the mechanics
41
- of robots' rigid bodies and their interactions with eventual obstacles
42
- in the environment---to *implicit* models--- methods, treating
43
- artificial motion as a statistical pattern to learn given multiple
44
- sensorimotor
45
- readingsΒ [@agrawalComputationalSensorimotorLearning; @bekrisStateRobotMotion2024].
46
- A variety of methods have been developed between these two extrema. For
47
- instance, Β @hansenTemporalDifferenceLearning2022 show how learning-based
48
- systems can benefit from information on the physics of problems,
49
- complementing a traditional learning method such as Temporal Difference
50
- (TD)-learningΒ @suttonReinforcementLearningIntroduction2018 with
51
- Model-Predictive Control (MPC). Conversely, as explicit models may be
52
- relying on assumptions proving overly simplistic---or even
53
- unrealistic---in practice, learning can prove effective to improve
54
- modeling of complex phenomena or complement
55
- perceptionΒ [@mccormacSemanticFusionDense3D2016]. Such examples aim at
56
- demonstrating the richness of approaches to robotics, and
57
- FigureΒ [1](#fig:generating-motion-atlas){reference-type="ref"
58
- reference="fig:generating-motion-atlas"} graphically illustrates some of
59
- the most relevant techniques. Such a list is clearly far from being
60
- exhaustive, and we refer toΒ @bekrisStateRobotMotion2024 for a more
61
- comprehensive overview of both general and application-specific methods
62
- for motion generation. In this section, we wish to introduce the
63
- inherent benefits of ---the core focus on this tutorial.
64
-
65
- ## Different Types of Motion
66
-
67
- ![Different kinds of motions are achieved with potentially very
68
- different robotic platforms. From left to right, top to bottom: ViperX,
69
- SO-100, Boston Dynamics' Spot, Open-Duck, 1X's NEO, Boston Dynamics'
70
- Atlas. This is an example list of robotic platforms and is (very) far
71
- from being
72
- exhaustive.](figures/ch2/ch2-platforms.png){#fig:robotics-platforms-atlas
73
- width="70%"}
74
-
75
- In the vast majority of instances, robotics deals with producing motion
76
- via actuating joints connecting nearly entirely-rigid links. A key
77
- distinction between focus areas in robotics is based on whether the
78
- generated motion modifies (1) the absolute state of the environment (via
79
- dexterity), (2) the relative state of the robot with respect to its
80
- environment (exercising mobility skills), or (3) a combination of the
81
- two (FigureΒ [2](#fig:robotics-platforms-atlas){reference-type="ref"
82
- reference="fig:robotics-platforms-atlas"}).
83
-
84
- Effects such as (1) are typically achieved *through* the robot, i.e.
85
- generating motion to perform an action inducing a desirable
86
- modification, effectively *manipulating* the environment (manipulation).
87
- Motions like (2) may result in changes in the robot's physical location
88
- within its environment. Generally, modifications to a robot's location
89
- within its environment may be considered instances of the general
90
- *locomotion* problem, further specified as *wheeled* or *legged*
91
- locomotion based on whenever a robot makes use of wheels or leg(s) to
92
- move in the environment. Lastly, an increased level of dynamism in the
93
- robot-environment interactions can be obtained combining (1) and (2),
94
- thus designing systems capable to interact with *and* move within their
95
- environment. This category is problems is typically termed *mobile
96
- manipulation*, and is characterized by a typically much larger set of
97
- control variables compared to either locomotion or manipulation alone.
98
-
99
- The traditional body of work developed since the very inception of
100
- robotics is increasingly complemented by learning-based approaches. ML
101
- has indeed proven particularly transformative across the entire robotics
102
- stack, first empowering planning-based techniques with improved state
103
- estimation used for traditional
104
- planningΒ [@tangPerceptionNavigationAutonomous2023] and then end-to-end
105
- replacing controllers, effectively yielding perception-to-action
106
- methodsΒ [@koberReinforcementLearningRobotics]. Work in producing robots
107
- capable of navigating a diverse set of terrains demonstrated the premise
108
- of both dynamics and learning-based approaches for
109
- locomotionΒ [@griffinWalkingStabilizationUsing2017; @jiDribbleBotDynamicLegged2023; @leeLearningQuadrupedalLocomotion2020; @margolisRapidLocomotionReinforcement2022],
110
- and recent works on whole-body control indicated the premise of
111
- learning-based approaches to generate rich motion on complex robots,
112
- including
113
- humanoidsΒ [@zhangWoCoCoLearningWholeBody2024; @bjorckGR00TN1Open2025].
114
- Manipulation has also been widely studied, particularly considering its
115
- relevance for many impactful use-cases ranging from high-risk
116
- applications for
117
- humansΒ [@fujitaDevelopmentRobotsNuclear2020; @alizadehComprehensiveSurveySpace2024]
118
- to manufacturingΒ [@sannemanStateIndustrialRobotics2020]. While explicit
119
- models have proven fundamental in achieving important milestones towards
120
- the development of modern robotics, recent works leveraging implicit
121
- models proved particularly promising in surpassing scalability and
122
- applicability challenges via
123
- learningΒ [@koberReinforcementLearningRobotics].
124
-
125
- ## Example: Planar Manipulation
126
-
127
- Robot manipulators typically consist of a series of links and joints,
128
- articulated in a chain finally connected to an *end-effector*. Actuated
129
- joints are considered responsible for generating motion of the links,
130
- while the end effector is instead used to perform specific actions at
131
- the target location (e.g., grasping/releasing objects via
132
- closing/opening a gripper end-effector, using a specialized tool like a
133
- screwdriver, etc.).
134
-
135
- Recently, the development of low-cost manipulators like the
136
- ALOHAΒ [@zhaoLearningFineGrainedBimanual2023]
137
- ALOHA-2Β [@aldacoALOHA2Enhanced] and
138
- SO-100/SO-101Β [@knightStandardOpenSO100] platforms significantly lowered
139
- the barrier to entry to robotics, considering the increased
140
- accessibility of these robots compared to more traditional platforms
141
- like the Franka Emika Panda arm
142
- (FigureΒ [3](#fig:robotic-platforms-costs){reference-type="ref"
143
- reference="fig:robotic-platforms-costs"}).
144
-
145
- ![Cheaper, more accessible robots are starting to rival traditional
146
- platforms like the Panda arm platforms in adoption in
147
- resource-constrained scenarios. The SO-100, in particular, has a cost in
148
- the 100s of Euros, and can be entirely 3D-printed in hours, while the
149
- industrially-manufactured Panda arm costs tens of thousands of Euros and
150
- is not openly
151
- available.](figures/ch2/ch2-cost-accessibility.png){#fig:robotic-platforms-costs
152
- width="40%"}
153
-
154
- Deriving an intuition as per why learning-based approaches are gaining
155
- popularity in the robotics community requires briefly analyzing
156
- traditional approaches for manipulation, leveraging tools like forward
157
- and inverse kinematics (FK, IK) and control theory. Providing a detailed
158
- overview of these methods falls (well) out of the scope of this
159
- tutorial, and we refer the reader to works
160
- includingΒ @sicilianoSpringerHandbookRobotics2016
161
- [@lynchModernRoboticsMechanics2017; @tedrakeRoboticManipulationPerception; @tedrakeUnderactuatedRoboticsAlgorithms]
162
- for a much more comprehensive description of these techniques. Here, we
163
- mostly wish to highlight the benefits of ML over these traditional
164
- techniques
165
-
166
- ![The SO-100 arm is a 6-dof manipulator arm. Preventing some of its
167
- joints (shoulder pane, wrist flex and wrist roll) from actuating, it can
168
- be represented as a traditional 2-dof planar manipulator (the gripper
169
- joint in the end-effector is not considered towards the count of the
170
- degrees of freedom used to produce
171
- motion).](figures/ch2/ch2-so100-to-planar-manipulator.png){#fig:make-so100-planar-manipulator
172
- width="70%"}
173
-
174
- Consider the (simple) case where a SO-100 is restrained from actuating
175
- (1) the shoulder pane and (2) the wrist flex and roll motors. This
176
- effectively reduces the degrees of freedom of the SO-100 from the
177
- original 5+1 (5 joints + 1 gripper) to 2+1 (shoulder lift, elbow flex +
178
- gripper). As the end-effector does not impact motion in this model, the
179
- SO-100 is effectively reduced to the planar manipulator robot presented
180
- in FigureΒ [4](#fig:make-so100-planar-manipulator){reference-type="ref"
181
- reference="fig:make-so100-planar-manipulator"}, where spheres represent
182
- actuators, and solid lines indicate length-$l$ links from the base of
183
- the SO-100 to the end-effector (*ee*).
184
-
185
- Further, let us make the simplifying assumption that actuators can
186
- produce rotations up to $2 \pi$ radians. In practice, this is seldom the
187
- case due to movement obstructions caused by the robot body itself (for
188
- instance, the shoulder lift cannot produce counter-clockwise movement
189
- due to the presence of the robot's base used to secure the SO-100 to its
190
- support and host the robot bus), but we will introduce movement
191
- obstruction at a later stage.
192
-
193
- All these simplifying assumptions leave us with the planar manipulator
194
- of FigureΒ [5](#fig:planar-manipulation-simple){reference-type="ref"
195
- reference="fig:planar-manipulation-simple"}, free of moving its
196
- end-effector by controlling the angles $\theta_1$ and $\theta_2$,
197
- jointly referred to as the robot's *configuration*, and indicated with
198
- $q = [\theta_1, \theta_2 ] \in [-\pi, +\pi]^2$. The axis attached to the
199
- joints indicate the associated reference frame, whereas circular arrows
200
- indicate the maximal feasible rotation allowed at each joint. In this
201
- tutorial, we do not cover topics related to spatial algebra, and we
202
- instead refer the reader to @lynchModernRoboticsMechanics2017
203
- [ChapterΒ 2] and @tedrakeRoboticManipulationPerception [ChapterΒ 3] for
204
- excellent explanations of the mechanics and theoretical foundations of
205
- producing motion on rigid bodies.
206
-
207
- <figure id="fig:planar-manipulator-floor-shelf">
208
- <figure id="fig:planar-manipulation-simple">
209
- <img src="figures/ch2/ch2-planar-manipulator-free.png"
210
- style="height:3.2cm" />
211
- <figcaption>Free to move</figcaption>
212
- </figure>
213
- <figure id="fig:planar-manipulator-floor">
214
- <img src="figures/ch2/ch2-planar-manipulator-floor.png"
215
- style="height:3.2cm" />
216
- <figcaption>Constrained by the surface</figcaption>
217
- </figure>
218
- <figure id="fig:planar-manipulator-floor-shelf">
219
- <img src="figures/ch2/ch2-planar-manipulator-floor-shelf.png"
220
- style="height:3.2cm" />
221
- <figcaption>Constrained by surface and (fixed) obstacle</figcaption>
222
- </figure>
223
- <figcaption>Planar, 2-dof schematic representation of the SO-100
224
- manipulator under diverse deployment settings. From left to right:
225
- completely free of moving; constrained by the presence of the surface;
226
- constrained by the surface and presence of obstacles. Circular arrows
227
- around each joint indicate the maximal rotation feasible at that
228
- joint.</figcaption>
229
- </figure>
230
-
231
- Considering the (toy) example presented in
232
- FigureΒ [5](#fig:planar-manipulation-simple){reference-type="ref"
233
- reference="fig:planar-manipulation-simple"}, then we can analytically
234
- write the end-effector's position $p \in \mathbb R^2$ as a function of
235
- the robot's configuration,
236
- $p = p(q), p: \mathcal Q \mapsto \mathbb R^2$. In particular, we have:
237
- $$\begin{equation*}
238
- p(q) =
239
- \begin{pmatrix}
240
- p_x(\theta_1, \theta_2) \\
241
- p_y(\theta_1, \theta_2)
242
- \end{pmatrix}
243
- =
244
- \begin{pmatrix}
245
- l \cos(\theta_1) + l \cos(\theta_1 + \theta_2) \\
246
- l \sin(\theta_1) + l \sin(\theta_1 + \theta_2)
247
- \end{pmatrix}
248
- \in S^{n=2}_{l_1+l_2} = \{ p(q) \in \mathbb R^2: \Vert p(q) \Vert_2^2 \leq (2l)^2, \ \forall q \in \mathcal Q \}
249
- \end{equation*}$$
250
-
251
- Deriving the end-effector's *pose*---position *and* orientation---in
252
- some $m$-dimensional space
253
- $\vec{p} \in \mathcal{P} \subset \mathbb{R}^{m}$ starting from the
254
- configuration $\q \in \mathcal Q \subset \mathbb R^n$ of a $n$-joints
255
- robot is referred to as *forward kinematics* (FK), whereas identifying
256
- the configuration corresponding to any given target pose is termed
257
- *inverse kinematics* (IK). In that, FK is used to map a robot
258
- configuration into the corresponding end-effector pose, whereas IK is
259
- used to reconstruct the configuration(s) given an end-effector pose.
260
-
261
- In the simplified case here considered (for which $\vec{p} \equiv p$, as
262
- the orientation of the end-effector is disregarded for simplicity), one
263
- can solve the problem of controlling the end-effector's location to
264
- reach a goal position $p^*$ by solving analytically for
265
- $q: p(q) = f_{\FK}(q) = p^*$. However, in the general case, one might
266
- not be able to solve this problem analytically, and can typically resort
267
- to iterative optimization methods comparing candidate solutions using a
268
- loss function (in the simplest case, $\Vert p(q) - p^* \Vert_2^2$ is a
269
- natural candidate), yielding:
270
-
271
- $$\begin{align}
272
- \min_{q \in \mathcal Q} \Vert p(q) - p^* \Vert_2^2 \, .
273
- \label{eq:ik_problem}
274
- \end{align}$$
275
-
276
- Exact analytical solutions to IK are even less appealing when one
277
- considers the presence of obstacles in the robot's workspace, resulting
278
- in constraints on the possible values of
279
- $q \in \mathcal Q \subseteq [-\pi, +\pi]^n \subset \mathbb R^n$ in the
280
- general case of $n$-links robots.
281
-
282
- For instance, the robot in
283
- FigureΒ [6](#fig:planar-manipulator-floor){reference-type="ref"
284
- reference="fig:planar-manipulator-floor"} is (very naturally) obstacled
285
- by the presence of the surface upon which it rests: $\theta_1$ can now
286
- exclusively vary within $[0, \pi]$, while possible variations in
287
- $\theta_2$ depend on $\theta_1$ (when $\theta_1 \to 0$ or
288
- $\theta_1 \to \pi$, further downwards movements are restricted). Even
289
- for a simplified kinematic model, developing techniques to
290
- solveΒ eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
291
- reference="eq:ik_problem"} is in general non-trivial in the presence of
292
- constraints, particularly considering that the feasible set of solutions
293
- $\mathcal Q$ may change across problems.
294
- FigureΒ [8](#fig:planar-manipulator-floor-shelf){reference-type="ref"
295
- reference="fig:planar-manipulator-floor-shelf"} provides an example of
296
- how the environment influences the feasible set considered, with a new
297
- set of constraints deriving from the position of a new obstacle.
298
-
299
- However, IK---solving
300
- eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
301
- reference="eq:ik_problem"} for a feasible $q$---only proves useful in
302
- determining information regarding the robot's configuration in the goal
303
- pose, and crucially does not provide information on the *trajectory* to
304
- follow over time to reach a target pose. Expert-defined trajectories
305
- obviate to this problem providing a length-$K$ succession of goal poses
306
- $\tau_K = [p^*_0, p^*_1, \dots p^*_K]$ for tracking. In practice,
307
- trajectories can also be obtained automatically through *motion
308
- planning* algorithms, thus avoiding expensive trajectory definition from
309
- human experts. However, tracking $\tau_K$ via IK can prove prohibitively
310
- expensive, as tracking would require $K$ resolutions of
311
- eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
312
- reference="eq:ik_problem"} (one for each target pose). *Differential*
313
- inverse kinematics (diff-IK) complements IK via closed-form solution of
314
- a variant of
315
- eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
316
- reference="eq:ik_problem"}. Let $J(q)$ denote the Jacobian matrix of
317
- (partial) derivatives of the FK-function
318
- $f_\FK: \mathcal Q \mapsto \mathcal P$, such that
319
- $J(q) = \frac{\partial f_{FK}(q)}{\partial q }$. Then, one can apply the
320
- chain rule to any $p(q) = f_{\FK}(q)$, deriving $\dot p = J(q) \dot q$,
321
- and thus finally relating variations in the robot configurations to
322
- variations in pose, thereby providing a platform for control.
323
-
324
- Given a desired end-effector trajectory $\targetvel(t)$ (1) indicating
325
- anchor regions in space and (2) how much time to spend in each region,
326
- diff-IK finds $\dot q(t)$ solving for joints' *velocities* instead of
327
- *configurations*, $$\begin{align}
328
- \dot q(t) = \arg\min_\nu \; \lVert J(q(t)) \nu - \targetvel (t) \rVert_2^2
329
- \label{eq:reg_ik_velocity}
330
- \end{align}$$
331
-
332
- UnlikeΒ eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
333
- reference="eq:ik_problem"}, solving for $\dot q$ is much less dependent
334
- on the environment (typically, variations in velocity are constrained by
335
- physical limits on the actuators). Conveniently,
336
- eq.Β [\[eq:reg_ik_velocity\]](#eq:reg_ik_velocity){reference-type="ref"
337
- reference="eq:reg_ik_velocity"} also often admits the closed-form
338
- solution $\dot q = J(q)^+ \targetvel$, where $J^+(q)$ denotes the
339
- Moore-Penrose pseudo-inverse of $J(q)$. Finally, discrete-time joint
340
- configurations $q$ can be reconstructed from joint velocities $\dot q$
341
- using forward-integration on the continuous-time joint velocity ,
342
- $q_{t+1} = q_t + \Delta t\,\dot q_t$ for a given $\Delta t$, resulting
343
- in tracking via diff-IK.
344
-
345
- Following trajectories with diff-IK is a valid option in well-controlled
346
- and static environments (e.g., industrial manipulators in controlled
347
- manufacturing settings), and relies on the ability to define a set of
348
- target velocities to track
349
- $[\targetvel_0, \targetvel_1, \dots, \targetvel_k ]$---an error-prone
350
- task largely requiring human expertise. Furthermore, diff-IK relies on
351
- the ability to (1) access $J(q) \, \forall q \in \mathcal Q$ and (2)
352
- compute its pseudo-inverse at every iteration of a given control
353
- cycle---a challenging assumption in highly dynamical settings, or for
354
- complex kinematic chains.
355
-
356
- ### Adding Feedback Loops
357
-
358
- While very effective when a goal trajectory has been well specified, the
359
- performance of diff-IK can degrade significantly in the presence of
360
- modeling/tracking errors, or in the presence of non-modeled dynamics in
361
- the environment.
362
-
363
- ::: wrapfigure
364
- r0.3
365
- ![image](figures/ch2/ch2-planar-manipulator-floor-box.png){width="\\linewidth"}
366
- :::
367
-
368
- One such case is presented in
369
- FigureΒ [\[fig:planar-manipulator-box-velocity\]](#fig:planar-manipulator-box-velocity){reference-type="ref"
370
- reference="fig:planar-manipulator-box-velocity"}, where another rigid
371
- body other than the manipulator is moving in the environment along the
372
- horizontal axis, with velocity $\dot x_B$. Accounting analytically for
373
- the presence of this disturbance---for instance, to prevent the midpoint
374
- of the link from ever colliding with the object---requires access to
375
- $\dot x_B$ at least, to derive the equation characterizing the motion of
376
- the environment.
377
-
378
- Less predictable disturbances however (e.g.,
379
- $\dot x_B \leftarrow \dot x_B + \eps, \eps \sim N(0,1)$) may prove
380
- challenging to model analytically, and one could attain the same result
381
- of preventing link-object collision by adding a condition on the
382
- distance between the midpoint of $l$ and $x_B$, enforced through a
383
- feedback loop on the position of the robot and object at each control
384
- cycle.
385
-
386
- To mitigate the effect of modeling errors, sensing noise and other
387
- disturbances, classical pipelines indeed do augment diff-IK with
388
- feedback control looping back quantities of interest. In practice,
389
- following a trajectory with a closed feedback loop might consist in
390
- backwarding the error between the target and measured pose,
391
- $\Delta p = \targetpos - p(q)$, hereby modifying the control applied to
392
- $\dot q = J(q)^+ (\targetvel + k_p \Delta p )$, with $k_p$ defined as
393
- the (proportional) gain.
394
-
395
- More advanced techniques for control consisting in feedback
396
- linearization, PID control, Linear Quatratic Regulator (LQR) or
397
- Model-Predictive Control (MPC) can be employed to stabilize tracking and
398
- reject moderate perturbations, and we refer to
399
- @sicilianoSpringerHandbookRobotics2016 [ChapterΒ 8] for in-detail
400
- explanation of these concepts, or [@tedrakeRoboticManipulationPerception
401
- ChapterΒ 8] for a simple, intuitive example in the case of a point-mass
402
- system. Nonetheless, feedback control presents its challenges as well:
403
- tuning gains remains laborious and system-specific. Further,
404
- manipulation tasks present intermittent contacts inducing hybrid
405
- dynamics (mode switches) and discontinuities in the Jacobian,
406
- challenging the stability guarantees of the controller and thus often
407
- necessitating rather conservative gains and substantial hand-tuning.
408
-
409
- We point the interested reader toΒ @sicilianoSpringerHandbookRobotics2016
410
- [ChapterΒ 2,7,8], @lynchModernRoboticsMechanics2017 [ChapterΒ 6,11],
411
- andΒ @tedrakeRoboticManipulationPerception [ChapterΒ 3,8] for extended
412
- coverage of FK, IK, diff-IK and control for (diff-)IK.
413
-
414
- ## Limitations of Dynamics-based Robotics
415
-
416
- Despite the last 60+ years of robotics research, autonomous robots are
417
- still largely incapable of performing tasks at human-level performance
418
- in the physical world generalizing across (1) robot embodiments
419
- (different manipulators, different locomotion platforms, etc.) and (2)
420
- tasks (tying shoe-laces, manipulating a diverse set of objects). While
421
- essential in the early development of robotics, the aforementioned
422
- methods require significant human expertise to be used in practice, and
423
- are typically specific to a particular applicative problem.
424
-
425
- ![Dynamics-based approaches to robotics suffer from several limitations:
426
- (1) orchestrating multiple components poses integration challenges; (2)
427
- the need to develop custom processing pipelines for the sensing
428
- modalities and tasks considered hinders scalability; (3) simplified
429
- analytical models of physical phenomena (here friction at the gripper;
430
- credits toΒ @antonovaReinforcementLearningPivoting2017) limit real-world
431
- performance. Lastly, (4) dynamics-based methods overlook trends in the
432
- availability and growth of robotics
433
- data.](figures/ch2/ch2-classical-limitations.png){#fig:classical-limitations
434
- width="90%"}
435
-
436
- Dynamics-based robotics pipelines have historically been now within most
437
- architectures for specific purposes. That is, sensing, state estimation,
438
- mapping, planning, (diff-)IK, and low-level control have been
439
- traditionally developed as distinct modules with fixed interfaces.
440
- Pipelining these specific modules proved error-prone, and brittleness
441
- emerges---alongside compounding errors---whenever changes incur (e.g.,
442
- changes in lighting for sensing, occlusion/failure of sensors, control
443
- failures). Adapting such a stack to new tasks or robotic platforms often
444
- entails re-specifying objectives, constraints, and heuristics at
445
- multiple stages, incurring significant engineering overhead.
446
-
447
- Moreover, classical planners operate on compact, assumed-sufficient
448
- state representations; extending them to reason directly over raw,
449
- heterogeneous and noisy data streams is non-trivial. This results in a ,
450
- as incorporating high-dimensional perceptual inputs (RGB, depth,
451
- tactile, audio) traditionally required extensive engineering efforts to
452
- extract meaningful features for control. Also, the large number of
453
- tasks, coupled with the adoption of *per-task* planners, goal
454
- parameterizations, and safety constraints, results in an explosion in
455
- design and validation options, with little opportunity to reuse
456
- solutions across tasks.
457
-
458
- Setting aside integration and scalability challenges: developing
459
- accurate modeling of contact, friction, and compliance for complicated
460
- systems remains difficult. Rigid-body approximations are often
461
- insufficient in the presence of deformable objects, and of the methods
462
- developed. In the case of complex, time-dependent and/or non-linear
463
- dynamics, even moderate mismatches in parameters, unmodeled evolutions,
464
- or grasp-induced couplings can qualitatively affect the observed
465
- dynamics.
466
-
467
- Lastly, dynamics-based methods (naturally) overlook the rather recent .
468
- The curation of academic datasets by large centralized groups of human
469
- experts in
470
- roboticsΒ [@collaborationOpenXEmbodimentRobotic2025; @khazatskyDROIDLargeScaleInTheWild2025]
471
- is now increasingly complemented by a by individuals with varied
472
- expertise. If not tangentially, dynamics-based approaches are not posed
473
- to maximally benefit from this trend, which holds the premise of
474
- allowing generalization in the space of tasks and embodiments, like data
475
- was the cornerstone for advancements in
476
- visionΒ [@alayracFlamingoVisualLanguage2022] and natural-language
477
- understandingΒ [@brownLanguageModelsAre2020].
478
-
479
- Taken together, these limitations
480
- (FigureΒ [9](#fig:classical-limitations){reference-type="ref"
481
- reference="fig:classical-limitations"}) motivate the exploration of
482
- learning-based approaches that can (1) integrate perception and control
483
- more tightly, (2) adapt across tasks and embodiments with reduced expert
484
- modeling interventions and (3) scale gracefully in performance as more
485
- robotics data becomes available.
486
-
487
- [^1]: In here, we refer to both *kinematics* and *dynamics*-based
488
- control.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/scripts/latex-to-mdx/mdx-converter.mjs CHANGED
@@ -124,12 +124,12 @@ function addComponentImports(content) {
124
 
125
 
126
  /**
127
- * Convert grouped figures (subfigures) to MultiImage components
128
  * @param {string} content - MDX content
129
- * @returns {string} - Content with MultiImage components for grouped figures
130
  */
131
- function convertSubfiguresToMultiImage(content) {
132
- console.log(' πŸ–ΌοΈβœ¨ Converting subfigures to MultiImage components...');
133
 
134
  let convertedCount = 0;
135
 
@@ -187,8 +187,8 @@ function convertSubfiguresToMultiImage(content) {
187
  .replace(/'/g, "\\'")
188
  .trim();
189
 
190
- // Mark MultiImage component as used
191
- usedComponents.add('MultiImage');
192
 
193
  // Determine layout based on number of images
194
  let layout = 'auto';
@@ -196,12 +196,12 @@ function convertSubfiguresToMultiImage(content) {
196
  else if (images.length === 3) layout = '3-column';
197
  else if (images.length === 4) layout = '4-column';
198
 
199
- // Generate MultiImage component
200
  const imagesJson = images.map(img =>
201
  ` {\n src: ${img.src},\n alt: "${img.alt}",\n caption: "${img.caption}",\n id: "${img.id}"\n }`
202
  ).join(',\n');
203
 
204
- return `<MultiImage
205
  images={[
206
  ${imagesJson}
207
  ]}
@@ -213,7 +213,7 @@ ${imagesJson}
213
  });
214
 
215
  if (convertedCount > 0) {
216
- console.log(` βœ… Converted ${convertedCount} subfigure group(s) to MultiImage component(s)`);
217
  } else {
218
  console.log(' ℹ️ No subfigure groups found');
219
  }
@@ -222,23 +222,23 @@ ${imagesJson}
222
  }
223
 
224
  /**
225
- * Transform images to ResponsiveImage components
226
  * @param {string} content - MDX content
227
- * @returns {string} - Content with ResponsiveImage components
228
  */
229
  /**
230
- * Create ResponsiveImage component with import
231
  * @param {string} src - Clean image source
232
  * @param {string} alt - Alt text
233
  * @param {string} id - Element ID
234
  * @param {string} caption - Figure caption
235
  * @param {string} width - Optional width
236
- * @returns {string} - ResponsiveImage component markup
237
  */
238
- function createResponsiveImageComponent(src, alt = '', id = '', caption = '', width = '') {
239
  const varName = generateImageVarName(src);
240
  imageImports.set(src, varName);
241
- usedComponents.add('ResponsiveImage');
242
 
243
  const props = [];
244
  props.push(`src={${varName}}`);
@@ -249,11 +249,11 @@ function createResponsiveImageComponent(src, alt = '', id = '', caption = '', wi
249
  if (alt) props.push(`alt="${alt}"`);
250
  if (caption) props.push(`caption={'${caption}'}`);
251
 
252
- return `<ResponsiveImage\n ${props.join('\n ')}\n/>`;
253
  }
254
 
255
  function transformImages(content) {
256
- console.log(' πŸ–ΌοΈ Transforming images to ResponsiveImage components with imports...');
257
 
258
  let hasImages = false;
259
 
@@ -297,7 +297,7 @@ function transformImages(content) {
297
  const altText = cleanAltText(cleanCap);
298
  hasImages = true;
299
 
300
- return createResponsiveImageComponent(cleanSrc, altText, id, cleanCap);
301
  }
302
  );
303
 
@@ -309,7 +309,7 @@ function transformImages(content) {
309
  const cleanAlt = cleanAltText(alt || 'Figure');
310
  hasImages = true;
311
 
312
- return createResponsiveImageComponent(cleanSrc, cleanAlt);
313
  }
314
  );
315
 
@@ -320,7 +320,7 @@ function transformImages(content) {
320
  const cleanSrc = cleanSrcPath(src);
321
  hasImages = true;
322
 
323
- return createResponsiveImageComponent(cleanSrc, 'Figure');
324
  }
325
  );
326
 
@@ -333,7 +333,7 @@ function transformImages(content) {
333
  const altText = cleanAltText(cleanCap);
334
  hasImages = true;
335
 
336
- return createResponsiveImageComponent(cleanSrc, altText, id, cleanCap);
337
  }
338
  );
339
 
@@ -346,7 +346,7 @@ function transformImages(content) {
346
  const altText = cleanAltText(cleanCap);
347
  hasImages = true;
348
 
349
- return createResponsiveImageComponent(cleanSrc, altText, id, cleanCap);
350
  }
351
  );
352
 
@@ -364,12 +364,12 @@ function transformImages(content) {
364
  if (idMatch) id = idMatch[1];
365
  }
366
 
367
- return createResponsiveImageComponent(cleanSrc, cleanAlt, id);
368
  }
369
  );
370
 
371
  if (hasImages) {
372
- console.log(' βœ… ResponsiveImage components with imports will be created');
373
  }
374
 
375
  return content;
@@ -822,7 +822,7 @@ function processMdxContent(content, latexContent = '') {
822
  processedContent = formatDisplayMathBlocks(processedContent);
823
  processedContent = removeHtmlComments(processedContent);
824
  processedContent = cleanMdxSyntax(processedContent);
825
- processedContent = convertSubfiguresToMultiImage(processedContent);
826
  processedContent = transformImages(processedContent);
827
  processedContent = transformStyledSpans(processedContent);
828
  processedContent = transformReferenceLinks(processedContent);
 
124
 
125
 
126
  /**
127
+ * Convert grouped figures (subfigures) to MultiFigure components
128
  * @param {string} content - MDX content
129
+ * @returns {string} - Content with MultiFigure components for grouped figures
130
  */
131
+ function convertSubfiguresToMultiFigure(content) {
132
+ console.log(' πŸ–ΌοΈβœ¨ Converting subfigures to MultiFigure components...');
133
 
134
  let convertedCount = 0;
135
 
 
187
  .replace(/'/g, "\\'")
188
  .trim();
189
 
190
+ // Mark MultiFigure component as used
191
+ usedComponents.add('MultiFigure');
192
 
193
  // Determine layout based on number of images
194
  let layout = 'auto';
 
196
  else if (images.length === 3) layout = '3-column';
197
  else if (images.length === 4) layout = '4-column';
198
 
199
+ // Generate MultiFigure component
200
  const imagesJson = images.map(img =>
201
  ` {\n src: ${img.src},\n alt: "${img.alt}",\n caption: "${img.caption}",\n id: "${img.id}"\n }`
202
  ).join(',\n');
203
 
204
+ return `<MultiFigure
205
  images={[
206
  ${imagesJson}
207
  ]}
 
213
  });
214
 
215
  if (convertedCount > 0) {
216
+ console.log(` βœ… Converted ${convertedCount} subfigure group(s) to MultiFigure component(s)`);
217
  } else {
218
  console.log(' ℹ️ No subfigure groups found');
219
  }
 
222
  }
223
 
224
  /**
225
+ * Transform images to Figure components
226
  * @param {string} content - MDX content
227
+ * @returns {string} - Content with Figure components
228
  */
229
  /**
230
+ * Create Figure component with import
231
  * @param {string} src - Clean image source
232
  * @param {string} alt - Alt text
233
  * @param {string} id - Element ID
234
  * @param {string} caption - Figure caption
235
  * @param {string} width - Optional width
236
+ * @returns {string} - Figure component markup
237
  */
238
+ function createFigureComponent(src, alt = '', id = '', caption = '', width = '') {
239
  const varName = generateImageVarName(src);
240
  imageImports.set(src, varName);
241
+ usedComponents.add('Figure');
242
 
243
  const props = [];
244
  props.push(`src={${varName}}`);
 
249
  if (alt) props.push(`alt="${alt}"`);
250
  if (caption) props.push(`caption={'${caption}'}`);
251
 
252
+ return `<Figure\n ${props.join('\n ')}\n/>`;
253
  }
254
 
255
  function transformImages(content) {
256
+ console.log(' πŸ–ΌοΈ Transforming images to Figure components with imports...');
257
 
258
  let hasImages = false;
259
 
 
297
  const altText = cleanAltText(cleanCap);
298
  hasImages = true;
299
 
300
+ return createFigureComponent(cleanSrc, altText, id, cleanCap);
301
  }
302
  );
303
 
 
309
  const cleanAlt = cleanAltText(alt || 'Figure');
310
  hasImages = true;
311
 
312
+ return createFigureComponent(cleanSrc, cleanAlt);
313
  }
314
  );
315
 
 
320
  const cleanSrc = cleanSrcPath(src);
321
  hasImages = true;
322
 
323
+ return createFigureComponent(cleanSrc, 'Figure');
324
  }
325
  );
326
 
 
333
  const altText = cleanAltText(cleanCap);
334
  hasImages = true;
335
 
336
+ return createFigureComponent(cleanSrc, altText, id, cleanCap);
337
  }
338
  );
339
 
 
346
  const altText = cleanAltText(cleanCap);
347
  hasImages = true;
348
 
349
+ return createFigureComponent(cleanSrc, altText, id, cleanCap);
350
  }
351
  );
352
 
 
364
  if (idMatch) id = idMatch[1];
365
  }
366
 
367
+ return createFigureComponent(cleanSrc, cleanAlt, id);
368
  }
369
  );
370
 
371
  if (hasImages) {
372
+ console.log(' βœ… Figure components with imports will be created');
373
  }
374
 
375
  return content;
 
822
  processedContent = formatDisplayMathBlocks(processedContent);
823
  processedContent = removeHtmlComments(processedContent);
824
  processedContent = cleanMdxSyntax(processedContent);
825
+ processedContent = convertSubfiguresToMultiFigure(processedContent);
826
  processedContent = transformImages(processedContent);
827
  processedContent = transformStyledSpans(processedContent);
828
  processedContent = transformReferenceLinks(processedContent);
app/scripts/latex-to-mdx/post-processor.mjs CHANGED
@@ -263,7 +263,7 @@ function fixAllAttributes(content) {
263
  return `data-reference="${before}-${after}"`;
264
  });
265
 
266
- // Fix id attributes containing colons (like in ResponsiveImage components)
267
  content = content.replace(/id="([^"]*):([^"]*)"/g, (match, before, after) => {
268
  fixedCount++;
269
  return `id="${before}-${after}"`;
 
263
  return `data-reference="${before}-${after}"`;
264
  });
265
 
266
+ // Fix id attributes containing colons (like in Figure components)
267
  content = content.replace(/id="([^"]*):([^"]*)"/g, (match, before, after) => {
268
  fixedCount++;
269
  return `id="${before}-${after}"`;
app/scripts/sync-template.mjs CHANGED
@@ -28,7 +28,7 @@ const PRESERVE_PATHS = [
28
  // Project-specific content
29
  'app/src/content',
30
 
31
- // Public data (symlink to our data)
32
  'app/public/data',
33
 
34
  // Local configuration
@@ -75,7 +75,7 @@ console.log('');
75
  async function executeCommand(command, options = {}) {
76
  try {
77
  if (isDryRun && !options.allowInDryRun) {
78
- console.log(`[DRY-RUN] Commande: ${command}`);
79
  return '';
80
  }
81
  console.log(`$ ${command}`);
@@ -141,7 +141,20 @@ async function syncFile(sourcePath, targetPath) {
141
  }
142
  }
143
 
144
- // CrΓ©er un backup si le fichier existe dΓ©jΓ  (et que ce n'est pas un lien symbolique)
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  if (await pathExists(targetPath)) {
146
  try {
147
  const stats = await fs.lstat(targetPath);
@@ -161,11 +174,11 @@ async function syncFile(sourcePath, targetPath) {
161
  // Assurer que le rΓ©pertoire parent existe
162
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
163
 
164
- // VΓ©rifier si la source est un lien symbolique
165
  try {
166
  const sourceStats = await fs.lstat(sourcePath);
167
  if (sourceStats.isSymbolicLink()) {
168
- console.log(`πŸ”— SYMLINK (ignored): ${relativeTarget}`);
169
  return;
170
  }
171
  } catch (error) {
@@ -173,7 +186,7 @@ async function syncFile(sourcePath, targetPath) {
173
  return;
174
  }
175
 
176
- // Supprimer le fichier cible s'il existe (pour gΓ©rer les liens symboliques)
177
  if (await pathExists(targetPath)) {
178
  await fs.rm(targetPath, { recursive: true, force: true });
179
  }
@@ -212,19 +225,55 @@ async function cloneOrUpdateTemplate() {
212
 
213
  // Nettoyer le dossier temporaire s'il existe
214
  if (await pathExists(TEMP_DIR)) {
215
- if (!isDryRun) {
216
- await fs.rm(TEMP_DIR, { recursive: true, force: true });
217
- } else {
218
  console.log(`[DRY-RUN] Suppression: ${TEMP_DIR}`);
219
  }
220
  }
221
 
222
- // Cloner le repo template (mΓͺme en dry-run pour pouvoir comparer)
223
  await executeCommand(`git clone ${TEMPLATE_REPO} "${TEMP_DIR}"`, { allowInDryRun: true });
224
 
225
  return TEMP_DIR;
226
  }
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  async function showSummary(templateDir) {
229
  console.log('\nπŸ“Š SYNCHRONIZATION SUMMARY');
230
  console.log('================================');
@@ -265,19 +314,23 @@ async function cleanup() {
265
 
266
  async function main() {
267
  try {
268
- // VΓ©rifier qu'on est dans le bon rΓ©pertoire
269
  const packageJsonPath = path.join(APP_ROOT, 'package.json');
270
  if (!(await pathExists(packageJsonPath))) {
271
- throw new Error(`Package.json non trouvé dans ${APP_ROOT}. Êtes-vous dans le bon répertoire ?`);
272
  }
273
 
274
- // Cloner le template
275
  const templateDir = await cloneOrUpdateTemplate();
276
 
277
  // Synchroniser
278
  console.log('\nπŸ”„ Synchronisation en cours...');
279
  await syncDirectory(templateDir, PROJECT_ROOT);
280
 
 
 
 
 
281
  // Afficher le rΓ©sumΓ©
282
  await showSummary(templateDir);
283
 
@@ -292,7 +345,7 @@ async function main() {
292
  }
293
  }
294
 
295
- // Gestion des signaux pour nettoyer en cas d'interruption
296
  process.on('SIGINT', async () => {
297
  console.log('\n\n⚠️ Interruption detected, cleaning up...');
298
  await cleanup();
 
28
  // Project-specific content
29
  'app/src/content',
30
 
31
+ // Public data (symlink to our data) - CRITICAL: preserve this symlink
32
  'app/public/data',
33
 
34
  // Local configuration
 
75
  async function executeCommand(command, options = {}) {
76
  try {
77
  if (isDryRun && !options.allowInDryRun) {
78
+ console.log(`[DRY-RUN] Command: ${command}`);
79
  return '';
80
  }
81
  console.log(`$ ${command}`);
 
141
  }
142
  }
143
 
144
+ // Check if target file is a symbolic link to preserve
145
+ if (await pathExists(targetPath)) {
146
+ try {
147
+ const targetStats = await fs.lstat(targetPath);
148
+ if (targetStats.isSymbolicLink()) {
149
+ console.log(`πŸ”— SYMLINK TARGET (preserved): ${relativeTarget}`);
150
+ return;
151
+ }
152
+ } catch (error) {
153
+ console.warn(`⚠️ Impossible de vérifier ${targetPath}: ${error.message}`);
154
+ }
155
+ }
156
+
157
+ // Create backup if file already exists (and is not a symbolic link)
158
  if (await pathExists(targetPath)) {
159
  try {
160
  const stats = await fs.lstat(targetPath);
 
174
  // Assurer que le rΓ©pertoire parent existe
175
  await fs.mkdir(path.dirname(targetPath), { recursive: true });
176
 
177
+ // Check if source is a symbolic link
178
  try {
179
  const sourceStats = await fs.lstat(sourcePath);
180
  if (sourceStats.isSymbolicLink()) {
181
+ console.log(`πŸ”— SYMLINK SOURCE (ignored): ${relativeTarget}`);
182
  return;
183
  }
184
  } catch (error) {
 
186
  return;
187
  }
188
 
189
+ // Remove target file if it exists (to handle symbolic links)
190
  if (await pathExists(targetPath)) {
191
  await fs.rm(targetPath, { recursive: true, force: true });
192
  }
 
225
 
226
  // Nettoyer le dossier temporaire s'il existe
227
  if (await pathExists(TEMP_DIR)) {
228
+ await fs.rm(TEMP_DIR, { recursive: true, force: true });
229
+ if (isDryRun) {
 
230
  console.log(`[DRY-RUN] Suppression: ${TEMP_DIR}`);
231
  }
232
  }
233
 
234
+ // Clone template repo (even in dry-run to be able to compare)
235
  await executeCommand(`git clone ${TEMPLATE_REPO} "${TEMP_DIR}"`, { allowInDryRun: true });
236
 
237
  return TEMP_DIR;
238
  }
239
 
240
+ async function ensureDataSymlink() {
241
+ const dataSymlinkPath = path.join(APP_ROOT, 'public', 'data');
242
+ const dataSourcePath = path.join(APP_ROOT, 'src', 'content', 'assets', 'data');
243
+
244
+ // Check if symlink exists and is correct
245
+ if (await pathExists(dataSymlinkPath)) {
246
+ try {
247
+ const stats = await fs.lstat(dataSymlinkPath);
248
+ if (stats.isSymbolicLink()) {
249
+ const target = await fs.readlink(dataSymlinkPath);
250
+ const expectedTarget = path.relative(path.dirname(dataSymlinkPath), dataSourcePath);
251
+ if (target === expectedTarget) {
252
+ console.log('πŸ”— Data symlink is correct');
253
+ return;
254
+ } else {
255
+ console.log(`⚠️ Data symlink points to wrong target: ${target} (expected: ${expectedTarget})`);
256
+ }
257
+ } else {
258
+ console.log('⚠️ app/public/data exists but is not a symlink');
259
+ }
260
+ } catch (error) {
261
+ console.log(`⚠️ Error checking symlink: ${error.message}`);
262
+ }
263
+ }
264
+
265
+ // Recreate symlink
266
+ if (!isDryRun) {
267
+ if (await pathExists(dataSymlinkPath)) {
268
+ await fs.rm(dataSymlinkPath, { recursive: true, force: true });
269
+ }
270
+ await fs.symlink(path.relative(path.dirname(dataSymlinkPath), dataSourcePath), dataSymlinkPath);
271
+ console.log('βœ… Data symlink recreated');
272
+ } else {
273
+ console.log('[DRY-RUN] Would recreate data symlink');
274
+ }
275
+ }
276
+
277
  async function showSummary(templateDir) {
278
  console.log('\nπŸ“Š SYNCHRONIZATION SUMMARY');
279
  console.log('================================');
 
314
 
315
  async function main() {
316
  try {
317
+ // Verify we're in the correct directory
318
  const packageJsonPath = path.join(APP_ROOT, 'package.json');
319
  if (!(await pathExists(packageJsonPath))) {
320
+ throw new Error(`Package.json not found in ${APP_ROOT}. Are you in the correct directory?`);
321
  }
322
 
323
+ // Clone the template
324
  const templateDir = await cloneOrUpdateTemplate();
325
 
326
  // Synchroniser
327
  console.log('\nπŸ”„ Synchronisation en cours...');
328
  await syncDirectory(templateDir, PROJECT_ROOT);
329
 
330
+ // S'assurer que le lien symbolique des donnΓ©es est correct
331
+ console.log('\nπŸ”— VΓ©rification du lien symbolique des donnΓ©es...');
332
+ await ensureDataSymlink();
333
+
334
  // Afficher le rΓ©sumΓ©
335
  await showSummary(templateDir);
336
 
 
345
  }
346
  }
347
 
348
+ // Signal handling to clean up on interruption
349
  process.on('SIGINT', async () => {
350
  console.log('\n\n⚠️ Interruption detected, cleaning up...');
351
  await cleanup();
app/src/components/ColorPicker.astro DELETED
@@ -1,118 +0,0 @@
1
- ---
2
- ---
3
- <div class="color-picker" style="width:100%; margin: 10px 0;">
4
- <style>
5
- .color-picker .picker__stack { display:flex; flex-direction:column; gap:12px; }
6
- .color-picker .current-card { display:grid; grid-template-columns: 30% 70%; align-items: center; gap:14px; padding:14px 32px 14px 16px; border:1px solid var(--border-color); background: var(--surface-bg); border-radius: 12px; }
7
- .color-picker .current-left { display:flex; flex-direction: column; gap:8px; min-width: 0; }
8
- .color-picker .current-right { display:flex; flex-direction: column; gap:8px; padding-left: 14px; border-left: 1px solid var(--border-color); }
9
- .color-picker .current-main { display:flex; align-items:center; gap:12px; min-width: 0; }
10
- .color-picker .current-swatch { width: 64px; height: 64px; border-radius: 8px; border: 1px solid var(--border-color); }
11
- .color-picker .current-text { display:flex; flex-direction: column; line-height: 1.2; min-width: 0; }
12
- .color-picker .current-name { font-size: 14px; font-weight: 800; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
13
- .color-picker .current-hex, .color-picker .current-extra { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
14
- .color-picker .picker__label { font-weight:700; font-size: 12px; color: var(--muted-color); text-transform: uppercase; letter-spacing: .02em; }
15
- .color-picker .hue-slider { position:relative; height:16px; 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; flex: 1 1 auto; min-width: 200px; }
16
- .color-picker .hue-knob { position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2; box-shadow: 0 0 0 1px rgba(0,0,0,.05); }
17
- .color-picker .hue-slider:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
18
- .color-picker .hue-value { font-variant-numeric: tabular-nums; color: var(--muted-color); font-size: 12px; }
19
- @media (max-width: 720px) { .color-picker .current-card { grid-template-columns: 1fr; } .color-picker .current-right { padding-left: 0; border-left: none; } }
20
- </style>
21
- <div class="picker__stack">
22
- <div class="current-card">
23
- <div class="current-left">
24
- <div class="current-main">
25
- <div class="current-swatch" aria-label="Current color" title="Current color"></div>
26
- <div class="current-text">
27
- <div class="current-name">β€”</div>
28
- <div class="current-hex">β€”</div>
29
- <div class="current-extra current-lch">β€”</div>
30
- <div class="current-extra current-rgb">β€”</div>
31
- </div>
32
- </div>
33
- </div>
34
- <div class="current-right">
35
- <div class="picker__label">Hue</div>
36
- <div class="hue-slider" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="337" tabindex="0">
37
- <div class="hue-knob"></div>
38
- </div>
39
- <div class="hue-value">337Β°</div>
40
- </div>
41
- </div>
42
- </div>
43
- </div>
44
- <script>
45
- (() => {
46
- const COLOR_NAMES = [{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Big bang Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Pink","hex":"#ff0099"},{"name":"Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
47
- if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
48
-
49
- if (!window.__colorPickerBus) {
50
- window.__colorPickerBus = (() => {
51
- let hue = 337; let adjusting=false; const listeners = new Set();
52
- return { get: () => ({ hue, adjusting }), publish: (sourceId, nextHue, isAdj) => { hue=((nextHue%360)+360)%360; adjusting=!!isAdj; listeners.forEach(fn => { try { fn({ sourceId, hue, adjusting }); } catch{} }); }, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
53
- })();
54
- }
55
-
56
- const bootstrap = () => {
57
- const root = document.querySelector('.color-picker'); if (!root || root.dataset.mounted) return; root.dataset.mounted='true';
58
- const slider = root.querySelector('.hue-slider'); const knob = root.querySelector('.hue-knob'); const hueValue = root.querySelector('.hue-value'); const currentSwatch = root.querySelector('.current-swatch'); const currentName = root.querySelector('.current-name'); const currentHex = root.querySelector('.current-hex'); const currentLch = root.querySelector('.current-lch'); const currentRgb = root.querySelector('.current-rgb');
59
- const bus = window.__colorPickerBus; const instanceId = Math.random().toString(36).slice(2);
60
- const getKnobRadius = () => { try { const w = knob ? knob.getBoundingClientRect().width : 0; return w ? w/2 : 8; } catch { return 8; } };
61
- const hexToHsl = (H) => {
62
- const s = H.replace('#','');
63
- const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s;
64
- const bigint = parseInt(v, 16);
65
- let r = (bigint >> 16) & 255, g = (bigint >> 8) & 255, b = bigint & 255;
66
- r /= 255; g /= 255; b /= 255;
67
- const max = Math.max(r, g, b), min = Math.min(r, g, b);
68
- let h = 0, s2 = 0, l = (max + min) / 2;
69
- if (max !== min) {
70
- const d = max - min;
71
- s2 = l > 0.5 ? d / (2 - max - min) : d / (max + min);
72
- switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; }
73
- h /= 6;
74
- }
75
- return { h: Math.round(h*360), s: Math.round(s2*100), l: Math.round(l*100) };
76
- };
77
- const hslToHex = (h, s, l) => {
78
- s /= 100; l /= 100;
79
- const k = n => (n + h/30) % 12;
80
- const a = s * Math.min(l, 1 - l);
81
- const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
82
- const toHex = x => Math.round(255 * x).toString(16).padStart(2, '0');
83
- return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`.toUpperCase();
84
- };
85
- // Precompute hues for the provided color-name list
86
- const NAME_HUES = COLOR_NAMES.map((c) => {
87
- const hh = hexToHsl(c.hex).h || 0;
88
- return { name: c.name, hue: hh };
89
- });
90
- // Pick closest name by circular hue distance; fallback to coarse labels
91
- const getName = (hex) => {
92
- const h = hexToHsl(hex).h || 0;
93
- let bestName = 'β€”';
94
- let best = 361;
95
- for (let i = 0; i < NAME_HUES.length; i++) {
96
- const hh = NAME_HUES[i].hue;
97
- const d = Math.abs(hh - h);
98
- const dist = Math.min(d, 360 - d);
99
- if (dist < best) { best = dist; bestName = NAME_HUES[i].name; }
100
- }
101
- if (bestName !== 'β€”') return bestName;
102
- const labels=['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta'];
103
- const idx=Math.round(((h%360)/360)*(labels.length-1));
104
- return labels[idx];
105
- };
106
- const updateUI = (h, adjusting) => { const rect = slider.getBoundingClientRect(); const r=Math.min(getKnobRadius(), Math.max(0, rect.width/2 - 1)); const t=Math.max(0, Math.min(1, (h/360))); const leftPx = r + t * Math.max(0, (rect.width - 2*r)); if (knob) knob.style.left = (leftPx/rect.width*100) + '%'; if (hueValue) hueValue.textContent=`${Math.round(h)}Β°`; if (slider) slider.setAttribute('aria-valuenow', String(Math.round(h))); const L=62, S=72; const baseHex=hslToHex(h,S,L); if (currentSwatch) currentSwatch.style.background=baseHex; if (currentName) currentName.textContent=getName(baseHex); if (currentHex) currentHex.textContent=baseHex; if (currentLch) currentLch.textContent = `HSL ${L}, ${S}, ${Math.round(h)}Β°`; if (currentRgb){ const hex=baseHex.replace('#',''); const R=parseInt(hex.slice(0,2),16), G=parseInt(hex.slice(2,4),16), B=parseInt(hex.slice(4,6),16); currentRgb.textContent=`RGB ${R}, ${G}, ${B}`; } const hoverHex=hslToHex(h, Math.max(0,S-10), Math.max(0, L-8)); const rootEl=document.documentElement; rootEl.style.setProperty('--primary-color', baseHex); rootEl.style.setProperty('--primary-color-hover', hoverHex); };
107
- const getHueFromEvent = (ev) => { const rect=slider.getBoundingClientRect(); const clientX=ev.touches ? ev.touches[0].clientX : ev.clientX; const x = clientX - rect.left; const r=Math.min(getKnobRadius(), Math.max(0, rect.width/2 - 1)); const effX=Math.max(r, Math.min(rect.width - r, x)); const denom=Math.max(1, rect.width - 2*r); const t=(effX - r) / denom; return t*360; };
108
- const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => { if (sourceId === instanceId) return; updateUI(hue, adjusting); });
109
- try { let initH=337; if (window.ColorPalettes && typeof window.ColorPalettes.getPrimary==='function'){ const hex=window.ColorPalettes.getPrimary(); initH = hexToHsl(hex).h || initH; } else { const cssPrimary=getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); if (cssPrimary) { initH = hexToHsl(cssPrimary).h || initH; } } const { hue: sharedHue } = bus.get(); updateUI(initH ?? sharedHue, false); bus.publish(instanceId, initH ?? sharedHue, false); } catch { const { hue: sharedHue } = bus.get(); updateUI(sharedHue, false); }
110
- const onDown = (ev) => { ev.preventDefault(); const h=getHueFromEvent(ev); updateUI(h, true); bus.publish(instanceId, h, true); const move=(e)=>{ e.preventDefault && e.preventDefault(); const hh=getHueFromEvent(e); updateUI(hh, true); bus.publish(instanceId, hh, true); }; const up=()=>{ bus.publish(instanceId, getHueFromEvent(ev), false); 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 }); };
111
- if (slider){ slider.addEventListener('mousedown', onDown); slider.addEventListener('touchstart', onDown, { passive:false }); slider.addEventListener('keydown', (e)=>{ const step=e.shiftKey?10:2; if (e.key==='ArrowLeft'){ e.preventDefault(); const { hue } = bus.get(); const h=hue-step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); } if (e.key==='ArrowRight'){ e.preventDefault(); const { hue } = bus.get(); const h=hue+step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); } }); }
112
- const ro=new MutationObserver(()=>{ if (!document.body.contains(root)){ unsubscribe && unsubscribe(); ro.disconnect(); } }); ro.observe(document.body, { childList:true, subtree:true });
113
- };
114
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once:true }); else bootstrap();
115
- })();
116
- </script>
117
-
118
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/components/{ResponsiveImage.astro β†’ Figure.astro} RENAMED
@@ -175,13 +175,10 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
175
 
176
  <script is:inline>
177
  (() => {
178
- console.log("ResponsiveImage script: Starting execution");
179
  const scriptEl = document.currentScript;
180
- console.log("ResponsiveImage script: scriptEl =", scriptEl);
181
  const root = scriptEl ? scriptEl.previousElementSibling : null;
182
- console.log("ResponsiveImage script: root =", root);
183
  if (!root) {
184
- console.log("ResponsiveImage script: No root element found, exiting");
185
  return;
186
  }
187
  const img =
@@ -190,9 +187,8 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
190
  : root.querySelector
191
  ? root.querySelector("img")
192
  : null;
193
- console.log("ResponsiveImage script: img =", img);
194
  if (!img) {
195
- console.log("ResponsiveImage script: No img element found, exiting");
196
  return;
197
  }
198
 
@@ -209,13 +205,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
209
  };
210
 
211
  const initZoomIfNeeded = () => {
212
- console.log("ResponsiveImage: checking zoom for", img);
213
- console.log(
214
- "ResponsiveImage: data-zoomable =",
215
- img.getAttribute("data-zoomable"),
216
- );
217
  if (img.getAttribute("data-zoomable") !== "1") return;
218
- console.log("ResponsiveImage: initializing zoom for", img);
219
  const isDark =
220
  document.documentElement.getAttribute("data-theme") === "dark";
221
  const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
@@ -224,7 +214,6 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
224
  const instance = window.mediumZoom
225
  ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
226
  : null;
227
- console.log("ResponsiveImage: zoom instance created:", instance);
228
  if (!instance) return;
229
  let onScrollLike;
230
  const attachCloseOnScroll = () => {
@@ -268,7 +257,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
268
  });
269
  };
270
 
271
- // Gestion zoom global pour masquer autres ResponsiveImage
272
  const setupGlobalZoomBehavior = () => {
273
  img.addEventListener("click", () => {
274
  if (img.getAttribute("data-zoomable") === "1") {
@@ -277,7 +266,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
277
  .querySelectorAll(".ri-root.zoom-active")
278
  .forEach((el) => el.classList.remove("zoom-active"));
279
 
280
- // Ajouter zoom-active Γ  cet ri-root
281
  root.classList.add("zoom-active");
282
  }
283
  });
@@ -421,26 +410,26 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
421
  background: var(--primary-color);
422
  }
423
 
424
- /* Quand une image est zoomΓ©e, cacher TOUS les ResponsiveImage de la page */
425
  :global(.medium-zoom--opened) .ri-root {
426
  opacity: 0;
427
  z-index: calc(var(--z-base) - 1);
428
  transition: opacity 0.3s ease;
429
  }
430
 
431
- /* L'image actuellement zoomΓ©e reste visible */
432
  :global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
433
  opacity: 1;
434
  z-index: var(--z-overlay);
435
  }
436
 
437
- /* Fallback pour navigateurs sans support :has() */
438
  :global(.medium-zoom--opened) .ri-root.zoom-active {
439
  opacity: 1 !important;
440
  z-index: var(--z-overlay) !important;
441
  }
442
 
443
- /* SpΓ©cifiquement masquer bouton download et figcaption lors du zoom */
444
  :global(.medium-zoom--opened) .img-dl-btn {
445
  opacity: 0;
446
  z-index: calc(var(--z-base) - 1);
@@ -453,7 +442,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
453
  transition: opacity 0.3s ease;
454
  }
455
 
456
- /* MΓͺme pour l'image zoomΓ©e active, masquer bouton et caption pour une expΓ©rience propre */
457
  :global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
458
  opacity: 0;
459
  z-index: calc(var(--z-base) - 1);
 
175
 
176
  <script is:inline>
177
  (() => {
 
178
  const scriptEl = document.currentScript;
 
179
  const root = scriptEl ? scriptEl.previousElementSibling : null;
 
180
  if (!root) {
181
+ console.log("Figure script: No root element found, exiting");
182
  return;
183
  }
184
  const img =
 
187
  : root.querySelector
188
  ? root.querySelector("img")
189
  : null;
 
190
  if (!img) {
191
+ console.log("Figure script: No img element found, exiting");
192
  return;
193
  }
194
 
 
205
  };
206
 
207
  const initZoomIfNeeded = () => {
 
 
 
 
 
208
  if (img.getAttribute("data-zoomable") !== "1") return;
 
209
  const isDark =
210
  document.documentElement.getAttribute("data-theme") === "dark";
211
  const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
 
214
  const instance = window.mediumZoom
215
  ? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
216
  : null;
 
217
  if (!instance) return;
218
  let onScrollLike;
219
  const attachCloseOnScroll = () => {
 
257
  });
258
  };
259
 
260
+ // Global zoom management to hide other Figures
261
  const setupGlobalZoomBehavior = () => {
262
  img.addEventListener("click", () => {
263
  if (img.getAttribute("data-zoomable") === "1") {
 
266
  .querySelectorAll(".ri-root.zoom-active")
267
  .forEach((el) => el.classList.remove("zoom-active"));
268
 
269
+ // Add zoom-active to this ri-root
270
  root.classList.add("zoom-active");
271
  }
272
  });
 
410
  background: var(--primary-color);
411
  }
412
 
413
+ /* When an image is zoomed, hide ALL Figures on the page */
414
  :global(.medium-zoom--opened) .ri-root {
415
  opacity: 0;
416
  z-index: calc(var(--z-base) - 1);
417
  transition: opacity 0.3s ease;
418
  }
419
 
420
+ /* The currently zoomed image remains visible */
421
  :global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
422
  opacity: 1;
423
  z-index: var(--z-overlay);
424
  }
425
 
426
+ /* Fallback for browsers without :has() support */
427
  :global(.medium-zoom--opened) .ri-root.zoom-active {
428
  opacity: 1 !important;
429
  z-index: var(--z-overlay) !important;
430
  }
431
 
432
+ /* Specifically hide download button and figcaption during zoom */
433
  :global(.medium-zoom--opened) .img-dl-btn {
434
  opacity: 0;
435
  z-index: calc(var(--z-base) - 1);
 
442
  transition: opacity 0.3s ease;
443
  }
444
 
445
+ /* Even for active zoomed image, hide button and caption for clean experience */
446
  :global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
447
  opacity: 0;
448
  z-index: calc(var(--z-base) - 1);
app/src/components/{MultiImage.astro β†’ MultiFigure.astro} RENAMED
@@ -1,6 +1,6 @@
1
  ---
2
  // @ts-ignore - types provided by Astro at runtime
3
- import ResponsiveImage from "./ResponsiveImage.astro";
4
 
5
  interface ImageItem {
6
  /** Source image imported via astro:assets */
@@ -83,7 +83,7 @@ const gridColumns = getGridColumns();
83
  >
84
  {images.map((image, index) => (
85
  <div class="multi-image-item">
86
- <ResponsiveImage
87
  src={image.src}
88
  alt={image.alt}
89
  zoomable={image.zoomable ?? zoomable}
@@ -121,7 +121,7 @@ const gridColumns = getGridColumns();
121
  >
122
  {images.map((image, index) => (
123
  <div class="multi-image-item">
124
- <ResponsiveImage
125
  src={image.src}
126
  alt={image.alt}
127
  zoomable={image.zoomable ?? zoomable}
@@ -198,7 +198,7 @@ const gridColumns = getGridColumns();
198
  z-index: var(--z-overlay);
199
  }
200
 
201
- /* Fallback pour navigateurs sans support :has() */
202
  :global(.medium-zoom--opened) .multi-image-item.zoom-active {
203
  opacity: 1 !important;
204
  z-index: var(--z-overlay) !important;
@@ -261,9 +261,9 @@ const gridColumns = getGridColumns();
261
  }
262
  }
263
 
264
- /* Equal height images when desired */
265
  .multi-image[data-layout*="column"] .multi-image-item :global(img) {
266
- height: 200px;
267
  object-fit: contain;
268
  }
269
 
@@ -281,9 +281,9 @@ const gridColumns = getGridColumns();
281
  </style>
282
 
283
  <script>
284
- // Enhanced medium-zoom integration for MultiImage
285
  document.addEventListener("DOMContentLoaded", () => {
286
- // AmΓ©lioration du comportement des MultiImage avec medium-zoom
287
  const multiImages = document.querySelectorAll(".multi-image");
288
 
289
  multiImages.forEach((multiImage) => {
@@ -298,7 +298,7 @@ const gridColumns = getGridColumns();
298
  const activeItem = img.closest(".multi-image-item");
299
  const riRoot = img.closest(".ri-root");
300
 
301
- // Nettoyer TOUS les zoom-active (MultiImage items et ResponsiveImage)
302
  document
303
  .querySelectorAll(
304
  ".multi-image-item.zoom-active, .ri-root.zoom-active",
@@ -328,7 +328,7 @@ const gridColumns = getGridColumns();
328
  }
329
  });
330
 
331
- // Γ‰couter les Γ©vΓ©nements clavier pour fermer le zoom
332
  document.addEventListener("keydown", (e) => {
333
  if (e.key === "Escape") {
334
  document
 
1
  ---
2
  // @ts-ignore - types provided by Astro at runtime
3
+ import Figure from "./Figure.astro";
4
 
5
  interface ImageItem {
6
  /** Source image imported via astro:assets */
 
83
  >
84
  {images.map((image, index) => (
85
  <div class="multi-image-item">
86
+ <Figure
87
  src={image.src}
88
  alt={image.alt}
89
  zoomable={image.zoomable ?? zoomable}
 
121
  >
122
  {images.map((image, index) => (
123
  <div class="multi-image-item">
124
+ <Figure
125
  src={image.src}
126
  alt={image.alt}
127
  zoomable={image.zoomable ?? zoomable}
 
198
  z-index: var(--z-overlay);
199
  }
200
 
201
+ /* Fallback for browsers without :has() support */
202
  :global(.medium-zoom--opened) .multi-image-item.zoom-active {
203
  opacity: 1 !important;
204
  z-index: var(--z-overlay) !important;
 
261
  }
262
  }
263
 
264
+ /* Images maintain natural aspect ratio */
265
  .multi-image[data-layout*="column"] .multi-image-item :global(img) {
266
+ height: auto;
267
  object-fit: contain;
268
  }
269
 
 
281
  </style>
282
 
283
  <script>
284
+ // Enhanced medium-zoom integration for MultiFigure
285
  document.addEventListener("DOMContentLoaded", () => {
286
+ // Improve MultiFigure behavior with medium-zoom
287
  const multiImages = document.querySelectorAll(".multi-image");
288
 
289
  multiImages.forEach((multiImage) => {
 
298
  const activeItem = img.closest(".multi-image-item");
299
  const riRoot = img.closest(".ri-root");
300
 
301
+ // Nettoyer TOUS les zoom-active (MultiFigure items et Figure)
302
  document
303
  .querySelectorAll(
304
  ".multi-image-item.zoom-active, .ri-root.zoom-active",
 
328
  }
329
  });
330
 
331
+ // Listen for keyboard events to close zoom
332
  document.addEventListener("keydown", (e) => {
333
  if (e.key === "Escape") {
334
  document
app/src/components/Palettes.astro DELETED
@@ -1,170 +0,0 @@
1
- ---
2
- const rootId = `palettes-${Math.random().toString(36).slice(2)}`;
3
- ---
4
- <div class="palettes" id={rootId} style="width:100%; margin: 10px 0;">
5
- <style is:global>
6
- .palettes { box-sizing: border-box; }
7
- .palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; max-width: 100%; }
8
- .palettes .palette-card { position: relative; display: grid; grid-template-columns: 1fr minmax(0, 220px); align-items: stretch; gap: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; min-height: 60px; }
9
- .palettes .palette-card__preview { width: 48px; height: 48px; border-radius: 999px; flex: 0 0 auto; background-size: cover; background-position: center; }
10
- .palettes .palette-card__copy { position: absolute; top: 50%; left: 100%; transform: translateY(-50%); z-index: 3; border-left: none; border-top-left-radius: 0; border-bottom-left-radius: 0; }
11
- .palettes .palette-card__copy svg { width: 18px; height: 18px; fill: currentColor; display: block; color: inherit; }
12
- .palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 2px; margin: 0; min-height: 60px; }
13
- .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; height: auto; border-radius: 0; border: 1px solid var(--border-color); }
14
- .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
15
- .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
16
- .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: flex-start; gap: 12px; min-width: 0; padding-right: 12px; }
17
- .palettes .palette-card__preview { width: 48px; height: 48px; border-radius: 999px; position: relative; flex: 0 0 auto; overflow: hidden; }
18
- .palettes .palette-card__preview .dot { position: absolute; width: 4px; height: 4px; background: #fff; border-radius: 999px; box-shadow: 0 0 0 1px var(--border-color); }
19
- .palettes .palette-card__preview .donut-hole { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 24px; height: 24px; border-radius: 999px; background: var(--surface-bg); box-shadow: 0 0 0 1px var(--border-color) inset; }
20
-
21
- .palettes .palette-card__content__info { display: flex; flex-direction: column; }
22
- .palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
23
- .palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
24
-
25
- .palettes .palettes__select { width: 100%; max-width: 100%; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
26
- .palettes .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
27
- .palettes .palettes__controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 8px 0 14px; }
28
- .palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1 1 280px; max-width: 100%; }
29
- .palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; }
30
- .palettes .palettes__label-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
31
- .palettes .ghost-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-color); color: var(--muted-color); background: transparent; font-variant-numeric: tabular-nums; }
32
- .palettes .palettes__count { display: flex; align-items: center; gap: 8px; max-width: 100%; }
33
- .palettes .palettes__count input[type="range"] { width: 100%; }
34
- .palettes .palettes__count output { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted-color); }
35
- .palettes input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; cursor: pointer; accent-color: var(--primary-color); }
36
- .palettes input[type="range"]:focus { outline: none; }
37
- .palettes input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: var(--border-color); border-radius: 999px; }
38
- .palettes input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; margin-top: -6px; width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
39
- .palettes input[type="range"]::-moz-range-track { height: 6px; background: var(--border-color); border: none; border-radius: 999px; }
40
- .palettes input[type="range"]::-moz-range-progress { height: 6px; background: var(--primary-color); border-radius: 999px; }
41
- .palettes input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
42
- html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
43
- html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
44
- html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) !important; }
45
- html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) !important; }
46
- html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) !important; }
47
- @media (max-width: 1100px) { .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; } .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); } .palettes .palette-card__content { border-right: none; padding-right: 0; } .palettes .palette-card__copy { display: none; } }
48
- </style>
49
- <div class="palettes__controls">
50
- <div class="palettes__field">
51
- <label class="palettes__label" for="cb-select">Color vision simulation</label>
52
- <select id="cb-select" class="palettes__select">
53
- <option value="none">Normal color vision β€” typical for most people</option>
54
- <option value="achromatopsia">Achromatopsia β€” no color at all</option>
55
- <option value="protanopia">Protanopia β€” reduced/absent reds</option>
56
- <option value="deuteranopia">Deuteranopia β€” reduced/absent greens</option>
57
- <option value="tritanopia">Tritanopia β€” reduced/absent blues</option>
58
- </select>
59
- </div>
60
- <div class="palettes__field">
61
- <div class="palettes__label-row">
62
- <label class="palettes__label" for="color-count">Number of colors</label>
63
- <output id="color-count-out" for="color-count" class="ghost-badge">8</output>
64
- </div>
65
- <div class="palettes__count">
66
- <input id="color-count" type="range" min="6" max="10" step="1" value="8" aria-label="Number of colors" />
67
- </div>
68
- </div>
69
- </div>
70
- <div class="palettes__grid"></div>
71
- <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
72
- <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
73
- <defs>
74
- <filter id="cb-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>
75
- <filter id="cb-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>
76
- <filter id="cb-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>
77
- <filter id="cb-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>
78
- </defs>
79
- </svg>
80
- </div>
81
- </div>
82
- <script type="module" is:inline>
83
- import '/scripts/color-palettes.js';
84
- const ROOT_ID = "{rootId}";
85
- (() => {
86
- const cards = [
87
- { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.' },
88
- { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
89
- { key: 'diverging', title: 'Diverging', desc: 'For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.' }
90
- ];
91
- const getPaletteColors = (key, count) => {
92
- const total=Number(count)||6;
93
- if (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function') {
94
- return window.ColorPalettes.getColors(key,total) || [];
95
- }
96
- return [];
97
- };
98
- const render = () => {
99
- const root = document.getElementById(ROOT_ID) || document.querySelector('.palettes');
100
- if (!root) return;
101
- const grid=root.querySelector('.palettes__grid'); if (!grid) return;
102
- const input=document.getElementById('color-count'); const total=input ? Number(input.value)||6 : 6;
103
- const html = cards.map(c => {
104
- const colors=getPaletteColors(c.key,total);
105
- const swatches=colors.map(col=>`<div class=\"sw\" style=\"background:${col}\"></div>`).join('');
106
- const baseHex = (window.ColorPalettes && typeof window.ColorPalettes.getPrimary==='function') ? window.ColorPalettes.getPrimary() : (colors[0] || '#FF0000');
107
- const hueDeg = (()=>{ try { const s=baseHex.replace('#',''); const v=s.length===3?s.split('').map(ch=>ch+ch).join(''):s; const r=parseInt(v.slice(0,2),16)/255, g=parseInt(v.slice(2,4),16)/255, b=parseInt(v.slice(4,6),16)/255; const M=Math.max(r,g,b), m=Math.min(r,g,b), d=M-m; if (d===0) return 0; let h=0; if (M===r) h=((g-b)/d)%6; else if (M===g) h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if (h<0) h+=360; return h; } catch { return 0; } })();
108
- const gradient = c.key==='categorical'
109
- ? (() => {
110
- const steps = 60; // smooth hue wheel (fixed orientation)
111
- const wheel = Array.from({ length: steps }, (_, i) => `hsl(${Math.round((i/steps)*360)}, 100%, 50%)`).join(', ');
112
- return `conic-gradient(${wheel})`;
113
- })()
114
- : (colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : `linear-gradient(90deg, var(--border-color), var(--border-color))`);
115
- const previewInner = (()=>{
116
- if (c.key !== 'categorical' || !colors.length) return '';
117
- const ring = 18; const cx = 24; const cy = 24; const offset = (hueDeg/360) * 2 * Math.PI;
118
- return colors.map((col,i)=>{
119
- const angle = offset + (i/colors.length) * 2 * Math.PI;
120
- const x = cx + ring * Math.cos(angle);
121
- const y = cy + ring * Math.sin(angle);
122
- return `<span class=\"dot\" style=\"left:${x-2}px; top:${y-2}px\"></span>`;
123
- }).join('');
124
- })();
125
- const donutHole = (c.key === 'categorical') ? '<span class=\"donut-hole\"></span>' : '';
126
- return `
127
- <div class="palette-card" data-colors="${colors.join(',')}">
128
- <div class="palette-card__content">
129
- <div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div>
130
- <div class="palette-card__content__info">
131
- <div class="palette-card__title">${c.title}</div>
132
- <div class="palette-card__desc">${c.desc}</div>
133
- </div>
134
- </div>
135
- <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
136
- <button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette">
137
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
138
- </button>
139
- </div>`;
140
- }).join('');
141
- grid.innerHTML=html;
142
- };
143
- const MODE_TO_CLASS = { protanopia:'cb-protanopia', deuteranopia:'cb-deuteranopia', tritanopia:'cb-tritanopia', achromatopsia:'cb-achromatopsia' };
144
- const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
145
- const clearCbClasses = () => { const rootEl=document.documentElement; CLEAR_CLASSES.forEach(cls=>rootEl.classList.remove(cls)); };
146
- const applyCbClass = (mode) => { clearCbClasses(); const cls=MODE_TO_CLASS[mode]; if (cls) document.documentElement.classList.add(cls); };
147
- const currentCbMode = () => { const rootEl=document.documentElement; for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { if (rootEl.classList.contains(cls)) return mode; } return 'none'; };
148
- const setupCbSim = () => { const select=document.getElementById('cb-select'); if (!select) return; try { select.value=currentCbMode(); } catch{} select.addEventListener('change', () => applyCbClass(select.value)); };
149
- const setupCountControl = () => { const input=document.getElementById('color-count'); const out=document.getElementById('color-count-out'); if (!input) return; const clamp=(n,min,max)=>Math.max(min,Math.min(max,n)); const read=()=>clamp(Number(input.value)||6,6,10); const syncOut=()=>{ if (out) out.textContent=String(read()); }; const onChange=()=>{ syncOut(); render(); }; syncOut(); input.addEventListener('input', onChange); document.addEventListener('palettes:updated', () => { syncOut(); render(); }); };
150
- let copyDelegationSetup=false; const setupCopyDelegation = () => { if (copyDelegationSetup) return; const grid=document.querySelector('.palettes .palettes__grid'); if (!grid) return; grid.addEventListener('click', async (e) => { const btn = e.target.closest ? e.target.closest('.palette-card__copy') : null; if (!btn) return; const card = btn.closest('.palette-card'); if (!card) return; const colors=(card.dataset.colors||'').split(',').filter(Boolean); const json=JSON.stringify(colors,null,2); try { await navigator.clipboard.writeText(json); const old=btn.innerHTML; btn.innerHTML='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; setTimeout(()=> btn.innerHTML=old, 900); } catch { window.prompt('Copy palette', json); } }); copyDelegationSetup=true; };
151
- const bootstrap = () => {
152
- setupCbSim();
153
- setupCountControl();
154
- setupCopyDelegation();
155
- // Render immediately
156
- render();
157
- // Re-render on palette updates
158
- document.addEventListener('palettes:updated', render);
159
- // Force an immediate notify after listeners are attached (ensures initial render)
160
- try {
161
- if (window.ColorPalettes && typeof window.ColorPalettes.notify === 'function') window.ColorPalettes.notify();
162
- else if (window.ColorPalettes && typeof window.ColorPalettes.refresh === 'function') window.ColorPalettes.refresh();
163
- } catch {}
164
- };
165
- if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); } else { bootstrap(); }
166
- })();
167
- </script>
168
-
169
-
170
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/components/Sidenote.astro CHANGED
@@ -13,27 +13,27 @@
13
  const containers = document.querySelectorAll(".sidenote-container");
14
 
15
  containers.forEach((container) => {
16
- // Trouve l'élément précédent (frère juste avant)
17
  const previousElement = container.previousElementSibling;
18
 
19
  if (previousElement) {
20
- // Rend le conteneur de la sidenote relatif Γ  l'Γ©lΓ©ment prΓ©cΓ©dent
21
  previousElement.style.position = "relative";
22
 
23
- // DΓ©place le conteneur de la sidenote comme enfant de l'Γ©lΓ©ment prΓ©cΓ©dent
24
  previousElement.appendChild(container);
25
 
26
- // Style le conteneur pour qu'il se positionne correctement
27
  container.style.position = "absolute";
28
  container.style.top = "0";
29
  container.style.right = "-292px"; // 260px width + 32px gap
30
  container.style.width = "260px";
31
 
32
- // Affiche le container avec un fade-in
33
  container.style.display = "block";
34
  container.style.opacity = "0";
35
 
36
- // Fade-in avec transition
37
  setTimeout(() => {
38
  container.style.opacity = "1";
39
  }, 10);
@@ -46,8 +46,8 @@
46
  .sidenote-container {
47
  /* CachΓ© par dΓ©faut, sera affichΓ© par JS */
48
  display: none;
49
- margin: 12px 0;
50
- /* Transition pour le fade-in */
51
  transition: opacity 0.3s ease-in-out;
52
  }
53
 
 
13
  const containers = document.querySelectorAll(".sidenote-container");
14
 
15
  containers.forEach((container) => {
16
+ // Find the previous element (sibling just before)
17
  const previousElement = container.previousElementSibling;
18
 
19
  if (previousElement) {
20
+ // Make the sidenote container relative to the previous element
21
  previousElement.style.position = "relative";
22
 
23
+ // Move the sidenote container as child of the previous element
24
  previousElement.appendChild(container);
25
 
26
+ // Style the container so it positions correctly
27
  container.style.position = "absolute";
28
  container.style.top = "0";
29
  container.style.right = "-292px"; // 260px width + 32px gap
30
  container.style.width = "260px";
31
 
32
+ // Display the container with a fade-in
33
  container.style.display = "block";
34
  container.style.opacity = "0";
35
 
36
+ // Fade-in with transition
37
  setTimeout(() => {
38
  container.style.opacity = "1";
39
  }, 10);
 
46
  .sidenote-container {
47
  /* CachΓ© par dΓ©faut, sera affichΓ© par JS */
48
  display: none;
49
+ margin: 0 ;
50
+ /* Transition for fade-in */
51
  transition: opacity 0.3s ease-in-out;
52
  }
53
 
app/src/components/demo/ColorPicker.astro ADDED
@@ -0,0 +1,633 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+
3
+ ---
4
+
5
+ <div class="color-picker" style="width:100%; margin: 10px 0;">
6
+ <style>
7
+ .color-picker .picker__stack {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 12px;
11
+ }
12
+ .color-picker .current-card {
13
+ display: grid;
14
+ grid-template-columns: 40% 60%;
15
+ align-items: center;
16
+ gap: 14px;
17
+ padding: 14px 32px 14px 16px;
18
+ border: 1px solid var(--border-color);
19
+ background: var(--surface-bg);
20
+ border-radius: 12px;
21
+ }
22
+ .color-picker .current-left {
23
+ display: flex;
24
+ flex-direction: column;
25
+ gap: 8px;
26
+ min-width: 0;
27
+ }
28
+ .color-picker .current-right {
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: 8px;
32
+ padding-left: 14px;
33
+ border-left: 1px solid var(--border-color);
34
+ }
35
+ .color-picker .current-main {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 12px;
39
+ min-width: 0;
40
+ }
41
+ .color-picker .current-swatch {
42
+ width: 64px;
43
+ height: 64px;
44
+ border-radius: 8px;
45
+ border: 1px solid var(--border-color);
46
+ }
47
+ .color-picker .current-text {
48
+ display: flex;
49
+ flex-direction: column;
50
+ line-height: 1.2;
51
+ min-width: 0;
52
+ }
53
+ .color-picker .current-name {
54
+ font-size: 14px;
55
+ font-weight: 800;
56
+ color: var(--text-color);
57
+ white-space: nowrap;
58
+ overflow: hidden;
59
+ text-overflow: ellipsis;
60
+ max-width: clamp(140px, 28vw, 260px);
61
+ }
62
+ .color-picker .current-hex,
63
+ .color-picker .current-extra {
64
+ font-size: 11px;
65
+ color: var(--muted-color);
66
+ letter-spacing: 0.02em;
67
+ white-space: nowrap;
68
+ overflow: hidden;
69
+ text-overflow: ellipsis;
70
+ max-width: clamp(140px, 28vw, 260px);
71
+ }
72
+ :global(.color-label) {
73
+ color: var(--muted-color);
74
+ opacity: 0.6 !important;
75
+ }
76
+ :global(.color-value) {
77
+ color: var(--text-color);
78
+ font-weight: 500;
79
+ }
80
+ .color-picker .picker__label {
81
+ font-weight: 700;
82
+ font-size: 12px;
83
+ color: var(--muted-color);
84
+ text-transform: uppercase;
85
+ letter-spacing: 0.02em;
86
+ }
87
+ .color-picker .hue-slider {
88
+ position: relative;
89
+ height: 16px;
90
+ border-radius: 10px;
91
+ border: 1px solid var(--border-color);
92
+ background: linear-gradient(
93
+ to right,
94
+ #f00 0%,
95
+ #ff0 17%,
96
+ #0f0 33%,
97
+ #0ff 50%,
98
+ #00f 67%,
99
+ #f0f 83%,
100
+ #f00 100%
101
+ );
102
+ cursor: ew-resize;
103
+ touch-action: none;
104
+ flex: 1 1 auto;
105
+ min-width: 200px;
106
+ }
107
+ .color-picker .hue-knob {
108
+ position: absolute;
109
+ top: 50%;
110
+ left: 93.6%;
111
+ width: 14px;
112
+ height: 14px;
113
+ border-radius: 50%;
114
+ border: 2px solid #fff;
115
+ transform: translate(-50%, -50%);
116
+ background: var(--surface-bg);
117
+ z-index: 2;
118
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
119
+ }
120
+ .color-picker .hue-slider:focus-visible {
121
+ outline: 2px solid var(--primary-color);
122
+ outline-offset: 2px;
123
+ }
124
+ .color-picker .hue-value {
125
+ font-variant-numeric: tabular-nums;
126
+ color: var(--muted-color);
127
+ font-size: 12px;
128
+ }
129
+ @media (max-width: 720px) {
130
+ .color-picker .current-card {
131
+ grid-template-columns: 1fr;
132
+ }
133
+ .color-picker .current-right {
134
+ padding-left: 0;
135
+ border-left: none;
136
+ }
137
+ }
138
+ </style>
139
+ <div class="picker__stack">
140
+ <div class="current-card">
141
+ <div class="current-left">
142
+ <div class="current-main">
143
+ <div
144
+ class="current-swatch"
145
+ aria-label="Current color"
146
+ title="Current color"
147
+ >
148
+ </div>
149
+ <div class="current-text">
150
+ <div class="current-name">β€”</div>
151
+ <div class="current-extra current-lch">β€”</div>
152
+ <div class="current-extra current-rgb">β€”</div>
153
+ <div class="current-hex">β€”</div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ <div class="current-right">
158
+ <div class="picker__label">Hue</div>
159
+ <div
160
+ class="hue-slider"
161
+ role="slider"
162
+ aria-label="Hue"
163
+ aria-valuemin="0"
164
+ aria-valuemax="360"
165
+ aria-valuenow="214"
166
+ tabindex="0"
167
+ >
168
+ <div class="hue-knob"></div>
169
+ </div>
170
+ <div class="hue-value">214Β°</div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <script>
176
+ (() => {
177
+ const COLOR_NAMES = [
178
+ { name: "Candy Apple Red", hex: "#ff0800" },
179
+ { name: "Boiling Magma", hex: "#ff3300" },
180
+ { name: "Aerospace Orange", hex: "#ff4f00" },
181
+ { name: "Burtuqali Orange", hex: "#ff6700" },
182
+ { name: "American Orange", hex: "#ff8b00" },
183
+ { name: "Cheese", hex: "#ffa600" },
184
+ { name: "Amber", hex: "#ffbf00" },
185
+ { name: "Demonic Yellow", hex: "#ffe700" },
186
+ { name: "Bat-Signal", hex: "#feff00" },
187
+ { name: "Bitter Lime", hex: "#cfff00" },
188
+ { name: "Electric Lime", hex: "#ccff00" },
189
+ { name: "Bright Yellow Green", hex: "#9dff00" },
190
+ { name: "Lasting Lime", hex: "#88ff00" },
191
+ { name: "Bright Green", hex: "#66ff00" },
192
+ { name: "Chlorophyll Green", hex: "#4aff00" },
193
+ { name: "Green Screen", hex: "#22ff00" },
194
+ { name: "Electric Pickle", hex: "#00ff04" },
195
+ { name: "Acid", hex: "#00ff22" },
196
+ { name: "Lucent Lime", hex: "#00ff33" },
197
+ { name: "Cathode Green", hex: "#00ff55" },
198
+ { name: "Booger Buster", hex: "#00ff77" },
199
+ { name: "Green Gas", hex: "#00ff99" },
200
+ { name: "Enthusiasm", hex: "#00ffaa" },
201
+ { name: "Ice Ice Baby", hex: "#00ffdd" },
202
+ { name: "Master Sword Blue", hex: "#00ffee" },
203
+ { name: "Agressive Aqua", hex: "#00fbff" },
204
+ { name: "Vivid Sky Blue", hex: "#00ccff" },
205
+ { name: "Capri", hex: "#00bfff" },
206
+ { name: "Sky of Magritte", hex: "#0099ff" },
207
+ { name: "Azure", hex: "#007fff" },
208
+ { name: "Blue Ribbon", hex: "#0066ff" },
209
+ { name: "Blinking Blue", hex: "#0033ff" },
210
+ { name: "Icelandic Water", hex: "#0011ff" },
211
+ { name: "Blue", hex: "#0000ff" },
212
+ { name: "Blue Pencil", hex: "#2200ff" },
213
+ { name: "Electric Ultramarine", hex: "#3f00ff" },
214
+ { name: "Aladdin's Feather", hex: "#5500ff" },
215
+ { name: "Purple Climax", hex: "#8800ff" },
216
+ { name: "Amethyst Ganzstar", hex: "#8f00ff" },
217
+ { name: "Electric Purple", hex: "#bf00ff" },
218
+ { name: "Phlox", hex: "#df00ff" },
219
+ { name: "Brusque Pink", hex: "#ee00ff" },
220
+ { name: "Bright Magenta", hex: "#ff08e8" },
221
+ { name: "Big bang Pink", hex: "#ff00bb" },
222
+ { name: "Mean Girls Lipstick", hex: "#ff00ae" },
223
+ { name: "Pink", hex: "#ff0099" },
224
+ { name: "Hot Flamingoes", hex: "#ff005d" },
225
+ { name: "Blazing Dragonfruit", hex: "#ff0054" },
226
+ { name: "Carmine Red", hex: "#ff0038" },
227
+ { name: "Bright Red", hex: "#ff000d" },
228
+ ];
229
+ if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
230
+
231
+ if (!window.__colorPickerBus) {
232
+ window.__colorPickerBus = (() => {
233
+ let hue = 214;
234
+ let adjusting = false;
235
+ const listeners = new Set();
236
+ return {
237
+ get: () => ({ hue, adjusting }),
238
+ publish: (sourceId, nextHue, isAdj) => {
239
+ hue = ((nextHue % 360) + 360) % 360;
240
+ adjusting = !!isAdj;
241
+ listeners.forEach((fn) => {
242
+ try {
243
+ fn({ sourceId, hue, adjusting });
244
+ } catch {}
245
+ });
246
+ },
247
+ subscribe: (fn) => {
248
+ listeners.add(fn);
249
+ return () => listeners.delete(fn);
250
+ },
251
+ };
252
+ })();
253
+ }
254
+
255
+ const bootstrap = () => {
256
+ const root = document.querySelector(".color-picker");
257
+ if (!root || root.dataset.mounted) return;
258
+ root.dataset.mounted = "true";
259
+ const slider = root.querySelector(".hue-slider");
260
+ const knob = root.querySelector(".hue-knob");
261
+ const hueValue = root.querySelector(".hue-value");
262
+ const currentSwatch = root.querySelector(".current-swatch");
263
+ const currentName = root.querySelector(".current-name");
264
+ const currentHex = root.querySelector(".current-hex");
265
+ const currentLch = root.querySelector(".current-lch");
266
+ const currentRgb = root.querySelector(".current-rgb");
267
+ const bus = window.__colorPickerBus;
268
+ const instanceId = Math.random().toString(36).slice(2);
269
+ const getKnobRadius = () => {
270
+ try {
271
+ const w = knob ? knob.getBoundingClientRect().width : 0;
272
+ return w ? w / 2 : 8;
273
+ } catch {
274
+ return 8;
275
+ }
276
+ };
277
+ const hexToHsl = (H) => {
278
+ const s = H.replace("#", "");
279
+ const v =
280
+ s.length === 3
281
+ ? s
282
+ .split("")
283
+ .map((ch) => ch + ch)
284
+ .join("")
285
+ : s;
286
+ const bigint = parseInt(v, 16);
287
+ let r = (bigint >> 16) & 255,
288
+ g = (bigint >> 8) & 255,
289
+ b = bigint & 255;
290
+ r /= 255;
291
+ g /= 255;
292
+ b /= 255;
293
+ const max = Math.max(r, g, b),
294
+ min = Math.min(r, g, b);
295
+ let h = 0,
296
+ s2 = 0,
297
+ l = (max + min) / 2;
298
+ if (max !== min) {
299
+ const d = max - min;
300
+ s2 = l > 0.5 ? d / (2 - max - min) : d / (max + min);
301
+ switch (max) {
302
+ case r:
303
+ h = (g - b) / d + (g < b ? 6 : 0);
304
+ break;
305
+ case g:
306
+ h = (b - r) / d + 2;
307
+ break;
308
+ default:
309
+ h = (r - g) / d + 4;
310
+ }
311
+ h /= 6;
312
+ }
313
+ return {
314
+ h: Math.round(h * 360),
315
+ s: Math.round(s2 * 100),
316
+ l: Math.round(l * 100),
317
+ };
318
+ };
319
+ const hslToHex = (h, s, l) => {
320
+ s /= 100;
321
+ l /= 100;
322
+ const k = (n) => (n + h / 30) % 12;
323
+ const a = s * Math.min(l, 1 - l);
324
+ const f = (n) =>
325
+ l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
326
+ const toHex = (x) =>
327
+ Math.round(255 * x)
328
+ .toString(16)
329
+ .padStart(2, "0");
330
+ return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`.toUpperCase();
331
+ };
332
+
333
+ // OKLCH conversion functions
334
+ const srgbToLinear = (u) =>
335
+ u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
336
+ const linearToSrgb = (u) =>
337
+ u <= 0.0031308
338
+ ? 12.92 * u
339
+ : 1.055 * Math.pow(Math.max(0, u), 1 / 2.4) - 0.055;
340
+ const rgbToOklab = (r, g, b) => {
341
+ const rl = srgbToLinear(r),
342
+ gl = srgbToLinear(g),
343
+ bl = srgbToLinear(b);
344
+ const l = Math.cbrt(
345
+ 0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl,
346
+ );
347
+ const m = Math.cbrt(
348
+ 0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl,
349
+ );
350
+ const s = Math.cbrt(
351
+ 0.0883024619 * rl + 0.2817188366 * gl + 0.6299787005 * bl,
352
+ );
353
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
354
+ const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
355
+ const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
356
+ return { L, a, b: b2 };
357
+ };
358
+ const oklabToOklch = (L, a, b) => {
359
+ const C = Math.sqrt(a * a + b * b);
360
+ let h = (Math.atan2(b, a) * 180) / Math.PI;
361
+ if (h < 0) h += 360;
362
+ return { L, C, h };
363
+ };
364
+ const hexToOklch = (hex) => {
365
+ const s = hex.replace("#", "");
366
+ const v =
367
+ s.length === 3
368
+ ? s
369
+ .split("")
370
+ .map((ch) => ch + ch)
371
+ .join("")
372
+ : s;
373
+ const r = parseInt(v.slice(0, 2), 16) / 255;
374
+ const g = parseInt(v.slice(2, 4), 16) / 255;
375
+ const b = parseInt(v.slice(4, 6), 16) / 255;
376
+ const { L, a, b: bb } = rgbToOklab(r, g, b);
377
+ return oklabToOklch(L, a, bb);
378
+ };
379
+ // Precompute hues for the provided color-name list
380
+ const NAME_HUES = COLOR_NAMES.map((c) => {
381
+ const hh = hexToHsl(c.hex).h || 0;
382
+ return { name: c.name, hue: hh };
383
+ });
384
+ // Pick closest name by circular hue distance; fallback to coarse labels
385
+ const getName = (hex) => {
386
+ const h = hexToHsl(hex).h || 0;
387
+ let bestName = "β€”";
388
+ let best = 361;
389
+ for (let i = 0; i < NAME_HUES.length; i++) {
390
+ const hh = NAME_HUES[i].hue;
391
+ const d = Math.abs(hh - h);
392
+ const dist = Math.min(d, 360 - d);
393
+ if (dist < best) {
394
+ best = dist;
395
+ bestName = NAME_HUES[i].name;
396
+ }
397
+ }
398
+ if (bestName !== "β€”") return bestName;
399
+ const labels = [
400
+ "Red",
401
+ "Orange",
402
+ "Yellow",
403
+ "Lime",
404
+ "Green",
405
+ "Cyan",
406
+ "Blue",
407
+ "Indigo",
408
+ "Violet",
409
+ "Magenta",
410
+ ];
411
+ const idx = Math.round(((h % 360) / 360) * (labels.length - 1));
412
+ return labels[idx];
413
+ };
414
+ // OKLCH to RGB conversion functions
415
+ const oklchToOklab = (L, C, hDeg) => {
416
+ const h = (hDeg * Math.PI) / 180;
417
+ return { L, a: C * Math.cos(h), b: C * Math.sin(h) };
418
+ };
419
+ const oklabToRgb = (L, a, b) => {
420
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
421
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
422
+ const s_ = L - 0.0894841775 * a - 1.291485548 * b;
423
+ const l = l_ * l_ * l_;
424
+ const m = m_ * m_ * m_;
425
+ const s = s_ * s_ * s_;
426
+ const r = linearToSrgb(
427
+ +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
428
+ );
429
+ const g = linearToSrgb(
430
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
431
+ );
432
+ const b3 = linearToSrgb(
433
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
434
+ );
435
+ return { r, g, b: b3 };
436
+ };
437
+ const oklchToHex = (L, C, h) => {
438
+ const { a, b } = oklchToOklab(L, C, h);
439
+ const rgb = oklabToRgb(L, a, b);
440
+ const toHex = (x) =>
441
+ Math.round(255 * x)
442
+ .toString(16)
443
+ .padStart(2, "0");
444
+ return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase();
445
+ };
446
+
447
+ const updateUI = (h, adjusting) => {
448
+ const rect = slider.getBoundingClientRect();
449
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
450
+ const t = Math.max(0, Math.min(1, h / 360));
451
+ const leftPx = r + t * Math.max(0, rect.width - 2 * r);
452
+ if (knob) knob.style.left = (leftPx / rect.width) * 100 + "%";
453
+ if (hueValue) hueValue.innerHTML = `<strong>${Math.round(h)}Β°</strong>`;
454
+ if (slider) slider.setAttribute("aria-valuenow", String(Math.round(h)));
455
+
456
+ // Generate OKLCH color directly (similar to CSS variables)
457
+ const L = 0.75; // 75% lightness
458
+ const C = 0.12; // 12% chroma
459
+ const oklchColor = `oklch(${L} ${C} ${h})`;
460
+ const baseHex = oklchToHex(L, C, h);
461
+
462
+ if (currentSwatch) currentSwatch.style.background = baseHex;
463
+ if (currentName) currentName.textContent = getName(baseHex);
464
+ if (currentHex)
465
+ currentHex.innerHTML = `<span class="color-label">#</span><span class="color-value">${baseHex.replace("#", "")}</span>`;
466
+
467
+ // Display OKLCH values
468
+ if (currentLch)
469
+ currentLch.innerHTML = `<span class="color-label">OKLCH</span> <span class="color-value">${(L * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value">${(C * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value"><strong>${Math.round(h)}</strong></span><span class="color-label">Β°</span>`;
470
+
471
+ if (currentRgb) {
472
+ const hex = baseHex.replace("#", "");
473
+ const R = parseInt(hex.slice(0, 2), 16),
474
+ G = parseInt(hex.slice(2, 4), 16),
475
+ B = parseInt(hex.slice(4, 6), 16);
476
+ currentRgb.innerHTML = `<span class="color-label">RGB</span> <span class="color-value">${R}</span><span class="color-label">,</span> <span class="color-value">${G}</span><span class="color-label">,</span> <span class="color-value">${B}</span>`;
477
+ }
478
+
479
+ // Apply OKLCH color to CSS variables
480
+ const hoverOklch = `oklch(${L} ${C} ${h})`;
481
+ const rootEl = document.documentElement;
482
+ rootEl.style.setProperty("--primary-color", oklchColor);
483
+ rootEl.style.setProperty("--primary-color-hover", hoverOklch);
484
+ };
485
+
486
+ // Update UI position only, without modifying CSS colors
487
+ const updateUIPositionOnly = (h) => {
488
+ const rect = slider.getBoundingClientRect();
489
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
490
+ const t = Math.max(0, Math.min(1, h / 360));
491
+ const leftPx = r + t * Math.max(0, rect.width - 2 * r);
492
+ if (knob) knob.style.left = (leftPx / rect.width) * 100 + "%";
493
+ if (hueValue) hueValue.innerHTML = `<strong>${Math.round(h)}Β°</strong>`;
494
+ if (slider) slider.setAttribute("aria-valuenow", String(Math.round(h)));
495
+
496
+ // Generate OKLCH color for display only
497
+ const L = 0.75; // 75% lightness
498
+ const C = 0.12; // 12% chroma
499
+ const baseHex = oklchToHex(L, C, h);
500
+
501
+ if (currentSwatch) currentSwatch.style.background = baseHex;
502
+ if (currentName) currentName.textContent = getName(baseHex);
503
+ if (currentHex)
504
+ currentHex.innerHTML = `<span class="color-label">#</span><span class="color-value">${baseHex.replace("#", "")}</span>`;
505
+
506
+ // Display OKLCH values
507
+ if (currentLch)
508
+ currentLch.innerHTML = `<span class="color-label">OKLCH</span> <span class="color-value">${(L * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value">${(C * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value"><strong>${Math.round(h)}</strong></span><span class="color-label">Β°</span>`;
509
+
510
+ if (currentRgb) {
511
+ const hex = baseHex.replace("#", "");
512
+ const R = parseInt(hex.slice(0, 2), 16),
513
+ G = parseInt(hex.slice(2, 4), 16),
514
+ B = parseInt(hex.slice(4, 6), 16);
515
+ currentRgb.innerHTML = `<span class="color-label">RGB</span> <span class="color-value">${R}</span><span class="color-label">,</span> <span class="color-value">${G}</span><span class="color-label">,</span> <span class="color-value">${B}</span>`;
516
+ }
517
+
518
+ // DO NOT modify CSS variables - just show the position
519
+ };
520
+ const getHueFromEvent = (ev) => {
521
+ const rect = slider.getBoundingClientRect();
522
+ const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
523
+ const x = clientX - rect.left;
524
+ const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
525
+ const effX = Math.max(r, Math.min(rect.width - r, x));
526
+ const denom = Math.max(1, rect.width - 2 * r);
527
+ const t = (effX - r) / denom;
528
+ return t * 360;
529
+ };
530
+ const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => {
531
+ if (sourceId === instanceId) return;
532
+ updateUI(hue, adjusting);
533
+ });
534
+ try {
535
+ let initH = 214;
536
+
537
+ // Try to get OKLCH directly from ColorPalettes first
538
+ if (
539
+ window.ColorPalettes &&
540
+ typeof window.ColorPalettes.getPrimaryOKLCH === "function"
541
+ ) {
542
+ const oklch = window.ColorPalettes.getPrimaryOKLCH();
543
+ if (oklch && oklch.h !== undefined) {
544
+ initH = oklch.h; // Use exact OKLCH hue, no conversion!
545
+ }
546
+ } else {
547
+ // Fallback: try to parse OKLCH directly from CSS
548
+ const cssPrimary = getComputedStyle(document.documentElement)
549
+ .getPropertyValue("--primary-color")
550
+ .trim();
551
+ if (cssPrimary && cssPrimary.includes("oklch")) {
552
+ const oklchMatch = cssPrimary.match(/oklch\(([^)]+)\)/);
553
+ if (oklchMatch) {
554
+ const values = oklchMatch[1]
555
+ .split(/\s+/)
556
+ .map((v) => parseFloat(v.trim()));
557
+ if (values.length >= 3) {
558
+ initH = values[2]; // Direct OKLCH hue, no conversion!
559
+ }
560
+ }
561
+ } else if (cssPrimary) {
562
+ // Only convert if it's not OKLCH
563
+ initH = hexToHsl(cssPrimary).h || initH;
564
+ }
565
+ }
566
+
567
+ const { hue: sharedHue } = bus.get();
568
+ // Only update UI position, don't modify CSS colors on initialization
569
+ updateUIPositionOnly(initH ?? sharedHue);
570
+ bus.publish(instanceId, initH ?? sharedHue, false);
571
+ } catch {
572
+ const { hue: sharedHue } = bus.get();
573
+ updateUIPositionOnly(sharedHue);
574
+ }
575
+ const onDown = (ev) => {
576
+ ev.preventDefault();
577
+ const h = getHueFromEvent(ev);
578
+ updateUI(h, true);
579
+ bus.publish(instanceId, h, true);
580
+ const move = (e) => {
581
+ e.preventDefault && e.preventDefault();
582
+ const hh = getHueFromEvent(e);
583
+ updateUI(hh, true);
584
+ bus.publish(instanceId, hh, true);
585
+ };
586
+ const up = () => {
587
+ bus.publish(instanceId, getHueFromEvent(ev), false);
588
+ window.removeEventListener("mousemove", move);
589
+ window.removeEventListener("touchmove", move);
590
+ window.removeEventListener("mouseup", up);
591
+ window.removeEventListener("touchend", up);
592
+ };
593
+ window.addEventListener("mousemove", move, { passive: false });
594
+ window.addEventListener("touchmove", move, { passive: false });
595
+ window.addEventListener("mouseup", up, { once: true });
596
+ window.addEventListener("touchend", up, { once: true });
597
+ };
598
+ if (slider) {
599
+ slider.addEventListener("mousedown", onDown);
600
+ slider.addEventListener("touchstart", onDown, { passive: false });
601
+ slider.addEventListener("keydown", (e) => {
602
+ const step = e.shiftKey ? 10 : 2;
603
+ if (e.key === "ArrowLeft") {
604
+ e.preventDefault();
605
+ const { hue } = bus.get();
606
+ const h = hue - step;
607
+ updateUI(h, true);
608
+ bus.publish(instanceId, h, true);
609
+ bus.publish(instanceId, h, false);
610
+ }
611
+ if (e.key === "ArrowRight") {
612
+ e.preventDefault();
613
+ const { hue } = bus.get();
614
+ const h = hue + step;
615
+ updateUI(h, true);
616
+ bus.publish(instanceId, h, true);
617
+ bus.publish(instanceId, h, false);
618
+ }
619
+ });
620
+ }
621
+ const ro = new MutationObserver(() => {
622
+ if (!document.body.contains(root)) {
623
+ unsubscribe && unsubscribe();
624
+ ro.disconnect();
625
+ }
626
+ });
627
+ ro.observe(document.body, { childList: true, subtree: true });
628
+ };
629
+ if (document.readyState === "loading")
630
+ document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
631
+ else bootstrap();
632
+ })();
633
+ </script>
app/src/components/demo/Palettes.astro ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ const rootId = `palettes-${Math.random().toString(36).slice(2)}`;
3
+ ---
4
+
5
+ <div class="palettes" id={rootId} style="width:100%; margin: 10px 0;">
6
+ <style is:global>
7
+ .palettes {
8
+ box-sizing: border-box;
9
+ }
10
+ .palettes .palettes__grid {
11
+ display: grid;
12
+ grid-template-columns: 1fr;
13
+ gap: 12px;
14
+ max-width: 100%;
15
+ }
16
+ .palettes .palette-card {
17
+ position: relative;
18
+ display: grid;
19
+ grid-template-columns: 1fr minmax(0, 220px);
20
+ align-items: stretch;
21
+ gap: 12px;
22
+ border: 1px solid var(--border-color);
23
+ border-radius: 10px;
24
+ background: var(--surface-bg);
25
+ padding: 12px;
26
+ transition:
27
+ box-shadow 0.18s ease,
28
+ transform 0.18s ease,
29
+ border-color 0.18s ease;
30
+ min-height: 60px;
31
+ }
32
+ .palettes .palette-card__preview {
33
+ width: 48px;
34
+ height: 48px;
35
+ border-radius: 999px;
36
+ flex: 0 0 auto;
37
+ background-size: cover;
38
+ background-position: center;
39
+ }
40
+ .palettes .palette-card__copy {
41
+ position: absolute;
42
+ top: 50%;
43
+ left: 100%;
44
+ transform: translateY(-50%);
45
+ z-index: 3;
46
+ border-left: none;
47
+ border-top-left-radius: 0;
48
+ border-bottom-left-radius: 0;
49
+ }
50
+ .palettes .palette-card__copy svg {
51
+ width: 18px;
52
+ height: 18px;
53
+ fill: currentColor;
54
+ display: block;
55
+ color: inherit;
56
+ }
57
+ .palettes .palette-card__swatches {
58
+ display: grid;
59
+ grid-template-columns: repeat(6, minmax(0, 1fr));
60
+ grid-auto-rows: 1fr;
61
+ gap: 2px;
62
+ margin: 0;
63
+ min-height: 60px;
64
+ }
65
+ .palettes .palette-card__swatches .sw {
66
+ width: 100%;
67
+ min-width: 0;
68
+ height: auto;
69
+ border-radius: 0;
70
+ border: 1px solid var(--border-color);
71
+ }
72
+ .palettes .palette-card__swatches .sw:first-child {
73
+ border-top-left-radius: 8px;
74
+ border-bottom-left-radius: 8px;
75
+ }
76
+ .palettes .palette-card__swatches .sw:last-child {
77
+ border-top-right-radius: 8px;
78
+ border-bottom-right-radius: 8px;
79
+ }
80
+ .palettes .palette-card__content {
81
+ display: flex;
82
+ flex-direction: row;
83
+ align-items: center;
84
+ justify-content: flex-start;
85
+ gap: 12px;
86
+ min-width: 0;
87
+ padding-right: 12px;
88
+ }
89
+ .palettes .palette-card__preview {
90
+ width: 48px;
91
+ height: 48px;
92
+ border-radius: 999px;
93
+ position: relative;
94
+ flex: 0 0 auto;
95
+ overflow: hidden;
96
+ }
97
+ .palettes .palette-card__preview .dot {
98
+ position: absolute;
99
+ width: 4px;
100
+ height: 4px;
101
+ background: #fff;
102
+ border-radius: 999px;
103
+ box-shadow: 0 0 6px rgba(0, 0, 0, 1);
104
+ }
105
+ .palettes .palette-card__preview .donut-hole {
106
+ position: absolute;
107
+ left: 50%;
108
+ top: 50%;
109
+ transform: translate(-50%, -50%);
110
+ width: 24px;
111
+ height: 24px;
112
+ border-radius: 999px;
113
+ background: var(--surface-bg);
114
+ box-shadow: 0 0 0 1px var(--border-color) inset;
115
+ }
116
+
117
+ .palettes .palette-card__content__info {
118
+ display: flex;
119
+ flex-direction: column;
120
+ }
121
+ .palettes .palette-card__title {
122
+ text-align: left;
123
+ font-weight: 800;
124
+ font-size: 15px;
125
+ }
126
+ .palettes .palette-card__desc {
127
+ text-align: left;
128
+ color: var(--muted-color);
129
+ line-height: 1.5;
130
+ font-size: 12px;
131
+ }
132
+
133
+ .palettes .palettes__select {
134
+ width: 100%;
135
+ max-width: 100%;
136
+ border: 1px solid var(--border-color);
137
+ background: var(--surface-bg);
138
+ color: var(--text-color);
139
+ padding: 8px 10px;
140
+ border-radius: 8px;
141
+ }
142
+ .palettes .sr-only {
143
+ position: absolute;
144
+ width: 1px;
145
+ height: 1px;
146
+ padding: 0;
147
+ margin: -1px;
148
+ overflow: hidden;
149
+ clip: rect(0, 0, 1px, 1px);
150
+ white-space: nowrap;
151
+ border: 0;
152
+ }
153
+ .palettes .palettes__controls {
154
+ display: flex;
155
+ flex-wrap: wrap;
156
+ gap: 16px;
157
+ align-items: center;
158
+ margin: 8px 0 14px;
159
+ }
160
+ .palettes .palettes__field {
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: 6px;
164
+ min-width: 0;
165
+ flex: 1 1 280px;
166
+ max-width: 100%;
167
+ }
168
+ .palettes .palettes__label {
169
+ font-size: 12px;
170
+ color: var(--muted-color);
171
+ font-weight: 800;
172
+ }
173
+ .palettes .palettes__label-row {
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: space-between;
177
+ gap: 10px;
178
+ }
179
+ .palettes .ghost-badge {
180
+ font-size: 11px;
181
+ padding: 1px 6px;
182
+ border-radius: 999px;
183
+ border: 1px solid var(--border-color);
184
+ color: var(--muted-color);
185
+ background: transparent;
186
+ font-variant-numeric: tabular-nums;
187
+ }
188
+ .palettes .palettes__count {
189
+ display: flex;
190
+ align-items: center;
191
+ gap: 8px;
192
+ max-width: 100%;
193
+ }
194
+ .palettes .palettes__count input[type="range"] {
195
+ width: 100%;
196
+ }
197
+ .palettes .palettes__count output {
198
+ min-width: 28px;
199
+ text-align: center;
200
+ font-variant-numeric: tabular-nums;
201
+ font-size: 12px;
202
+ color: var(--muted-color);
203
+ }
204
+ .palettes input[type="range"] {
205
+ -webkit-appearance: none;
206
+ appearance: none;
207
+ height: 24px;
208
+ background: transparent;
209
+ cursor: pointer;
210
+ accent-color: var(--primary-color);
211
+ }
212
+ .palettes input[type="range"]:focus {
213
+ outline: none;
214
+ }
215
+ .palettes input[type="range"]::-webkit-slider-runnable-track {
216
+ height: 6px;
217
+ background: var(--border-color);
218
+ border-radius: 999px;
219
+ }
220
+ .palettes input[type="range"]::-webkit-slider-thumb {
221
+ -webkit-appearance: none;
222
+ appearance: none;
223
+ margin-top: -6px;
224
+ width: 18px;
225
+ height: 18px;
226
+ background: var(--primary-color);
227
+ border: 2px solid var(--surface-bg);
228
+ border-radius: 50%;
229
+ }
230
+ .palettes input[type="range"]::-moz-range-track {
231
+ height: 6px;
232
+ background: var(--border-color);
233
+ border: none;
234
+ border-radius: 999px;
235
+ }
236
+ .palettes input[type="range"]::-moz-range-progress {
237
+ height: 6px;
238
+ background: var(--primary-color);
239
+ border-radius: 999px;
240
+ }
241
+ .palettes input[type="range"]::-moz-range-thumb {
242
+ width: 18px;
243
+ height: 18px;
244
+ background: var(--primary-color);
245
+ border: 2px solid var(--surface-bg);
246
+ border-radius: 50%;
247
+ }
248
+ html.cb-grayscale,
249
+ body.cb-grayscale {
250
+ filter: grayscale(1) !important;
251
+ }
252
+ html.cb-protanopia,
253
+ body.cb-protanopia {
254
+ filter: url(#cb-protanopia) !important;
255
+ }
256
+ html.cb-deuteranopia,
257
+ body.cb-deuteranopia {
258
+ filter: url(#cb-deuteranopia) !important;
259
+ }
260
+ html.cb-tritanopia,
261
+ body.cb-tritanopia {
262
+ filter: url(#cb-tritanopia) !important;
263
+ }
264
+ html.cb-achromatopsia,
265
+ body.cb-achromatopsia {
266
+ filter: url(#cb-achromatopsia) !important;
267
+ }
268
+ @media (max-width: 1100px) {
269
+ .palettes .palette-card {
270
+ grid-template-columns: 1fr;
271
+ align-items: stretch;
272
+ gap: 10px;
273
+ }
274
+ .palettes .palette-card__swatches {
275
+ grid-template-columns: repeat(6, minmax(0, 1fr));
276
+ }
277
+ .palettes .palette-card__content {
278
+ border-right: none;
279
+ padding-right: 0;
280
+ }
281
+ .palettes .palette-card__copy {
282
+ display: none;
283
+ }
284
+ }
285
+ </style>
286
+ <div class="palettes__controls">
287
+ <div class="palettes__field">
288
+ <label class="palettes__label" for="cb-select"
289
+ >Color vision simulation</label
290
+ >
291
+ <select id="cb-select" class="palettes__select">
292
+ <option value="none"
293
+ >Normal color vision β€” typical for most people</option
294
+ >
295
+ <option value="achromatopsia">Achromatopsia β€” no color at all</option>
296
+ <option value="protanopia">Protanopia β€” reduced/absent reds</option>
297
+ <option value="deuteranopia"
298
+ >Deuteranopia β€” reduced/absent greens</option
299
+ >
300
+ <option value="tritanopia">Tritanopia β€” reduced/absent blues</option>
301
+ </select>
302
+ </div>
303
+ <div class="palettes__field">
304
+ <div class="palettes__label-row">
305
+ <label class="palettes__label" for="color-count">Number of colors</label
306
+ >
307
+ <output id="color-count-out" for="color-count" class="ghost-badge"
308
+ >8</output
309
+ >
310
+ </div>
311
+ <div class="palettes__count">
312
+ <input
313
+ id="color-count"
314
+ type="range"
315
+ min="6"
316
+ max="10"
317
+ step="1"
318
+ value="8"
319
+ aria-label="Number of colors"
320
+ />
321
+ </div>
322
+ </div>
323
+ </div>
324
+ <div class="palettes__grid"></div>
325
+ <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
326
+ <svg
327
+ aria-hidden="true"
328
+ focusable="false"
329
+ width="0"
330
+ height="0"
331
+ style="position:absolute; left:-9999px; overflow:hidden;"
332
+ >
333
+ <defs>
334
+ <filter id="cb-protanopia"
335
+ ><feColorMatrix
336
+ type="matrix"
337
+ 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"
338
+ ></feColorMatrix></filter
339
+ >
340
+ <filter id="cb-deuteranopia"
341
+ ><feColorMatrix
342
+ type="matrix"
343
+ 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"
344
+ ></feColorMatrix></filter
345
+ >
346
+ <filter id="cb-tritanopia"
347
+ ><feColorMatrix
348
+ type="matrix"
349
+ 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"
350
+ ></feColorMatrix></filter
351
+ >
352
+ <filter id="cb-achromatopsia"
353
+ ><feColorMatrix
354
+ type="matrix"
355
+ 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"
356
+ ></feColorMatrix></filter
357
+ >
358
+ </defs>
359
+ </svg>
360
+ </div>
361
+ </div>
362
+ <script type="module" is:inline>
363
+ import "/scripts/color-palettes.js";
364
+ const ROOT_ID = "{rootId}";
365
+ (() => {
366
+ const cards = [
367
+ {
368
+ key: "categorical",
369
+ title: "Categorical",
370
+ desc: "For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.",
371
+ },
372
+ {
373
+ key: "sequential",
374
+ title: "Sequential",
375
+ desc: "For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.",
376
+ },
377
+ {
378
+ key: "diverging",
379
+ title: "Diverging",
380
+ desc: "For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.",
381
+ },
382
+ ];
383
+ const getPaletteColors = (key, count) => {
384
+ const total = Number(count) || 6;
385
+ if (
386
+ window.ColorPalettes &&
387
+ typeof window.ColorPalettes.getColors === "function"
388
+ ) {
389
+ return window.ColorPalettes.getColors(key, total) || [];
390
+ }
391
+ return [];
392
+ };
393
+ const render = () => {
394
+ const root =
395
+ document.getElementById(ROOT_ID) || document.querySelector(".palettes");
396
+ if (!root) return;
397
+ const grid = root.querySelector(".palettes__grid");
398
+ if (!grid) return;
399
+ const input = document.getElementById("color-count");
400
+ const total = input ? Number(input.value) || 6 : 6;
401
+ const html = cards
402
+ .map((c) => {
403
+ const colors = getPaletteColors(c.key, total);
404
+ const swatches = colors
405
+ .map(
406
+ (col) => `<div class=\"sw\" style=\"background:${col}\"></div>`,
407
+ )
408
+ .join("");
409
+ const baseHex =
410
+ window.ColorPalettes &&
411
+ typeof window.ColorPalettes.getPrimary === "function"
412
+ ? window.ColorPalettes.getPrimary()
413
+ : colors[0] || "#FF0000";
414
+ const hueDeg = (() => {
415
+ try {
416
+ const s = baseHex.replace("#", "");
417
+ const v =
418
+ s.length === 3
419
+ ? s
420
+ .split("")
421
+ .map((ch) => ch + ch)
422
+ .join("")
423
+ : s;
424
+ const r = parseInt(v.slice(0, 2), 16) / 255,
425
+ g = parseInt(v.slice(2, 4), 16) / 255,
426
+ b = parseInt(v.slice(4, 6), 16) / 255;
427
+ const M = Math.max(r, g, b),
428
+ m = Math.min(r, g, b),
429
+ d = M - m;
430
+ if (d === 0) return 0;
431
+ let h = 0;
432
+ if (M === r) h = ((g - b) / d) % 6;
433
+ else if (M === g) h = (b - r) / d + 2;
434
+ else h = (r - g) / d + 4;
435
+ h *= 60;
436
+ if (h < 0) h += 360;
437
+ return h;
438
+ } catch {
439
+ return 0;
440
+ }
441
+ })();
442
+ const gradient =
443
+ c.key === "categorical"
444
+ ? (() => {
445
+ const steps = 60; // smooth hue wheel (fixed orientation)
446
+ const wheel = Array.from(
447
+ { length: steps },
448
+ (_, i) =>
449
+ `hsl(${Math.round((i / steps) * 360)}, 100%, 50%)`,
450
+ ).join(", ");
451
+ return `conic-gradient(${wheel})`;
452
+ })()
453
+ : colors.length
454
+ ? `linear-gradient(90deg, ${colors.join(", ")})`
455
+ : `linear-gradient(90deg, var(--border-color), var(--border-color))`;
456
+ const previewInner = (() => {
457
+ if (c.key !== "categorical" || !colors.length) return "";
458
+ const ring = 18;
459
+ const cx = 24;
460
+ const cy = 24;
461
+ const offset = (hueDeg / 360) * 2 * Math.PI;
462
+ return colors
463
+ .map((col, i) => {
464
+ const angle = offset + (i / colors.length) * 2 * Math.PI;
465
+ const x = cx + ring * Math.cos(angle);
466
+ const y = cy + ring * Math.sin(angle);
467
+ return `<span class=\"dot\" style=\"left:${x - 2}px; top:${y - 2}px\"></span>`;
468
+ })
469
+ .join("");
470
+ })();
471
+ const donutHole =
472
+ c.key === "categorical" ? '<span class="donut-hole"></span>' : "";
473
+ return `
474
+ <div class="palette-card" data-colors="${colors.join(",")}">
475
+ <div class="palette-card__content">
476
+ <div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div>
477
+ <div class="palette-card__content__info">
478
+ <div class="palette-card__title">${c.title}</div>
479
+ <div class="palette-card__desc">${c.desc}</div>
480
+ </div>
481
+ </div>
482
+ <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
483
+ <button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette">
484
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
485
+ </button>
486
+ </div>`;
487
+ })
488
+ .join("");
489
+ grid.innerHTML = html;
490
+ };
491
+ const MODE_TO_CLASS = {
492
+ protanopia: "cb-protanopia",
493
+ deuteranopia: "cb-deuteranopia",
494
+ tritanopia: "cb-tritanopia",
495
+ achromatopsia: "cb-achromatopsia",
496
+ };
497
+ const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
498
+ const clearCbClasses = () => {
499
+ const rootEl = document.documentElement;
500
+ CLEAR_CLASSES.forEach((cls) => rootEl.classList.remove(cls));
501
+ };
502
+ const applyCbClass = (mode) => {
503
+ clearCbClasses();
504
+ const cls = MODE_TO_CLASS[mode];
505
+ if (cls) document.documentElement.classList.add(cls);
506
+ };
507
+ const currentCbMode = () => {
508
+ const rootEl = document.documentElement;
509
+ for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) {
510
+ if (rootEl.classList.contains(cls)) return mode;
511
+ }
512
+ return "none";
513
+ };
514
+ const setupCbSim = () => {
515
+ const select = document.getElementById("cb-select");
516
+ if (!select) return;
517
+ try {
518
+ select.value = currentCbMode();
519
+ } catch {}
520
+ select.addEventListener("change", () => applyCbClass(select.value));
521
+ };
522
+ const setupCountControl = () => {
523
+ const input = document.getElementById("color-count");
524
+ const out = document.getElementById("color-count-out");
525
+ if (!input) return;
526
+ const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
527
+ const read = () => clamp(Number(input.value) || 6, 6, 10);
528
+ const syncOut = () => {
529
+ if (out) out.textContent = String(read());
530
+ };
531
+ const onChange = () => {
532
+ syncOut();
533
+ render();
534
+ };
535
+ syncOut();
536
+ input.addEventListener("input", onChange);
537
+ document.addEventListener("palettes:updated", () => {
538
+ syncOut();
539
+ render();
540
+ });
541
+ };
542
+ let copyDelegationSetup = false;
543
+ const setupCopyDelegation = () => {
544
+ if (copyDelegationSetup) return;
545
+ const grid = document.querySelector(".palettes .palettes__grid");
546
+ if (!grid) return;
547
+ grid.addEventListener("click", async (e) => {
548
+ const btn = e.target.closest
549
+ ? e.target.closest(".palette-card__copy")
550
+ : null;
551
+ if (!btn) return;
552
+ const card = btn.closest(".palette-card");
553
+ if (!card) return;
554
+ const colors = (card.dataset.colors || "").split(",").filter(Boolean);
555
+ const json = JSON.stringify(colors, null, 2);
556
+ try {
557
+ await navigator.clipboard.writeText(json);
558
+ const old = btn.innerHTML;
559
+ btn.innerHTML =
560
+ '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
561
+ setTimeout(() => (btn.innerHTML = old), 900);
562
+ } catch {
563
+ window.prompt("Copy palette", json);
564
+ }
565
+ });
566
+ copyDelegationSetup = true;
567
+ };
568
+ const bootstrap = () => {
569
+ setupCbSim();
570
+ setupCountControl();
571
+ setupCopyDelegation();
572
+ // Render immediately
573
+ render();
574
+ // Re-render on palette updates
575
+ document.addEventListener("palettes:updated", render);
576
+ // Force an immediate notify after listeners are attached (ensures initial render)
577
+ try {
578
+ if (
579
+ window.ColorPalettes &&
580
+ typeof window.ColorPalettes.notify === "function"
581
+ )
582
+ window.ColorPalettes.notify();
583
+ else if (
584
+ window.ColorPalettes &&
585
+ typeof window.ColorPalettes.refresh === "function"
586
+ )
587
+ window.ColorPalettes.refresh();
588
+ } catch {}
589
+ };
590
+ if (document.readyState === "loading") {
591
+ document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
592
+ } else {
593
+ bootstrap();
594
+ }
595
+ })();
596
+ </script>
app/src/components/trackio/Trackio.svelte CHANGED
@@ -1,14 +1,20 @@
1
  <script>
2
- import * as d3 from 'd3';
3
- import { formatAbbrev, smoothMetricData } from './core/chart-utils.js';
4
- import { generateRunNames, genCurves, Random, Performance, generateMassiveTestDataset } from './core/data-generator.js';
5
- import Legend from './components/Legend.svelte';
6
- import Cell from './components/Cell.svelte';
7
- import FullscreenModal from './components/FullscreenModal.svelte';
8
- import { onMount, onDestroy } from 'svelte';
9
- import { jitterTrigger } from './core/store.js';
10
-
11
- export let variant = 'classic'; // 'classic' | 'oblivion'
 
 
 
 
 
 
12
  export let normalizeLoss = true;
13
  export let logScaleX = false;
14
  export let smoothing = false;
@@ -17,99 +23,143 @@
17
  let gridEl;
18
  let legendItems = [];
19
  const cellsDef = [
20
- { metric:'epoch', title:'Epoch' },
21
- { metric:'train_accuracy', title:'Train accuracy' },
22
- { metric:'train_loss', title:'Train loss' },
23
- { metric:'val_accuracy', title:'Val accuracy' },
24
- { metric:'val_loss', title:'Val loss', wide:true }
25
  ];
26
  let preparedData = {};
27
  let colorsByRun = {};
28
-
29
  // Variables for data management (will be initialized in onMount)
30
  let dataByMetric = new Map();
31
  let metricsToDraw = [];
32
  let currentRunList = [];
33
  let cycleIdx = 2;
34
-
35
  // Dynamic color palette using color-palettes.js helper
36
- let dynamicPalette = ['#0ea5e9', '#8b5cf6', '#f59e0b', '#ef4444', '#10b981', '#f97316', '#3b82f6', '#8b5ad6']; // fallback
37
-
 
 
 
 
 
 
 
 
 
38
  const updateDynamicPalette = () => {
39
- if (typeof window !== 'undefined' && window.ColorPalettes && currentRunList.length > 0) {
 
 
 
 
40
  try {
41
- dynamicPalette = window.ColorPalettes.getColors('categorical', currentRunList.length);
 
 
 
42
  } catch (e) {
43
- console.warn('Failed to generate dynamic palette:', e);
44
  // Keep fallback palette
45
  }
46
  }
47
  };
48
-
49
  const colorForRun = (name) => {
50
  const idx = currentRunList.indexOf(name);
51
- return idx >= 0 ? dynamicPalette[idx % dynamicPalette.length] : '#999';
52
  };
53
-
54
 
55
  // Jitter function - generates completely new data with new runs
56
- function jitterData(){
57
- console.log('jitterData called - generating new data with random number of runs'); // Debug log
58
-
 
 
59
  // Generate new random data with weighted probability for fewer runs
60
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
61
  const rand = Math.random();
62
  let wantRuns;
63
- if (rand < 0.4) wantRuns = 2; // 40% chance
64
- else if (rand < 0.7) wantRuns = 3; // 30% chance
65
- else if (rand < 0.85) wantRuns = 4; // 15% chance
66
- else if (rand < 0.95) wantRuns = 5; // 10% chance
67
- else wantRuns = 6; // 5% chance
 
 
 
 
68
  // Use realistic ML training step counts
69
  const stepsCount = Random.trainingSteps();
70
  const runsSim = generateRunNames(wantRuns, stepsCount);
71
- const steps = Array.from({length: stepsCount}, (_,i)=> i+1);
72
  const nextByMetric = new Map();
73
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
74
-
 
 
 
 
 
 
75
  // Initialize data structure
76
  TARGET_METRICS.forEach((tgt) => {
77
  const map = {};
78
- runsSim.forEach((r) => { map[r] = []; });
 
 
79
  nextByMetric.set(tgt, map);
80
  });
81
-
82
  // Generate curves for each run
83
- runsSim.forEach(run => {
84
  const curves = genCurves(stepsCount);
85
- steps.forEach((s,i)=>{
86
- nextByMetric.get('epoch')[run].push({ step:s, value:s });
87
- nextByMetric.get('train_accuracy')[run].push({ step:s, value: curves.accTrain[i] });
88
- nextByMetric.get('val_accuracy')[run].push({ step:s, value: curves.accVal[i] });
89
- nextByMetric.get('train_loss')[run].push({ step:s, value: curves.lossTrain[i] });
90
- nextByMetric.get('val_loss')[run].push({ step:s, value: curves.lossVal[i] });
 
 
 
 
 
 
 
 
91
  });
92
  });
93
-
94
  // Update all reactive data
95
- nextByMetric.forEach((v,k)=> dataByMetric.set(k,v));
96
  metricsToDraw = TARGET_METRICS;
97
  currentRunList = runsSim.slice();
98
  updateDynamicPalette(); // Generate new colors based on run count
99
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
100
  updatePreparedData();
101
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
102
-
103
- console.log(`jitterData completed - generated ${wantRuns} runs with ${stepsCount} steps`); // Debug log
 
 
 
 
104
  }
105
 
106
  // Public API: allow external theme switch
107
- function setTheme(name){
108
- variant = name === 'oblivion' ? 'oblivion' : 'classic';
109
  updateThemeClass();
110
-
111
  // Debug log for font application
112
- if (typeof window !== 'undefined') {
113
  console.log(`Theme switched to: ${variant}`);
114
  if (hostEl) {
115
  const computedStyle = getComputedStyle(hostEl);
@@ -135,41 +185,67 @@
135
 
136
  // Public API: generate massive test dataset
137
  function generateMassiveDataset(steps = null, runs = 3) {
138
- console.log('πŸ§ͺ Generating massive test dataset for sampling validation...');
139
-
 
 
140
  const result = generateMassiveTestDataset(steps, runs);
141
-
142
  // Update reactive data with massive dataset
143
  result.dataByMetric.forEach((v, k) => dataByMetric.set(k, v));
144
- metricsToDraw = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
 
 
 
 
 
 
145
  currentRunList = result.runNames.slice();
146
  updateDynamicPalette();
147
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
148
  updatePreparedData();
149
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
150
-
151
- console.log(`βœ… Massive dataset loaded: ${result.stepCount} steps Γ— ${result.runNames.length} runs`);
 
 
 
 
152
  console.log(`πŸ“Š Total data points: ${result.totalPoints.toLocaleString()}`);
153
  console.log(`🎯 Description: ${result.description}`);
154
-
155
  return result;
156
  }
157
 
158
  // Public API: add live data point for simulation
159
  function addLiveDataPoint(runName, dataPoint) {
160
  console.log(`Adding live data point for run "${runName}":`, dataPoint);
161
-
162
  // Add run to currentRunList if it doesn't exist
163
  if (!currentRunList.includes(runName)) {
164
  currentRunList = [...currentRunList, runName];
165
  updateDynamicPalette();
166
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
167
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
 
 
168
  }
169
-
170
  // Initialize data structures for the run if needed
171
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
172
- TARGET_METRICS.forEach(metric => {
 
 
 
 
 
 
173
  if (!dataByMetric.has(metric)) {
174
  dataByMetric.set(metric, {});
175
  }
@@ -178,114 +254,125 @@
178
  metricData[runName] = [];
179
  }
180
  });
181
-
182
  // Add the new data points to each metric
183
  const step = dataPoint.step;
184
-
185
  // Add epoch data
186
- const epochData = dataByMetric.get('epoch');
187
  epochData[runName].push({ step, value: step });
188
-
189
  // Add accuracy data (train and val get the same value for simplicity)
190
  if (dataPoint.accuracy !== undefined) {
191
- const trainAccData = dataByMetric.get('train_accuracy');
192
- const valAccData = dataByMetric.get('val_accuracy');
193
-
194
  // Add some noise between train and val accuracy
195
  const trainAcc = dataPoint.accuracy;
196
- const valAcc = Math.max(0, Math.min(1, dataPoint.accuracy - 0.01 - Math.random() * 0.03));
197
-
 
 
 
198
  trainAccData[runName].push({ step, value: trainAcc });
199
  valAccData[runName].push({ step, value: valAcc });
200
  }
201
-
202
  // Add loss data (train and val get the same value for simplicity)
203
  if (dataPoint.loss !== undefined) {
204
- const trainLossData = dataByMetric.get('train_loss');
205
- const valLossData = dataByMetric.get('val_loss');
206
-
207
  // Add some noise between train and val loss
208
  const trainLoss = dataPoint.loss;
209
  const valLoss = dataPoint.loss + 0.05 + Math.random() * 0.1;
210
-
211
  trainLossData[runName].push({ step, value: trainLoss });
212
  valLossData[runName].push({ step, value: valLoss });
213
  }
214
-
215
  // Update all metrics to draw
216
  metricsToDraw = TARGET_METRICS;
217
-
218
  // Update prepared data with new values
219
  updatePreparedData();
220
-
221
- console.log(`Live data point added successfully. Total runs: ${currentRunList.length}`);
 
 
222
  }
223
 
224
  // Update prepared data with optional smoothing
225
  let preparedRawData = {}; // Store original data for background display
226
-
227
  function updatePreparedData() {
228
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
 
 
 
 
 
 
229
  let dataToUse = {};
230
  let rawDataToStore = {};
231
-
232
- TARGET_METRICS.forEach(metric => {
233
  const rawData = dataByMetric.get(metric);
234
  if (rawData) {
235
  // Store original data
236
  rawDataToStore[metric] = rawData;
237
-
238
  // Apply smoothing if enabled (except for epoch which should stay exact)
239
- dataToUse[metric] = (smoothing && metric !== 'epoch')
240
- ? smoothMetricData(rawData, 5) // Window size of 5
241
- : rawData;
 
242
  }
243
  });
244
-
245
  preparedData = dataToUse;
246
  preparedRawData = rawDataToStore;
247
  console.log(`Prepared data updated, smoothing: ${smoothing}`);
248
  }
249
 
250
- function updateThemeClass(){
251
  if (!hostEl) return;
252
- hostEl.classList.toggle('theme--classic', variant === 'classic');
253
- hostEl.classList.toggle('theme--oblivion', variant === 'oblivion');
254
- hostEl.setAttribute('data-variant', variant);
255
  }
256
 
257
  $: updateThemeClass();
258
 
259
-
260
  // Chart logic now handled by Cell.svelte
261
-
262
  // Fullscreen navigation state
263
  let currentFullscreenIndex = 0;
264
  let isModalOpen = false;
265
-
266
  function handleNavigate(newIndex) {
267
  currentFullscreenIndex = newIndex;
268
  }
269
-
270
  function openModal(index) {
271
  currentFullscreenIndex = index;
272
  isModalOpen = true;
273
  }
274
-
275
  function closeModal() {
276
  isModalOpen = false;
277
  }
278
-
279
  // Prepare all charts data for navigation
280
- $: allChartsData = cellsDef.map(c => ({
281
  metricKey: c.metric,
282
  titleText: c.title,
283
  metricData: (preparedData && preparedData[c.metric]) || {},
284
- rawMetricData: (preparedRawData && preparedRawData[c.metric]) || {}
285
  }));
286
-
287
  // Color function for the modal
288
- $: modalColorForRun = (name) => colorsByRun[name] || '#999';
289
 
290
  let cleanup = null;
291
  onMount(() => {
@@ -293,66 +380,97 @@
293
  hostEl.__setTheme = setTheme;
294
 
295
  // Jitter & Simulate functions
296
- function rebuildLegend(){
297
  updateDynamicPalette(); // Update colors when adding new data
298
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
 
 
 
299
  }
300
-
301
- function simulateData(){
302
  // Generate new random data with weighted probability for fewer runs
303
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
304
  const rand = Math.random();
305
  let wantRuns;
306
- if (rand < 0.4) wantRuns = 2; // 40% chance
307
- else if (rand < 0.7) wantRuns = 3; // 30% chance
308
- else if (rand < 0.85) wantRuns = 4; // 15% chance
309
- else if (rand < 0.95) wantRuns = 5; // 10% chance
310
- else wantRuns = 6; // 5% chance
 
 
 
 
311
  // Use realistic ML training step counts with cycling scenarios
312
  let stepsCount;
313
  if (cycleIdx === 0) {
314
- stepsCount = Random.trainingStepsForScenario('prototyping');
315
  } else if (cycleIdx === 1) {
316
- stepsCount = Random.trainingStepsForScenario('development');
317
  } else if (cycleIdx === 2) {
318
- stepsCount = Random.trainingStepsForScenario('production');
319
  } else if (cycleIdx === 3) {
320
- stepsCount = Random.trainingStepsForScenario('research');
321
  } else if (cycleIdx === 4) {
322
- stepsCount = Random.trainingStepsForScenario('llm');
323
  } else if (cycleIdx === 5) {
324
- stepsCount = Random.trainingStepsForScenario('massive');
325
  } else {
326
  stepsCount = Random.trainingSteps(); // Full range for variety
327
  }
328
  cycleIdx = (cycleIdx + 1) % 7; // Cycle through 7 scenarios now
329
-
330
  const runsSim = generateRunNames(wantRuns, stepsCount);
331
- const steps = Array.from({length: stepsCount}, (_,i)=> i+1);
332
  const nextByMetric = new Map();
333
- const TARGET_METRICS = ['epoch', 'train_accuracy', 'train_loss', 'val_accuracy', 'val_loss'];
334
- const mList = (metricsToDraw && metricsToDraw.length) ? metricsToDraw : TARGET_METRICS;
 
 
 
 
 
 
 
335
  mList.forEach((tgt) => {
336
  const map = {};
337
- runsSim.forEach((r) => { map[r] = []; });
 
 
338
  nextByMetric.set(tgt, map);
339
  });
340
- runsSim.forEach(run => {
341
  const curves = genCurves(stepsCount);
342
- steps.forEach((s,i)=>{
343
- if (mList.includes('epoch')) nextByMetric.get('epoch')[run].push({ step:s, value:s });
344
- if (mList.includes('train_accuracy')) nextByMetric.get('train_accuracy')[run].push({ step:s, value: curves.accTrain[i] });
345
- if (mList.includes('val_accuracy')) nextByMetric.get('val_accuracy')[run].push({ step:s, value: curves.accVal[i] });
346
- if (mList.includes('train_loss')) nextByMetric.get('train_loss')[run].push({ step:s, value: curves.lossTrain[i] });
347
- if (mList.includes('val_loss')) nextByMetric.get('val_loss')[run].push({ step:s, value: curves.lossVal[i] });
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  });
349
  });
350
- nextByMetric.forEach((v,k)=> dataByMetric.set(k,v));
351
  currentRunList = runsSim.slice();
352
  rebuildLegend();
353
  updatePreparedData();
354
  updateDynamicPalette(); // Update colors when rebuilding
355
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
 
 
356
  }
357
  // No need for event listeners anymore - we'll use reactive statement
358
 
@@ -360,105 +478,158 @@
360
  simulateData();
361
  // Svelte Cells will react to preparedData/colorsByRun updates
362
 
363
- cleanup = () => {
364
  // No cleanup needed for reactive statements
365
  };
366
  });
367
 
368
- onDestroy(() => { if (cleanup) cleanup(); });
 
 
369
 
370
  // Expose instance for debugging and external theme control
371
  onMount(() => {
372
- window.trackioInstance = { jitterData, addLiveDataPoint, generateMassiveDataset };
 
 
 
 
373
  if (hostEl) {
374
- hostEl.__trackioInstance = { setTheme, setLogScaleX, setSmoothing, jitterData, addLiveDataPoint, generateMassiveDataset };
 
 
 
 
 
 
 
375
  }
376
-
377
  // Initialize dynamic palette
378
  updateDynamicPalette();
379
-
380
  // Listen for palette updates from color-palettes.js
381
  const handlePaletteUpdate = () => {
382
  updateDynamicPalette();
383
  // Rebuild legend and colors if needed
384
  if (currentRunList.length > 0) {
385
- legendItems = currentRunList.map((name) => ({ name, color: colorForRun(name) }));
386
- colorsByRun = Object.fromEntries(currentRunList.map((name) => [name, colorForRun(name)]));
 
 
 
 
 
387
  }
388
  };
389
-
390
- document.addEventListener('palettes:updated', handlePaletteUpdate);
391
-
392
  // Cleanup listener on destroy
393
  return () => {
394
- document.removeEventListener('palettes:updated', handlePaletteUpdate);
395
  };
396
  });
397
 
398
  // React to jitter trigger from store
399
  $: {
400
- console.log('Reactive statement triggered, jitterTrigger value:', $jitterTrigger);
 
 
 
401
  if ($jitterTrigger > 0) {
402
- console.log('Jitter trigger activated:', $jitterTrigger, 'calling jitterData()');
 
 
 
 
403
  jitterData();
404
  }
405
  }
406
 
407
  // Legend ghost helpers (hover effects)
408
- function ghostRun(run){
409
  try {
410
- hostEl.classList.add('hovering');
411
-
412
  // Ghost the chart lines and points
413
- hostEl.querySelectorAll('.cell').forEach(cell => {
414
- cell.querySelectorAll('svg .lines path.run-line').forEach(p => p.classList.toggle('ghost', p.getAttribute('data-run') !== run));
415
- cell.querySelectorAll('svg .lines path.raw-line').forEach(p => p.classList.toggle('ghost', p.getAttribute('data-run') !== run));
416
- cell.querySelectorAll('svg .points circle.pt').forEach(c => c.classList.toggle('ghost', c.getAttribute('data-run') !== run));
 
 
 
 
 
 
 
 
 
 
 
 
417
  });
418
-
419
  // Ghost the legend items
420
- hostEl.querySelectorAll('.legend-bottom .item').forEach(item => {
421
- const itemRun = item.getAttribute('data-run');
422
- item.classList.toggle('ghost', itemRun !== run);
423
  });
424
- } catch(_) {}
425
  }
426
- function clearGhost(){
427
  try {
428
- hostEl.classList.remove('hovering');
429
-
430
  // Clear ghost from chart lines and points
431
- hostEl.querySelectorAll('.cell').forEach(cell => {
432
- cell.querySelectorAll('svg .lines path.run-line').forEach(p => p.classList.remove('ghost'));
433
- cell.querySelectorAll('svg .lines path.raw-line').forEach(p => p.classList.remove('ghost'));
434
- cell.querySelectorAll('svg .points circle.pt').forEach(c => c.classList.remove('ghost'));
 
 
 
 
 
 
435
  });
436
-
437
  // Clear ghost from legend items
438
- hostEl.querySelectorAll('.legend-bottom .item').forEach(item => {
439
- item.classList.remove('ghost');
440
  });
441
- } catch(_) {}
442
  }
443
  </script>
444
 
445
  <div class="trackio theme--classic" bind:this={hostEl} data-variant={variant}>
446
  <div class="trackio__header">
447
- <Legend items={legendItems} on:legend-hover={(e) => { const run = e?.detail?.name; if (!run) return; ghostRun(run); }} on:legend-leave={() => { clearGhost(); }} />
 
 
 
 
 
 
 
 
 
 
448
  </div>
449
  <div class="trackio__grid" bind:this={gridEl}>
450
  {#each cellsDef as c, i}
451
- <Cell
452
- metricKey={c.metric}
453
- titleText={c.title}
454
- wide={c.wide}
455
- {variant}
456
- {normalizeLoss}
457
- {logScaleX}
458
- {smoothing}
459
- metricData={(preparedData && preparedData[c.metric]) || {}}
460
- rawMetricData={(preparedRawData && preparedRawData[c.metric]) || {}}
461
- colorForRun={(name)=> colorsByRun[name] || '#999'}
462
  {hostEl}
463
  currentIndex={i}
464
  onOpenModal={openModal}
@@ -467,9 +638,17 @@
467
  </div>
468
  <div class="trackio__footer">
469
  <small>
470
- Built with <a href="https://github.com/huggingface/trackio" target="_blank" rel="noopener noreferrer">TrackIO</a>
 
 
 
 
471
  <span class="separator">β€’</span>
472
- <a href="https://huggingface.co/docs/hub/spaces-sdks-docker" target="_blank" rel="noopener noreferrer">Use via API</a>
 
 
 
 
473
  </small>
474
  </div>
475
  </div>
@@ -477,7 +656,7 @@
477
  <!-- Centralized Fullscreen Modal -->
478
  <FullscreenModal
479
  visible={isModalOpen}
480
- title={allChartsData[currentFullscreenIndex]?.titleText || ''}
481
  metricData={allChartsData[currentFullscreenIndex]?.metricData || {}}
482
  rawMetricData={allChartsData[currentFullscreenIndex]?.rawMetricData || {}}
483
  colorForRun={modalColorForRun}
@@ -485,8 +664,8 @@
485
  {logScaleX}
486
  {smoothing}
487
  {normalizeLoss}
488
- metricKey={allChartsData[currentFullscreenIndex]?.metricKey || ''}
489
- titleText={allChartsData[currentFullscreenIndex]?.titleText || ''}
490
  currentIndex={currentFullscreenIndex}
491
  totalCharts={cellsDef.length}
492
  onNavigate={handleNavigate}
@@ -499,12 +678,13 @@
499
  ========================= */
500
 
501
  /* Font imports for themes - ensure Roboto Mono is loaded for Oblivion theme */
502
- @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap');
503
-
504
  /* Fallback font-face declaration */
505
  @font-face {
506
- font-family: 'Roboto Mono Fallback';
507
- src: url('https://fonts.gstatic.com/s/robotomono/v23/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW4AJi8SJQt.woff2') format('woff2');
 
508
  font-weight: 400;
509
  font-style: normal;
510
  font-display: swap;
@@ -515,31 +695,37 @@
515
  position: relative;
516
  --z-tooltip: 50;
517
  --z-overlay: 99999999;
518
-
519
  /* Typography */
520
- --trackio-font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
 
 
 
 
 
 
521
  --trackio-font-weight-normal: 400;
522
  --trackio-font-weight-medium: 600;
523
  --trackio-font-weight-bold: 700;
524
-
525
  /* Apply font-family to root element */
526
  font-family: var(--trackio-font-family);
527
-
528
  /* Base color system for Classic theme */
529
  --trackio-base: #323232;
530
  --trackio-primary: var(--trackio-base);
531
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 28%, transparent);
532
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
533
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
534
-
535
  /* Chart rendering */
536
- --trackio-chart-grid-type: 'lines'; /* 'lines' | 'dots' */
537
  --trackio-chart-axis-stroke: var(--trackio-dim);
538
  --trackio-chart-axis-text: var(--trackio-text);
539
  --trackio-chart-grid-stroke: var(--trackio-subtle);
540
  --trackio-chart-grid-opacity: 1;
541
  }
542
-
543
  /* Dark mode overrides for Classic theme */
544
  :global([data-theme="dark"]) .trackio.theme--classic {
545
  --trackio-base: #ffffff;
@@ -547,28 +733,28 @@
547
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 25%, transparent);
548
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
549
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
550
-
551
  /* Cell background for dark mode */
552
  --trackio-cell-background: rgba(255, 255, 255, 0.03);
553
  }
554
-
555
  .trackio.theme--classic {
556
  /* Cell styling */
557
  --trackio-cell-background: rgba(0, 0, 0, 0.02);
558
  --trackio-cell-border: var(--border-color, rgba(0, 0, 0, 0.1));
559
  --trackio-cell-corner-inset: 0px;
560
  --trackio-cell-gap: 12px;
561
-
562
  /* Typography */
563
  --trackio-text-primary: var(--text-color, rgba(0, 0, 0, 0.9));
564
  --trackio-text-secondary: var(--muted-color, rgba(0, 0, 0, 0.6));
565
- --trackio-text-accent: var(--primary-color, #E889AB);
566
-
567
  /* Tooltip */
568
  --trackio-tooltip-background: var(--surface-bg, white);
569
  --trackio-tooltip-border: var(--border-color, rgba(0, 0, 0, 0.1));
570
  --trackio-tooltip-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
571
-
572
  /* Legend */
573
  --trackio-legend-text: var(--text-color, rgba(0, 0, 0, 0.9));
574
  --trackio-legend-swatch-border: var(--border-color, rgba(0, 0, 0, 0.1));
@@ -577,14 +763,14 @@
577
  /* Dark mode adjustments */
578
  :global([data-theme="dark"]) .trackio {
579
  --trackio-chart-axis-stroke: rgba(255, 255, 255, 0.18);
580
- --trackio-chart-axis-text: rgba(255, 255, 255, 0.60);
581
  --trackio-chart-grid-stroke: rgba(255, 255, 255, 0.08);
582
  }
583
 
584
  /* =========================
585
  THEME: CLASSIC (Default)
586
  ========================= */
587
-
588
  .trackio.theme--classic {
589
  /* Keep default values - no overrides needed */
590
  }
@@ -592,64 +778,96 @@
592
  /* =========================
593
  THEME: OBLIVION
594
  ========================= */
595
-
596
  .trackio.theme--oblivion {
597
  /* Core oblivion color system - Light mode: darker colors for visibility */
598
  --trackio-oblivion-base: #2a2a2a;
599
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
600
- --trackio-oblivion-dim: color-mix(in srgb, var(--trackio-oblivion-base) 30%, transparent);
601
- --trackio-oblivion-subtle: color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent);
602
- --trackio-oblivion-ghost: color-mix(in srgb, var(--trackio-oblivion-base) 4%, transparent);
603
-
 
 
 
 
 
 
 
 
 
 
 
 
604
  /* Chart rendering overrides */
605
- --trackio-chart-grid-type: 'dots';
606
  --trackio-chart-axis-stroke: var(--trackio-oblivion-dim);
607
  --trackio-chart-axis-text: var(--trackio-oblivion-primary);
608
  --trackio-chart-grid-stroke: var(--trackio-oblivion-dim);
609
  --trackio-chart-grid-opacity: 0.6;
610
  }
611
-
612
  /* Dark mode overrides for Oblivion theme */
613
  :global([data-theme="dark"]) .trackio.theme--oblivion {
614
  --trackio-oblivion-base: #ffffff;
615
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
616
- --trackio-oblivion-dim: color-mix(in srgb, var(--trackio-oblivion-base) 25%, transparent);
617
- --trackio-oblivion-subtle: color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent);
618
- --trackio-oblivion-ghost: color-mix(in srgb, var(--trackio-oblivion-base) 4%, transparent);
 
 
 
 
 
 
 
 
 
 
 
 
619
  }
620
-
621
  .trackio.theme--oblivion {
622
  /* Cell styling overrides */
623
  --trackio-cell-background: var(--trackio-oblivion-subtle);
624
  --trackio-cell-border: var(--trackio-oblivion-dim);
625
  --trackio-cell-corner-inset: 6px;
626
  --trackio-cell-gap: 0px;
627
-
628
  /* HUD-specific variables */
629
  --trackio-oblivion-hud-gap: 10px;
630
  --trackio-oblivion-hud-corner-size: 8px;
631
- --trackio-oblivion-hud-bg-gradient:
632
- radial-gradient(1200px 200px at 20% -10%, var(--trackio-oblivion-ghost), transparent 80%),
633
- radial-gradient(900px 200px at 80% 110%, var(--trackio-oblivion-ghost), transparent 80%);
634
-
 
 
 
 
 
 
 
635
  /* Typography overrides */
636
  --trackio-text-primary: var(--trackio-oblivion-primary);
637
  --trackio-text-secondary: var(--trackio-oblivion-dim);
638
  --trackio-text-accent: var(--trackio-oblivion-primary);
639
-
640
  /* Tooltip overrides */
641
  --trackio-tooltip-background: var(--trackio-oblivion-subtle);
642
  --trackio-tooltip-border: var(--trackio-oblivion-dim);
643
- --trackio-tooltip-shadow:
644
- 0 8px 32px color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent),
645
  0 2px 8px color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent);
646
-
647
  /* Legend overrides */
648
  --trackio-legend-text: var(--trackio-oblivion-primary);
649
  --trackio-legend-swatch-border: var(--trackio-oblivion-dim);
650
-
651
  /* Font styling overrides */
652
- --trackio-font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace;
 
653
  font-family: var(--trackio-font-family) !important;
654
  color: var(--trackio-text-primary);
655
  }
@@ -657,29 +875,42 @@
657
  /* Force Roboto Mono application in Oblivion theme */
658
  .trackio.theme--oblivion,
659
  .trackio.theme--oblivion * {
660
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
661
  }
662
-
663
  /* Specific overrides for different elements in Oblivion */
664
  .trackio.theme--oblivion .cell-title,
665
  .trackio.theme--oblivion .legend-bottom,
666
  .trackio.theme--oblivion .legend-title,
667
  .trackio.theme--oblivion .item {
668
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
669
  }
670
 
671
  /* Dark mode adjustments for Oblivion */
672
  :global([data-theme="dark"]) .trackio.theme--oblivion {
673
  --trackio-oblivion-base: #ffffff;
674
- --trackio-oblivion-hud-bg-gradient:
675
- radial-gradient(1400px 260px at 20% -10%, color-mix(in srgb, var(--trackio-oblivion-base) 6.5%, transparent), transparent 80%),
676
- radial-gradient(1100px 240px at 80% 110%, color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent), transparent 80%),
677
- linear-gradient(180deg, color-mix(in srgb, var(--trackio-oblivion-base) 3.5%, transparent), transparent 45%);
678
-
679
- --trackio-tooltip-shadow:
680
- 0 8px 32px color-mix(in srgb, var(--trackio-oblivion-base) 5%, transparent),
 
 
 
 
 
 
 
 
 
 
 
681
  0 2px 8px color-mix(in srgb, black 10%, transparent);
682
-
683
  background: #0f1115;
684
  }
685
 
@@ -692,9 +923,11 @@
692
  grid-template-columns: repeat(2, minmax(0, 1fr));
693
  gap: var(--trackio-cell-gap);
694
  }
695
-
696
  @media (max-width: 980px) {
697
- .trackio__grid { grid-template-columns: 1fr; }
 
 
698
  }
699
 
700
  .trackio__header {
@@ -712,28 +945,37 @@
712
  .trackio .axes line {
713
  stroke: var(--trackio-chart-axis-stroke);
714
  }
715
-
716
  .trackio .axes text {
717
  fill: var(--trackio-chart-axis-text);
718
  font-family: var(--trackio-font-family);
719
  }
720
-
721
  /* Force font-family for SVG text in Oblivion */
722
  .trackio.theme--oblivion .axes text {
723
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
724
  }
725
-
726
  .trackio .grid line {
727
  stroke: var(--trackio-chart-grid-stroke);
728
  opacity: var(--trackio-chart-grid-opacity);
729
  }
730
 
731
  /* Grid type switching */
732
- .trackio .grid-dots { display: none; }
733
- .trackio.theme--oblivion .grid { display: none; }
734
- .trackio.theme--oblivion .grid-dots { display: block; }
 
 
 
 
 
 
735
  .trackio.theme--oblivion .cell-bg,
736
- .trackio.theme--oblivion .cell-corners { display: block; }
 
 
737
 
738
  /* =========================
739
  FOOTER
@@ -777,8 +1019,7 @@
777
  }
778
 
779
  .trackio.theme--oblivion .trackio__footer small {
780
- font-family: 'Roboto Mono', 'Roboto Mono Fallback', ui-monospace, SFMono-Regular, Menlo, monospace !important;
 
781
  }
782
  </style>
783
-
784
-
 
1
  <script>
2
+ import * as d3 from "d3";
3
+ import { formatAbbrev, smoothMetricData } from "./core/chart-utils.js";
4
+ import {
5
+ generateRunNames,
6
+ genCurves,
7
+ Random,
8
+ Performance,
9
+ generateMassiveTestDataset,
10
+ } from "./core/data-generator.js";
11
+ import Legend from "./components/Legend.svelte";
12
+ import Cell from "./components/Cell.svelte";
13
+ import FullscreenModal from "./components/FullscreenModal.svelte";
14
+ import { onMount, onDestroy } from "svelte";
15
+ import { jitterTrigger } from "./core/store.js";
16
+
17
+ export let variant = "classic"; // 'classic' | 'oblivion'
18
  export let normalizeLoss = true;
19
  export let logScaleX = false;
20
  export let smoothing = false;
 
23
  let gridEl;
24
  let legendItems = [];
25
  const cellsDef = [
26
+ { metric: "epoch", title: "Epoch" },
27
+ { metric: "train_accuracy", title: "Train accuracy" },
28
+ { metric: "train_loss", title: "Train loss" },
29
+ { metric: "val_accuracy", title: "Val accuracy" },
30
+ { metric: "val_loss", title: "Val loss", wide: true },
31
  ];
32
  let preparedData = {};
33
  let colorsByRun = {};
34
+
35
  // Variables for data management (will be initialized in onMount)
36
  let dataByMetric = new Map();
37
  let metricsToDraw = [];
38
  let currentRunList = [];
39
  let cycleIdx = 2;
40
+
41
  // Dynamic color palette using color-palettes.js helper
42
+ let dynamicPalette = [
43
+ "#0ea5e9",
44
+ "#8b5cf6",
45
+ "#f59e0b",
46
+ "#ef4444",
47
+ "#10b981",
48
+ "#f97316",
49
+ "#3b82f6",
50
+ "#8b5ad6",
51
+ ]; // fallback
52
+
53
  const updateDynamicPalette = () => {
54
+ if (
55
+ typeof window !== "undefined" &&
56
+ window.ColorPalettes &&
57
+ currentRunList.length > 0
58
+ ) {
59
  try {
60
+ dynamicPalette = window.ColorPalettes.getColors(
61
+ "categorical",
62
+ currentRunList.length,
63
+ );
64
  } catch (e) {
65
+ console.warn("Failed to generate dynamic palette:", e);
66
  // Keep fallback palette
67
  }
68
  }
69
  };
70
+
71
  const colorForRun = (name) => {
72
  const idx = currentRunList.indexOf(name);
73
+ return idx >= 0 ? dynamicPalette[idx % dynamicPalette.length] : "#999";
74
  };
 
75
 
76
  // Jitter function - generates completely new data with new runs
77
+ function jitterData() {
78
+ console.log(
79
+ "jitterData called - generating new data with random number of runs",
80
+ ); // Debug log
81
+
82
  // Generate new random data with weighted probability for fewer runs
83
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
84
  const rand = Math.random();
85
  let wantRuns;
86
+ if (rand < 0.4)
87
+ wantRuns = 2; // 40% chance
88
+ else if (rand < 0.7)
89
+ wantRuns = 3; // 30% chance
90
+ else if (rand < 0.85)
91
+ wantRuns = 4; // 15% chance
92
+ else if (rand < 0.95)
93
+ wantRuns = 5; // 10% chance
94
+ else wantRuns = 6; // 5% chance
95
  // Use realistic ML training step counts
96
  const stepsCount = Random.trainingSteps();
97
  const runsSim = generateRunNames(wantRuns, stepsCount);
98
+ const steps = Array.from({ length: stepsCount }, (_, i) => i + 1);
99
  const nextByMetric = new Map();
100
+ const TARGET_METRICS = [
101
+ "epoch",
102
+ "train_accuracy",
103
+ "train_loss",
104
+ "val_accuracy",
105
+ "val_loss",
106
+ ];
107
+
108
  // Initialize data structure
109
  TARGET_METRICS.forEach((tgt) => {
110
  const map = {};
111
+ runsSim.forEach((r) => {
112
+ map[r] = [];
113
+ });
114
  nextByMetric.set(tgt, map);
115
  });
116
+
117
  // Generate curves for each run
118
+ runsSim.forEach((run) => {
119
  const curves = genCurves(stepsCount);
120
+ steps.forEach((s, i) => {
121
+ nextByMetric.get("epoch")[run].push({ step: s, value: s });
122
+ nextByMetric
123
+ .get("train_accuracy")
124
+ [run].push({ step: s, value: curves.accTrain[i] });
125
+ nextByMetric
126
+ .get("val_accuracy")
127
+ [run].push({ step: s, value: curves.accVal[i] });
128
+ nextByMetric
129
+ .get("train_loss")
130
+ [run].push({ step: s, value: curves.lossTrain[i] });
131
+ nextByMetric
132
+ .get("val_loss")
133
+ [run].push({ step: s, value: curves.lossVal[i] });
134
  });
135
  });
136
+
137
  // Update all reactive data
138
+ nextByMetric.forEach((v, k) => dataByMetric.set(k, v));
139
  metricsToDraw = TARGET_METRICS;
140
  currentRunList = runsSim.slice();
141
  updateDynamicPalette(); // Generate new colors based on run count
142
+ legendItems = currentRunList.map((name) => ({
143
+ name,
144
+ color: colorForRun(name),
145
+ }));
146
  updatePreparedData();
147
+ colorsByRun = Object.fromEntries(
148
+ currentRunList.map((name) => [name, colorForRun(name)]),
149
+ );
150
+
151
+ console.log(
152
+ `jitterData completed - generated ${wantRuns} runs with ${stepsCount} steps`,
153
+ ); // Debug log
154
  }
155
 
156
  // Public API: allow external theme switch
157
+ function setTheme(name) {
158
+ variant = name === "oblivion" ? "oblivion" : "classic";
159
  updateThemeClass();
160
+
161
  // Debug log for font application
162
+ if (typeof window !== "undefined") {
163
  console.log(`Theme switched to: ${variant}`);
164
  if (hostEl) {
165
  const computedStyle = getComputedStyle(hostEl);
 
185
 
186
  // Public API: generate massive test dataset
187
  function generateMassiveDataset(steps = null, runs = 3) {
188
+ console.log(
189
+ "πŸ§ͺ Generating massive test dataset for sampling validation...",
190
+ );
191
+
192
  const result = generateMassiveTestDataset(steps, runs);
193
+
194
  // Update reactive data with massive dataset
195
  result.dataByMetric.forEach((v, k) => dataByMetric.set(k, v));
196
+ metricsToDraw = [
197
+ "epoch",
198
+ "train_accuracy",
199
+ "train_loss",
200
+ "val_accuracy",
201
+ "val_loss",
202
+ ];
203
  currentRunList = result.runNames.slice();
204
  updateDynamicPalette();
205
+ legendItems = currentRunList.map((name) => ({
206
+ name,
207
+ color: colorForRun(name),
208
+ }));
209
  updatePreparedData();
210
+ colorsByRun = Object.fromEntries(
211
+ currentRunList.map((name) => [name, colorForRun(name)]),
212
+ );
213
+
214
+ console.log(
215
+ `βœ… Massive dataset loaded: ${result.stepCount} steps Γ— ${result.runNames.length} runs`,
216
+ );
217
  console.log(`πŸ“Š Total data points: ${result.totalPoints.toLocaleString()}`);
218
  console.log(`🎯 Description: ${result.description}`);
219
+
220
  return result;
221
  }
222
 
223
  // Public API: add live data point for simulation
224
  function addLiveDataPoint(runName, dataPoint) {
225
  console.log(`Adding live data point for run "${runName}":`, dataPoint);
226
+
227
  // Add run to currentRunList if it doesn't exist
228
  if (!currentRunList.includes(runName)) {
229
  currentRunList = [...currentRunList, runName];
230
  updateDynamicPalette();
231
+ colorsByRun = Object.fromEntries(
232
+ currentRunList.map((name) => [name, colorForRun(name)]),
233
+ );
234
+ legendItems = currentRunList.map((name) => ({
235
+ name,
236
+ color: colorForRun(name),
237
+ }));
238
  }
239
+
240
  // Initialize data structures for the run if needed
241
+ const TARGET_METRICS = [
242
+ "epoch",
243
+ "train_accuracy",
244
+ "train_loss",
245
+ "val_accuracy",
246
+ "val_loss",
247
+ ];
248
+ TARGET_METRICS.forEach((metric) => {
249
  if (!dataByMetric.has(metric)) {
250
  dataByMetric.set(metric, {});
251
  }
 
254
  metricData[runName] = [];
255
  }
256
  });
257
+
258
  // Add the new data points to each metric
259
  const step = dataPoint.step;
260
+
261
  // Add epoch data
262
+ const epochData = dataByMetric.get("epoch");
263
  epochData[runName].push({ step, value: step });
264
+
265
  // Add accuracy data (train and val get the same value for simplicity)
266
  if (dataPoint.accuracy !== undefined) {
267
+ const trainAccData = dataByMetric.get("train_accuracy");
268
+ const valAccData = dataByMetric.get("val_accuracy");
269
+
270
  // Add some noise between train and val accuracy
271
  const trainAcc = dataPoint.accuracy;
272
+ const valAcc = Math.max(
273
+ 0,
274
+ Math.min(1, dataPoint.accuracy - 0.01 - Math.random() * 0.03),
275
+ );
276
+
277
  trainAccData[runName].push({ step, value: trainAcc });
278
  valAccData[runName].push({ step, value: valAcc });
279
  }
280
+
281
  // Add loss data (train and val get the same value for simplicity)
282
  if (dataPoint.loss !== undefined) {
283
+ const trainLossData = dataByMetric.get("train_loss");
284
+ const valLossData = dataByMetric.get("val_loss");
285
+
286
  // Add some noise between train and val loss
287
  const trainLoss = dataPoint.loss;
288
  const valLoss = dataPoint.loss + 0.05 + Math.random() * 0.1;
289
+
290
  trainLossData[runName].push({ step, value: trainLoss });
291
  valLossData[runName].push({ step, value: valLoss });
292
  }
293
+
294
  // Update all metrics to draw
295
  metricsToDraw = TARGET_METRICS;
296
+
297
  // Update prepared data with new values
298
  updatePreparedData();
299
+
300
+ console.log(
301
+ `Live data point added successfully. Total runs: ${currentRunList.length}`,
302
+ );
303
  }
304
 
305
  // Update prepared data with optional smoothing
306
  let preparedRawData = {}; // Store original data for background display
307
+
308
  function updatePreparedData() {
309
+ const TARGET_METRICS = [
310
+ "epoch",
311
+ "train_accuracy",
312
+ "train_loss",
313
+ "val_accuracy",
314
+ "val_loss",
315
+ ];
316
  let dataToUse = {};
317
  let rawDataToStore = {};
318
+
319
+ TARGET_METRICS.forEach((metric) => {
320
  const rawData = dataByMetric.get(metric);
321
  if (rawData) {
322
  // Store original data
323
  rawDataToStore[metric] = rawData;
324
+
325
  // Apply smoothing if enabled (except for epoch which should stay exact)
326
+ dataToUse[metric] =
327
+ smoothing && metric !== "epoch"
328
+ ? smoothMetricData(rawData, 5) // Window size of 5
329
+ : rawData;
330
  }
331
  });
332
+
333
  preparedData = dataToUse;
334
  preparedRawData = rawDataToStore;
335
  console.log(`Prepared data updated, smoothing: ${smoothing}`);
336
  }
337
 
338
+ function updateThemeClass() {
339
  if (!hostEl) return;
340
+ hostEl.classList.toggle("theme--classic", variant === "classic");
341
+ hostEl.classList.toggle("theme--oblivion", variant === "oblivion");
342
+ hostEl.setAttribute("data-variant", variant);
343
  }
344
 
345
  $: updateThemeClass();
346
 
 
347
  // Chart logic now handled by Cell.svelte
348
+
349
  // Fullscreen navigation state
350
  let currentFullscreenIndex = 0;
351
  let isModalOpen = false;
352
+
353
  function handleNavigate(newIndex) {
354
  currentFullscreenIndex = newIndex;
355
  }
356
+
357
  function openModal(index) {
358
  currentFullscreenIndex = index;
359
  isModalOpen = true;
360
  }
361
+
362
  function closeModal() {
363
  isModalOpen = false;
364
  }
365
+
366
  // Prepare all charts data for navigation
367
+ $: allChartsData = cellsDef.map((c) => ({
368
  metricKey: c.metric,
369
  titleText: c.title,
370
  metricData: (preparedData && preparedData[c.metric]) || {},
371
+ rawMetricData: (preparedRawData && preparedRawData[c.metric]) || {},
372
  }));
373
+
374
  // Color function for the modal
375
+ $: modalColorForRun = (name) => colorsByRun[name] || "#999";
376
 
377
  let cleanup = null;
378
  onMount(() => {
 
380
  hostEl.__setTheme = setTheme;
381
 
382
  // Jitter & Simulate functions
383
+ function rebuildLegend() {
384
  updateDynamicPalette(); // Update colors when adding new data
385
+ legendItems = currentRunList.map((name) => ({
386
+ name,
387
+ color: colorForRun(name),
388
+ }));
389
  }
390
+
391
+ function simulateData() {
392
  // Generate new random data with weighted probability for fewer runs
393
  // Higher probability for 2-3 runs, lower for 4-5-6 runs
394
  const rand = Math.random();
395
  let wantRuns;
396
+ if (rand < 0.4)
397
+ wantRuns = 2; // 40% chance
398
+ else if (rand < 0.7)
399
+ wantRuns = 3; // 30% chance
400
+ else if (rand < 0.85)
401
+ wantRuns = 4; // 15% chance
402
+ else if (rand < 0.95)
403
+ wantRuns = 5; // 10% chance
404
+ else wantRuns = 6; // 5% chance
405
  // Use realistic ML training step counts with cycling scenarios
406
  let stepsCount;
407
  if (cycleIdx === 0) {
408
+ stepsCount = Random.trainingStepsForScenario("prototyping");
409
  } else if (cycleIdx === 1) {
410
+ stepsCount = Random.trainingStepsForScenario("development");
411
  } else if (cycleIdx === 2) {
412
+ stepsCount = Random.trainingStepsForScenario("production");
413
  } else if (cycleIdx === 3) {
414
+ stepsCount = Random.trainingStepsForScenario("research");
415
  } else if (cycleIdx === 4) {
416
+ stepsCount = Random.trainingStepsForScenario("llm");
417
  } else if (cycleIdx === 5) {
418
+ stepsCount = Random.trainingStepsForScenario("massive");
419
  } else {
420
  stepsCount = Random.trainingSteps(); // Full range for variety
421
  }
422
  cycleIdx = (cycleIdx + 1) % 7; // Cycle through 7 scenarios now
423
+
424
  const runsSim = generateRunNames(wantRuns, stepsCount);
425
+ const steps = Array.from({ length: stepsCount }, (_, i) => i + 1);
426
  const nextByMetric = new Map();
427
+ const TARGET_METRICS = [
428
+ "epoch",
429
+ "train_accuracy",
430
+ "train_loss",
431
+ "val_accuracy",
432
+ "val_loss",
433
+ ];
434
+ const mList =
435
+ metricsToDraw && metricsToDraw.length ? metricsToDraw : TARGET_METRICS;
436
  mList.forEach((tgt) => {
437
  const map = {};
438
+ runsSim.forEach((r) => {
439
+ map[r] = [];
440
+ });
441
  nextByMetric.set(tgt, map);
442
  });
443
+ runsSim.forEach((run) => {
444
  const curves = genCurves(stepsCount);
445
+ steps.forEach((s, i) => {
446
+ if (mList.includes("epoch"))
447
+ nextByMetric.get("epoch")[run].push({ step: s, value: s });
448
+ if (mList.includes("train_accuracy"))
449
+ nextByMetric
450
+ .get("train_accuracy")
451
+ [run].push({ step: s, value: curves.accTrain[i] });
452
+ if (mList.includes("val_accuracy"))
453
+ nextByMetric
454
+ .get("val_accuracy")
455
+ [run].push({ step: s, value: curves.accVal[i] });
456
+ if (mList.includes("train_loss"))
457
+ nextByMetric
458
+ .get("train_loss")
459
+ [run].push({ step: s, value: curves.lossTrain[i] });
460
+ if (mList.includes("val_loss"))
461
+ nextByMetric
462
+ .get("val_loss")
463
+ [run].push({ step: s, value: curves.lossVal[i] });
464
  });
465
  });
466
+ nextByMetric.forEach((v, k) => dataByMetric.set(k, v));
467
  currentRunList = runsSim.slice();
468
  rebuildLegend();
469
  updatePreparedData();
470
  updateDynamicPalette(); // Update colors when rebuilding
471
+ colorsByRun = Object.fromEntries(
472
+ currentRunList.map((name) => [name, colorForRun(name)]),
473
+ );
474
  }
475
  // No need for event listeners anymore - we'll use reactive statement
476
 
 
478
  simulateData();
479
  // Svelte Cells will react to preparedData/colorsByRun updates
480
 
481
+ cleanup = () => {
482
  // No cleanup needed for reactive statements
483
  };
484
  });
485
 
486
+ onDestroy(() => {
487
+ if (cleanup) cleanup();
488
+ });
489
 
490
  // Expose instance for debugging and external theme control
491
  onMount(() => {
492
+ window.trackioInstance = {
493
+ jitterData,
494
+ addLiveDataPoint,
495
+ generateMassiveDataset,
496
+ };
497
  if (hostEl) {
498
+ hostEl.__trackioInstance = {
499
+ setTheme,
500
+ setLogScaleX,
501
+ setSmoothing,
502
+ jitterData,
503
+ addLiveDataPoint,
504
+ generateMassiveDataset,
505
+ };
506
  }
507
+
508
  // Initialize dynamic palette
509
  updateDynamicPalette();
510
+
511
  // Listen for palette updates from color-palettes.js
512
  const handlePaletteUpdate = () => {
513
  updateDynamicPalette();
514
  // Rebuild legend and colors if needed
515
  if (currentRunList.length > 0) {
516
+ legendItems = currentRunList.map((name) => ({
517
+ name,
518
+ color: colorForRun(name),
519
+ }));
520
+ colorsByRun = Object.fromEntries(
521
+ currentRunList.map((name) => [name, colorForRun(name)]),
522
+ );
523
  }
524
  };
525
+
526
+ document.addEventListener("palettes:updated", handlePaletteUpdate);
527
+
528
  // Cleanup listener on destroy
529
  return () => {
530
+ document.removeEventListener("palettes:updated", handlePaletteUpdate);
531
  };
532
  });
533
 
534
  // React to jitter trigger from store
535
  $: {
536
+ console.log(
537
+ "Reactive statement triggered, jitterTrigger value:",
538
+ $jitterTrigger,
539
+ );
540
  if ($jitterTrigger > 0) {
541
+ console.log(
542
+ "Jitter trigger activated:",
543
+ $jitterTrigger,
544
+ "calling jitterData()",
545
+ );
546
  jitterData();
547
  }
548
  }
549
 
550
  // Legend ghost helpers (hover effects)
551
+ function ghostRun(run) {
552
  try {
553
+ hostEl.classList.add("hovering");
554
+
555
  // Ghost the chart lines and points
556
+ hostEl.querySelectorAll(".cell").forEach((cell) => {
557
+ cell
558
+ .querySelectorAll("svg .lines path.run-line")
559
+ .forEach((p) =>
560
+ p.classList.toggle("ghost", p.getAttribute("data-run") !== run),
561
+ );
562
+ cell
563
+ .querySelectorAll("svg .lines path.raw-line")
564
+ .forEach((p) =>
565
+ p.classList.toggle("ghost", p.getAttribute("data-run") !== run),
566
+ );
567
+ cell
568
+ .querySelectorAll("svg .points circle.pt")
569
+ .forEach((c) =>
570
+ c.classList.toggle("ghost", c.getAttribute("data-run") !== run),
571
+ );
572
  });
573
+
574
  // Ghost the legend items
575
+ hostEl.querySelectorAll(".legend-bottom .item").forEach((item) => {
576
+ const itemRun = item.getAttribute("data-run");
577
+ item.classList.toggle("ghost", itemRun !== run);
578
  });
579
+ } catch (_) {}
580
  }
581
+ function clearGhost() {
582
  try {
583
+ hostEl.classList.remove("hovering");
584
+
585
  // Clear ghost from chart lines and points
586
+ hostEl.querySelectorAll(".cell").forEach((cell) => {
587
+ cell
588
+ .querySelectorAll("svg .lines path.run-line")
589
+ .forEach((p) => p.classList.remove("ghost"));
590
+ cell
591
+ .querySelectorAll("svg .lines path.raw-line")
592
+ .forEach((p) => p.classList.remove("ghost"));
593
+ cell
594
+ .querySelectorAll("svg .points circle.pt")
595
+ .forEach((c) => c.classList.remove("ghost"));
596
  });
597
+
598
  // Clear ghost from legend items
599
+ hostEl.querySelectorAll(".legend-bottom .item").forEach((item) => {
600
+ item.classList.remove("ghost");
601
  });
602
+ } catch (_) {}
603
  }
604
  </script>
605
 
606
  <div class="trackio theme--classic" bind:this={hostEl} data-variant={variant}>
607
  <div class="trackio__header">
608
+ <Legend
609
+ items={legendItems}
610
+ on:legend-hover={(e) => {
611
+ const run = e?.detail?.name;
612
+ if (!run) return;
613
+ ghostRun(run);
614
+ }}
615
+ on:legend-leave={() => {
616
+ clearGhost();
617
+ }}
618
+ />
619
  </div>
620
  <div class="trackio__grid" bind:this={gridEl}>
621
  {#each cellsDef as c, i}
622
+ <Cell
623
+ metricKey={c.metric}
624
+ titleText={c.title}
625
+ wide={c.wide}
626
+ {variant}
627
+ {normalizeLoss}
628
+ {logScaleX}
629
+ {smoothing}
630
+ metricData={(preparedData && preparedData[c.metric]) || {}}
631
+ rawMetricData={(preparedRawData && preparedRawData[c.metric]) || {}}
632
+ colorForRun={(name) => colorsByRun[name] || "#999"}
633
  {hostEl}
634
  currentIndex={i}
635
  onOpenModal={openModal}
 
638
  </div>
639
  <div class="trackio__footer">
640
  <small>
641
+ Built with <a
642
+ href="https://github.com/huggingface/trackio"
643
+ target="_blank"
644
+ rel="noopener noreferrer">TrackIO</a
645
+ >
646
  <span class="separator">β€’</span>
647
+ <a
648
+ href="https://huggingface.co/docs/hub/spaces-sdks-docker"
649
+ target="_blank"
650
+ rel="noopener noreferrer">Use via API</a
651
+ >
652
  </small>
653
  </div>
654
  </div>
 
656
  <!-- Centralized Fullscreen Modal -->
657
  <FullscreenModal
658
  visible={isModalOpen}
659
+ title={allChartsData[currentFullscreenIndex]?.titleText || ""}
660
  metricData={allChartsData[currentFullscreenIndex]?.metricData || {}}
661
  rawMetricData={allChartsData[currentFullscreenIndex]?.rawMetricData || {}}
662
  colorForRun={modalColorForRun}
 
664
  {logScaleX}
665
  {smoothing}
666
  {normalizeLoss}
667
+ metricKey={allChartsData[currentFullscreenIndex]?.metricKey || ""}
668
+ titleText={allChartsData[currentFullscreenIndex]?.titleText || ""}
669
  currentIndex={currentFullscreenIndex}
670
  totalCharts={cellsDef.length}
671
  onNavigate={handleNavigate}
 
678
  ========================= */
679
 
680
  /* Font imports for themes - ensure Roboto Mono is loaded for Oblivion theme */
681
+ @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap");
682
+
683
  /* Fallback font-face declaration */
684
  @font-face {
685
+ font-family: "Roboto Mono Fallback";
686
+ src: url("https://fonts.gstatic.com/s/robotomono/v23/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW4AJi8SJQt.woff2")
687
+ format("woff2");
688
  font-weight: 400;
689
  font-style: normal;
690
  font-display: swap;
 
695
  position: relative;
696
  --z-tooltip: 50;
697
  --z-overlay: 99999999;
698
+
699
  /* Typography */
700
+ --trackio-font-family: var(
701
+ --font-mono,
702
+ ui-monospace,
703
+ SFMono-Regular,
704
+ Menlo,
705
+ monospace
706
+ );
707
  --trackio-font-weight-normal: 400;
708
  --trackio-font-weight-medium: 600;
709
  --trackio-font-weight-bold: 700;
710
+
711
  /* Apply font-family to root element */
712
  font-family: var(--trackio-font-family);
713
+
714
  /* Base color system for Classic theme */
715
  --trackio-base: #323232;
716
  --trackio-primary: var(--trackio-base);
717
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 28%, transparent);
718
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
719
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
720
+
721
  /* Chart rendering */
722
+ --trackio-chart-grid-type: "lines"; /* 'lines' | 'dots' */
723
  --trackio-chart-axis-stroke: var(--trackio-dim);
724
  --trackio-chart-axis-text: var(--trackio-text);
725
  --trackio-chart-grid-stroke: var(--trackio-subtle);
726
  --trackio-chart-grid-opacity: 1;
727
  }
728
+
729
  /* Dark mode overrides for Classic theme */
730
  :global([data-theme="dark"]) .trackio.theme--classic {
731
  --trackio-base: #ffffff;
 
733
  --trackio-dim: color-mix(in srgb, var(--trackio-base) 25%, transparent);
734
  --trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
735
  --trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
736
+
737
  /* Cell background for dark mode */
738
  --trackio-cell-background: rgba(255, 255, 255, 0.03);
739
  }
740
+
741
  .trackio.theme--classic {
742
  /* Cell styling */
743
  --trackio-cell-background: rgba(0, 0, 0, 0.02);
744
  --trackio-cell-border: var(--border-color, rgba(0, 0, 0, 0.1));
745
  --trackio-cell-corner-inset: 0px;
746
  --trackio-cell-gap: 12px;
747
+
748
  /* Typography */
749
  --trackio-text-primary: var(--text-color, rgba(0, 0, 0, 0.9));
750
  --trackio-text-secondary: var(--muted-color, rgba(0, 0, 0, 0.6));
751
+ --trackio-text-accent: var(--primary-color);
752
+
753
  /* Tooltip */
754
  --trackio-tooltip-background: var(--surface-bg, white);
755
  --trackio-tooltip-border: var(--border-color, rgba(0, 0, 0, 0.1));
756
  --trackio-tooltip-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
757
+
758
  /* Legend */
759
  --trackio-legend-text: var(--text-color, rgba(0, 0, 0, 0.9));
760
  --trackio-legend-swatch-border: var(--border-color, rgba(0, 0, 0, 0.1));
 
763
  /* Dark mode adjustments */
764
  :global([data-theme="dark"]) .trackio {
765
  --trackio-chart-axis-stroke: rgba(255, 255, 255, 0.18);
766
+ --trackio-chart-axis-text: rgba(255, 255, 255, 0.6);
767
  --trackio-chart-grid-stroke: rgba(255, 255, 255, 0.08);
768
  }
769
 
770
  /* =========================
771
  THEME: CLASSIC (Default)
772
  ========================= */
773
+
774
  .trackio.theme--classic {
775
  /* Keep default values - no overrides needed */
776
  }
 
778
  /* =========================
779
  THEME: OBLIVION
780
  ========================= */
781
+
782
  .trackio.theme--oblivion {
783
  /* Core oblivion color system - Light mode: darker colors for visibility */
784
  --trackio-oblivion-base: #2a2a2a;
785
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
786
+ --trackio-oblivion-dim: color-mix(
787
+ in srgb,
788
+ var(--trackio-oblivion-base) 30%,
789
+ transparent
790
+ );
791
+ --trackio-oblivion-subtle: color-mix(
792
+ in srgb,
793
+ var(--trackio-oblivion-base) 8%,
794
+ transparent
795
+ );
796
+ --trackio-oblivion-ghost: color-mix(
797
+ in srgb,
798
+ var(--trackio-oblivion-base) 4%,
799
+ transparent
800
+ );
801
+
802
  /* Chart rendering overrides */
803
+ --trackio-chart-grid-type: "dots";
804
  --trackio-chart-axis-stroke: var(--trackio-oblivion-dim);
805
  --trackio-chart-axis-text: var(--trackio-oblivion-primary);
806
  --trackio-chart-grid-stroke: var(--trackio-oblivion-dim);
807
  --trackio-chart-grid-opacity: 0.6;
808
  }
809
+
810
  /* Dark mode overrides for Oblivion theme */
811
  :global([data-theme="dark"]) .trackio.theme--oblivion {
812
  --trackio-oblivion-base: #ffffff;
813
  --trackio-oblivion-primary: var(--trackio-oblivion-base);
814
+ --trackio-oblivion-dim: color-mix(
815
+ in srgb,
816
+ var(--trackio-oblivion-base) 25%,
817
+ transparent
818
+ );
819
+ --trackio-oblivion-subtle: color-mix(
820
+ in srgb,
821
+ var(--trackio-oblivion-base) 8%,
822
+ transparent
823
+ );
824
+ --trackio-oblivion-ghost: color-mix(
825
+ in srgb,
826
+ var(--trackio-oblivion-base) 4%,
827
+ transparent
828
+ );
829
  }
830
+
831
  .trackio.theme--oblivion {
832
  /* Cell styling overrides */
833
  --trackio-cell-background: var(--trackio-oblivion-subtle);
834
  --trackio-cell-border: var(--trackio-oblivion-dim);
835
  --trackio-cell-corner-inset: 6px;
836
  --trackio-cell-gap: 0px;
837
+
838
  /* HUD-specific variables */
839
  --trackio-oblivion-hud-gap: 10px;
840
  --trackio-oblivion-hud-corner-size: 8px;
841
+ --trackio-oblivion-hud-bg-gradient: radial-gradient(
842
+ 1200px 200px at 20% -10%,
843
+ var(--trackio-oblivion-ghost),
844
+ transparent 80%
845
+ ),
846
+ radial-gradient(
847
+ 900px 200px at 80% 110%,
848
+ var(--trackio-oblivion-ghost),
849
+ transparent 80%
850
+ );
851
+
852
  /* Typography overrides */
853
  --trackio-text-primary: var(--trackio-oblivion-primary);
854
  --trackio-text-secondary: var(--trackio-oblivion-dim);
855
  --trackio-text-accent: var(--trackio-oblivion-primary);
856
+
857
  /* Tooltip overrides */
858
  --trackio-tooltip-background: var(--trackio-oblivion-subtle);
859
  --trackio-tooltip-border: var(--trackio-oblivion-dim);
860
+ --trackio-tooltip-shadow: 0 8px 32px
861
+ color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent),
862
  0 2px 8px color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent);
863
+
864
  /* Legend overrides */
865
  --trackio-legend-text: var(--trackio-oblivion-primary);
866
  --trackio-legend-swatch-border: var(--trackio-oblivion-dim);
867
+
868
  /* Font styling overrides */
869
+ --trackio-font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
870
+ SFMono-Regular, Menlo, monospace;
871
  font-family: var(--trackio-font-family) !important;
872
  color: var(--trackio-text-primary);
873
  }
 
875
  /* Force Roboto Mono application in Oblivion theme */
876
  .trackio.theme--oblivion,
877
  .trackio.theme--oblivion * {
878
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
879
+ SFMono-Regular, Menlo, monospace !important;
880
  }
881
+
882
  /* Specific overrides for different elements in Oblivion */
883
  .trackio.theme--oblivion .cell-title,
884
  .trackio.theme--oblivion .legend-bottom,
885
  .trackio.theme--oblivion .legend-title,
886
  .trackio.theme--oblivion .item {
887
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
888
+ SFMono-Regular, Menlo, monospace !important;
889
  }
890
 
891
  /* Dark mode adjustments for Oblivion */
892
  :global([data-theme="dark"]) .trackio.theme--oblivion {
893
  --trackio-oblivion-base: #ffffff;
894
+ --trackio-oblivion-hud-bg-gradient: radial-gradient(
895
+ 1400px 260px at 20% -10%,
896
+ color-mix(in srgb, var(--trackio-oblivion-base) 6.5%, transparent),
897
+ transparent 80%
898
+ ),
899
+ radial-gradient(
900
+ 1100px 240px at 80% 110%,
901
+ color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent),
902
+ transparent 80%
903
+ ),
904
+ linear-gradient(
905
+ 180deg,
906
+ color-mix(in srgb, var(--trackio-oblivion-base) 3.5%, transparent),
907
+ transparent 45%
908
+ );
909
+
910
+ --trackio-tooltip-shadow: 0 8px 32px
911
+ color-mix(in srgb, var(--trackio-oblivion-base) 5%, transparent),
912
  0 2px 8px color-mix(in srgb, black 10%, transparent);
913
+
914
  background: #0f1115;
915
  }
916
 
 
923
  grid-template-columns: repeat(2, minmax(0, 1fr));
924
  gap: var(--trackio-cell-gap);
925
  }
926
+
927
  @media (max-width: 980px) {
928
+ .trackio__grid {
929
+ grid-template-columns: 1fr;
930
+ }
931
  }
932
 
933
  .trackio__header {
 
945
  .trackio .axes line {
946
  stroke: var(--trackio-chart-axis-stroke);
947
  }
948
+
949
  .trackio .axes text {
950
  fill: var(--trackio-chart-axis-text);
951
  font-family: var(--trackio-font-family);
952
  }
953
+
954
  /* Force font-family for SVG text in Oblivion */
955
  .trackio.theme--oblivion .axes text {
956
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
957
+ SFMono-Regular, Menlo, monospace !important;
958
  }
959
+
960
  .trackio .grid line {
961
  stroke: var(--trackio-chart-grid-stroke);
962
  opacity: var(--trackio-chart-grid-opacity);
963
  }
964
 
965
  /* Grid type switching */
966
+ .trackio .grid-dots {
967
+ display: none;
968
+ }
969
+ .trackio.theme--oblivion .grid {
970
+ display: none;
971
+ }
972
+ .trackio.theme--oblivion .grid-dots {
973
+ display: block;
974
+ }
975
  .trackio.theme--oblivion .cell-bg,
976
+ .trackio.theme--oblivion .cell-corners {
977
+ display: block;
978
+ }
979
 
980
  /* =========================
981
  FOOTER
 
1019
  }
1020
 
1021
  .trackio.theme--oblivion .trackio__footer small {
1022
+ font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
1023
+ SFMono-Regular, Menlo, monospace !important;
1024
  }
1025
  </style>
 
 
app/src/components/{TrackioWrapper.astro β†’ trackio/TrackioWrapper.astro} RENAMED
@@ -1,12 +1,15 @@
1
  ---
2
  // TrackioWrapper.astro
3
- import Trackio from './trackio/Trackio.svelte';
4
  ---
5
 
6
  <!-- Ensure Roboto Mono is loaded for Oblivion theme -->
7
- <link rel="preconnect" href="https://fonts.googleapis.com">
8
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
- <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap" rel="stylesheet">
 
 
 
10
 
11
  <div class="trackio-wrapper">
12
  <div class="trackio-controls">
@@ -20,11 +23,11 @@ import Trackio from './trackio/Trackio.svelte';
20
  </div>
21
  <div class="scale-controls">
22
  <label>
23
- <input type="checkbox" id="log-scale-x" checked>
24
  Log Scale X
25
  </label>
26
  <label>
27
- <input type="checkbox" id="smooth-data" checked>
28
  Smooth
29
  </label>
30
  </div>
@@ -33,15 +36,24 @@ import Trackio from './trackio/Trackio.svelte';
33
  <button class="button button--ghost" type="button" id="randomize-btn">
34
  Randomize Data
35
  </button>
36
- <button class="button button--primary" type="button" id="start-simulation-btn">
 
 
 
 
37
  Live Run
38
  </button>
39
- <button class="button button--danger" type="button" id="stop-simulation-btn" style="display: none;">
 
 
 
 
 
40
  Stop
41
  </button>
42
  </div>
43
  </div>
44
-
45
  <div class="trackio-container">
46
  <Trackio client:load variant="classic" logScaleX={true} smoothing={true} />
47
  </div>
@@ -49,245 +61,284 @@ import Trackio from './trackio/Trackio.svelte';
49
 
50
  <script>
51
  // @ts-nocheck
52
- document.addEventListener('DOMContentLoaded', async () => {
53
- const themeSelect = document.getElementById('theme-select');
54
- const randomizeBtn = document.getElementById('randomize-btn');
55
- const startSimulationBtn = document.getElementById('start-simulation-btn');
56
- const stopSimulationBtn = document.getElementById('stop-simulation-btn');
57
- const logScaleXCheckbox = document.getElementById('log-scale-x');
58
- const smoothDataCheckbox = document.getElementById('smooth-data');
59
- const trackioContainer = document.querySelector('.trackio-container');
60
-
61
- if (!themeSelect || !randomizeBtn || !startSimulationBtn || !stopSimulationBtn ||
62
- !logScaleXCheckbox || !smoothDataCheckbox || !trackioContainer) return;
63
-
64
- // Variables pour la simulation
 
 
 
 
 
 
 
 
65
  let simulationInterval = null;
66
  let currentSimulationRun = null;
67
  let currentStep = 0;
68
-
69
  // Import the store function
70
- const { triggerJitter } = await import('./trackio/core/store.js');
71
-
72
  // Theme change handler
73
- themeSelect.addEventListener('change', (e) => {
74
  const target = e.target;
75
- if (!target || !('value' in target)) return;
76
-
77
  const newVariant = target.value;
78
  console.log(`Theme changed to: ${newVariant}`); // Debug log
79
-
80
  // Find the trackio element and call setTheme on the Svelte instance
81
  const trackioEl = debugTrackioState();
82
  if (trackioEl && trackioEl.__trackioInstance) {
83
- console.log('βœ… Calling setTheme on Trackio instance');
84
  trackioEl.__trackioInstance.setTheme(newVariant);
85
  } else {
86
- console.warn('❌ No Trackio instance found for theme change');
87
  }
88
  });
89
 
90
  // Log scale X change handler
91
- logScaleXCheckbox.addEventListener('change', (e) => {
92
  const target = e.target;
93
- if (!target || !('checked' in target)) return;
94
-
95
  const isLogScale = target.checked;
96
  console.log(`Log scale X changed to: ${isLogScale}`); // Debug log
97
-
98
  // Find the trackio element and call setLogScaleX on the Svelte instance
99
  const trackioEl = debugTrackioState();
100
  if (trackioEl && trackioEl.__trackioInstance) {
101
- console.log('βœ… Calling setLogScaleX on Trackio instance');
102
  trackioEl.__trackioInstance.setLogScaleX(isLogScale);
103
  } else {
104
- console.warn('❌ Trackio instance not found for log scale change');
105
  }
106
  });
107
 
108
  // Smooth data change handler
109
- smoothDataCheckbox.addEventListener('change', (e) => {
110
  const target = e.target;
111
- if (!target || !('checked' in target)) return;
112
-
113
  const isSmooth = target.checked;
114
  console.log(`Smooth data changed to: ${isSmooth}`); // Debug log
115
-
116
  // Find the trackio element and call setSmoothing on the Svelte instance
117
  const trackioEl = debugTrackioState();
118
  if (trackioEl && trackioEl.__trackioInstance) {
119
- console.log('βœ… Calling setSmoothing on Trackio instance');
120
  trackioEl.__trackioInstance.setSmoothing(isSmooth);
121
  } else {
122
- console.warn('❌ Trackio instance not found for smooth change');
123
  }
124
  });
125
-
126
  // Debug function to check trackio state
127
  function debugTrackioState() {
128
- const trackioEl = trackioContainer.querySelector('.trackio');
129
- console.log('πŸ” Debug Trackio state:', {
130
  container: !!trackioContainer,
131
  trackioEl: !!trackioEl,
132
  hasInstance: !!(trackioEl && trackioEl.__trackioInstance),
133
- availableMethods: trackioEl && trackioEl.__trackioInstance ? Object.keys(trackioEl.__trackioInstance) : 'none',
134
- windowInstance: !!window.trackioInstance
 
 
 
135
  });
136
  return trackioEl;
137
  }
138
-
139
  // Initialize with default checked states - increased delay and retry logic
140
  function initializeTrackio(attempt = 1) {
141
  console.log(`πŸš€ Initializing Trackio (attempt ${attempt})`);
142
-
143
  const trackioEl = debugTrackioState();
144
-
145
  if (trackioEl && trackioEl.__trackioInstance) {
146
- console.log('βœ… Trackio instance found, applying initial settings');
147
-
148
  if (logScaleXCheckbox.checked) {
149
- console.log('Initializing with log scale X enabled');
150
  trackioEl.__trackioInstance.setLogScaleX(true);
151
  }
152
-
153
  if (smoothDataCheckbox.checked) {
154
- console.log('Initializing with smoothing enabled');
155
  trackioEl.__trackioInstance.setSmoothing(true);
156
  }
157
  } else {
158
- console.log('❌ Trackio instance not ready yet');
159
  if (attempt < 10) {
160
  setTimeout(() => initializeTrackio(attempt + 1), 200 * attempt);
161
  } else {
162
- console.error('Failed to initialize Trackio after 10 attempts');
163
  }
164
  }
165
  }
166
-
167
  // Start initialization
168
  setTimeout(() => initializeTrackio(), 100);
169
-
170
- // Fonction pour gΓ©nΓ©rer une nouvelle valeur de mΓ©trique simulΓ©e
171
  function generateSimulatedValue(step, metric) {
172
  const baseProgress = Math.min(1, step / 100); // Normalise sur 100 steps
173
-
174
- if (metric === 'loss') {
175
- // Loss qui dΓ©croit avec du bruit
176
  const baseLoss = 2.0 * Math.exp(-0.05 * step);
177
  const noise = (Math.random() - 0.5) * 0.2;
178
  return Math.max(0.01, baseLoss + noise);
179
- } else if (metric === 'accuracy') {
180
- // Accuracy qui augmente avec du bruit
181
  const baseAcc = 0.1 + 0.8 * (1 - Math.exp(-0.04 * step));
182
  const noise = (Math.random() - 0.5) * 0.05;
183
  return Math.max(0, Math.min(1, baseAcc + noise));
184
  }
185
  return Math.random();
186
  }
187
-
188
- // Gestionnaire pour dΓ©marrer la simulation
189
  function startSimulation() {
190
  if (simulationInterval) {
191
  clearInterval(simulationInterval);
192
  }
193
-
194
  // GΓ©nΓ©rer un nouveau nom de run
195
- const adjectives = ['live', 'real-time', 'streaming', 'dynamic', 'active', 'running'];
196
- const nouns = ['experiment', 'trial', 'session', 'training', 'run', 'test'];
197
- const randomAdj = adjectives[Math.floor(Math.random() * adjectives.length)];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
199
  currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
200
  currentStep = 1; // Commencer Γ  step 1
201
-
202
  console.log(`Starting simulation for run: ${currentSimulationRun}`);
203
-
204
  // Interface UI
205
- startSimulationBtn.style.display = 'none';
206
- stopSimulationBtn.style.display = 'inline-flex';
207
  startSimulationBtn.disabled = true;
208
-
209
  // Ajouter le premier point
210
  addSimulationStep();
211
-
212
  // Continuer chaque seconde
213
  simulationInterval = setInterval(() => {
214
  currentStep++;
215
  addSimulationStep();
216
-
217
- // ArrΓͺter aprΓ¨s 200 steps pour Γ©viter l'infini
218
  if (currentStep > 200) {
219
  stopSimulation();
220
  }
221
  }, 1000); // Chaque seconde
222
  }
223
-
224
- // Fonction pour ajouter un nouveau point de donnΓ©es
225
  function addSimulationStep() {
226
- const trackioEl = trackioContainer.querySelector('.trackio');
227
  if (trackioEl && trackioEl.__trackioInstance) {
228
  const newDataPoint = {
229
  step: currentStep,
230
- loss: generateSimulatedValue(currentStep, 'loss'),
231
- accuracy: generateSimulatedValue(currentStep, 'accuracy')
232
  };
233
-
234
- console.log(`Adding simulation step ${currentStep} for run ${currentSimulationRun}:`, newDataPoint);
235
-
 
 
 
236
  // Ajouter le point via l'instance Trackio
237
- if (typeof trackioEl.__trackioInstance.addLiveDataPoint === 'function') {
238
- trackioEl.__trackioInstance.addLiveDataPoint(currentSimulationRun, newDataPoint);
 
 
 
 
 
239
  } else {
240
- console.warn('addLiveDataPoint method not found on Trackio instance');
241
  }
242
  }
243
  }
244
-
245
- // Gestionnaire pour arrΓͺter la simulation
246
  function stopSimulation() {
247
  if (simulationInterval) {
248
  clearInterval(simulationInterval);
249
  simulationInterval = null;
250
  }
251
-
252
  console.log(`Stopping simulation for run: ${currentSimulationRun}`);
253
-
254
  // Interface UI
255
- startSimulationBtn.style.display = 'inline-flex';
256
- stopSimulationBtn.style.display = 'none';
257
  startSimulationBtn.disabled = false;
258
-
259
  currentSimulationRun = null;
260
  currentStep = 0;
261
  }
262
-
263
- // Event listeners pour les boutons de simulation
264
- startSimulationBtn.addEventListener('click', startSimulation);
265
- stopSimulationBtn.addEventListener('click', stopSimulation);
266
-
267
  // ArrΓͺter la simulation si l'utilisateur quitte la page
268
- window.addEventListener('beforeunload', stopSimulation);
269
-
270
  // Randomize data handler - now uses the store
271
- randomizeBtn.addEventListener('click', () => {
272
- console.log('Randomize button clicked - triggering jitter via store'); // Debug log
273
-
274
  // ArrΓͺter la simulation en cours si elle tourne
275
  if (simulationInterval) {
276
  stopSimulation();
277
  }
278
-
279
  // Add vibration animation
280
- randomizeBtn.classList.add('vibrating');
281
  setTimeout(() => {
282
- randomizeBtn.classList.remove('vibrating');
283
  }, 600);
284
-
285
  // Test direct window approach as well
286
- if (window.trackioInstance && typeof window.trackioInstance.jitterData === 'function') {
287
- console.log('Found window.trackioInstance, calling jitterData directly'); // Debug log
 
 
 
 
 
288
  window.trackioInstance.jitterData();
289
  } else {
290
- console.log('No window.trackioInstance found, using store trigger'); // Debug log
291
  triggerJitter();
292
  }
293
  });
@@ -299,7 +350,7 @@ import Trackio from './trackio/Trackio.svelte';
299
  width: 100%;
300
  margin: 0px 0 20px 0;
301
  }
302
-
303
  .trackio-controls {
304
  display: flex;
305
  justify-content: space-between;
@@ -310,21 +361,21 @@ import Trackio from './trackio/Trackio.svelte';
310
  gap: 16px;
311
  flex-wrap: nowrap;
312
  }
313
-
314
  .controls-left {
315
  display: flex;
316
  align-items: center;
317
  gap: 24px;
318
  flex-wrap: wrap;
319
  }
320
-
321
  .controls-right {
322
  display: flex;
323
  align-items: center;
324
  gap: 12px;
325
  flex-wrap: wrap;
326
  }
327
-
328
  .btn-randomize {
329
  display: inline-flex;
330
  align-items: center;
@@ -339,16 +390,16 @@ import Trackio from './trackio/Trackio.svelte';
339
  cursor: pointer;
340
  transition: all 0.15s ease;
341
  }
342
-
343
  .btn-randomize:hover {
344
  background: var(--accent-hover, #005a9e);
345
  transform: translateY(-1px);
346
  }
347
-
348
  .btn-randomize:active {
349
  transform: translateY(0);
350
  }
351
-
352
  .theme-selector {
353
  display: flex;
354
  align-items: center;
@@ -357,12 +408,12 @@ import Trackio from './trackio/Trackio.svelte';
357
  flex-shrink: 0;
358
  white-space: nowrap;
359
  }
360
-
361
  .theme-selector label {
362
  font-weight: 500;
363
  color: var(--text-color);
364
  }
365
-
366
  .theme-select {
367
  padding: 6px 12px;
368
  border: 1px solid var(--border-color);
@@ -373,12 +424,12 @@ import Trackio from './trackio/Trackio.svelte';
373
  cursor: pointer;
374
  transition: border-color 0.15s ease;
375
  }
376
-
377
  .theme-select:focus {
378
  outline: none;
379
  border-color: var(--accent-color, #007acc);
380
  }
381
-
382
  .scale-controls {
383
  display: flex;
384
  align-items: center;
@@ -386,53 +437,74 @@ import Trackio from './trackio/Trackio.svelte';
386
  flex-shrink: 0;
387
  white-space: nowrap;
388
  }
389
-
390
- /* Animation de vibration pour le bouton */
391
  @keyframes vibrate {
392
- 0% { transform: translateX(0); }
393
- 10% { transform: translateX(-2px) rotate(-1deg); }
394
- 20% { transform: translateX(2px) rotate(1deg); }
395
- 30% { transform: translateX(-2px) rotate(-1deg); }
396
- 40% { transform: translateX(2px) rotate(1deg); }
397
- 50% { transform: translateX(-1px) rotate(-0.5deg); }
398
- 60% { transform: translateX(1px) rotate(0.5deg); }
399
- 70% { transform: translateX(-1px) rotate(-0.5deg); }
400
- 80% { transform: translateX(1px) rotate(0.5deg); }
401
- 90% { transform: translateX(-0.5px) rotate(-0.25deg); }
402
- 100% { transform: translateX(0) rotate(0); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  }
404
-
405
  .button.vibrating {
406
  animation: vibrate 0.6s ease-in-out;
407
  }
408
-
409
  .trackio-container {
410
  width: 100%;
411
  margin-top: 10px;
412
  border: 1px solid var(--border-color);
413
  padding: 24px 12px;
414
-
415
  }
416
-
417
  @media (max-width: 768px) {
418
  .trackio-controls {
419
  flex-direction: column;
420
  align-items: stretch;
421
  gap: 12px;
422
  }
423
-
424
  .controls-left {
425
  flex-direction: column;
426
  align-items: stretch;
427
  gap: 12px;
428
  }
429
-
430
  .theme-selector {
431
  justify-content: space-between;
432
  }
433
-
434
  .scale-controls {
435
  justify-content: space-between;
436
  }
437
  }
438
- </style>
 
1
  ---
2
  // TrackioWrapper.astro
3
+ import Trackio from "./Trackio.svelte";
4
  ---
5
 
6
  <!-- Ensure Roboto Mono is loaded for Oblivion theme -->
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
 
14
  <div class="trackio-wrapper">
15
  <div class="trackio-controls">
 
23
  </div>
24
  <div class="scale-controls">
25
  <label>
26
+ <input type="checkbox" id="log-scale-x" checked />
27
  Log Scale X
28
  </label>
29
  <label>
30
+ <input type="checkbox" id="smooth-data" checked />
31
  Smooth
32
  </label>
33
  </div>
 
36
  <button class="button button--ghost" type="button" id="randomize-btn">
37
  Randomize Data
38
  </button>
39
+ <button
40
+ class="button button--primary"
41
+ type="button"
42
+ id="start-simulation-btn"
43
+ >
44
  Live Run
45
  </button>
46
+ <button
47
+ class="button button--danger"
48
+ type="button"
49
+ id="stop-simulation-btn"
50
+ style="display: none;"
51
+ >
52
  Stop
53
  </button>
54
  </div>
55
  </div>
56
+
57
  <div class="trackio-container">
58
  <Trackio client:load variant="classic" logScaleX={true} smoothing={true} />
59
  </div>
 
61
 
62
  <script>
63
  // @ts-nocheck
64
+ document.addEventListener("DOMContentLoaded", async () => {
65
+ const themeSelect = document.getElementById("theme-select");
66
+ const randomizeBtn = document.getElementById("randomize-btn");
67
+ const startSimulationBtn = document.getElementById("start-simulation-btn");
68
+ const stopSimulationBtn = document.getElementById("stop-simulation-btn");
69
+ const logScaleXCheckbox = document.getElementById("log-scale-x");
70
+ const smoothDataCheckbox = document.getElementById("smooth-data");
71
+ const trackioContainer = document.querySelector(".trackio-container");
72
+
73
+ if (
74
+ !themeSelect ||
75
+ !randomizeBtn ||
76
+ !startSimulationBtn ||
77
+ !stopSimulationBtn ||
78
+ !logScaleXCheckbox ||
79
+ !smoothDataCheckbox ||
80
+ !trackioContainer
81
+ )
82
+ return;
83
+
84
+ // Variables for simulation
85
  let simulationInterval = null;
86
  let currentSimulationRun = null;
87
  let currentStep = 0;
88
+
89
  // Import the store function
90
+ const { triggerJitter } = await import("./core/store.js");
91
+
92
  // Theme change handler
93
+ themeSelect.addEventListener("change", (e) => {
94
  const target = e.target;
95
+ if (!target || !("value" in target)) return;
96
+
97
  const newVariant = target.value;
98
  console.log(`Theme changed to: ${newVariant}`); // Debug log
99
+
100
  // Find the trackio element and call setTheme on the Svelte instance
101
  const trackioEl = debugTrackioState();
102
  if (trackioEl && trackioEl.__trackioInstance) {
103
+ console.log("βœ… Calling setTheme on Trackio instance");
104
  trackioEl.__trackioInstance.setTheme(newVariant);
105
  } else {
106
+ console.warn("❌ No Trackio instance found for theme change");
107
  }
108
  });
109
 
110
  // Log scale X change handler
111
+ logScaleXCheckbox.addEventListener("change", (e) => {
112
  const target = e.target;
113
+ if (!target || !("checked" in target)) return;
114
+
115
  const isLogScale = target.checked;
116
  console.log(`Log scale X changed to: ${isLogScale}`); // Debug log
117
+
118
  // Find the trackio element and call setLogScaleX on the Svelte instance
119
  const trackioEl = debugTrackioState();
120
  if (trackioEl && trackioEl.__trackioInstance) {
121
+ console.log("βœ… Calling setLogScaleX on Trackio instance");
122
  trackioEl.__trackioInstance.setLogScaleX(isLogScale);
123
  } else {
124
+ console.warn("❌ Trackio instance not found for log scale change");
125
  }
126
  });
127
 
128
  // Smooth data change handler
129
+ smoothDataCheckbox.addEventListener("change", (e) => {
130
  const target = e.target;
131
+ if (!target || !("checked" in target)) return;
132
+
133
  const isSmooth = target.checked;
134
  console.log(`Smooth data changed to: ${isSmooth}`); // Debug log
135
+
136
  // Find the trackio element and call setSmoothing on the Svelte instance
137
  const trackioEl = debugTrackioState();
138
  if (trackioEl && trackioEl.__trackioInstance) {
139
+ console.log("βœ… Calling setSmoothing on Trackio instance");
140
  trackioEl.__trackioInstance.setSmoothing(isSmooth);
141
  } else {
142
+ console.warn("❌ Trackio instance not found for smooth change");
143
  }
144
  });
145
+
146
  // Debug function to check trackio state
147
  function debugTrackioState() {
148
+ const trackioEl = trackioContainer.querySelector(".trackio");
149
+ console.log("πŸ” Debug Trackio state:", {
150
  container: !!trackioContainer,
151
  trackioEl: !!trackioEl,
152
  hasInstance: !!(trackioEl && trackioEl.__trackioInstance),
153
+ availableMethods:
154
+ trackioEl && trackioEl.__trackioInstance
155
+ ? Object.keys(trackioEl.__trackioInstance)
156
+ : "none",
157
+ windowInstance: !!window.trackioInstance,
158
  });
159
  return trackioEl;
160
  }
161
+
162
  // Initialize with default checked states - increased delay and retry logic
163
  function initializeTrackio(attempt = 1) {
164
  console.log(`πŸš€ Initializing Trackio (attempt ${attempt})`);
165
+
166
  const trackioEl = debugTrackioState();
167
+
168
  if (trackioEl && trackioEl.__trackioInstance) {
169
+ console.log("βœ… Trackio instance found, applying initial settings");
170
+
171
  if (logScaleXCheckbox.checked) {
172
+ console.log("Initializing with log scale X enabled");
173
  trackioEl.__trackioInstance.setLogScaleX(true);
174
  }
175
+
176
  if (smoothDataCheckbox.checked) {
177
+ console.log("Initializing with smoothing enabled");
178
  trackioEl.__trackioInstance.setSmoothing(true);
179
  }
180
  } else {
181
+ console.log("❌ Trackio instance not ready yet");
182
  if (attempt < 10) {
183
  setTimeout(() => initializeTrackio(attempt + 1), 200 * attempt);
184
  } else {
185
+ console.error("Failed to initialize Trackio after 10 attempts");
186
  }
187
  }
188
  }
189
+
190
  // Start initialization
191
  setTimeout(() => initializeTrackio(), 100);
192
+
193
+ // Function to generate a new simulated metric value
194
  function generateSimulatedValue(step, metric) {
195
  const baseProgress = Math.min(1, step / 100); // Normalise sur 100 steps
196
+
197
+ if (metric === "loss") {
198
+ // Loss that decreases with noise
199
  const baseLoss = 2.0 * Math.exp(-0.05 * step);
200
  const noise = (Math.random() - 0.5) * 0.2;
201
  return Math.max(0.01, baseLoss + noise);
202
+ } else if (metric === "accuracy") {
203
+ // Accuracy that increases with noise
204
  const baseAcc = 0.1 + 0.8 * (1 - Math.exp(-0.04 * step));
205
  const noise = (Math.random() - 0.5) * 0.05;
206
  return Math.max(0, Math.min(1, baseAcc + noise));
207
  }
208
  return Math.random();
209
  }
210
+
211
+ // Handler to start simulation
212
  function startSimulation() {
213
  if (simulationInterval) {
214
  clearInterval(simulationInterval);
215
  }
216
+
217
  // GΓ©nΓ©rer un nouveau nom de run
218
+ const adjectives = [
219
+ "live",
220
+ "real-time",
221
+ "streaming",
222
+ "dynamic",
223
+ "active",
224
+ "running",
225
+ ];
226
+ const nouns = [
227
+ "experiment",
228
+ "trial",
229
+ "session",
230
+ "training",
231
+ "run",
232
+ "test",
233
+ ];
234
+ const randomAdj =
235
+ adjectives[Math.floor(Math.random() * adjectives.length)];
236
  const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
237
  currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
238
  currentStep = 1; // Commencer Γ  step 1
239
+
240
  console.log(`Starting simulation for run: ${currentSimulationRun}`);
241
+
242
  // Interface UI
243
+ startSimulationBtn.style.display = "none";
244
+ stopSimulationBtn.style.display = "inline-flex";
245
  startSimulationBtn.disabled = true;
246
+
247
  // Ajouter le premier point
248
  addSimulationStep();
249
+
250
  // Continuer chaque seconde
251
  simulationInterval = setInterval(() => {
252
  currentStep++;
253
  addSimulationStep();
254
+
255
+ // Stop after 200 steps to avoid infinity
256
  if (currentStep > 200) {
257
  stopSimulation();
258
  }
259
  }, 1000); // Chaque seconde
260
  }
261
+
262
+ // Function to add a new data point
263
  function addSimulationStep() {
264
+ const trackioEl = trackioContainer.querySelector(".trackio");
265
  if (trackioEl && trackioEl.__trackioInstance) {
266
  const newDataPoint = {
267
  step: currentStep,
268
+ loss: generateSimulatedValue(currentStep, "loss"),
269
+ accuracy: generateSimulatedValue(currentStep, "accuracy"),
270
  };
271
+
272
+ console.log(
273
+ `Adding simulation step ${currentStep} for run ${currentSimulationRun}:`,
274
+ newDataPoint,
275
+ );
276
+
277
  // Ajouter le point via l'instance Trackio
278
+ if (
279
+ typeof trackioEl.__trackioInstance.addLiveDataPoint === "function"
280
+ ) {
281
+ trackioEl.__trackioInstance.addLiveDataPoint(
282
+ currentSimulationRun,
283
+ newDataPoint,
284
+ );
285
  } else {
286
+ console.warn("addLiveDataPoint method not found on Trackio instance");
287
  }
288
  }
289
  }
290
+
291
+ // Handler to stop simulation
292
  function stopSimulation() {
293
  if (simulationInterval) {
294
  clearInterval(simulationInterval);
295
  simulationInterval = null;
296
  }
297
+
298
  console.log(`Stopping simulation for run: ${currentSimulationRun}`);
299
+
300
  // Interface UI
301
+ startSimulationBtn.style.display = "inline-flex";
302
+ stopSimulationBtn.style.display = "none";
303
  startSimulationBtn.disabled = false;
304
+
305
  currentSimulationRun = null;
306
  currentStep = 0;
307
  }
308
+
309
+ // Event listeners for simulation buttons
310
+ startSimulationBtn.addEventListener("click", startSimulation);
311
+ stopSimulationBtn.addEventListener("click", stopSimulation);
312
+
313
  // ArrΓͺter la simulation si l'utilisateur quitte la page
314
+ window.addEventListener("beforeunload", stopSimulation);
315
+
316
  // Randomize data handler - now uses the store
317
+ randomizeBtn.addEventListener("click", () => {
318
+ console.log("Randomize button clicked - triggering jitter via store"); // Debug log
319
+
320
  // ArrΓͺter la simulation en cours si elle tourne
321
  if (simulationInterval) {
322
  stopSimulation();
323
  }
324
+
325
  // Add vibration animation
326
+ randomizeBtn.classList.add("vibrating");
327
  setTimeout(() => {
328
+ randomizeBtn.classList.remove("vibrating");
329
  }, 600);
330
+
331
  // Test direct window approach as well
332
+ if (
333
+ window.trackioInstance &&
334
+ typeof window.trackioInstance.jitterData === "function"
335
+ ) {
336
+ console.log(
337
+ "Found window.trackioInstance, calling jitterData directly",
338
+ ); // Debug log
339
  window.trackioInstance.jitterData();
340
  } else {
341
+ console.log("No window.trackioInstance found, using store trigger"); // Debug log
342
  triggerJitter();
343
  }
344
  });
 
350
  width: 100%;
351
  margin: 0px 0 20px 0;
352
  }
353
+
354
  .trackio-controls {
355
  display: flex;
356
  justify-content: space-between;
 
361
  gap: 16px;
362
  flex-wrap: nowrap;
363
  }
364
+
365
  .controls-left {
366
  display: flex;
367
  align-items: center;
368
  gap: 24px;
369
  flex-wrap: wrap;
370
  }
371
+
372
  .controls-right {
373
  display: flex;
374
  align-items: center;
375
  gap: 12px;
376
  flex-wrap: wrap;
377
  }
378
+
379
  .btn-randomize {
380
  display: inline-flex;
381
  align-items: center;
 
390
  cursor: pointer;
391
  transition: all 0.15s ease;
392
  }
393
+
394
  .btn-randomize:hover {
395
  background: var(--accent-hover, #005a9e);
396
  transform: translateY(-1px);
397
  }
398
+
399
  .btn-randomize:active {
400
  transform: translateY(0);
401
  }
402
+
403
  .theme-selector {
404
  display: flex;
405
  align-items: center;
 
408
  flex-shrink: 0;
409
  white-space: nowrap;
410
  }
411
+
412
  .theme-selector label {
413
  font-weight: 500;
414
  color: var(--text-color);
415
  }
416
+
417
  .theme-select {
418
  padding: 6px 12px;
419
  border: 1px solid var(--border-color);
 
424
  cursor: pointer;
425
  transition: border-color 0.15s ease;
426
  }
427
+
428
  .theme-select:focus {
429
  outline: none;
430
  border-color: var(--accent-color, #007acc);
431
  }
432
+
433
  .scale-controls {
434
  display: flex;
435
  align-items: center;
 
437
  flex-shrink: 0;
438
  white-space: nowrap;
439
  }
440
+
441
+ /* Vibration animation for button */
442
  @keyframes vibrate {
443
+ 0% {
444
+ transform: translateX(0);
445
+ }
446
+ 10% {
447
+ transform: translateX(-2px) rotate(-1deg);
448
+ }
449
+ 20% {
450
+ transform: translateX(2px) rotate(1deg);
451
+ }
452
+ 30% {
453
+ transform: translateX(-2px) rotate(-1deg);
454
+ }
455
+ 40% {
456
+ transform: translateX(2px) rotate(1deg);
457
+ }
458
+ 50% {
459
+ transform: translateX(-1px) rotate(-0.5deg);
460
+ }
461
+ 60% {
462
+ transform: translateX(1px) rotate(0.5deg);
463
+ }
464
+ 70% {
465
+ transform: translateX(-1px) rotate(-0.5deg);
466
+ }
467
+ 80% {
468
+ transform: translateX(1px) rotate(0.5deg);
469
+ }
470
+ 90% {
471
+ transform: translateX(-0.5px) rotate(-0.25deg);
472
+ }
473
+ 100% {
474
+ transform: translateX(0) rotate(0);
475
+ }
476
  }
477
+
478
  .button.vibrating {
479
  animation: vibrate 0.6s ease-in-out;
480
  }
481
+
482
  .trackio-container {
483
  width: 100%;
484
  margin-top: 10px;
485
  border: 1px solid var(--border-color);
486
  padding: 24px 12px;
 
487
  }
488
+
489
  @media (max-width: 768px) {
490
  .trackio-controls {
491
  flex-direction: column;
492
  align-items: stretch;
493
  gap: 12px;
494
  }
495
+
496
  .controls-left {
497
  flex-direction: column;
498
  align-items: stretch;
499
  gap: 12px;
500
  }
501
+
502
  .theme-selector {
503
  justify-content: space-between;
504
  }
505
+
506
  .scale-controls {
507
  justify-content: space-between;
508
  }
509
  }
510
+ </style>
app/src/components/trackio/core/adaptive-sampler.js CHANGED
@@ -7,24 +7,24 @@
7
  export class AdaptiveSampler {
8
  constructor(options = {}) {
9
  this.options = {
10
- maxPoints: 400, // Seuil pour dΓ©clencher le sampling
11
- targetPoints: 200, // Nombre cible de points après sampling
12
- preserveFeatures: true, // PrΓ©server les pics/vallΓ©es importantes
13
  adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod'
14
- smoothingWindow: 3, // FenΓͺtre pour dΓ©tection des features
15
  ...options
16
  };
17
  }
18
 
19
  /**
20
- * DΓ©termine si le sampling est nΓ©cessaire
21
  */
22
  needsSampling(dataLength) {
23
  return dataLength > this.options.maxPoints;
24
  }
25
 
26
  /**
27
- * Point d'entrΓ©e principal pour le sampling
28
  */
29
  sampleSeries(data, strategy = null) {
30
  if (!Array.isArray(data) || data.length === 0) {
@@ -234,11 +234,11 @@ export class AdaptiveSampler {
234
  const index = Math.floor(logProgress * (totalLength - 1));
235
  indices.push(Math.max(1, Math.min(totalLength - 2, index)));
236
  }
237
- return [...new Set(indices)]; // Supprimer les doublons
238
  }
239
 
240
  /**
241
- * Γ‰chantillonnage basΓ© sur la variation locale
242
  */
243
  sampleByVariation(data, targetPoints) {
244
  const variations = [];
@@ -290,7 +290,7 @@ export class AdaptiveSampler {
290
  * Reconstruit les donnΓ©es complΓ¨tes pour une zone spΓ©cifique (pour le zoom)
291
  */
292
  getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
293
- // Cette mΓ©thode permettrait de rΓ©cupΓ©rer plus de dΓ©tails
294
  // quand l'utilisateur zoom sur une zone spΓ©cifique
295
  const startIdx = originalData.findIndex(d => d.step >= startStep);
296
  const endIdx = originalData.findIndex(d => d.step > endStep);
 
7
  export class AdaptiveSampler {
8
  constructor(options = {}) {
9
  this.options = {
10
+ maxPoints: 400, // Threshold to trigger sampling
11
+ targetPoints: 200, // Target number of points after sampling
12
+ preserveFeatures: true, // Preserve important peaks/valleys
13
  adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod'
14
+ smoothingWindow: 3, // Window for feature detection
15
  ...options
16
  };
17
  }
18
 
19
  /**
20
+ * Determine if sampling is necessary
21
  */
22
  needsSampling(dataLength) {
23
  return dataLength > this.options.maxPoints;
24
  }
25
 
26
  /**
27
+ * Main entry point for sampling
28
  */
29
  sampleSeries(data, strategy = null) {
30
  if (!Array.isArray(data) || data.length === 0) {
 
234
  const index = Math.floor(logProgress * (totalLength - 1));
235
  indices.push(Math.max(1, Math.min(totalLength - 2, index)));
236
  }
237
+ return [...new Set(indices)]; // Remove duplicates
238
  }
239
 
240
  /**
241
+ * Sampling based on local variation
242
  */
243
  sampleByVariation(data, targetPoints) {
244
  const variations = [];
 
290
  * Reconstruit les donnΓ©es complΓ¨tes pour une zone spΓ©cifique (pour le zoom)
291
  */
292
  getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
293
+ // This method would allow recovering more details
294
  // quand l'utilisateur zoom sur une zone spΓ©cifique
295
  const startIdx = originalData.findIndex(d => d.step >= startStep);
296
  const endIdx = originalData.findIndex(d => d.step > endStep);
app/src/content/assets/data/data.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6bbaf02f1b470da41754e3828e81e76ef386d9b3cfb8b57dcc7cbfd4225956cc
3
+ size 14121778
.devcontainer/devcontainer.json β†’ app/src/content/assets/data/font-sprite-mapping.json RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:79a20e69bb53755d95829b1a9a67f8b7ad9e6cad4859412d6e7d3bc7d5570c93
3
- size 1282
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ea1b487ebafe8d495737674a7eb6492b06551aeb97de79728afeb4aba7c39f29
3
+ size 9766
app/src/content/assets/data/font-sprite.svg ADDED
app/src/content/assets/data/font_manifest.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7a587c5fd3fb85fdd26d485f57ef3e4feb8370593d46f57289b55f873beac4b4
3
+ size 153794
app/src/content/assets/data/typography_data.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:403e0095f2bcaa963cdfbb8d00a3695565623764d6f87b890bf58d7a2304acfc
3
+ size 68739
app/src/content/assets/sprites/font-sprite.svg ADDED
app/src/content/chapters/demo/best-pratices.mdx CHANGED
@@ -1,7 +1,7 @@
1
 
2
  import visualPoster from '../../assets/image/visual-vocabulary-poster.png';
3
  import Note from '../../../components/Note.astro';
4
- import ResponsiveImage from '../../../components/ResponsiveImage.astro';
5
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
6
  import Sidenote from '../../../components/Sidenote.astro';
7
 
@@ -44,7 +44,7 @@ Favor **concise captions** and callouts that clarify what to look at and why it
44
 
45
  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**.
46
 
47
- <ResponsiveImage
48
  src={visualPoster}
49
  alt="Visual Vocabulary: choosing the right chart by task"
50
  linkHref="https://raw.githubusercontent.com/Financial-Times/chart-doctor/refs/heads/main/visual-vocabulary/poster.png"
 
1
 
2
  import visualPoster from '../../assets/image/visual-vocabulary-poster.png';
3
  import Note from '../../../components/Note.astro';
4
+ import Figure from '../../../components/Figure.astro';
5
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
6
  import Sidenote from '../../../components/Sidenote.astro';
7
 
 
44
 
45
  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**.
46
 
47
+ <Figure
48
  src={visualPoster}
49
  alt="Visual Vocabulary: choosing the right chart by task"
50
  linkHref="https://raw.githubusercontent.com/Financial-Times/chart-doctor/refs/heads/main/visual-vocabulary/poster.png"
app/src/content/chapters/demo/components.mdx CHANGED
@@ -7,8 +7,8 @@ import Wide from '../../../components/Wide.astro';
7
  import Note from '../../../components/Note.astro';
8
  import FullWidth from '../../../components/FullWidth.astro';
9
  import Accordion from '../../../components/Accordion.astro';
10
- import Figure from '../../../components/ResponsiveImage.astro';
11
- import SubFigures from '../../../components/MultiImage.astro';
12
  import Quote from '../../../components/Quote.astro';
13
 
14
  ## Components
@@ -20,8 +20,8 @@ import Quote from '../../../components/Quote.astro';
20
  To use any component in your MDX file, add the import statement at the top:
21
 
22
  ```mdx
23
- import Figure from '../components/ResponsiveImage.astro';
24
- import SubFigures from '../components/MultiImage.astro';
25
  import Note from '../components/Note.astro';
26
 
27
  # Your content
@@ -94,7 +94,7 @@ Here are the components that are available:
94
 
95
  <Accordion title="Code example">
96
  ```mdx
97
- import Figure from '../../../components/ResponsiveImage.astro'
98
  import myImage from './assets/image/placeholder.jpg'
99
 
100
  <Figure src={myImage} alt="Optimized figure with caption" />
@@ -304,8 +304,8 @@ import Note from '../../../components/Note.astro'
304
 
305
  Elegant quotes with optional source attribution.
306
 
307
- <Quote source="Geoffrey Hinton, <a href='https://list-quotes.com/quotes/geoffrey-hinton-300077/'>Interview on Brain Complexity</a>">
308
- In the brain, you have connections between the neurons called synapses, and they can change. All your knowledge is stored in those synapses. You have about 1,000-trillion synapses - 10 to the 15, it's a very big number.
309
  </Quote>
310
 
311
  | Prop | Required | Type | Description |
@@ -316,8 +316,8 @@ Elegant quotes with optional source attribution.
316
  ```mdx
317
  import Quote from '../../../components/Quote.astro'
318
 
319
- <Quote source="Geoffrey Hinton, <a href='https://list-quotes.com/quotes/geoffrey-hinton-300077/'>Interview on Brain Complexity</a>">
320
- In the brain, you have connections between the neurons called synapses, and they can change. All your knowledge is stored in those synapses. You have about 1,000-trillion synapses - 10 to the 15, it's a very big number.
321
  </Quote>
322
  ```
323
  </Accordion>
 
7
  import Note from '../../../components/Note.astro';
8
  import FullWidth from '../../../components/FullWidth.astro';
9
  import Accordion from '../../../components/Accordion.astro';
10
+ import Figure from '../../../components/Figure.astro';
11
+ import SubFigures from '../../../components/MultiFigure.astro';
12
  import Quote from '../../../components/Quote.astro';
13
 
14
  ## Components
 
20
  To use any component in your MDX file, add the import statement at the top:
21
 
22
  ```mdx
23
+ import Figure from '../components/Figure.astro';
24
+ import SubFigures from '../components/MultiFigure.astro';
25
  import Note from '../components/Note.astro';
26
 
27
  # Your content
 
94
 
95
  <Accordion title="Code example">
96
  ```mdx
97
+ import Figure from '../../../components/Figure.astro'
98
  import myImage from './assets/image/placeholder.jpg'
99
 
100
  <Figure src={myImage} alt="Optimized figure with caption" />
 
304
 
305
  Elegant quotes with optional source attribution.
306
 
307
+ <Quote source="Geoffrey Hinton, <a href='https://www.nature.com/articles/323533a0'>Learning representations by back-propagating errors</a>">
308
+ Backpropagation allows neural networks to discover their own internal representations of data.
309
  </Quote>
310
 
311
  | Prop | Required | Type | Description |
 
316
  ```mdx
317
  import Quote from '../../../components/Quote.astro'
318
 
319
+ <Quote source="Geoffrey Hinton, <a href='https://www.nature.com/articles/323533a0'>Learning representations by back-propagating errors</a>">
320
+ Backpropagation allows neural networks to discover their own internal representations of data.
321
  </Quote>
322
  ```
323
  </Accordion>
app/src/content/chapters/demo/debug-components.mdx CHANGED
@@ -1,6 +1,6 @@
1
  import Accordion from '../../../components/Accordion.astro';
2
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
3
- import ResponsiveImage from '../../../components/ResponsiveImage.astro';
4
  import Wide from '../../../components/Wide.astro';
5
  import FullWidth from '../../../components/FullWidth.astro';
6
  import Note from '../../../components/Note.astro';
 
1
  import Accordion from '../../../components/Accordion.astro';
2
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
3
+ import Figure from '../../../components/Figure.astro';
4
  import Wide from '../../../components/Wide.astro';
5
  import FullWidth from '../../../components/FullWidth.astro';
6
  import Note from '../../../components/Note.astro';
app/src/content/chapters/demo/introduction.mdx CHANGED
@@ -2,12 +2,13 @@ import Sidenote from "../../../components/Sidenote.astro";
2
 
3
  Welcome to this single‑page **research article template**. It helps you publish **clear**, **modern**, and **interactive technical writing** with **minimal setup**.
4
 
5
- Grounded in up to date good practices in web dev, it favors **interactive explanations**, **clear notation**, and **inspectable examples** over static snapshots.
6
-
7
  <Sidenote>
8
  Reading time: 20–25 minutes.
9
  </Sidenote>
10
 
 
 
 
11
  #### Features
12
 
13
  <div className="tag-list">
 
2
 
3
  Welcome to this single‑page **research article template**. It helps you publish **clear**, **modern**, and **interactive technical writing** with **minimal setup**.
4
 
 
 
5
  <Sidenote>
6
  Reading time: 20–25 minutes.
7
  </Sidenote>
8
 
9
+ Grounded in up to date good practices in web dev, it favors **interactive explanations**, **clear notation**, and **inspectable examples** over static snapshots.
10
+
11
+
12
  #### Features
13
 
14
  <div className="tag-list">
app/src/content/chapters/demo/markdown.mdx CHANGED
@@ -6,7 +6,7 @@ import Wide from '../../../components/Wide.astro';
6
  import Note from '../../../components/Note.astro';
7
  import FullWidth from '../../../components/FullWidth.astro';
8
  import Accordion from '../../../components/Accordion.astro';
9
- import ResponsiveImage from '../../../components/ResponsiveImage.astro';
10
 
11
  ## Markdown
12
 
@@ -301,7 +301,7 @@ In research articles, you may have to make references to anything. They are basi
301
  <HtmlEmbed id="neural-network-mnist-like"/>
302
  [Chart 1](#neural-network-mnist-like)
303
 
304
- <ResponsiveImage id="placeholder-image" src="..."/>
305
  [Fig 1](#placeholder-image)
306
  ```
307
  </Accordion>
 
6
  import Note from '../../../components/Note.astro';
7
  import FullWidth from '../../../components/FullWidth.astro';
8
  import Accordion from '../../../components/Accordion.astro';
9
+ import Figure from '../../../components/Figure.astro';
10
 
11
  ## Markdown
12
 
 
301
  <HtmlEmbed id="neural-network-mnist-like"/>
302
  [Chart 1](#neural-network-mnist-like)
303
 
304
+ <Figure id="placeholder-image" src="..."/>
305
  [Fig 1](#placeholder-image)
306
  ```
307
  </Accordion>
app/src/content/chapters/demo/vibe-coding-charts.mdx CHANGED
@@ -1,6 +1,7 @@
1
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
2
  import Note from '../../../components/Note.astro';
3
- import TrackioWrapper from '../../../components/TrackioWrapper.astro';
 
4
 
5
  ## Vibe coding charts
6
 
@@ -27,15 +28,19 @@ I want you to code a d3 chart that visualizes the data.
27
  4. Once the chart created, iterate with littles adjustments to make it better.
28
  5. And that's it! πŸŽ‰
29
 
30
- ### Real‑world examples
31
 
32
- They can be found in the `app/src/content/embeds` folder and you can also use them as a starting point or examples to vibe code with.
33
 
34
  <HtmlEmbed
 
35
  title="d3-benchmark: LLM Benchmark"
36
  src="d3-benchmark.html" desc={`Figure 1: Grouped bar chart comparing model scores across benchmarks (MMLU, GSM8K, HellaSwag, TruthfulQA, ARC‑C). Each group is a benchmark; colors encode models; values are accuracy/score (higher is better).`} />
 
 
37
  ---
38
- <HtmlEmbed
 
39
  src="d3-line.html"
40
  title="d3-line: Average Ranking of Models"
41
  desc='Figure 2: Average Ranking of Models trained with internally deduplicated / merged samples. No clear benefit in merging can be seen with respect to model performance.<br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'
@@ -44,33 +49,77 @@ They can be found in the `app/src/content/embeds` folder and you can also use th
44
  defaultMetric: ['average_rank']
45
  }}
46
  />
 
47
  ---
48
- <HtmlEmbed
49
- src="d3-neural-network.html"
50
- id="neural-network-mnist-like"
51
- title="d3-neural-network: MNIST-like Neural Network"
52
- desc={`Figure 3: Interactive MNIST-like neural network. Draw a digit on the left canvas; activations propagate through hidden layers (node size/opacity reflect activation). The right side displays class probabilities (0–9) with the top class emphasized.`}
53
- />
54
  ---
55
- <HtmlEmbed
56
- src="d3-matrix.html"
57
- title="d3-matrix: Baseline and Ξ” (Improved βˆ’ Baseline)"
58
- frameless
59
- desc={`<p>
60
- Figure 4: Left: baseline matrix (row-normalized, sequential palette).
61
- Right: Ξ” (Improved βˆ’ Baseline) in percentage points, using a diverging palette centered at 0 to highlight improvements vs degradations.
 
62
  </p>`}
63
- />
 
 
64
  ---
65
- <HtmlEmbed title="d3-line-quad: Comparison across thresholds" frameless src="d3-line-quad.html" desc={"Figure 5: Comparison across thresholds for all four filters individually: Formatting, Relevance, Visual Dependency, and Image-Question Correspondence <br/> Credit: "+'<a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  ---
67
- <HtmlEmbed src="d3-bar.html" title="d3-bar: Memory usage with recomputation" desc={`Figure 6: Memory usage with recomputation.<br/>Credits: <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">Ultrascale playbook</a>`}/>
68
- ---
69
- <HtmlEmbed src="d3-pie.html" title="d3-pie: Pie charts by category" desc='Figure 7: Comparison across thresholds for all four filters individually: Formatting, Relevance, Visual Dependency, and Image-Question Correspondence <br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>' />
70
- ---
71
- <HtmlEmbed src="d3-pie-quad.html" title="d3-pie-quad: Quad donuts by metric" align="center" frameless desc={'Quad view: Answer Tokens, Number of Samples, Number of Turns, Number of Images.'} />
 
 
 
 
 
72
  ---
73
- <HtmlEmbed src="d3-scatter.html" title="d3-scatter: 2D projection by category" desc={`Figure 8: Dataset visualization via UMAP <br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>`} frameless align="center" />
 
 
74
 
75
  {/* ### Trackio redesign experiment
76
  <TrackioWrapper /> */}
 
1
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
2
  import Note from '../../../components/Note.astro';
3
+ import TrackioWrapper from '../../../components/trackio/TrackioWrapper.astro';
4
+ import Sidenote from '../../../components/Sidenote.astro';
5
 
6
  ## Vibe coding charts
7
 
 
28
  4. Once the chart created, iterate with littles adjustments to make it better.
29
  5. And that's it! πŸŽ‰
30
 
31
+ ### Base examples
32
 
33
+ These are fundamental chart types that serve as building blocks for more complex visualizations. You can find all the source code in `app/src/content/embeds`.
34
 
35
  <HtmlEmbed
36
+ id="fig1"
37
  title="d3-benchmark: LLM Benchmark"
38
  src="d3-benchmark.html" desc={`Figure 1: Grouped bar chart comparing model scores across benchmarks (MMLU, GSM8K, HellaSwag, TruthfulQA, ARC‑C). Each group is a benchmark; colors encode models; values are accuracy/score (higher is better).`} />
39
+ <Sidenote> Example of <a href="#color-palettes" target="_blank">categorical color palette</a> </Sidenote>
40
+
41
  ---
42
+ <HtmlEmbed
43
+ id="fig2"
44
  src="d3-line.html"
45
  title="d3-line: Average Ranking of Models"
46
  desc='Figure 2: Average Ranking of Models trained with internally deduplicated / merged samples. No clear benefit in merging can be seen with respect to model performance.<br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'
 
49
  defaultMetric: ['average_rank']
50
  }}
51
  />
52
+
53
  ---
54
+ <HtmlEmbed id="fig4" src="d3-pie.html" title="d3-pie: Simple pie chart" frameless desc={'Figure 4: Simple pie chart showing data distribution across categories.<br/>Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'} />
55
+
56
+
 
 
 
57
  ---
58
+ <HtmlEmbed
59
+ id="fig3"
60
+ src="d3-matrix.html"
61
+ title="d3-matrix: Baseline and Ξ” (Improved βˆ’ Baseline)"
62
+ frameless
63
+ desc={`<p>
64
+ Figure 3: Left: baseline matrix (row-normalized, sequential palette).
65
+ Right: Ξ” (Improved βˆ’ Baseline) in percentage points, using a diverging palette centered at 0 to highlight improvements vs degradations.
66
  </p>`}
67
+ />
68
+ <Sidenote> Example of <a href="#color-palettes" target="_blank">sequential</a> and <a href="#color-palettes" target="_blank">diverging color palette</a> </Sidenote>
69
+
70
  ---
71
+
72
+ <HtmlEmbed
73
+ id="fig8"
74
+ src="d3-equation-editor.html"
75
+ frameless
76
+ title="Interactive Mathematical Function Plotter"
77
+ desc={`Figure 8: Interactive <strong>equation editor</strong> with real-time function plotting. Edit mathematical expressions and see their graphs update instantly. Supports common functions (sin, cos, exp, etc.) with customizable domain range.`}
78
+ />
79
+
80
+
81
+ ### Advanced examples
82
+
83
+ These are more complex, interactive visualizations that demonstrate advanced D3 capabilities and real-world applications.
84
+
85
+ <HtmlEmbed
86
+ id="fig5"
87
+ src="d3-neural-network.html"
88
+ id="neural-network-mnist-like"
89
+ frameless
90
+ title="MNIST-like Neural Network"
91
+ desc={`Figure 5: Interactive MNIST-like neural network. Draw a digit on the left canvas; activations propagate through hidden layers (node size/opacity reflect activation). The right side displays class probabilities (0–9) with the top class emphasized.`}
92
+ />
93
+ ---
94
+
95
+ <HtmlEmbed
96
+ id="fig6"
97
+ src="arxiv/arxiv.html"
98
+ title="arXiv: Research Paper Clustering"
99
+ desc={`Figure 6: Interactive visualization of ~8k recent arXiv submissions via UMAP dimensionality reduction. Each point represents a research paper positioned by semantic similarity. Colors indicate academic categories (cs.AI, cs.LG, cs.CV, etc.).`}
100
+ data="data.json"
101
+ frameless
102
+ align="center"
103
+ />
104
+
105
+ <Sidenote>
106
+ Recent arXiv publications (~8k papers from last month) via TF-IDF embeddings projected to 2D via UMAP.
107
+ </Sidenote>
108
  ---
109
+
110
+ <HtmlEmbed
111
+ id="fig7"
112
+ src="d3-umap-typography.html"
113
+ title="Visual Similarity of Typefaces"
114
+ desc={`Figure 7: Interactive 2D visualization of 382 <strong>Google Fonts</strong> clustered by visual similarity via <strong>UMAP</strong>. Each point represents a typeface positioned based on pixel-level differences computed from font matrices.`}
115
+ frameless
116
+ align="center"
117
+ />
118
+
119
  ---
120
+
121
+
122
+ <HtmlEmbed id="fig9" src="d3-pie-quad.html" title="d3-pie-quad: Quad donuts by metric" align="center" frameless desc={'Figure 9: Quad view: Answer Tokens, Number of Samples, Number of Turns, Number of Images.<br/>Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'} />
123
 
124
  {/* ### Trackio redesign experiment
125
  <TrackioWrapper /> */}
app/src/content/chapters/demo/writing-your-content.mdx CHANGED
@@ -6,8 +6,8 @@ import Wide from '../../../components/Wide.astro';
6
  import Note from '../../../components/Note.astro';
7
  import FullWidth from '../../../components/FullWidth.astro';
8
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
9
- import ColorPicker from '../../../components/ColorPicker.astro';
10
- import Palettes from '../../../components/Palettes.astro';
11
  import audioDemo from '../../assets/audio/audio-example.wav';
12
  import Accordion from '../../../components/Accordion.astro';
13
 
@@ -87,7 +87,7 @@ Your story. Write your content here.
87
  <small className="muted">**Content** in app/src/content/article.mdx</small>
88
  ```mdx
89
  import placeholder from '../../assets/image/placeholder.png'
90
- import ResponsiveImage from '../../../components/ResponsiveImage.astro'
91
  import Sidenote from '../../../components/Sidenote.astro'
92
 
93
  This paragraph is written in Markdown.
@@ -95,7 +95,7 @@ This paragraph is written in Markdown.
95
  <Sidenote>
96
  A short callout inserted via a component.
97
  </Sidenote>
98
- <ResponsiveImage src={placeholder} alt="Sample image with optimization" />
99
 
100
  This paragraph is also written in Markdown.
101
  ```
@@ -144,6 +144,7 @@ Use the **color picker** below to see how the primary color affects the theme.
144
 
145
  Here is a suggestion of **color palettes** for your **data visualizations** that align with your **brand identity**. These palettes are generated from your `--primary-color`.
146
 
 
147
  <Palettes />
148
  <br/>
149
  **Use color with care.**
 
6
  import Note from '../../../components/Note.astro';
7
  import FullWidth from '../../../components/FullWidth.astro';
8
  import HtmlEmbed from '../../../components/HtmlEmbed.astro';
9
+ import ColorPicker from '../../../components/demo/ColorPicker.astro';
10
+ import Palettes from '../../../components/demo/Palettes.astro';
11
  import audioDemo from '../../assets/audio/audio-example.wav';
12
  import Accordion from '../../../components/Accordion.astro';
13
 
 
87
  <small className="muted">**Content** in app/src/content/article.mdx</small>
88
  ```mdx
89
  import placeholder from '../../assets/image/placeholder.png'
90
+ import Figure from '../../../components/Figure.astro'
91
  import Sidenote from '../../../components/Sidenote.astro'
92
 
93
  This paragraph is written in Markdown.
 
95
  <Sidenote>
96
  A short callout inserted via a component.
97
  </Sidenote>
98
+ <Figure src={placeholder} alt="Sample image with optimization" />
99
 
100
  This paragraph is also written in Markdown.
101
  ```
 
144
 
145
  Here is a suggestion of **color palettes** for your **data visualizations** that align with your **brand identity**. These palettes are generated from your `--primary-color`.
146
 
147
+
148
  <Palettes />
149
  <br/>
150
  **Use color with care.**
app/src/content/chapters/your-first-chapter.mdx ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # this is an example chapter
2
+
app/src/content/embeds/arxiv/arxiv.html ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="arxiv-umap"></div>
2
+
3
+ <style>
4
+ .arxiv-umap {
5
+ position: relative;
6
+ }
7
+
8
+ .arxiv-umap svg {
9
+ display: block;
10
+ }
11
+
12
+ /* Tooltip styling comme d3-scatter */
13
+ .arxiv-umap .d3-tooltip {
14
+ z-index: 20;
15
+ backdrop-filter: saturate(1.12) blur(8px);
16
+ }
17
+
18
+ .arxiv-umap .d3-tooltip__inner {
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: 8px;
22
+ min-width: 280px;
23
+ max-width: 400px;
24
+ max-height: 90vh;
25
+ overflow-y: auto;
26
+ word-wrap: break-word;
27
+ word-break: break-word;
28
+ }
29
+
30
+ .arxiv-umap .paper-header {
31
+ background: linear-gradient(135deg, var(--surface-bg), var(--code-bg));
32
+ padding: 12px;
33
+ margin: -10px -12px 8px -12px;
34
+ border-radius: 8px 8px 0 0;
35
+ border-bottom: 1px solid var(--border-color);
36
+ }
37
+
38
+ .arxiv-umap .paper-title {
39
+ font-weight: 700;
40
+ font-size: 14px;
41
+ line-height: 1.4;
42
+ margin-bottom: 8px;
43
+ display: -webkit-box;
44
+ -webkit-line-clamp: 3;
45
+ -webkit-box-orient: vertical;
46
+ overflow: hidden;
47
+ text-overflow: ellipsis;
48
+ color: var(--text-color);
49
+ }
50
+
51
+ .arxiv-umap .paper-meta {
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: space-between;
55
+ margin-bottom: 6px;
56
+ }
57
+
58
+ .arxiv-umap .paper-category {
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 6px;
62
+ font-size: 11px;
63
+ color: var(--muted-color);
64
+ }
65
+
66
+ .arxiv-umap .paper-badges {
67
+ display: flex;
68
+ gap: 6px;
69
+ align-items: center;
70
+ }
71
+
72
+ .arxiv-umap .paper-authors {
73
+ font-size: 11px;
74
+ color: var(--muted-color);
75
+ font-style: italic;
76
+ margin-bottom: 0;
77
+ display: -webkit-box;
78
+ -webkit-line-clamp: 2;
79
+ -webkit-box-orient: vertical;
80
+ overflow: hidden;
81
+ opacity: 0.9;
82
+ }
83
+
84
+ .arxiv-umap .paper-abstract {
85
+ font-size: 11px;
86
+ line-height: 1.4;
87
+ color: var(--text-color);
88
+ padding-top: 0;
89
+ display: -webkit-box;
90
+ -webkit-line-clamp: 6;
91
+ -webkit-box-orient: vertical;
92
+ overflow: hidden;
93
+ text-overflow: ellipsis;
94
+ max-height: none;
95
+ }
96
+
97
+ .arxiv-umap .paper-year {
98
+ background: var(--primary-color);
99
+ color: white;
100
+ padding: 3px 8px;
101
+ border-radius: 12px;
102
+ font-size: 10px;
103
+ font-weight: 700;
104
+ letter-spacing: 0.5px;
105
+ white-space: nowrap;
106
+ }
107
+
108
+ .arxiv-umap .paper-id {
109
+ background: var(--border-color);
110
+ color: var(--muted-color);
111
+ padding: 2px 6px;
112
+ border-radius: 4px;
113
+ font-size: 9px;
114
+ font-weight: 500;
115
+ font-family: monospace;
116
+ white-space: nowrap;
117
+ }
118
+ </style>
119
+
120
+ <script>
121
+ (() => {
122
+ const ensureD3 = (cb) => {
123
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
124
+ let s = document.getElementById('d3-cdn-script');
125
+ if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
126
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
127
+ s.addEventListener('load', onReady, { once: true });
128
+ if (window.d3) onReady();
129
+ };
130
+
131
+ const bootstrap = () => {
132
+ const scriptEl = document.currentScript;
133
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
134
+ if (!(container && container.classList && container.classList.contains('arxiv-umap'))) {
135
+ const cs = Array.from(document.querySelectorAll('.arxiv-umap')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
136
+ container = cs[cs.length - 1] || null;
137
+ }
138
+ if (!container) return;
139
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
140
+
141
+ // Tooltip
142
+ container.style.position = container.style.position || 'relative';
143
+ let tip = container.querySelector('.d3-tooltip'); let tipInner;
144
+ if (!tip) {
145
+ tip = document.createElement('div'); tip.className = 'd3-tooltip';
146
+ Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '10px 12px', borderRadius: '12px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)', opacity: '0', transition: 'opacity .12s ease', backdropFilter: 'saturate(1.12) blur(8px)' });
147
+ tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip);
148
+ } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
149
+
150
+ // SVG
151
+ const svg = d3.select(container).append('svg').attr('width', '100%').style('display', 'block');
152
+ const gRoot = svg.append('g');
153
+ const gDots = gRoot.append('g').attr('class', 'dots');
154
+ const gCentroids = gRoot.append('g').attr('class', 'centroids');
155
+
156
+ // State & scales
157
+ let width = 800, height = 360; const margin = { top: 8, right: 12, bottom: 8, left: 12 };
158
+ const x = d3.scaleLinear();
159
+ const y = d3.scaleLinear();
160
+ const color = d3.scaleOrdinal();
161
+ const radius = () => 3;
162
+ let isDarkMode = false;
163
+
164
+ // Beautiful category labels
165
+ const categoryLabels = {
166
+ 'cs': 'Computer Science',
167
+ 'math': 'Mathematics',
168
+ 'physics': 'Physics',
169
+ 'stat': 'Statistics',
170
+ 'eess': 'Electrical Engineering',
171
+ 'econ': 'Economics',
172
+ 'q-bio': 'Quantitative Biology',
173
+ 'q-fin': 'Quantitative Finance',
174
+ 'astro-ph': 'Astrophysics',
175
+ 'cond-mat': 'Condensed Matter',
176
+ 'gr-qc': 'General Relativity',
177
+ 'hep-ex': 'High Energy Physics - Experiment',
178
+ 'hep-lat': 'High Energy Physics - Lattice',
179
+ 'hep-ph': 'High Energy Physics - Phenomenology',
180
+ 'hep-th': 'High Energy Physics - Theory',
181
+ 'math-ph': 'Mathematical Physics',
182
+ 'nlin': 'Nonlinear Sciences',
183
+ 'nucl-ex': 'Nuclear Experiment',
184
+ 'nucl-th': 'Nuclear Theory',
185
+ 'quant-ph': 'Quantum Physics'
186
+ };
187
+
188
+ function getCategoryLabel(category) {
189
+ return categoryLabels[category] || category;
190
+ }
191
+
192
+ // Inverse mapping: from family names to domain codes for colors
193
+ const familyToDomainCode = {
194
+ 'Computer Science': 'cs',
195
+ 'Physics': 'physics',
196
+ 'Astrophysics': 'astro-ph',
197
+ 'Condensed Matter': 'cond-mat',
198
+ 'Quantum Physics': 'quant-ph',
199
+ 'Mathematics': 'math',
200
+ 'Statistics': 'stat',
201
+ 'Mathematical Physics': 'math-ph',
202
+ 'Engineering': 'eess',
203
+ 'Biology': 'q-bio',
204
+ 'Economics': 'econ',
205
+ 'Finance': 'q-fin',
206
+ 'General Relativity': 'gr-qc',
207
+ 'Particle Physics': 'hep-ph',
208
+ 'Nonlinear Sciences': 'nlin',
209
+ 'Nuclear Physics': 'nucl-ex'
210
+ };
211
+
212
+ function getFamilyColor(familyName) {
213
+ const domainCode = familyToDomainCode[familyName] || familyName;
214
+ return color(domainCode) || 'var(--text-color)';
215
+ }
216
+
217
+ function getDotStrokeColor(fillColor = null) {
218
+ if (!fillColor) return 'var(--muted-color)';
219
+
220
+ let resolvedColor = fillColor;
221
+ if (fillColor.startsWith('var(')) {
222
+ const tempEl = document.createElement('div');
223
+ tempEl.style.color = fillColor;
224
+ document.body.appendChild(tempEl);
225
+ resolvedColor = getComputedStyle(tempEl).color;
226
+ document.body.removeChild(tempEl);
227
+ }
228
+
229
+ try {
230
+ const colorObj = d3.color(resolvedColor);
231
+ if (!colorObj) return 'var(--muted-color)';
232
+
233
+ return isDarkMode ?
234
+ colorObj.darker(0.3).toString() :
235
+ colorObj.brighter(0.8).toString();
236
+ } catch {
237
+ return 'var(--muted-color)';
238
+ }
239
+ }
240
+
241
+ // Data loading
242
+ async function fetchFirstAvailable(paths) {
243
+ for (const p of paths) {
244
+ try {
245
+ const res = await fetch(p, { cache: 'no-cache' });
246
+ if (res.ok) { return await res.json(); }
247
+ } catch (e) { }
248
+ }
249
+ throw new Error('Failed to load data from provided paths');
250
+ }
251
+
252
+ let data = [];
253
+ let categories = [];
254
+ let centroids = [];
255
+
256
+ // Mapping des domaines vers les 9 grandes familles
257
+ const domainToFamily = {
258
+ 'cs': 'Computer Science',
259
+ 'physics': 'Physics',
260
+ 'astro-ph': 'Astrophysics',
261
+ 'cond-mat': 'Condensed Matter',
262
+ 'quant-ph': 'Quantum Physics',
263
+ 'math': 'Mathematics',
264
+ 'stat': 'Statistics',
265
+ 'math-ph': 'Mathematical Physics',
266
+ 'eess': 'Engineering',
267
+ 'q-bio': 'Biology',
268
+ 'econ': 'Economics',
269
+ 'q-fin': 'Finance',
270
+ 'gr-qc': 'General Relativity',
271
+ 'hep-ex': 'Particle Physics',
272
+ 'hep-lat': 'Particle Physics',
273
+ 'hep-ph': 'Particle Physics',
274
+ 'hep-th': 'Particle Physics',
275
+ 'nlin': 'Nonlinear Sciences',
276
+ 'nucl-ex': 'Nuclear Physics',
277
+ 'nucl-th': 'Nuclear Physics'
278
+ };
279
+
280
+ function calculateCentroids(data) {
281
+ // Group by the 9 major scientific families
282
+ const groups = d3.group(data, d => {
283
+ const category = d.primary_category;
284
+ const fullDomain = category.split('.')[0]; // Keep full domain like "astro-ph", "cond-mat"
285
+ return domainToFamily[fullDomain] || domainToFamily[fullDomain.split('-')[0]] || 'Other Sciences';
286
+ });
287
+
288
+ centroids = Array.from(groups.entries()).map(([family, points]) => {
289
+ // Calculate local density for each point
290
+ const densities = points.map(point => {
291
+ const neighbors = points.filter(p => {
292
+ const distance = Math.sqrt(
293
+ Math.pow(p.x - point.x, 2) + Math.pow(p.y - point.y, 2)
294
+ );
295
+ return distance < 0.1; // Rayon de voisinage
296
+ });
297
+ return neighbors.length; // DensitΓ© = nombre de voisins
298
+ });
299
+
300
+ // Centroid pondΓ©rΓ© par la densitΓ©
301
+ const totalWeight = d3.sum(densities);
302
+ const x = d3.sum(points, (d, i) => d.x * densities[i]) / totalWeight;
303
+ const y = d3.sum(points, (d, i) => d.y * densities[i]) / totalWeight;
304
+
305
+ // Maximum density point for information
306
+ const maxDensityIndex = d3.maxIndex(densities);
307
+ const densityCenter = points[maxDensityIndex];
308
+
309
+ return {
310
+ category: family,
311
+ x,
312
+ y,
313
+ count: points.length,
314
+ density: totalWeight / points.length, // DensitΓ© moyenne
315
+ maxDensityPoint: densityCenter
316
+ };
317
+ }).filter(centroid => centroid.count >= 100); // Only show top 9 families
318
+ }
319
+
320
+ function updateScales(data) {
321
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
322
+ isDarkMode = !!isDark;
323
+
324
+ width = container.clientWidth || 800; height = Math.max(260, Math.round(width / 3)); svg.attr('width', width).attr('height', height);
325
+ const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
326
+
327
+ const xExtent = d3.extent(data, d => d.x);
328
+ const yExtent = d3.extent(data, d => d.y);
329
+ x.domain([xExtent[0], xExtent[1]]).range([0, innerWidth]).nice();
330
+ y.domain([yExtent[0], yExtent[1]]).range([innerHeight, 0]).nice();
331
+
332
+ return { innerWidth, innerHeight };
333
+ }
334
+
335
+ // Helper function to shuffle array with fixed seed
336
+ function shuffleArray(array, seed = 5) {
337
+ const shuffled = [...array];
338
+ // Simple seeded random number generator
339
+ let rng = seed;
340
+ const seededRandom = () => {
341
+ rng = (rng * 9301 + 49297) % 233280;
342
+ return rng / 233280;
343
+ };
344
+
345
+ for (let i = shuffled.length - 1; i > 0; i--) {
346
+ const j = Math.floor(seededRandom() * (i + 1));
347
+ [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
348
+ }
349
+ return shuffled;
350
+ }
351
+
352
+ function refreshPalette() {
353
+ try {
354
+ const cats = categories && categories.length ? categories.length : 6;
355
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
356
+ const arr = window.ColorPalettes.getColors('categorical', cats) || [];
357
+ if (arr && arr.length) {
358
+ // Randomize color order
359
+ const shuffledColors = shuffleArray(arr);
360
+ color.range(shuffledColors);
361
+ return;
362
+ }
363
+ }
364
+ // fallback with randomization
365
+ const fallbackColors = (d3.schemeTableau10 ? d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab']).slice(0, cats);
366
+ const shuffledFallback = shuffleArray(fallbackColors);
367
+ color.range(shuffledFallback);
368
+ } catch {
369
+ const cats = categories && categories.length ? categories.length : 6;
370
+ const fallbackColors = (d3.schemeTableau10 ? d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab']).slice(0, cats);
371
+ const shuffledFallback = shuffleArray(fallbackColors);
372
+ color.range(shuffledFallback);
373
+ }
374
+ try { if (data && data.length) draw(); } catch { }
375
+ }
376
+
377
+ function draw() {
378
+ if (!data || !data.length) return;
379
+ const { innerWidth, innerHeight } = updateScales(data);
380
+ const fillFor = d => {
381
+ const category = d.primary_category;
382
+ // Extract main prefix - take everything before first dot
383
+ const mainCategory = category.split('.')[0];
384
+ return color(mainCategory);
385
+ };
386
+
387
+ // Calculate centroids
388
+ calculateCentroids(data);
389
+
390
+ // Points
391
+ const dots = gDots.selectAll('circle.dot').data(data, (d, i) => d.id || i);
392
+ dots.enter().append('circle').attr('class', 'dot')
393
+ .attr('cx', d => x(d.x)).attr('cy', d => y(d.y)).attr('r', radius())
394
+ .attr('fill', fillFor).attr('fill-opacity', 0.85)
395
+ .attr('stroke', d => getDotStrokeColor(fillFor(d))).attr('stroke-width', '0.75px')
396
+ .on('mouseenter', function (ev, d) {
397
+ d3.select(this).style('stroke', 'var(--text-color)').style('stroke-width', '1.5px').attr('fill-opacity', 1);
398
+ const swatch = `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="5" fill="${fillFor(d)}" /></svg>`;
399
+ // Keep title as is, let CSS handle truncation
400
+ const title = d.title || 'Untitled Paper';
401
+
402
+ // Format authors nicely
403
+ const authorsText = d.authors && d.authors.length > 0 ?
404
+ (d.authors.length <= 3 ? d.authors.join(', ') : `${d.authors.slice(0, 2).join(', ')} et al. (${d.authors.length} authors)`) :
405
+ 'Unknown authors';
406
+
407
+ // Get abstract if available, let CSS handle truncation with line-clamp
408
+ const abstract = d.abstract || 'No abstract available';
409
+
410
+ // Extract arXiv ID if available
411
+ const arxivId = d.url ? d.url.match(/abs\/([^\/]+)$/)?.[1] || '' : '';
412
+
413
+ tipInner.innerHTML = `
414
+ <div class="paper-header">
415
+ <div class="paper-title">${title}</div>
416
+ <div class="paper-meta">
417
+ <div class="paper-category">
418
+ ${swatch}
419
+ <span>${d.primary_category}</span>
420
+ </div>
421
+ <div class="paper-badges">
422
+ ${arxivId ? `<span class="paper-id">${arxivId}</span>` : ''}
423
+ </div>
424
+ </div>
425
+ <div class="paper-authors">${authorsText}</div>
426
+ </div>
427
+ <div class="paper-abstract">${abstract}</div>`;
428
+ tip.style.opacity = '1';
429
+ })
430
+ .on('mousemove', function (ev) { const [mx, my] = d3.pointer(ev, container); const ox = 12, oy = 12; tip.style.transform = `translate(${Math.round(mx + ox)}px, ${Math.round(my + oy)}px)`; })
431
+ .on('mouseleave', function (ev, d) { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px, -9999px)'; d3.select(this).style('stroke', getDotStrokeColor(fillFor(d))).style('stroke-width', '0.75px').attr('fill-opacity', 0.85); })
432
+ .on('click', function (ev, d) { if (d.url) window.open(d.url, '_blank'); })
433
+ .merge(dots)
434
+ .transition().duration(180)
435
+ .attr('cx', d => x(d.x)).attr('cy', d => y(d.y)).attr('r', radius())
436
+ .attr('fill', fillFor).attr('fill-opacity', 0.85)
437
+ .attr('stroke', d => getDotStrokeColor(fillFor(d))).attr('stroke-width', '0.75px');
438
+ dots.exit().remove();
439
+
440
+ // Centroids with labels (d3-scatter style)
441
+ const nodes = centroids.map((c) => ({
442
+ category: c.category,
443
+ count: c.count,
444
+ targetX: x(c.x),
445
+ targetY: y(c.y),
446
+ x: x(c.x),
447
+ y: y(c.y),
448
+ width: Math.max(18, (String(c.category || '').length || 6) * 11),
449
+ height: 16
450
+ }));
451
+
452
+ if (nodes.length > 1) {
453
+ const sim = d3.forceSimulation(nodes)
454
+ .force('x', d3.forceX((d) => d.targetX).strength(0.9))
455
+ .force('y', d3.forceY((d) => d.targetY).strength(0.9))
456
+ .force('collide', d3.forceCollide((d) => Math.hypot(d.width / 2, d.height / 2) + 15))
457
+ .stop();
458
+ for (let i = 0; i < 650; i++) sim.tick();
459
+ const maxOffset = 45;
460
+ nodes.forEach((n) => {
461
+ const dx = n.x - n.targetX, dy = n.y - n.targetY; const dist = Math.hypot(dx, dy);
462
+ if (dist > maxOffset && dist > 0) { const s = maxOffset / dist; n.x = n.targetX + dx * s; n.y = n.targetY + dy * s; }
463
+ });
464
+ }
465
+
466
+ const labels = gCentroids.selectAll('g.centroid').data(nodes, d => d.category || 'Unknown');
467
+ const enter = labels.enter().append('g').attr('class', 'centroid').attr('pointer-events', 'none');
468
+ enter.append('text').attr('class', 'label-bg').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle');
469
+ enter.append('text').attr('class', 'label-fg').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle');
470
+ const merged = enter.merge(labels);
471
+ merged
472
+ .transition().duration(180)
473
+ .attr('transform', d => `translate(${Math.round(d.x)}, ${Math.round(d.y)})`)
474
+ .each(function (d) {
475
+ const base = getFamilyColor(d.category || 'Unknown');
476
+ const bgNode = this.querySelector('text.label-bg');
477
+ const fgNode = this.querySelector('text.label-fg');
478
+ if (bgNode) {
479
+ bgNode.textContent = getCategoryLabel(d.category);
480
+ bgNode.style.setProperty('fill', "var(--page-bg)", 'important');
481
+ bgNode.style.setProperty('stroke', "var(--page-bg)");
482
+ bgNode.style.setProperty('stroke-width', '8px');
483
+ bgNode.style.setProperty('paint-order', 'stroke fill');
484
+ bgNode.style.setProperty('font-weight', '800');
485
+ bgNode.style.setProperty('font-size', '16px');
486
+ }
487
+ if (fgNode) {
488
+ fgNode.textContent = getCategoryLabel(d.category);
489
+ fgNode.style.setProperty('fill', base, 'important');
490
+ fgNode.style.setProperty('font-weight', '800');
491
+ fgNode.style.setProperty('font-size', '16px');
492
+ }
493
+ });
494
+ labels.exit().remove();
495
+ }
496
+
497
+ // Load data
498
+ let mountEl = container;
499
+ while (mountEl && !mountEl.getAttribute?.('data-datafiles') && !mountEl.getAttribute?.('data-config')) {
500
+ mountEl = mountEl.parentElement;
501
+ }
502
+ let providedData = null;
503
+ try {
504
+ const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
505
+ if (attr && attr.trim()) {
506
+ providedData = attr.trim();
507
+ }
508
+ } catch (_) { }
509
+ const DEFAULT_JSON = '/data/data.json';
510
+ const ensureDataPrefix = (p) => {
511
+ if (typeof p !== 'string' || !p) return p;
512
+ return p.includes('/') ? p : `/data/${p}`;
513
+ };
514
+ const JSON_PATHS = providedData ? [ensureDataPrefix(providedData)] : [
515
+ DEFAULT_JSON,
516
+ './assets/data/data.json',
517
+ '../assets/data/data.json',
518
+ '../../assets/data/data.json'
519
+ ];
520
+ const fetchFirstAvailableJson = async (paths) => {
521
+ for (const p of paths) { try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return await r.json(); } catch (_) { } }
522
+ throw new Error('JSON not found: data.json');
523
+ };
524
+
525
+ fetchFirstAvailableJson(JSON_PATHS).then(rawData => {
526
+ // Show only 1 point out of 5 for performance
527
+ data = rawData.filter((_, index) => index % 1 === 0);
528
+ console.log(`πŸ“Š Affichage de ${data.length} points (1 sur 1) sur ${rawData.length} total`);
529
+ // Extract main prefixes (math, cs, physics, etc.)
530
+ categories = Array.from(new Set(data.map(d => {
531
+ const category = d.primary_category;
532
+ // For categories like "math-ph", take only "math"
533
+ if (category.includes('-')) {
534
+ return category.split('-')[0];
535
+ }
536
+ // For categories like "cs.AI", take only "cs"
537
+ if (category.includes('.')) {
538
+ return category.split('.')[0];
539
+ }
540
+ // Otherwise, return the complete category
541
+ return category;
542
+ }).filter(Boolean)));
543
+ color.domain(categories);
544
+ refreshPalette();
545
+ draw();
546
+ }).catch(e => {
547
+ console.error('Failed to load data:', e);
548
+ gRoot.append('text').attr('x', width / 2).attr('y', height / 2).attr('text-anchor', 'middle').attr('fill', '#e74c3c').text('Failed to load data');
549
+ });
550
+
551
+ // Resize
552
+ if (window.ResizeObserver) {
553
+ const ro = new ResizeObserver(() => draw());
554
+ ro.observe(container);
555
+ } else {
556
+ window.addEventListener('resize', draw);
557
+ }
558
+ };
559
+
560
+ if (document.readyState === 'loading') {
561
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
562
+ } else {
563
+ ensureD3(bootstrap);
564
+ }
565
+ })();
566
+ </script>
app/src/content/embeds/arxiv/fetch_arxiv_api.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Script to retrieve papers from the arXiv API
4
+ Optimized for natural representation of scientific domains
5
+ """
6
+
7
+ import requests
8
+ import xml.etree.ElementTree as ET
9
+ import json
10
+ import time
11
+ import os
12
+ from urllib.parse import quote
13
+ from datetime import datetime, timedelta
14
+ from collections import Counter
15
+ import random
16
+
17
+ class ArxivFetcher:
18
+ def __init__(self):
19
+ self.base_url = "http://export.arxiv.org/api/query"
20
+ self.delay = 3 # Delay between requests (respecting API limits)
21
+
22
+ def fetch_by_category(self, categories, max_per_category=500, total_max=15000):
23
+ """Retrieve papers by category with global limit"""
24
+ print(f"πŸ” Retrieval by category (max {max_per_category} per cat, {total_max} total)")
25
+
26
+ all_papers = []
27
+
28
+ for i, category in enumerate(categories):
29
+ if len(all_papers) >= total_max:
30
+ break
31
+
32
+ print(f" [{i+1}/{len(categories)}] {category}...")
33
+
34
+ # Dynamic calculation of number to retrieve
35
+ remaining = total_max - len(all_papers)
36
+ fetch_count = min(max_per_category, remaining)
37
+
38
+ papers = self._fetch_category(category, fetch_count)
39
+ all_papers.extend(papers)
40
+
41
+ print(f" βœ… {len(papers)} papers retrieved (total: {len(all_papers)})")
42
+
43
+ # Delay between categories
44
+ if i < len(categories) - 1:
45
+ time.sleep(self.delay)
46
+
47
+ return all_papers[:total_max]
48
+
49
+ def fetch_recent_papers(self, days_back=30, max_results=15000):
50
+ """Retrieve recent papers from the last days"""
51
+ print(f"πŸ“… Retrieving papers from the last {days_back} days")
52
+
53
+ # End date: today
54
+ end_date = datetime.now()
55
+ # Start date: X days ago
56
+ start_date = end_date - timedelta(days=days_back)
57
+
58
+ # Format arXiv: YYYYMMDDHHMM
59
+ date_query = f"submittedDate:[{start_date.strftime('%Y%m%d%H%M')} TO {end_date.strftime('%Y%m%d%H%M')}]"
60
+
61
+ return self._fetch_with_query(date_query, max_results)
62
+
63
+ def _fetch_category(self, category, max_results):
64
+ """Retrieve papers from a specific category"""
65
+ query = f"cat:{category}"
66
+ return self._fetch_with_query(query, max_results)
67
+
68
+ def _fetch_with_query(self, query, max_results):
69
+ """Generic method to retrieve with a query"""
70
+ papers = []
71
+ start = 0
72
+ batch_size = min(1000, max_results) # arXiv limits to 1000 per request
73
+
74
+ while len(papers) < max_results:
75
+ remaining = max_results - len(papers)
76
+ current_batch = min(batch_size, remaining)
77
+
78
+ params = {
79
+ 'search_query': query,
80
+ 'start': start,
81
+ 'max_results': current_batch,
82
+ 'sortBy': 'submittedDate',
83
+ 'sortOrder': 'descending'
84
+ }
85
+
86
+ try:
87
+ response = requests.get(self.base_url, params=params, timeout=30)
88
+ response.raise_for_status()
89
+
90
+ batch_papers = self._parse_response(response.text)
91
+ if not batch_papers:
92
+ print(f" ⚠️ No results for start={start}")
93
+ break
94
+
95
+ papers.extend(batch_papers)
96
+ start += len(batch_papers)
97
+
98
+ print(f" πŸ“„ Batch {len(batch_papers)} papers (total: {len(papers)})")
99
+
100
+ # Delay between requests
101
+ time.sleep(self.delay)
102
+
103
+ except Exception as e:
104
+ print(f" ❌ Error: {e}")
105
+ break
106
+
107
+ return papers[:max_results]
108
+
109
+ def _parse_response(self, xml_content):
110
+ """Parse arXiv XML response"""
111
+ papers = []
112
+
113
+ try:
114
+ root = ET.fromstring(xml_content)
115
+
116
+ # arXiv Namespace
117
+ ns = {'atom': 'http://www.w3.org/2005/Atom',
118
+ 'arxiv': 'http://arxiv.org/schemas/atom'}
119
+
120
+ entries = root.findall('atom:entry', ns)
121
+
122
+ for entry in entries:
123
+ try:
124
+ # ID arXiv
125
+ arxiv_id = entry.find('atom:id', ns).text.split('/')[-1]
126
+
127
+ # Titre
128
+ title = entry.find('atom:title', ns).text.strip()
129
+ title = ' '.join(title.split()) # Clean spaces
130
+
131
+ # RΓ©sumΓ©
132
+ summary = entry.find('atom:summary', ns).text.strip()
133
+ summary = ' '.join(summary.split())[:500] # Limit size
134
+
135
+ # Auteurs
136
+ authors = []
137
+ for author in entry.findall('atom:author', ns):
138
+ name = author.find('atom:name', ns)
139
+ if name is not None:
140
+ authors.append(name.text.strip())
141
+
142
+ # CatΓ©gories
143
+ categories = []
144
+ primary_category = None
145
+
146
+ for category in entry.findall('atom:category', ns):
147
+ term = category.get('term')
148
+ if term:
149
+ categories.append(term)
150
+
151
+ # Primary category
152
+ primary_cat = entry.find('arxiv:primary_category', ns)
153
+ if primary_cat is not None:
154
+ primary_category = primary_cat.get('term')
155
+ elif categories:
156
+ primary_category = categories[0]
157
+
158
+ # Publication date
159
+ published = entry.find('atom:published', ns)
160
+ published_date = published.text if published is not None else None
161
+
162
+ paper = {
163
+ 'id': arxiv_id,
164
+ 'title': title,
165
+ 'summary': summary,
166
+ 'authors': authors,
167
+ 'categories': categories,
168
+ 'primary_category': primary_category,
169
+ 'published': published_date
170
+ }
171
+
172
+ papers.append(paper)
173
+
174
+ except Exception as e:
175
+ print(f" ⚠️ Error parsing entry: {e}")
176
+ continue
177
+
178
+ except ET.ParseError as e:
179
+ print(f"❌ XML parsing error: {e}")
180
+
181
+ return papers
182
+
183
+ def save_papers(papers, filename):
184
+ """Save papers to JSON"""
185
+ with open(filename, 'w', encoding='utf-8') as f:
186
+ json.dump(papers, f, indent=2, ensure_ascii=False)
187
+
188
+ size_mb = os.path.getsize(filename) / 1024 / 1024
189
+ print(f"πŸ’Ύ Saved: {filename} ({len(papers)} papers, {size_mb:.1f} MB)")
190
+
191
+ def main():
192
+ """Main arXiv data retrieval"""
193
+ print("πŸš€ ArXiv Data Fetcher - Version OptimisΓ©e")
194
+ print("=" * 50)
195
+
196
+ fetcher = ArxivFetcher()
197
+
198
+ # Simple approach: 1 month of recent data
199
+ print("\nπŸ“… SIMPLE APPROACH: 1 month of recent data")
200
+ print("🎯 Objective: retrieve everything available from the last month")
201
+ print("⚑ Without representativeness constraint - just natural data")
202
+
203
+ # Try with different periods to find data
204
+ monthly_papers = None
205
+ for days in [30, 60, 90, 120]: # 1, 2, 3, 4 months
206
+ print(f"\nπŸ” Attempt: {days} days...")
207
+ monthly_papers = fetcher.fetch_recent_papers(days_back=days, max_results=15000)
208
+ if monthly_papers and len(monthly_papers) > 1000:
209
+ print(f"βœ… {len(monthly_papers)} papers found over {days} days")
210
+ break
211
+ elif monthly_papers:
212
+ print(f"⚠️ Only {len(monthly_papers)} papers over {days} days")
213
+ else:
214
+ print(f"❌ No papers found over {days} days")
215
+
216
+ if not monthly_papers:
217
+ print("\nπŸ”„ Fallback: retrieval by popular categories")
218
+ # If no recent data, just take popular categories
219
+ popular_categories = [
220
+ 'cs.LG', 'cs.AI', 'cs.CV', 'cs.CL', 'cs.CR', 'cs.RO', 'cs.HC',
221
+ 'physics.comp-ph', 'physics.data-an', 'physics.optics',
222
+ 'math.ST', 'math.NA', 'math.OC', 'math.PR',
223
+ 'stat.ML', 'stat.ME', 'stat.AP',
224
+ 'eess.AS', 'eess.IV', 'eess.SP',
225
+ 'q-bio.QM', 'q-bio.BM', 'astro-ph.CO'
226
+ ]
227
+
228
+ monthly_papers = fetcher.fetch_by_category(
229
+ categories=popular_categories,
230
+ max_per_category=500,
231
+ total_max=15000
232
+ )
233
+
234
+ if monthly_papers:
235
+ save_papers(monthly_papers, "arxiv_monthly_papers.json")
236
+
237
+ # Statistiques finales
238
+ from collections import Counter
239
+
240
+ # Check paper structure
241
+ sample_keys = list(monthly_papers[0].keys()) if monthly_papers else []
242
+ category_key = 'primary_category' if 'primary_category' in sample_keys else 'categories'
243
+
244
+ domains = []
245
+ for paper in monthly_papers:
246
+ if category_key in paper:
247
+ cat = paper[category_key]
248
+ if isinstance(cat, list) and cat:
249
+ domains.append(cat[0].split('.')[0])
250
+ elif isinstance(cat, str):
251
+ domains.append(cat.split('.')[0])
252
+
253
+ domain_counts = Counter(domains)
254
+
255
+ print(f"\nπŸ“Š Natural distribution ({len(monthly_papers)} papers):")
256
+ for domain, count in domain_counts.most_common():
257
+ percentage = count / len(monthly_papers) * 100
258
+ print(f" {domain}: {count} papers ({percentage:.1f}%)")
259
+ else:
260
+ print("❌ Complete retrieval failure")
261
+
262
+ print("\nπŸŽ‰ Retrieval completed!")
263
+ print("πŸ“ Files created:")
264
+ for filename in ["arxiv_monthly_papers.json"]:
265
+ if os.path.exists(filename):
266
+ size = os.path.getsize(filename) / 1024 / 1024 # MB
267
+ print(f" - {filename} ({size:.1f} MB)")
268
+
269
+ if __name__ == "__main__":
270
+ main()
app/src/content/embeds/arxiv/generate_umap.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ UMAP Generator for arXiv papers
4
+ Creates 2D and 3D projections with density-weighted centroids
5
+ """
6
+
7
+ import json
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.feature_extraction.text import TfidfVectorizer
11
+ from sklearn.decomposition import TruncatedSVD
12
+ import umap
13
+ import os
14
+ import shutil
15
+ from datetime import datetime
16
+ from collections import Counter
17
+
18
+ def load_papers(filename="arxiv_monthly_papers.json"):
19
+ """Load papers from JSON file"""
20
+ if not os.path.exists(filename):
21
+ print(f"❌ File {filename} not found!")
22
+ print("πŸ’‘ Run fetch_arxiv_api.py first")
23
+ return None
24
+
25
+ with open(filename, 'r', encoding='utf-8') as f:
26
+ papers = json.load(f)
27
+
28
+ print(f"πŸ“š {len(papers)} papers loaded from {filename}")
29
+ return papers
30
+
31
+ def preprocess_papers(papers, sample_rate=5):
32
+ """Preprocess papers and sample if necessary"""
33
+ print(f"πŸ”„ Preprocessing papers...")
34
+
35
+ # Filter papers with missing data
36
+ valid_papers = []
37
+ for paper in papers:
38
+ if (paper.get('title') and
39
+ paper.get('summary') and
40
+ paper.get('primary_category')):
41
+ valid_papers.append(paper)
42
+
43
+ print(f"βœ… {len(valid_papers)} valid papers after filtering")
44
+
45
+ # Sampling for performance (1 out of N)
46
+ if sample_rate > 1:
47
+ sampled_papers = valid_papers[::sample_rate]
48
+ print(f"πŸ“Š Sampling 1/{sample_rate}: {len(sampled_papers)} papers retained")
49
+ return sampled_papers
50
+
51
+ return valid_papers
52
+
53
+ def create_embeddings(papers, max_features=5000, n_components=50):
54
+ """Create TF-IDF + SVD embeddings of papers"""
55
+ print(f"πŸ”’ Creating embeddings (max_features={max_features}, n_components={n_components})")
56
+
57
+ # Combine title and summary
58
+ texts = []
59
+ for paper in papers:
60
+ title = paper.get('title', '').strip()
61
+ summary = paper.get('summary', '').strip()
62
+ combined = f"{title} {summary}"
63
+ texts.append(combined)
64
+
65
+ # TF-IDF
66
+ print(" πŸ“ TF-IDF vectorization...")
67
+ tfidf = TfidfVectorizer(
68
+ max_features=max_features,
69
+ stop_words='english',
70
+ ngram_range=(1, 2),
71
+ min_df=2,
72
+ max_df=0.95
73
+ )
74
+
75
+ tfidf_matrix = tfidf.fit_transform(texts)
76
+ print(f" βœ… TF-IDF: {tfidf_matrix.shape}")
77
+
78
+ # Dimensionality reduction with SVD
79
+ print(f" πŸ”„ SVD reduction to {n_components} dimensions...")
80
+ svd = TruncatedSVD(n_components=n_components, random_state=42)
81
+ embeddings = svd.fit_transform(tfidf_matrix)
82
+
83
+ print(f" βœ… Final embeddings: {embeddings.shape}")
84
+ print(f" πŸ“Š Explained variance: {svd.explained_variance_ratio_.sum():.3f}")
85
+
86
+ return embeddings
87
+
88
+ def map_to_families(papers):
89
+ """Map categories to 9 main scientific families"""
90
+
91
+ # Mapping to 9 scientific families
92
+ domain_to_family = {
93
+ 'cs': 'Computer Science',
94
+ 'math': 'Mathematics',
95
+ 'physics': 'Physics',
96
+ 'stat': 'Statistics',
97
+ 'q-bio': 'Biology',
98
+ 'eess': 'Engineering',
99
+ 'astro-ph': 'Astrophysics',
100
+ 'cond-mat': 'Condensed Matter',
101
+ 'nucl': 'Nuclear Physics'
102
+ }
103
+
104
+ families = []
105
+ for paper in papers:
106
+ primary_cat = paper.get('primary_category', '')
107
+ if primary_cat:
108
+ domain = primary_cat.split('.')[0]
109
+ family = domain_to_family.get(domain, 'Other')
110
+ else:
111
+ family = 'Other'
112
+ families.append(family)
113
+
114
+ family_counts = Counter(families)
115
+ print(f"πŸ“Š Distribution by family:")
116
+ for family, count in family_counts.most_common():
117
+ print(f" {family}: {count} papers")
118
+
119
+ return families
120
+
121
+ def generate_umap_projection(embeddings, families, n_neighbors=50, min_dist=0.1, spread=0.5, n_components=2):
122
+ """Generate UMAP projection"""
123
+ print(f"🎯 UMAP projection (n_neighbors={n_neighbors}, min_dist={min_dist}, spread={spread}, n_components={n_components})")
124
+
125
+ # Configuration UMAP
126
+ reducer = umap.UMAP(
127
+ n_neighbors=n_neighbors,
128
+ min_dist=min_dist,
129
+ spread=spread,
130
+ n_components=n_components,
131
+ random_state=42,
132
+ metric='cosine'
133
+ )
134
+
135
+ # Projection
136
+ projection = reducer.fit_transform(embeddings)
137
+ print(f"βœ… Projection UMAP: {projection.shape}")
138
+
139
+ return projection
140
+
141
+ def calculate_density_weighted_centroids(projection, families, families_list):
142
+ """Calculate density-weighted centroids"""
143
+ print("🎯 Calculating density-weighted centroids...")
144
+
145
+ centroids = {}
146
+
147
+ for family in families_list:
148
+ # Points of this family
149
+ family_mask = np.array(families) == family
150
+ family_points = projection[family_mask]
151
+
152
+ if len(family_points) < 30: # Filter families too small
153
+ continue
154
+
155
+ if projection.shape[1] == 2: # 2D
156
+ # Calculate 2D density
157
+ densities = []
158
+ for point in family_points:
159
+ distances = np.linalg.norm(family_points - point, axis=1)
160
+ density = np.sum(distances < np.percentile(distances, 20)) # Local density
161
+ densities.append(density)
162
+
163
+ densities = np.array(densities)
164
+ weights = densities / densities.sum()
165
+
166
+ # Weighted centroid
167
+ centroid_x = np.sum(family_points[:, 0] * weights)
168
+ centroid_y = np.sum(family_points[:, 1] * weights)
169
+
170
+ centroids[family] = {
171
+ 'x': float(centroid_x),
172
+ 'y': float(centroid_y),
173
+ 'count': len(family_points)
174
+ }
175
+
176
+ else: # 3D
177
+ # Calculate 3D density
178
+ densities = []
179
+ for point in family_points:
180
+ distances = np.linalg.norm(family_points - point, axis=1)
181
+ density = np.sum(distances < np.percentile(distances, 20))
182
+ densities.append(density)
183
+
184
+ densities = np.array(densities)
185
+ weights = densities / densities.sum()
186
+
187
+ # Weighted centroid
188
+ centroid_x = np.sum(family_points[:, 0] * weights)
189
+ centroid_y = np.sum(family_points[:, 1] * weights)
190
+ centroid_z = np.sum(family_points[:, 2] * weights)
191
+
192
+ centroids[family] = {
193
+ 'x': float(centroid_x),
194
+ 'y': float(centroid_y),
195
+ 'z': float(centroid_z),
196
+ 'count': len(family_points)
197
+ }
198
+
199
+ print(f"βœ… {len(centroids)} centroids calculated")
200
+ return centroids
201
+
202
+ def save_visualization_data(papers, projection, families, centroids, output_prefix):
203
+ """Save visualization data"""
204
+
205
+ # Prepare data
206
+ viz_data = []
207
+ for i, paper in enumerate(papers):
208
+ if projection.shape[1] == 2: # 2D
209
+ point = {
210
+ 'id': paper.get('id', f'paper_{i}'),
211
+ 'title': paper.get('title', ''),
212
+ 'summary': paper.get('summary', '')[:200] + '...',
213
+ 'authors': ', '.join(paper.get('authors', [])[:3]), # Max 3 authors
214
+ 'category': paper.get('primary_category', ''),
215
+ 'family': families[i],
216
+ 'x': float(projection[i, 0]),
217
+ 'y': float(projection[i, 1])
218
+ }
219
+ else: # 3D
220
+ point = {
221
+ 'id': paper.get('id', f'paper_{i}'),
222
+ 'title': paper.get('title', ''),
223
+ 'summary': paper.get('summary', '')[:200] + '...',
224
+ 'authors': ', '.join(paper.get('authors', [])[:3]),
225
+ 'category': paper.get('primary_category', ''),
226
+ 'family': families[i],
227
+ 'x': float(projection[i, 0]),
228
+ 'y': float(projection[i, 1]),
229
+ 'z': float(projection[i, 2])
230
+ }
231
+ viz_data.append(point)
232
+
233
+ # Add centroids
234
+ viz_data_with_centroids = {
235
+ 'points': viz_data,
236
+ 'centroids': centroids,
237
+ 'metadata': {
238
+ 'total_papers': len(papers),
239
+ 'dimensions': projection.shape[1],
240
+ 'families': list(set(families)),
241
+ 'generated': datetime.now().isoformat()
242
+ }
243
+ }
244
+
245
+ # Save
246
+ output_file = f"{output_prefix}.json"
247
+ with open(output_file, 'w', encoding='utf-8') as f:
248
+ json.dump(viz_data_with_centroids, f, indent=2, ensure_ascii=False)
249
+
250
+ size_mb = os.path.getsize(output_file) / 1024 / 1024
251
+ print(f"πŸ’Ύ Data saved: {output_file} ({size_mb:.1f} MB)")
252
+
253
+ return output_file
254
+
255
+ def main():
256
+ """Main UMAP generation pipeline"""
257
+ print("πŸš€ ArXiv UMAP Generator")
258
+ print("=" * 40)
259
+
260
+ # 1. Data loading
261
+ papers = load_papers()
262
+ if not papers:
263
+ return
264
+
265
+ # 2. Preprocessing
266
+ papers = preprocess_papers(papers, sample_rate=5) # 1 point out of 5
267
+
268
+ # 3. Mapping to families
269
+ families = map_to_families(papers)
270
+ families_list = list(set(families))
271
+
272
+ # 4. Embedding creation
273
+ embeddings = create_embeddings(papers, max_features=3000, n_components=50)
274
+
275
+ # 5. UMAP projection generation
276
+
277
+ # UMAP 2D
278
+ print("\n🎯 Generating 2D UMAP...")
279
+ projection_2d = generate_umap_projection(
280
+ embeddings, families,
281
+ n_neighbors=50, min_dist=0.8, spread=1.0, n_components=2
282
+ )
283
+
284
+ centroids_2d = calculate_density_weighted_centroids(projection_2d, families, families_list)
285
+
286
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
287
+ output_2d = save_visualization_data(
288
+ papers, projection_2d, families, centroids_2d,
289
+ f"arxiv_umap_viz_2d_{timestamp}"
290
+ )
291
+
292
+ # UMAP 3D
293
+ print("\n🎯 Generating 3D UMAP...")
294
+ projection_3d = generate_umap_projection(
295
+ embeddings, families,
296
+ n_neighbors=50, min_dist=0.8, spread=1.0, n_components=3
297
+ )
298
+
299
+ centroids_3d = calculate_density_weighted_centroids(projection_3d, families, families_list)
300
+
301
+ output_3d = save_visualization_data(
302
+ papers, projection_3d, families, centroids_3d,
303
+ f"arxiv_umap_viz_3d_{timestamp}"
304
+ )
305
+
306
+ # Automatic copy to content/assets/data
307
+ import shutil
308
+ source_file = output_2d # Use 2D by default
309
+ target_dir = "../../assets/data"
310
+ target_file = os.path.join(target_dir, "data.json")
311
+
312
+ try:
313
+ # Create directory if necessary
314
+ os.makedirs(target_dir, exist_ok=True)
315
+ shutil.copy2(source_file, target_file)
316
+ print(f"\nβœ… AUTOMATIC COPY SUCCESSFUL!")
317
+ print(f"πŸ“ {source_file} β†’ {target_file}")
318
+ except Exception as e:
319
+ print(f"\n⚠️ Automatic copy failed: {e}")
320
+
321
+ print(f"\nπŸŽ‰ Generation completed!")
322
+ print(f"πŸ“ Files created:")
323
+ for f in [output_2d, output_3d]:
324
+ if os.path.exists(f):
325
+ size = os.path.getsize(f) / 1024 / 1024
326
+ print(f" - {f} ({size:.1f} MB)")
327
+
328
+ if __name__ == "__main__":
329
+ main()
app/src/content/embeds/banner.html CHANGED
@@ -1,267 +1,258 @@
1
  <div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></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
- .style('cursor', 'crosshair');
 
 
 
 
 
96
 
97
- const render = () => {
98
- const width = container.clientWidth || 800;
99
- const height = Math.max(260, Math.round(width / 3)); // keep ~3:1, min height
100
- svg.attr('width', width).attr('height', height);
 
101
 
102
- const xScale = d3.scaleLinear().domain([0, 3]).range([0, width]);
103
- const yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]);
 
 
104
 
105
- // Subtle stroke color depending on theme
106
- const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
107
- const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
108
- const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)';
109
 
 
 
 
 
110
 
111
- // Group for points (no blend mode for better print/PDF visibility)
112
- const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points');
113
 
114
- // Ensure container can host an absolute tooltip
115
- container.style.position = container.style.position || 'relative';
116
- let tip = container.querySelector('.d3-tooltip');
117
- let tipInner;
118
- if (!tip) {
119
- tip = document.createElement('div');
120
- tip.className = 'd3-tooltip';
121
- Object.assign(tip.style, {
122
- position: 'absolute',
123
- top: '0px',
124
- left: '0px',
125
- transform: 'translate(-9999px, -9999px)',
126
- pointerEvents: 'none',
127
- padding: '10px 12px',
128
- borderRadius: '12px',
129
- fontSize: '12px',
130
- lineHeight: '1.35',
131
- border: '1px solid var(--border-color)',
132
- background: 'var(--surface-bg)',
133
- color: 'var(--text-color)',
134
- boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
135
- opacity: '0',
136
- transition: 'opacity .12s ease',
137
- backdropFilter: 'saturate(1.12) blur(8px)',
138
- zIndex: '20'
139
- });
140
- tipInner = document.createElement('div');
141
- tipInner.className = 'd3-tooltip__inner';
142
- Object.assign(tipInner.style, {
143
- textAlign: 'left',
144
- display: 'flex',
145
- flexDirection: 'column',
146
- gap: '6px',
147
- minWidth: '220px'
148
- });
149
- tip.appendChild(tipInner);
150
- container.appendChild(tip);
151
- } else {
152
- tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
153
- }
154
 
155
- // Final filter: remove small dots very close to the galaxy center (after placement)
156
- const centerHoleRadius = 0.48; // elliptical radius threshold
157
- const smallSizeThreshold = 7.5; // same notion as Python size cut
158
- const rTotal = idx.map((i) => Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2));
159
- const idxFiltered = idx.filter((i, k) => !(rTotal[k] <= centerHoleRadius && sizesPx[i] < smallSizeThreshold));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
 
161
- const sel = g.selectAll('circle').data(idxFiltered, (i) => i);
162
- sel.join(
163
- (enter) => enter.append('circle')
164
- .attr('cx', (i) => xScale(X[i]))
165
- .attr('cy', (i) => yScale(Y[i]))
166
- .attr('r', (i) => sizesPx[i] / 2)
167
- .attr('fill', (i) => colorFor(Zraw[i]))
168
- .attr('fill-opacity', 0.9)
169
- .attr('stroke', strokeColor)
170
- .attr('stroke-width', 0.4)
171
- .on('mouseenter', function(ev, i) {
172
- d3.select(this).raise()
173
- .style('filter', `drop-shadow(0 0 8px ${glowColor})`)
174
- .transition().duration(120).ease(d3.easeCubicOut)
175
- .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
176
- .attr('stroke-width', 1.4)
177
- .attr('r', (sizesPx[i] / 2) * 1.25)
178
- .attr('fill-opacity', 1);
179
- const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
180
- const type = i < lenSpiral ? 'spiral' : 'bulge';
181
- const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
182
- tipInner.innerHTML =
183
- `<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
184
- `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
185
- `<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
186
- `<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
187
- `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
188
- tip.style.opacity = '1';
189
- })
190
- .on('mousemove', (ev, i) => {
191
- const [mx, my] = d3.pointer(ev, container);
192
- const offsetX = 10, offsetY = 12;
193
- tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
194
- })
195
- .on('mouseleave', function() {
196
- tip.style.opacity = '0';
197
- tip.style.transform = 'translate(-9999px, -9999px)';
198
- d3.select(this)
199
- .style('filter', null)
200
- .transition().duration(120).ease(d3.easeCubicOut)
201
- .attr('stroke', strokeColor)
202
- .attr('stroke-width', 0.4)
203
- .attr('r', (i2) => sizesPx[i2] / 2)
204
- .attr('fill-opacity', 0.9);
205
- }),
206
- (update) => update
207
- .attr('cx', (i) => xScale(X[i]))
208
- .attr('cy', (i) => yScale(Y[i]))
209
- .attr('r', (i) => sizesPx[i] / 2)
210
- .attr('fill', (i) => colorFor(Zraw[i]))
211
- .attr('fill-opacity', 0.9)
212
- .attr('stroke', strokeColor)
213
- .attr('stroke-width', 0.4)
214
- .on('mouseenter', function(ev, i) {
215
- d3.select(this).raise()
216
- .style('filter', `drop-shadow(0 0 8px ${glowColor})`)
217
- .transition().duration(120).ease(d3.easeCubicOut)
218
- .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
219
- .attr('stroke-width', 1.4)
220
- .attr('r', (sizesPx[i] / 2) * 1.25)
221
- .attr('fill-opacity', 1);
222
- const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
223
- const type = i < lenSpiral ? 'spiral' : 'bulge';
224
- const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
225
- tipInner.innerHTML =
226
- `<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
227
- `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
228
- `<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
229
- `<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
230
- `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
231
- tip.style.opacity = '1';
232
- })
233
- .on('mousemove', (ev, i) => {
234
- const [mx, my] = d3.pointer(ev, container);
235
- const offsetX = 10, offsetY = 12;
236
- tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
237
- })
238
- .on('mouseleave', function() {
239
- tip.style.opacity = '0';
240
- tip.style.transform = 'translate(-9999px, -9999px)';
241
- d3.select(this)
242
- .style('filter', null)
243
- .transition().duration(120).ease(d3.easeCubicOut)
244
- .attr('stroke', strokeColor)
245
- .attr('stroke-width', 0.4)
246
- .attr('r', (i2) => sizesPx[i2] / 2)
247
- .attr('fill-opacity', 0.9);
248
- })
249
- );
250
- };
251
 
252
- // First render + resize
253
- if (window.ResizeObserver) {
254
- const ro = new ResizeObserver(() => render());
255
- ro.observe(container);
256
- } else {
257
- window.addEventListener('resize', render);
258
- }
259
- render();
260
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
- if (document.readyState === 'loading') {
263
- document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
264
- } else { ensureD3(bootstrap); }
265
- })();
266
- </script>
 
 
 
 
267
 
 
 
 
 
 
 
1
  <div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></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
+ // Circle size settings
36
+ const minCircleSize = 4; // minimum diameter in pixels
37
+ const maxCircleSize = 12; // maximum diameter in pixels
 
 
 
 
 
 
 
38
 
39
+ // Generate spiral + bulge
40
+ const twoPi = Math.PI * 2;
41
+ const t = Float64Array.from({ length: numPoints }, () => Math.random() * (twoPi * numTurns));
42
+ const armIndices = Int16Array.from({ length: numPoints }, () => Math.floor(Math.random() * numArms));
43
+ const armOffsets = Float64Array.from(armIndices, (k) => k * (twoPi / numArms));
44
+ const theta = Float64Array.from(t, (tv, i) => tv + armOffsets[i] + d3.randomNormal.source(Math.random)(0, angleJitter)());
45
+ const rNorm = Float64Array.from(t, (tv) => Math.pow(tv / (twoPi * numTurns), 0.9));
46
+ const noiseScale = (rn) => posNoise * (0.8 + 0.6 * rn);
47
+ const noiseX = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
48
+ const noiseY = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
49
 
50
+ const xSpiral = Float64Array.from(theta, (th, i) => cx + a * rNorm[i] * Math.cos(th) + noiseX[i]);
51
+ const ySpiral = Float64Array.from(theta, (th, i) => cy + b * rNorm[i] * Math.sin(th) + noiseY[i]);
 
 
 
 
 
52
 
53
+ const bulgePoints = Math.floor(0.18 * numPoints);
54
+ const phiB = Float64Array.from({ length: bulgePoints }, () => twoPi * Math.random());
55
+ const rB = Float64Array.from({ length: bulgePoints }, () => Math.pow(Math.random(), 2.2) * 0.22);
56
+ const noiseXB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
57
+ const noiseYB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
58
+ const xBulge = Float64Array.from(phiB, (ph, i) => cx + a * rB[i] * Math.cos(ph) + noiseXB[i]);
59
+ const yBulge = Float64Array.from(phiB, (ph, i) => cy + b * rB[i] * Math.sin(ph) + noiseYB[i]);
60
 
61
+ // Concatenate
62
+ const X = Array.from(xSpiral).concat(Array.from(xBulge));
63
+ const Y = Array.from(ySpiral).concat(Array.from(yBulge));
64
+ const lenSpiral = xSpiral.length;
 
65
 
66
+ const zSpiral = Array.from(rNorm, (rn) => 1 - rn);
67
+ const maxRB = rB && rB.length ? (window.d3 && d3.max ? d3.max(rB) : Math.max.apply(null, Array.from(rB))) : 1;
68
+ const zBulge = Array.from(rB, (rb) => 1 - (maxRB ? rb / maxRB : 0));
69
+ const Zraw = zSpiral.concat(zBulge);
70
+ const sizesPx = Zraw.map((z) => minCircleSize + z * (maxCircleSize - minCircleSize)); // diameter in pixels
 
 
 
71
 
72
+ // Labels (same categories as Python version)
73
+ const labelOf = (i) => {
74
+ const z = Zraw[i];
75
+ if (z < 0.25) return 'tiny star';
76
+ if (z < 0.5) return 'small star';
77
+ if (z < 0.75) return 'medium star';
78
+ return 'large star';
79
+ };
80
 
81
+ // Sort by size ascending for z-index: small first, big last
82
+ const idx = d3.range(X.length).sort((i, j) => sizesPx[i] - sizesPx[j]);
 
 
 
 
 
 
 
 
83
 
84
+ // Colors: piecewise gradient [0 -> 0.5 -> 1]
85
+ const c0 = d3.rgb(78, 165, 183); // rgb(78, 165, 183)
86
+ const c1 = d3.rgb(206, 192, 250); // rgb(206, 192, 250)
87
+ const c2 = d3.rgb(232, 137, 171); // rgb(232, 137, 171)
88
+ const interp01 = d3.interpolateRgb(c0, c1);
89
+ const interp12 = d3.interpolateRgb(c1, c2);
90
+ const colorFor = (v) => {
91
+ const t = Math.max(0, Math.min(1, v));
92
+ return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);
93
+ };
94
 
95
+ // Create SVG
96
+ const svg = d3.select(container).append('svg')
97
+ .attr('width', '100%')
98
+ .style('display', 'block')
99
+ .style('cursor', 'crosshair');
100
 
101
+ const render = () => {
102
+ const width = container.clientWidth || 800;
103
+ const height = Math.max(260, Math.round(width / 3)); // keep ~3:1, min height
104
+ svg.attr('width', width).attr('height', height);
105
 
106
+ const xScale = d3.scaleLinear().domain([0, 3]).range([0, width]);
107
+ const yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]);
 
 
108
 
109
+ // Subtle stroke color depending on theme
110
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
111
+ const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
112
+ const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)';
113
 
 
 
114
 
115
+ // Group for points (no blend mode for better print/PDF visibility)
116
+ const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ // Ensure container can host an absolute tooltip
119
+ container.style.position = container.style.position || 'relative';
120
+ let tip = container.querySelector('.d3-tooltip');
121
+ let tipInner;
122
+ if (!tip) {
123
+ tip = document.createElement('div');
124
+ tip.className = 'd3-tooltip';
125
+ Object.assign(tip.style, {
126
+ position: 'absolute',
127
+ top: '0px',
128
+ left: '0px',
129
+ transform: 'translate(-9999px, -9999px)',
130
+ pointerEvents: 'none',
131
+ padding: '10px 12px',
132
+ borderRadius: '12px',
133
+ fontSize: '12px',
134
+ lineHeight: '1.35',
135
+ border: '1px solid var(--border-color)',
136
+ background: 'var(--surface-bg)',
137
+ color: 'var(--text-color)',
138
+ boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
139
+ opacity: '0',
140
+ transition: 'opacity .12s ease',
141
+ backdropFilter: 'saturate(1.12) blur(8px)',
142
+ zIndex: '20'
143
+ });
144
+ tipInner = document.createElement('div');
145
+ tipInner.className = 'd3-tooltip__inner';
146
+ Object.assign(tipInner.style, {
147
+ textAlign: 'left',
148
+ display: 'flex',
149
+ flexDirection: 'column',
150
+ gap: '6px',
151
+ minWidth: '220px'
152
+ });
153
+ tip.appendChild(tipInner);
154
+ container.appendChild(tip);
155
+ } else {
156
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
157
+ }
158
 
159
+ // Final filter: remove small dots very close to the galaxy center (after placement)
160
+ const centerHoleRadius = 0.48; // elliptical radius threshold
161
+ const smallSizeThreshold = 7.5; // same notion as Python size cut
162
+ const rTotal = idx.map((i) => Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2));
163
+ const idxFiltered = idx.filter((i, k) => !(rTotal[k] <= centerHoleRadius && sizesPx[i] < smallSizeThreshold));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
+ const sel = g.selectAll('circle').data(idxFiltered, (i) => i);
166
+ sel.join(
167
+ (enter) => enter.append('circle')
168
+ .attr('cx', (i) => xScale(X[i]))
169
+ .attr('cy', (i) => yScale(Y[i]))
170
+ .attr('r', (i) => sizesPx[i] / 2)
171
+ .attr('fill', (i) => colorFor(Zraw[i]))
172
+ .attr('fill-opacity', 0.9)
173
+ .on('mouseenter', function (ev, i) {
174
+ d3.select(this).raise()
175
+ .style('filter', `drop-shadow(0 0 8px ${glowColor})`)
176
+ .transition().duration(120).ease(d3.easeCubicOut)
177
+ .attr('r', (sizesPx[i] / 2) * 1.25)
178
+ .attr('fill-opacity', 1);
179
+ const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
180
+ const type = i < lenSpiral ? 'spiral' : 'bulge';
181
+ const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
182
+ tipInner.innerHTML =
183
+ `<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
184
+ `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
185
+ `<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
186
+ `<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
187
+ `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
188
+ tip.style.opacity = '1';
189
+ })
190
+ .on('mousemove', (ev, i) => {
191
+ const [mx, my] = d3.pointer(ev, container);
192
+ const offsetX = 10, offsetY = 12;
193
+ tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
194
+ })
195
+ .on('mouseleave', function () {
196
+ tip.style.opacity = '0';
197
+ tip.style.transform = 'translate(-9999px, -9999px)';
198
+ d3.select(this)
199
+ .style('filter', null)
200
+ .transition().duration(120).ease(d3.easeCubicOut)
201
+ .attr('r', (i2) => sizesPx[i2] / 2)
202
+ .attr('fill-opacity', 0.9);
203
+ }),
204
+ (update) => update
205
+ .attr('cx', (i) => xScale(X[i]))
206
+ .attr('cy', (i) => yScale(Y[i]))
207
+ .attr('r', (i) => sizesPx[i] / 2)
208
+ .attr('fill', (i) => colorFor(Zraw[i]))
209
+ .attr('fill-opacity', 0.9)
210
+ .on('mouseenter', function (ev, i) {
211
+ d3.select(this).raise()
212
+ .style('filter', `drop-shadow(0 0 8px ${glowColor})`)
213
+ .transition().duration(120).ease(d3.easeCubicOut)
214
+ .attr('r', (sizesPx[i] / 2) * 1.25)
215
+ .attr('fill-opacity', 1);
216
+ const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
217
+ const type = i < lenSpiral ? 'spiral' : 'bulge';
218
+ const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
219
+ tipInner.innerHTML =
220
+ `<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
221
+ `<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
222
+ `<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
223
+ `<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
224
+ `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
225
+ tip.style.opacity = '1';
226
+ })
227
+ .on('mousemove', (ev, i) => {
228
+ const [mx, my] = d3.pointer(ev, container);
229
+ const offsetX = 10, offsetY = 12;
230
+ tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
231
+ })
232
+ .on('mouseleave', function () {
233
+ tip.style.opacity = '0';
234
+ tip.style.transform = 'translate(-9999px, -9999px)';
235
+ d3.select(this)
236
+ .style('filter', null)
237
+ .transition().duration(120).ease(d3.easeCubicOut)
238
+ .attr('r', (i2) => sizesPx[i2] / 2)
239
+ .attr('fill-opacity', 0.9);
240
+ })
241
+ );
242
+ };
243
 
244
+ // First render + resize
245
+ if (window.ResizeObserver) {
246
+ const ro = new ResizeObserver(() => render());
247
+ ro.observe(container);
248
+ } else {
249
+ window.addEventListener('resize', render);
250
+ }
251
+ render();
252
+ };
253
 
254
+ if (document.readyState === 'loading') {
255
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
256
+ } else { ensureD3(bootstrap); }
257
+ })();
258
+ </script>
app/src/content/embeds/d3-bar.html CHANGED
@@ -1,51 +1,195 @@
1
- <div class="d3-bar" ></div>
2
  <style>
3
- .d3-bar .controls { margin-top: 0; display: flex; gap: 16px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
4
- .d3-bar .controls .control-group { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; }
5
- .d3-bar .controls label { font-size: 12px; color: var(--text-color); font-weight: 700; }
6
- .d3-bar .controls select { font-size: 12px; padding: 8px 28px 8px 10px; border: 1px solid var(--border-color); border-radius: 8px; background-color: var(--surface-bg); color: var(--text-color); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; background-size: 12px; -webkit-appearance: none; -moz-appearance: none; appearance: none; cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease; }
7
- [data-theme="dark"] .d3-bar .controls select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); }
8
- .d3-bar .controls select:hover { border-color: var(--primary-color); }
9
- .d3-bar .controls select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(232,137,171,.25); outline: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  /* Header (legend + controls) placed after chart */
11
- .d3-bar .chart-header { display: flex; align-items: flex-start; justify-content: flex-start; gap: 12px; margin: 8px 0 0 0; flex-wrap: wrap; }
12
- .d3-bar .legend-bottom { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; font-size: 12px; color: var(--text-color); }
13
- .d3-bar .legend-bottom .legend-title { font-size: 12px; font-weight: 700; color: var(--text-color); }
14
- .d3-bar .legend-bottom .items { display: flex; flex-wrap: wrap; gap: 8px 14px; }
15
- .d3-bar .legend-bottom .item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
16
- .d3-bar .legend-bottom .swatch { width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--border-color); display: inline-block; }
17
- .d3-bar.hovering .legend-bottom .item.ghost { opacity: .35; }
18
- .d3-bar.hovering .bars path.ghost { opacity: .35; }
19
- .d3-bar .axis-label { fill: var(--text-color); font-size: 12px; font-weight: 700; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  /* Apply axis/tick/grid purely via CSS */
21
  .d3-bar .axes path,
22
- .d3-bar .axes line { stroke: var(--axis-color); }
23
- .d3-bar .axes text { fill: var(--tick-color); }
24
- .d3-bar .grid line { stroke: var(--grid-color); }
 
 
 
 
 
 
 
 
 
25
  /* Tooltip improvements */
26
- .d3-bar .d3-tooltip { z-index: var(--z-tooltip); backdrop-filter: saturate(1.12) blur(8px); }
 
 
 
 
27
  /* Hover/transition styling for bars and legend */
28
- .d3-bar .bars path.bar { transition: opacity .12s ease, stroke .12s ease, stroke-width .12s ease; }
29
- .d3-bar .bars path.bar.highlight { stroke: none; stroke-width: 0; }
30
- .d3-bar.hovering .bars path.ghost { opacity: .25; }
31
- .d3-bar .legend-bottom .item.hovered { color: inherit; }
32
- .d3-bar .legend-bottom .item.hovered .swatch { border-color: var(--border-color); }
33
- .d3-bar .d3-tooltip .swatch { width: 12px; height: 12px; border-radius: 3px; border: 1px solid var(--border-color); display: inline-block; margin-right: 6px; vertical-align: -2px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  /* Chart card wrapper */
35
- .d3-bar .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; }
 
 
 
 
 
 
36
  /* Layout adjustments to give controls more space */
37
  .d3-bar .chart-header {
38
  padding-left: 8px;
39
  padding-right: 8px;
40
  gap: 20px;
41
  }
 
42
  .d3-bar .controls {
43
  justify-content: flex-start;
44
  min-width: 320px;
45
  }
 
46
  .d3-bar .controls .control-group {
47
  min-width: 150px;
48
  }
 
49
  .d3-bar .controls select {
50
  font-size: 13px;
51
  min-width: 160px;
@@ -69,13 +213,13 @@
69
  if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
70
 
71
  // Data, matching bar.py
72
- const seqLabels = ["1024","2048","4096","8192"];
73
- const seqScale = [1,2,4,8];
74
- const componentKeys = ['parameters','gradients','optimizer','activations'];
75
- const modelSizes = ["1B","3B","8B","70B","405B"];
76
- const paramsMem = { "1B":4.0, "3B":13.3, "8B":26.0, "70B":244.0, "405B":1520.0 };
77
- const actCoeff = { "1B":3.6, "3B":9.3, "8B":46.2, "70B":145.7, "405B":1519.9 };
78
- const recomputeModes = ["none","selective","full"];
79
 
80
  const activationsCurve = (sizeKey, mode) => {
81
  const coeff = actCoeff[sizeKey];
@@ -87,7 +231,7 @@
87
  const stackFor = (sizeKey, mode) => {
88
  const p = seqScale.map(() => paramsMem[sizeKey]);
89
  const g = seqScale.map(() => paramsMem[sizeKey]);
90
- const o = seqScale.map(() => 2*paramsMem[sizeKey]);
91
  const a = activationsCurve(sizeKey, mode);
92
  return { parameters: p, gradients: g, optimizer: o, activations: a };
93
  };
@@ -121,16 +265,16 @@
121
  const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card);
122
  // Place header after the chart card
123
  container.appendChild(header);
124
- const svg = d3.select(card).append('svg').attr('width','100%').style('display','block');
125
  const gRoot = svg.append('g');
126
- const gGrid = gRoot.append('g').attr('class','grid');
127
- const gAxes = gRoot.append('g').attr('class','axes');
128
- const gBars = gRoot.append('g').attr('class','bars');
129
 
130
  // Tooltip
131
  container.style.position = container.style.position || 'relative';
132
  let tip = container.querySelector('.d3-tooltip'); let tipInner;
133
- if (!tip) { tip = document.createElement('div'); tip.className = 'd3-tooltip'; Object.assign(tip.style,{ position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none', padding:'8px 10px', borderRadius:'8px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)', background:'var(--surface-bg)', color:'var(--text-color)', boxShadow:'0 4px 24px rgba(0,0,0,.18)', opacity:'0', transition:'opacity .12s ease' }); tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip); } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
134
 
135
  // State
136
  let currentSize = modelSizes[0];
@@ -138,32 +282,32 @@
138
  selRecomp.value = currentMode;
139
 
140
  // Layout & scales
141
- let width=800, height=360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
142
  const x0 = d3.scaleBand().paddingInner(0.25).paddingOuter(0.1); // groups (seq)
143
  const y = d3.scaleLinear();
144
- function getCategoricalColors(count){
145
  try {
146
  if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
147
  return window.ColorPalettes.getColors('categorical', count);
148
  }
149
- } catch(_) {}
150
- const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
151
- const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'];
152
  const pool = [primary, ...tableau];
153
- const arr = []; for (let i=0;i<count;i++){ arr.push(pool[i % pool.length]); }
154
  return arr;
155
  }
156
  const palette = getCategoricalColors(componentKeys.length);
157
- const colorMap = new Map(componentKeys.map((k,i)=>[k, palette[i]]));
158
  const colorOf = (key) => colorMap.get(key) || 'var(--primary-color)';
159
 
160
- function yMax(sizeKey, mode){
161
  const s = Y[mode][sizeKey];
162
- let max = 0; for (let i=0;i<seqLabels.length;i++){ const sum = s.parameters[i]+s.gradients[i]+s.optimizer[i]+s.activations[i]; if (sum>max) max=sum; }
163
- return max*1.05;
164
  }
165
 
166
- function renderLegend(){
167
  legendItems.innerHTML = componentKeys.map((key, i) => {
168
  const color = palette[i];
169
  return `<span class="item" data-key="${key}"><span class=\"swatch\" style=\"background:${color}\"></span><span>${key}</span></span>`;
@@ -183,8 +327,8 @@
183
  });
184
  }
185
 
186
- function updateScales(){
187
- width = container.clientWidth || 800; height = Math.max(260, Math.round(width/3)); svg.attr('width', width).attr('height', height);
188
  const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
189
 
190
  x0.domain(seqLabels).range([0, innerWidth]);
@@ -193,26 +337,26 @@
193
  // Grid
194
  gGrid.selectAll('*').remove();
195
  gGrid.selectAll('line').data(y.ticks(6)).join('line')
196
- .attr('x1', 0).attr('x2', innerWidth).attr('y1', (d)=>y(d)).attr('y2', (d)=>y(d))
197
  .attr('stroke', 'var(--grid-color)').attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
198
 
199
  // Axes
200
  gAxes.selectAll('*').remove();
201
- gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g)=>{ g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size','12px'); });
202
- gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g)=>{ g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size','12px'); });
203
 
204
  // Axis labels
205
- gAxes.append('text').attr('class','axis-label axis-label--x').attr('x', innerWidth/2).attr('y', innerHeight + 44).attr('text-anchor','middle').text('Sequence Length');
206
- gAxes.append('text').attr('class','axis-label axis-label--y').attr('text-anchor','middle').attr('transform', `translate(${-52},${innerHeight/2}) rotate(-90)`).text('Memory (GB)');
207
 
208
  renderLegend();
209
 
210
  return { innerWidth, innerHeight };
211
  }
212
 
213
- function drawBars(){
214
  const stacks = Y[currentMode][currentSize];
215
- const series = componentKeys.map((key, i)=>({ key, color: palette[i], values: stacks[key] }));
216
  // Stack values
217
  const stacked = seqLabels.map((label, i) => {
218
  let acc = 0; const items = [];
@@ -229,9 +373,9 @@
229
  const { innerWidth, innerHeight } = updateScales();
230
 
231
  const bandWidth = x0.bandwidth();
232
- const groups = gBars.selectAll('g.bar-group').data(stacked, d=>d.label);
233
- const groupsEnter = groups.enter().append('g').attr('class','bar-group');
234
- groupsEnter.merge(groups).attr('transform', (d)=>`translate(${x0(d.label)},0)`);
235
  groups.exit().remove();
236
 
237
  // Helper to draw per-corner rounded rectangle path
@@ -252,11 +396,11 @@
252
  + 'Z';
253
  };
254
 
255
- const bars = groupsEnter.merge(groups).selectAll('path.bar').data(d=>d.items, d=>d.key);
256
- bars.enter().append('path').attr('class','bar')
257
- .attr('d', (d)=> roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
258
- .attr('fill', (d)=>d.color)
259
- .on('mouseenter', function(ev, d){
260
  container.classList.add('hovering');
261
  gBars.selectAll('path.bar').classed('ghost', (dd) => !(dd && dd.key === d.key));
262
  const pct = d.total > 0 ? (d.value / d.total * 100) : 0;
@@ -274,7 +418,7 @@
274
  if (li) li.classList.add('hovered');
275
  legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== d.key));
276
  })
277
- .on('mousemove', function(ev, d){
278
  const [mx, my] = d3.pointer(ev, container);
279
  const offsetX = 12, offsetY = 12;
280
  const maxX = (container.clientWidth || 0) - (tip.offsetWidth + 6);
@@ -283,35 +427,33 @@
283
  const ty = Math.max(0, Math.min(my + offsetY, maxY));
284
  tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
285
  })
286
- .on('mouseleave', function(){
287
- tip.style.opacity='0';
288
- tip.style.transform='translate(-9999px, -9999px)';
289
  container.classList.remove('hovering');
290
  gBars.selectAll('path.bar').classed('ghost', false).classed('highlight', false);
291
  legendItems.querySelectorAll('.item').forEach(it => { it.classList.remove('hovered'); it.classList.remove('ghost'); });
292
  })
293
  .merge(bars)
294
  .transition().duration(200)
295
- .attr('d', (d)=> roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
296
- .attr('fill', (d)=>d.color);
297
  bars.exit().remove();
298
  }
299
 
300
- function update(){ drawBars(); }
301
 
302
  // Boot
303
  update();
304
  // controls already appended to footer; populate control groups
305
  controls.appendChild(groupSize); controls.appendChild(groupRecomp);
306
- selSize.addEventListener('change', (e)=>{ currentSize = e.target.value; update(); });
307
- selRecomp.addEventListener('change', (e)=>{ currentMode = e.target.value; update(); });
308
 
309
  const rerender = () => { update(); };
310
- if (window.ResizeObserver) { const ro = new ResizeObserver(()=>rerender()); ro.observe(container); } else { window.addEventListener('resize', rerender); }
311
  };
312
 
313
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
314
  })();
315
- </script>
316
-
317
-
 
1
+ <div class="d3-bar"></div>
2
  <style>
3
+ .d3-bar .controls {
4
+ margin-top: 0;
5
+ display: flex;
6
+ gap: 16px;
7
+ align-items: center;
8
+ justify-content: flex-end;
9
+ flex-wrap: wrap;
10
+ }
11
+
12
+ .d3-bar .controls .control-group {
13
+ display: flex;
14
+ flex-direction: column;
15
+ align-items: flex-start;
16
+ gap: 6px;
17
+ }
18
+
19
+ .d3-bar .controls label {
20
+ font-size: 12px;
21
+ color: var(--text-color);
22
+ font-weight: 700;
23
+ }
24
+
25
+ .d3-bar .controls select {
26
+ font-size: 12px;
27
+ padding: 8px 28px 8px 10px;
28
+ border: 1px solid var(--border-color);
29
+ border-radius: 8px;
30
+ background-color: var(--surface-bg);
31
+ color: var(--text-color);
32
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
33
+ background-repeat: no-repeat;
34
+ background-position: right 8px center;
35
+ background-size: 12px;
36
+ -webkit-appearance: none;
37
+ -moz-appearance: none;
38
+ appearance: none;
39
+ cursor: pointer;
40
+ transition: border-color .15s ease, box-shadow .15s ease;
41
+ }
42
+
43
+ [data-theme="dark"] .d3-bar .controls select {
44
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
45
+ }
46
+
47
+ .d3-bar .controls select:hover {
48
+ border-color: var(--primary-color);
49
+ }
50
+
51
+ .d3-bar .controls select:focus {
52
+ border-color: var(--primary-color);
53
+ box-shadow: 0 0 0 3px rgba(232, 137, 171, .25);
54
+ outline: none;
55
+ }
56
+
57
  /* Header (legend + controls) placed after chart */
58
+ .d3-bar .chart-header {
59
+ display: flex;
60
+ align-items: flex-start;
61
+ justify-content: flex-start;
62
+ gap: 12px;
63
+ margin: 8px 0 0 0;
64
+ flex-wrap: wrap;
65
+ }
66
+
67
+ .d3-bar .legend-bottom {
68
+ display: flex;
69
+ flex-direction: column;
70
+ align-items: flex-start;
71
+ gap: 6px;
72
+ font-size: 12px;
73
+ color: var(--text-color);
74
+ }
75
+
76
+ .d3-bar .legend-bottom .legend-title {
77
+ font-size: 12px;
78
+ font-weight: 700;
79
+ color: var(--text-color);
80
+ }
81
+
82
+ .d3-bar .legend-bottom .items {
83
+ display: flex;
84
+ flex-wrap: wrap;
85
+ gap: 8px 14px;
86
+ }
87
+
88
+ .d3-bar .legend-bottom .item {
89
+ display: inline-flex;
90
+ align-items: center;
91
+ gap: 6px;
92
+ white-space: nowrap;
93
+ }
94
+
95
+ .d3-bar .legend-bottom .swatch {
96
+ width: 14px;
97
+ height: 14px;
98
+ border-radius: 3px;
99
+ border: 1px solid var(--border-color);
100
+ display: inline-block;
101
+ }
102
+
103
+ .d3-bar.hovering .legend-bottom .item.ghost {
104
+ opacity: .35;
105
+ }
106
+
107
+ .d3-bar.hovering .bars path.ghost {
108
+ opacity: .35;
109
+ }
110
+
111
+ .d3-bar .axis-label {
112
+ fill: var(--text-color);
113
+ font-size: 12px;
114
+ font-weight: 700;
115
+ }
116
+
117
  /* Apply axis/tick/grid purely via CSS */
118
  .d3-bar .axes path,
119
+ .d3-bar .axes line {
120
+ stroke: var(--axis-color);
121
+ }
122
+
123
+ .d3-bar .axes text {
124
+ fill: var(--tick-color);
125
+ }
126
+
127
+ .d3-bar .grid line {
128
+ stroke: var(--grid-color);
129
+ }
130
+
131
  /* Tooltip improvements */
132
+ .d3-bar .d3-tooltip {
133
+ z-index: var(--z-tooltip);
134
+ backdrop-filter: saturate(1.12) blur(8px);
135
+ }
136
+
137
  /* Hover/transition styling for bars and legend */
138
+ .d3-bar .bars path.bar {
139
+ transition: opacity .12s ease, stroke .12s ease, stroke-width .12s ease;
140
+ }
141
+
142
+ .d3-bar .bars path.bar.highlight {
143
+ stroke: none;
144
+ stroke-width: 0;
145
+ }
146
+
147
+ .d3-bar.hovering .bars path.ghost {
148
+ opacity: .25;
149
+ }
150
+
151
+ .d3-bar .legend-bottom .item.hovered {
152
+ color: inherit;
153
+ }
154
+
155
+ .d3-bar .legend-bottom .item.hovered .swatch {
156
+ border-color: var(--border-color);
157
+ }
158
+
159
+ .d3-bar .d3-tooltip .swatch {
160
+ width: 12px;
161
+ height: 12px;
162
+ border-radius: 3px;
163
+ border: 1px solid var(--border-color);
164
+ display: inline-block;
165
+ margin-right: 6px;
166
+ vertical-align: -2px;
167
+ }
168
+
169
  /* Chart card wrapper */
170
+ .d3-bar .chart-card {
171
+ background: var(--surface-bg);
172
+ border: 1px solid var(--border-color);
173
+ border-radius: 10px;
174
+ padding: 8px;
175
+ }
176
+
177
  /* Layout adjustments to give controls more space */
178
  .d3-bar .chart-header {
179
  padding-left: 8px;
180
  padding-right: 8px;
181
  gap: 20px;
182
  }
183
+
184
  .d3-bar .controls {
185
  justify-content: flex-start;
186
  min-width: 320px;
187
  }
188
+
189
  .d3-bar .controls .control-group {
190
  min-width: 150px;
191
  }
192
+
193
  .d3-bar .controls select {
194
  font-size: 13px;
195
  min-width: 160px;
 
213
  if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
214
 
215
  // Data, matching bar.py
216
+ const seqLabels = ["1024", "2048", "4096", "8192"];
217
+ const seqScale = [1, 2, 4, 8];
218
+ const componentKeys = ['parameters', 'gradients', 'optimizer', 'activations'];
219
+ const modelSizes = ["1B", "3B", "8B", "70B", "405B"];
220
+ const paramsMem = { "1B": 4.0, "3B": 13.3, "8B": 26.0, "70B": 244.0, "405B": 1520.0 };
221
+ const actCoeff = { "1B": 3.6, "3B": 9.3, "8B": 46.2, "70B": 145.7, "405B": 1519.9 };
222
+ const recomputeModes = ["none", "selective", "full"];
223
 
224
  const activationsCurve = (sizeKey, mode) => {
225
  const coeff = actCoeff[sizeKey];
 
231
  const stackFor = (sizeKey, mode) => {
232
  const p = seqScale.map(() => paramsMem[sizeKey]);
233
  const g = seqScale.map(() => paramsMem[sizeKey]);
234
+ const o = seqScale.map(() => 2 * paramsMem[sizeKey]);
235
  const a = activationsCurve(sizeKey, mode);
236
  return { parameters: p, gradients: g, optimizer: o, activations: a };
237
  };
 
265
  const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card);
266
  // Place header after the chart card
267
  container.appendChild(header);
268
+ const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
269
  const gRoot = svg.append('g');
270
+ const gGrid = gRoot.append('g').attr('class', 'grid');
271
+ const gAxes = gRoot.append('g').attr('class', 'axes');
272
+ const gBars = gRoot.append('g').attr('class', 'bars');
273
 
274
  // Tooltip
275
  container.style.position = container.style.position || 'relative';
276
  let tip = container.querySelector('.d3-tooltip'); let tipInner;
277
+ if (!tip) { tip = document.createElement('div'); tip.className = 'd3-tooltip'; Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '8px 10px', borderRadius: '8px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 4px 24px rgba(0,0,0,.18)', opacity: '0', transition: 'opacity .12s ease' }); tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip); } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
278
 
279
  // State
280
  let currentSize = modelSizes[0];
 
282
  selRecomp.value = currentMode;
283
 
284
  // Layout & scales
285
+ let width = 800, height = 360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
286
  const x0 = d3.scaleBand().paddingInner(0.25).paddingOuter(0.1); // groups (seq)
287
  const y = d3.scaleLinear();
288
+ function getCategoricalColors(count) {
289
  try {
290
  if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
291
  return window.ColorPalettes.getColors('categorical', count);
292
  }
293
+ } catch (_) { }
294
+ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
295
+ const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab'];
296
  const pool = [primary, ...tableau];
297
+ const arr = []; for (let i = 0; i < count; i++) { arr.push(pool[i % pool.length]); }
298
  return arr;
299
  }
300
  const palette = getCategoricalColors(componentKeys.length);
301
+ const colorMap = new Map(componentKeys.map((k, i) => [k, palette[i]]));
302
  const colorOf = (key) => colorMap.get(key) || 'var(--primary-color)';
303
 
304
+ function yMax(sizeKey, mode) {
305
  const s = Y[mode][sizeKey];
306
+ let max = 0; for (let i = 0; i < seqLabels.length; i++) { const sum = s.parameters[i] + s.gradients[i] + s.optimizer[i] + s.activations[i]; if (sum > max) max = sum; }
307
+ return max * 1.05;
308
  }
309
 
310
+ function renderLegend() {
311
  legendItems.innerHTML = componentKeys.map((key, i) => {
312
  const color = palette[i];
313
  return `<span class="item" data-key="${key}"><span class=\"swatch\" style=\"background:${color}\"></span><span>${key}</span></span>`;
 
327
  });
328
  }
329
 
330
+ function updateScales() {
331
+ width = container.clientWidth || 800; height = Math.max(260, Math.round(width / 3)); svg.attr('width', width).attr('height', height);
332
  const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
333
 
334
  x0.domain(seqLabels).range([0, innerWidth]);
 
337
  // Grid
338
  gGrid.selectAll('*').remove();
339
  gGrid.selectAll('line').data(y.ticks(6)).join('line')
340
+ .attr('x1', 0).attr('x2', innerWidth).attr('y1', (d) => y(d)).attr('y2', (d) => y(d))
341
  .attr('stroke', 'var(--grid-color)').attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
342
 
343
  // Axes
344
  gAxes.selectAll('*').remove();
345
+ gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); });
346
+ gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); });
347
 
348
  // Axis labels
349
+ gAxes.append('text').attr('class', 'axis-label axis-label--x').attr('x', innerWidth / 2).attr('y', innerHeight + 44).attr('text-anchor', 'middle').text('Sequence Length');
350
+ gAxes.append('text').attr('class', 'axis-label axis-label--y').attr('text-anchor', 'middle').attr('transform', `translate(${-52},${innerHeight / 2}) rotate(-90)`).text('Memory (GB)');
351
 
352
  renderLegend();
353
 
354
  return { innerWidth, innerHeight };
355
  }
356
 
357
+ function drawBars() {
358
  const stacks = Y[currentMode][currentSize];
359
+ const series = componentKeys.map((key, i) => ({ key, color: palette[i], values: stacks[key] }));
360
  // Stack values
361
  const stacked = seqLabels.map((label, i) => {
362
  let acc = 0; const items = [];
 
373
  const { innerWidth, innerHeight } = updateScales();
374
 
375
  const bandWidth = x0.bandwidth();
376
+ const groups = gBars.selectAll('g.bar-group').data(stacked, d => d.label);
377
+ const groupsEnter = groups.enter().append('g').attr('class', 'bar-group');
378
+ groupsEnter.merge(groups).attr('transform', (d) => `translate(${x0(d.label)},0)`);
379
  groups.exit().remove();
380
 
381
  // Helper to draw per-corner rounded rectangle path
 
396
  + 'Z';
397
  };
398
 
399
+ const bars = groupsEnter.merge(groups).selectAll('path.bar').data(d => d.items, d => d.key);
400
+ bars.enter().append('path').attr('class', 'bar')
401
+ .attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
402
+ .attr('fill', (d) => d.color)
403
+ .on('mouseenter', function (ev, d) {
404
  container.classList.add('hovering');
405
  gBars.selectAll('path.bar').classed('ghost', (dd) => !(dd && dd.key === d.key));
406
  const pct = d.total > 0 ? (d.value / d.total * 100) : 0;
 
418
  if (li) li.classList.add('hovered');
419
  legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== d.key));
420
  })
421
+ .on('mousemove', function (ev, d) {
422
  const [mx, my] = d3.pointer(ev, container);
423
  const offsetX = 12, offsetY = 12;
424
  const maxX = (container.clientWidth || 0) - (tip.offsetWidth + 6);
 
427
  const ty = Math.max(0, Math.min(my + offsetY, maxY));
428
  tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
429
  })
430
+ .on('mouseleave', function () {
431
+ tip.style.opacity = '0';
432
+ tip.style.transform = 'translate(-9999px, -9999px)';
433
  container.classList.remove('hovering');
434
  gBars.selectAll('path.bar').classed('ghost', false).classed('highlight', false);
435
  legendItems.querySelectorAll('.item').forEach(it => { it.classList.remove('hovered'); it.classList.remove('ghost'); });
436
  })
437
  .merge(bars)
438
  .transition().duration(200)
439
+ .attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
440
+ .attr('fill', (d) => d.color);
441
  bars.exit().remove();
442
  }
443
 
444
+ function update() { drawBars(); }
445
 
446
  // Boot
447
  update();
448
  // controls already appended to footer; populate control groups
449
  controls.appendChild(groupSize); controls.appendChild(groupRecomp);
450
+ selSize.addEventListener('change', (e) => { currentSize = e.target.value; update(); });
451
+ selRecomp.addEventListener('change', (e) => { currentMode = e.target.value; update(); });
452
 
453
  const rerender = () => { update(); };
454
+ if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); } else { window.addEventListener('resize', rerender); }
455
  };
456
 
457
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
458
  })();
459
+ </script>
 
 
app/src/content/embeds/d3-equation-editor.html ADDED
@@ -0,0 +1,677 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-equation-editor"></div>
2
+ <style>
3
+ .d3-equation-editor {
4
+ position: relative;
5
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif;
6
+ }
7
+
8
+ .d3-equation-editor .chart-card {
9
+ background: var(--surface-bg);
10
+ border: 1px solid var(--border-color);
11
+ border-radius: 10px;
12
+ padding: 8px;
13
+ }
14
+
15
+
16
+ .d3-equation-editor .chart-header {
17
+ display: flex;
18
+ align-items: flex-start;
19
+ justify-content: flex-start;
20
+ gap: 24px;
21
+ margin: 16px 0 0 0;
22
+ flex-wrap: wrap;
23
+ }
24
+
25
+ .d3-equation-editor .controls {
26
+ display: flex;
27
+ flex-direction: column;
28
+ gap: 24px;
29
+ align-items: flex-start;
30
+ justify-content: flex-start;
31
+ width: 100%;
32
+ }
33
+
34
+ .d3-equation-editor .controls .control-group {
35
+ display: flex;
36
+ flex-direction: column;
37
+ align-items: flex-start;
38
+ gap: 8px;
39
+ }
40
+
41
+ .d3-equation-editor .controls .control-group.equation-group {
42
+ width: 100%;
43
+ }
44
+
45
+ .d3-equation-editor .controls .input-row {
46
+ display: flex;
47
+ gap: 24px;
48
+ align-items: flex-start;
49
+ justify-content: flex-start;
50
+ flex-wrap: wrap;
51
+ width: 100%;
52
+ }
53
+
54
+ .d3-equation-editor .controls .input-row .control-group.equation-group {
55
+ flex: 1;
56
+ min-width: 300px;
57
+ }
58
+
59
+ .d3-equation-editor .controls .control-group.domain-group {
60
+ flex: 0 0 240px;
61
+ }
62
+
63
+ .d3-equation-editor .controls label {
64
+ font-size: 13px;
65
+ color: var(--text-color);
66
+ font-weight: 600;
67
+ letter-spacing: -0.01em;
68
+ }
69
+
70
+ .d3-equation-editor .controls input[type="text"] {
71
+ font-size: 17px;
72
+ font-weight: 400;
73
+ padding: 14px 18px;
74
+ border: 1.5px solid var(--primary-color);
75
+ border-radius: var(--button-radius);
76
+ background-color: var(--surface-bg);
77
+ color: var(--text-color);
78
+ cursor: text;
79
+ transition: all .2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
80
+ width: 100%;
81
+ font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
82
+ line-height: 1.2;
83
+ }
84
+
85
+ [data-theme="dark"] .d3-equation-editor .controls input[type="text"] {
86
+ border: 1.5px solid var(--primary-color);
87
+ }
88
+
89
+
90
+ .d3-equation-editor .controls input[type="text"]:hover {
91
+ border-color: rgba(0, 123, 255, 0.3);
92
+ }
93
+
94
+ .d3-equation-editor .controls input[type="text"]:focus {
95
+ border-color: rgba(0, 123, 255, 0.6);
96
+ box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
97
+ outline: none;
98
+ }
99
+
100
+ [data-theme="dark"] .d3-equation-editor .controls input[type="text"]:hover {
101
+ border-color: rgba(10, 132, 255, 0.4);
102
+ }
103
+
104
+ [data-theme="dark"] .d3-equation-editor .controls input[type="text"]:focus {
105
+ border-color: rgba(10, 132, 255, 0.7);
106
+ box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.15);
107
+ }
108
+
109
+ .d3-equation-editor .controls input[type="range"] {
110
+ -webkit-appearance: none;
111
+ appearance: none;
112
+ height: 6px;
113
+ border-radius: 3px;
114
+ background: var(--border-color);
115
+ outline: none;
116
+ cursor: pointer;
117
+ width: 100%;
118
+ transition: background 0.2s ease;
119
+ }
120
+
121
+ .d3-equation-editor .controls input[type="range"]:hover {
122
+ background: var(--muted-color);
123
+ }
124
+
125
+ .d3-equation-editor .controls input[type="range"]::-webkit-slider-thumb {
126
+ -webkit-appearance: none;
127
+ appearance: none;
128
+ width: 18px;
129
+ height: 18px;
130
+ border-radius: 50%;
131
+ background: var(--primary-color);
132
+ cursor: pointer;
133
+ border: 2px solid var(--page-bg);
134
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
135
+ transition: all 0.2s ease;
136
+ }
137
+
138
+ .d3-equation-editor .controls input[type="range"]::-webkit-slider-thumb:hover {
139
+ background: var(--primary-color-hover);
140
+ transform: scale(1.1);
141
+ }
142
+
143
+ .d3-equation-editor .controls input[type="range"]::-moz-range-thumb {
144
+ width: 18px;
145
+ height: 18px;
146
+ border-radius: 50%;
147
+ background: var(--primary-color);
148
+ cursor: pointer;
149
+ border: 2px solid var(--page-bg);
150
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
151
+ transition: all 0.2s ease;
152
+ }
153
+
154
+ .d3-equation-editor .controls input[type="range"]::-moz-range-thumb:hover {
155
+ background: var(--primary-color-hover);
156
+ transform: scale(1.1);
157
+ }
158
+
159
+ .d3-equation-editor .legend-bottom {
160
+ display: flex;
161
+ flex-direction: column;
162
+ align-items: flex-start;
163
+ gap: 6px;
164
+ font-size: 12px;
165
+ color: var(--text-color);
166
+ }
167
+
168
+ .d3-equation-editor .legend-bottom .legend-title {
169
+ font-size: 12px;
170
+ font-weight: 700;
171
+ color: var(--text-color);
172
+ }
173
+
174
+ .d3-equation-editor .legend-bottom .items {
175
+ display: flex;
176
+ flex-wrap: wrap;
177
+ gap: 8px 14px;
178
+ }
179
+
180
+ .d3-equation-editor .legend-bottom .item {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ gap: 6px;
184
+ white-space: nowrap;
185
+ }
186
+
187
+ .d3-equation-editor .legend-bottom .swatch {
188
+ width: 14px;
189
+ height: 14px;
190
+ border-radius: 3px;
191
+ border: 1px solid var(--border-color);
192
+ display: inline-block;
193
+ }
194
+
195
+ .d3-equation-editor .axis-label {
196
+ fill: var(--text-color);
197
+ font-size: 12px;
198
+ font-weight: 700;
199
+ }
200
+
201
+ .d3-equation-editor .axes path,
202
+ .d3-equation-editor .axes line {
203
+ stroke: var(--axis-color);
204
+ }
205
+
206
+ .d3-equation-editor .axes text {
207
+ fill: var(--tick-color);
208
+ }
209
+
210
+ .d3-equation-editor .grid line {
211
+ stroke: var(--grid-color);
212
+ stroke-width: 1;
213
+ shape-rendering: crispEdges;
214
+ }
215
+
216
+ .d3-equation-editor .function-curve {
217
+ fill: none;
218
+ stroke-width: 2.5;
219
+ stroke-linejoin: round;
220
+ stroke-linecap: round;
221
+ }
222
+
223
+ .d3-equation-editor .d3-tooltip {
224
+ z-index: var(--z-tooltip);
225
+ backdrop-filter: saturate(1.12) blur(8px);
226
+ }
227
+
228
+ .d3-equation-editor .error-message {
229
+ color: var(--danger, #b00020);
230
+ font-size: 11px;
231
+ margin-top: 4px;
232
+ font-style: italic;
233
+ }
234
+
235
+ .d3-equation-editor .examples {
236
+ width: 100%;
237
+ }
238
+
239
+ .d3-equation-editor .examples .button {
240
+ margin: 0 10px 10px 0;
241
+ }
242
+ </style>
243
+ <script>
244
+ (() => {
245
+ const ensureD3 = (cb) => {
246
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
247
+ let s = document.getElementById('d3-cdn-script');
248
+ if (!s) {
249
+ s = document.createElement('script');
250
+ s.id = 'd3-cdn-script';
251
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
252
+ document.head.appendChild(s);
253
+ }
254
+ const onReady = () => {
255
+ if (window.d3 && typeof window.d3.select === 'function') cb();
256
+ };
257
+ s.addEventListener('load', onReady, { once: true });
258
+ if (window.d3) onReady();
259
+ };
260
+
261
+ const bootstrap = () => {
262
+ const scriptEl = document.currentScript;
263
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
264
+ if (!(container && container.classList && container.classList.contains('d3-equation-editor'))) {
265
+ const candidates = Array.from(document.querySelectorAll('.d3-equation-editor'))
266
+ .filter(el => !(el.dataset && el.dataset.mounted === 'true'));
267
+ container = candidates[candidates.length - 1] || null;
268
+ }
269
+ if (!container) return;
270
+ if (container.dataset) {
271
+ if (container.dataset.mounted === 'true') return;
272
+ container.dataset.mounted = 'true';
273
+ }
274
+
275
+ // Controls
276
+ const controls = document.createElement('div');
277
+ controls.className = 'controls';
278
+
279
+ // Input row (equation + domain)
280
+ const inputRow = document.createElement('div');
281
+ inputRow.className = 'input-row';
282
+
283
+ // Equation input
284
+ const groupEquation = document.createElement('div');
285
+ groupEquation.className = 'control-group equation-group';
286
+ const labelEquation = document.createElement('label');
287
+ labelEquation.textContent = 'Equation f(x) =';
288
+ const inputEquation = document.createElement('input');
289
+ inputEquation.type = 'text';
290
+ inputEquation.value = 'sin(x) * exp(-x^2/8) + 0.3*sin(3*x)';
291
+ inputEquation.placeholder = 'e.g., sin(x)*exp(-x^2/8), x^3 - 3*x, sin(x) + cos(2*x)';
292
+ groupEquation.appendChild(labelEquation);
293
+ groupEquation.appendChild(inputEquation);
294
+
295
+ // Domain range
296
+ const groupRange = document.createElement('div');
297
+ groupRange.className = 'control-group domain-group';
298
+ const labelRange = document.createElement('label');
299
+ labelRange.textContent = 'Domain';
300
+ const inputRange = document.createElement('input');
301
+ inputRange.type = 'range';
302
+ inputRange.min = '1';
303
+ inputRange.max = '10';
304
+ inputRange.step = '0.5';
305
+ inputRange.value = '4';
306
+ const rangeValue = document.createElement('span');
307
+ rangeValue.style.fontSize = '13px';
308
+ rangeValue.style.color = 'var(--muted-color)';
309
+ rangeValue.style.fontWeight = '500';
310
+ rangeValue.style.marginTop = '4px';
311
+ rangeValue.textContent = '[-4Ο€, 4Ο€]';
312
+ groupRange.appendChild(labelRange);
313
+ groupRange.appendChild(inputRange);
314
+ groupRange.appendChild(rangeValue);
315
+
316
+ inputRow.appendChild(groupEquation);
317
+ inputRow.appendChild(groupRange);
318
+
319
+ // Examples (more focused and pertinent)
320
+ const examples = document.createElement('div');
321
+ examples.className = 'examples';
322
+ const exampleFunctions = [
323
+ 'sin(x) * exp(-x^2/8)',
324
+ 'sin(x) + 0.5*cos(2*x)',
325
+ 'x^3 - 3*x',
326
+ 'sin(x) * exp(-x^2/8) + 0.3*sin(3*x)',
327
+ 'exp(-x^2/2) * cos(4*x)',
328
+ 'sin(x) + sin(3*x)/3 + sin(5*x)/5'
329
+ ];
330
+ exampleFunctions.forEach(func => {
331
+ const btn = document.createElement('button');
332
+ btn.className = 'button button--ghost';
333
+ btn.textContent = func;
334
+ btn.addEventListener('click', () => {
335
+ inputEquation.value = func;
336
+ updatePlot();
337
+ });
338
+ examples.appendChild(btn);
339
+ });
340
+
341
+ controls.appendChild(inputRow);
342
+ controls.appendChild(examples);
343
+
344
+ // Error message
345
+ const errorMsg = document.createElement('div');
346
+ errorMsg.className = 'error-message';
347
+ errorMsg.style.display = 'none';
348
+
349
+ // Header (controls only) to be placed after chart
350
+ const header = document.createElement('div');
351
+ header.className = 'chart-header';
352
+ header.appendChild(controls);
353
+
354
+ // SVG scaffolding inside a card wrapper
355
+ const card = document.createElement('div');
356
+ card.className = 'chart-card';
357
+ container.appendChild(card);
358
+ container.appendChild(header);
359
+ container.appendChild(errorMsg);
360
+
361
+ const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
362
+ const gRoot = svg.append('g');
363
+ const gGrid = gRoot.append('g').attr('class', 'grid');
364
+ const gAxes = gRoot.append('g').attr('class', 'axes');
365
+ const gCurve = gRoot.append('g').attr('class', 'curve');
366
+
367
+ // Tooltip
368
+ container.style.position = container.style.position || 'relative';
369
+ let tip = container.querySelector('.d3-tooltip');
370
+ let tipInner;
371
+ if (!tip) {
372
+ tip = document.createElement('div');
373
+ tip.className = 'd3-tooltip';
374
+ Object.assign(tip.style, {
375
+ position: 'absolute',
376
+ top: '0px',
377
+ left: '0px',
378
+ transform: 'translate(-9999px, -9999px)',
379
+ pointerEvents: 'none',
380
+ padding: '8px 10px',
381
+ borderRadius: 'var(--button-radius)',
382
+ fontSize: '12px',
383
+ lineHeight: '1.35',
384
+ border: '1px solid var(--border-color)',
385
+ background: 'var(--surface-bg)',
386
+ color: 'var(--text-color)',
387
+ boxShadow: '0 4px 24px rgba(0,0,0,.18)',
388
+ opacity: '0',
389
+ transition: 'opacity .12s ease'
390
+ });
391
+ tipInner = document.createElement('div');
392
+ tipInner.className = 'd3-tooltip__inner';
393
+ tipInner.style.textAlign = 'left';
394
+ tip.appendChild(tipInner);
395
+ container.appendChild(tip);
396
+ } else {
397
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
398
+ }
399
+
400
+ // State
401
+ let width = 800, height = 480;
402
+ const margin = { top: 16, right: 32, bottom: 44, left: 56 };
403
+ const xScale = d3.scaleLinear();
404
+ const yScale = d3.scaleLinear();
405
+ const line = d3.line()
406
+ .x(d => xScale(d.x))
407
+ .y(d => yScale(d.y))
408
+ .curve(d3.curveCardinal);
409
+
410
+ // Math parser - improved to handle complex expressions and exponents correctly
411
+ function safeEval(expr, x) {
412
+ try {
413
+ // First, replace x with the actual value in parentheses for safety
414
+ let cleanExpr = expr.replace(/\bx\b/g, `(${x})`);
415
+
416
+ // Replace math functions and constants
417
+ cleanExpr = cleanExpr
418
+ .replace(/\bsin\b/g, 'Math.sin')
419
+ .replace(/\bcos\b/g, 'Math.cos')
420
+ .replace(/\btan\b/g, 'Math.tan')
421
+ .replace(/\bexp\b/g, 'Math.exp')
422
+ .replace(/\blog\b/g, 'Math.log')
423
+ .replace(/\babs\b/g, 'Math.abs')
424
+ .replace(/\bsqrt\b/g, 'Math.sqrt')
425
+ .replace(/\bpi\b/g, 'Math.PI')
426
+ .replace(/\be\b/g, 'Math.E');
427
+
428
+ // Handle exponents more carefully - need to preserve operator precedence
429
+ // Convert x^n to Math.pow(x, n) for proper precedence
430
+ cleanExpr = cleanExpr.replace(/([^*+\-\/\s]+)\^([^*+\-\/\s]+)/g, 'Math.pow($1, $2)');
431
+
432
+ // Handle remaining ^ operators (fallback to **)
433
+ cleanExpr = cleanExpr.replace(/\^/g, '**');
434
+
435
+ // Handle implicit multiplication (e.g., 2x -> 2*x, sin(x)cos(x) -> sin(x)*cos(x))
436
+ cleanExpr = cleanExpr
437
+ .replace(/(\d)(\()/g, '$1*$2') // 2( -> 2*(
438
+ .replace(/(\))(\()/g, '$1*$2') // )( -> )*(
439
+ .replace(/(\))(\d)/g, '$1*$2') // )2 -> )*2
440
+ .replace(/(\d)([a-zA-Z])/g, '$1*$2'); // 2x -> 2*x
441
+
442
+ // Security check: only allow safe mathematical operations
443
+ const safePattern = /^[0-9+\-*/.()Math\w\s,]*$/;
444
+ const withoutMath = cleanExpr.replace(/Math\.\w+/g, '');
445
+ if (!safePattern.test(withoutMath)) {
446
+ throw new Error('Invalid expression');
447
+ }
448
+
449
+ const result = eval(cleanExpr);
450
+ return isFinite(result) ? result : NaN;
451
+ } catch (e) {
452
+ return NaN;
453
+ }
454
+ }
455
+
456
+ function getColor() {
457
+ try {
458
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
459
+ return window.ColorPalettes.getColors('categorical', 1)[0];
460
+ }
461
+ } catch (_) { }
462
+ return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4e79a7';
463
+ }
464
+
465
+ function updateScales() {
466
+ width = container.clientWidth || 800;
467
+ height = Math.max(280, Math.round(width / 3));
468
+ svg.attr('width', width).attr('height', height);
469
+ const innerWidth = width - margin.left - margin.right;
470
+ const innerHeight = height - margin.top - margin.bottom;
471
+ gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
472
+
473
+ const domainSize = parseFloat(inputRange.value);
474
+ const xDomain = [-domainSize * Math.PI, domainSize * Math.PI];
475
+ xScale.domain(xDomain).range([0, innerWidth]);
476
+
477
+ // Calculate y domain based on current function
478
+ const equation = inputEquation.value.trim();
479
+ if (equation) {
480
+ const testPoints = d3.range(xDomain[0], xDomain[1], (xDomain[1] - xDomain[0]) / 100);
481
+ const yValues = testPoints.map(x => safeEval(equation, x)).filter(y => !isNaN(y) && isFinite(y));
482
+ if (yValues.length > 0) {
483
+ const yExtent = d3.extent(yValues);
484
+ const yPadding = (yExtent[1] - yExtent[0]) * 0.1 || 1;
485
+ yScale.domain([yExtent[0] - yPadding, yExtent[1] + yPadding]).range([innerHeight, 0]);
486
+ } else {
487
+ yScale.domain([-2, 2]).range([innerHeight, 0]);
488
+ }
489
+ } else {
490
+ yScale.domain([-2, 2]).range([innerHeight, 0]);
491
+ }
492
+
493
+ // Grid
494
+ gGrid.selectAll('*').remove();
495
+ gGrid.selectAll('line.grid-x').data(xScale.ticks(8)).join('line')
496
+ .attr('class', 'grid-x')
497
+ .attr('x1', d => xScale(d)).attr('x2', d => xScale(d))
498
+ .attr('y1', 0).attr('y2', innerHeight)
499
+ .attr('stroke', 'var(--grid-color)')
500
+ .attr('stroke-width', 1)
501
+ .attr('shape-rendering', 'crispEdges');
502
+
503
+ gGrid.selectAll('line.grid-y').data(yScale.ticks(6)).join('line')
504
+ .attr('class', 'grid-y')
505
+ .attr('x1', 0).attr('x2', innerWidth)
506
+ .attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
507
+ .attr('stroke', 'var(--grid-color)')
508
+ .attr('stroke-width', 1)
509
+ .attr('shape-rendering', 'crispEdges');
510
+
511
+ // Axes
512
+ gAxes.selectAll('*').remove();
513
+ gAxes.append('g')
514
+ .attr('transform', `translate(0,${innerHeight})`)
515
+ .call(d3.axisBottom(xScale).ticks(8).tickFormat(d => {
516
+ const val = d / Math.PI;
517
+ if (Math.abs(val) < 0.01) return '0';
518
+ if (Math.abs(val - 1) < 0.01) return 'Ο€';
519
+ if (Math.abs(val + 1) < 0.01) return '-Ο€';
520
+ if (Math.abs(val - 0.5) < 0.01) return 'Ο€/2';
521
+ if (Math.abs(val + 0.5) < 0.01) return '-Ο€/2';
522
+ if (Math.abs(val % 1) < 0.01) return `${Math.round(val)}Ο€`;
523
+ return d3.format('.1f')(val) + 'Ο€';
524
+ }))
525
+ .call(g => {
526
+ g.selectAll('path, line').attr('stroke', 'var(--axis-color)');
527
+ g.selectAll('text').attr('fill', 'var(--tick-color)');
528
+ });
529
+
530
+ gAxes.append('g')
531
+ .call(d3.axisLeft(yScale).ticks(6))
532
+ .call(g => {
533
+ g.selectAll('path, line').attr('stroke', 'var(--axis-color)');
534
+ g.selectAll('text').attr('fill', 'var(--tick-color)');
535
+ });
536
+
537
+ // Axis labels
538
+ gAxes.append('text')
539
+ .attr('class', 'axis-label axis-label--x')
540
+ .attr('x', innerWidth / 2)
541
+ .attr('y', innerHeight + 44)
542
+ .attr('text-anchor', 'middle')
543
+ .text('x');
544
+
545
+ gAxes.append('text')
546
+ .attr('class', 'axis-label axis-label--y')
547
+ .attr('text-anchor', 'middle')
548
+ .attr('transform', `translate(${-44},${innerHeight / 2}) rotate(-90)`)
549
+ .text('f(x)');
550
+
551
+ return { innerWidth, innerHeight };
552
+ }
553
+
554
+ function updatePlot() {
555
+ errorMsg.style.display = 'none';
556
+ const equation = inputEquation.value.trim();
557
+
558
+ if (!equation) {
559
+ gCurve.selectAll('*').remove();
560
+ return;
561
+ }
562
+
563
+ updateScales();
564
+
565
+ // Generate data points
566
+ const domainSize = parseFloat(inputRange.value);
567
+ const xDomain = [-domainSize * Math.PI, domainSize * Math.PI];
568
+ const numPoints = Math.max(200, Math.min(1000, Math.round((xDomain[1] - xDomain[0]) * 50)));
569
+ const data = [];
570
+ let hasValidPoints = false;
571
+ let errorCount = 0;
572
+
573
+ for (let i = 0; i <= numPoints; i++) {
574
+ const x = xDomain[0] + (i / numPoints) * (xDomain[1] - xDomain[0]);
575
+ const y = safeEval(equation, x);
576
+
577
+ if (!isNaN(y) && isFinite(y)) {
578
+ data.push({ x, y });
579
+ hasValidPoints = true;
580
+ } else {
581
+ errorCount++;
582
+ }
583
+ }
584
+
585
+ if (!hasValidPoints) {
586
+ errorMsg.textContent = `Error: unable to evaluate equation "${equation}"`;
587
+ errorMsg.style.display = 'block';
588
+ gCurve.selectAll('*').remove();
589
+ return;
590
+ }
591
+
592
+ if (errorCount > numPoints * 0.5) {
593
+ errorMsg.textContent = `Warning: ${errorCount} invalid points out of ${numPoints}`;
594
+ errorMsg.style.display = 'block';
595
+ }
596
+
597
+ // Draw the curve
598
+ const color = getColor();
599
+ const path = gCurve.selectAll('path.function-curve').data([data]);
600
+
601
+ path.enter()
602
+ .append('path')
603
+ .attr('class', 'function-curve')
604
+ .attr('stroke', color)
605
+ .merge(path)
606
+ .transition()
607
+ .duration(150)
608
+ .attr('d', line)
609
+ .attr('stroke', color);
610
+
611
+ path.exit().remove();
612
+
613
+ // Hover interaction
614
+ const overlay = gCurve.selectAll('rect.overlay').data([0]);
615
+ const { innerWidth, innerHeight } = updateScales();
616
+
617
+ overlay.enter()
618
+ .append('rect')
619
+ .attr('class', 'overlay')
620
+ .attr('fill', 'transparent')
621
+ .style('cursor', 'crosshair')
622
+ .merge(overlay)
623
+ .attr('width', innerWidth)
624
+ .attr('height', innerHeight)
625
+ .on('mousemove', function (event) {
626
+ const [mx] = d3.pointer(event, this);
627
+ const x = xScale.invert(mx);
628
+ const y = safeEval(equation, x);
629
+
630
+ if (!isNaN(y) && isFinite(y)) {
631
+ tipInner.innerHTML = `
632
+ <div><strong>f(${x.toFixed(3)}) = ${y.toFixed(3)}</strong></div>
633
+ <div style="font-size:11px;color:var(--muted-color);margin-top:2px;">${equation}</div>
634
+ `;
635
+ tip.style.opacity = '1';
636
+ const tx = Math.max(0, Math.min(mx + margin.left + 12, (container.clientWidth || 0) - (tip.offsetWidth + 6)));
637
+ const ty = Math.max(0, Math.min(yScale(y) + margin.top + 12, (container.clientHeight || 0) - (tip.offsetHeight + 6)));
638
+ tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
639
+ }
640
+ })
641
+ .on('mouseleave', function () {
642
+ tip.style.opacity = '0';
643
+ tip.style.transform = 'translate(-9999px, -9999px)';
644
+ });
645
+ }
646
+
647
+
648
+ // Event listeners
649
+ inputEquation.addEventListener('input', updatePlot);
650
+ inputRange.addEventListener('input', () => {
651
+ const val = parseFloat(inputRange.value);
652
+ rangeValue.textContent = `[-${val}Ο€, ${val}Ο€]`;
653
+ updatePlot();
654
+ });
655
+
656
+ // Initial setup (already done above)
657
+
658
+ // Initial render
659
+ updatePlot();
660
+
661
+ // Resize handling
662
+ const rerender = () => updatePlot();
663
+ if (window.ResizeObserver) {
664
+ const ro = new ResizeObserver(() => rerender());
665
+ ro.observe(container);
666
+ } else {
667
+ window.addEventListener('resize', rerender);
668
+ }
669
+ };
670
+
671
+ if (document.readyState === 'loading') {
672
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
673
+ } else {
674
+ ensureD3(bootstrap);
675
+ }
676
+ })();
677
+ </script>
app/src/content/embeds/d3-line-quad.html CHANGED
@@ -1,74 +1,190 @@
1
  <div class="line-quad">
2
-
3
  <div class="line-quad__grid">
4
  <div class="quad-cell" data-title="Formatting Filter" data-csv="/data/formatting_filters.csv"></div>
5
  <div class="quad-cell" data-title="Relevance Filter" data-csv="/data/relevance_filters.csv"></div>
6
  <div class="quad-cell" data-title="Visual Dependency Filter" data-csv="/data/visual_dependency_filters.csv"></div>
7
- <div class="quad-cell" data-title="Image Correspondence Filter" data-csv="/data/image_correspondence_filters.csv"></div>
 
8
  </div>
9
  <noscript>JavaScript is required to render these charts.</noscript>
10
 
11
  </div>
12
  <style>
13
- .line-quad { position: relative; }
 
 
 
14
  /* Axis/tick/grid use global variables from _variables.css */
15
  /* Apply axis/tick/grid purely via CSS */
16
  .line-quad .axes path,
17
- .line-quad .axes line { stroke: var(--axis-color); }
18
- .line-quad .axes text { fill: var(--tick-color); }
19
- .line-quad .grid line { stroke: var(--grid-color); }
20
- .line-quad__grid { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
21
- @media (max-width: 980px) { .line-quad__grid { grid-template-columns: 1fr; } }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
-
24
 
25
- .quad-cell { border:1px solid var(--border-color); border-radius:10px; background: var(--surface-bg); display:flex; flex-direction:column; position: relative; }
 
 
 
 
 
 
 
 
 
26
  /* Stacking order to ensure hover/tooltip overlays are not hidden by neighbors */
27
- .line-quad__grid .quad-cell:nth-child(1) { z-index: 4; } /* top-left */
28
- .line-quad__grid .quad-cell:nth-child(3) { z-index: 3; } /* bottom-left */
29
- .line-quad__grid .quad-cell:nth-child(2) { z-index: 2; } /* top-right */
30
- .line-quad__grid .quad-cell:nth-child(4) { z-index: 1; } /* bottom-right */
31
- .quad-cell .cell-header { padding:8px 10px; border-bottom:1px solid var(--border-color); display:flex; align-items:center; justify-content:space-between; gap:8px; }
32
- .quad-cell .cell-title { font-size: 13px; font-weight: 700; color: var(--text-color); }
33
- .quad-cell .cell-controls { display:flex; align-items:center; gap:12px; }
34
- .quad-cell .cell-controls label { font-size:12px; color: var(--muted-color); display:flex; align-items:center; gap:6px; white-space:nowrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  .quad-cell select {
36
- font-size: 12px; padding: 6px 28px 6px 10px; border: 1px solid var(--border-color); border-radius: 8px;
37
- background-color: var(--surface-bg); color: var(--text-color);
 
 
 
 
38
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
39
- background-repeat: no-repeat; background-position: right 8px center; background-size: 12px;
40
- -webkit-appearance: none; appearance: none; cursor: pointer; transition: border-color .15s ease, box-shadow .15s ease;
 
 
 
 
 
41
  }
 
42
  [data-theme="dark"] .quad-cell select {
43
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
44
  }
45
- .quad-cell select:hover { border-color: var(--primary-color); }
46
- .quad-cell select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(232,137,171,.25); outline: none; }
47
- .quad-cell .cell-body { position: relative; }
48
- .quad-cell .cell-body { width:100%; overflow:hidden; }
49
- .quad-cell .cell-body svg { max-width:100%; height:auto; }
50
-
51
- .line-quad.hovering .lines path.ghost { opacity: .25; }
52
- .line-quad.hovering .points circle.ghost { opacity: .25; }
53
- .line-quad.hovering .areas path.ghost { opacity: .08; }
54
- .line-quad.hovering .legend-bottom .item.ghost { opacity: .35; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  /* Tooltip refined styling */
56
  .line-quad .d3-tooltip {
57
  z-index: 20;
58
  backdrop-filter: saturate(1.12) blur(8px);
59
  }
 
60
  .line-quad .d3-tooltip__inner {
61
  display: flex;
62
  flex-direction: column;
63
  gap: 6px;
64
  min-width: 220px;
65
  }
66
- .line-quad .d3-tooltip__inner > div:first-child {
 
67
  font-weight: 800;
68
  letter-spacing: 0.1px;
69
  margin-bottom: 0;
70
  }
71
- .line-quad .d3-tooltip__inner > div:nth-child(2) {
 
72
  font-size: 11px;
73
  color: var(--muted-color);
74
  display: block;
@@ -76,18 +192,22 @@
76
  margin-bottom: 2px;
77
  letter-spacing: 0.1px;
78
  }
79
- .line-quad .d3-tooltip__inner > div:nth-child(n+3) {
 
80
  padding-top: 6px;
81
  border-top: 1px solid var(--border-color);
82
  }
 
83
  .line-quad .d3-tooltip__inner svg {
84
  display: inline-block;
85
  vertical-align: middle;
86
  margin-right: 2px;
87
  }
 
88
  .line-quad .d3-tooltip__inner strong {
89
  margin-right: 6px;
90
  }
 
91
  .line-quad .d3-tooltip__color-dot {
92
  display: inline-block;
93
  width: 12px;
@@ -95,6 +215,7 @@
95
  border-radius: 3px;
96
  border: 1px solid var(--border-color);
97
  }
 
98
  /* Header layout (like d3-line-simple) */
99
  .line-quad__header {
100
  display: flex;
@@ -104,6 +225,7 @@
104
  margin: 8px 0 0 0;
105
  flex-wrap: wrap;
106
  }
 
107
  .line-quad__header .legend-bottom {
108
  display: flex;
109
  flex-direction: column;
@@ -112,22 +234,26 @@
112
  font-size: 12px;
113
  color: var(--text-color);
114
  }
 
115
  .line-quad__header .legend-bottom .legend-title {
116
  font-size: 12px;
117
  font-weight: 700;
118
  color: var(--text-color);
119
  }
 
120
  .line-quad__header .legend-bottom .items {
121
  display: flex;
122
  flex-wrap: wrap;
123
  gap: 8px 14px;
124
  }
 
125
  .line-quad__header .legend-bottom .item {
126
  display: inline-flex;
127
  align-items: center;
128
  gap: 6px;
129
  white-space: nowrap;
130
  }
 
131
  .line-quad__header .legend-bottom .swatch {
132
  width: 14px;
133
  height: 14px;
@@ -135,6 +261,7 @@
135
  border: 1px solid var(--border-color);
136
  display: inline-block;
137
  }
 
138
  .line-quad .controls {
139
  margin-top: 0;
140
  display: flex;
@@ -144,12 +271,14 @@
144
  width: auto;
145
  flex-wrap: wrap;
146
  }
 
147
  .line-quad .controls .control-group {
148
  display: flex;
149
  flex-direction: column;
150
  align-items: flex-start;
151
  gap: 6px;
152
  }
 
153
  .line-quad .controls label {
154
  font-size: 12px;
155
  color: var(--text-color);
@@ -159,6 +288,7 @@
159
  white-space: nowrap;
160
  font-weight: 700;
161
  }
 
162
  .line-quad .controls select {
163
  font-size: 12px;
164
  padding: 8px 28px 8px 10px;
@@ -175,11 +305,20 @@
175
  cursor: pointer;
176
  transition: border-color .15s ease, box-shadow .15s ease;
177
  }
 
178
  [data-theme="dark"] .line-quad .controls select {
179
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
180
  }
181
- .line-quad .controls select:hover { border-color: var(--primary-color); }
182
- .line-quad .controls select:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(232,137,171,.25); outline: none; }
 
 
 
 
 
 
 
 
183
  </style>
184
  <script>
185
  (() => {
@@ -208,7 +347,7 @@
208
  s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
209
  };
210
 
211
- function initRunLine(cell){
212
  const d3 = window.d3;
213
  const csvPath = cell.getAttribute('data-csv');
214
  const titleText = cell.getAttribute('data-title') || '';
@@ -221,14 +360,14 @@
221
 
222
  // Body & SVG
223
  const body = document.createElement('div'); body.className = 'cell-body'; cell.appendChild(body);
224
- const svg = d3.select(body).append('svg').attr('width','100%').style('display','block');
225
  const gRoot = svg.append('g');
226
- const gGrid = gRoot.append('g').attr('class','grid');
227
- const gAxes = gRoot.append('g').attr('class','axes');
228
- const gAreas = gRoot.append('g').attr('class','areas');
229
- const gLines = gRoot.append('g').attr('class','lines');
230
- const gPoints = gRoot.append('g').attr('class','points');
231
- const gHover = gRoot.append('g').attr('class','hover');
232
  // Removed per-cell legend; using global footer legend
233
 
234
  // Tooltip
@@ -238,26 +377,26 @@
238
  tip = document.createElement('div');
239
  tip.className = 'd3-tooltip';
240
  Object.assign(tip.style, {
241
- position:'absolute',
242
- top:'0',
243
- left:'0',
244
- transform:'translate(-9999px,-9999px)',
245
- pointerEvents:'none',
246
- padding:'10px 12px',
247
- borderRadius:'12px',
248
- fontSize:'12px',
249
- lineHeight:'1.35',
250
- border:'1px solid var(--border-color)',
251
- background:'var(--surface-bg)',
252
- color:'var(--text-color)',
253
- boxShadow:'0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
254
- opacity:'0',
255
- transition:'opacity .12s ease',
256
- backdropFilter:'saturate(1.12) blur(8px)'
257
  });
258
  tipInner = document.createElement('div');
259
  tipInner.className = 'd3-tooltip__inner';
260
- tipInner.style.textAlign='left';
261
  tip.appendChild(tipInner);
262
  cell.appendChild(tip);
263
  } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
@@ -270,18 +409,18 @@
270
  let isRankStrictFlag = false; let isRankMetricFlag = false; let rankTickMax = 1;
271
  let sharedYConfig = null; // { type: 'rank_strict', maxRank } | { type: 'value', min, max }
272
  let axisLabelY = 'Value';
273
-
274
  // Colors and markers (match original embeds)
275
  const getRunColors = (n) => {
276
- try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch(_) {}
277
- const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
278
- return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...(d3.schemeTableau10||[])].slice(0, n);
279
  };
280
  const pool = getRunColors(12);
281
- // Shapes supprimΓ©s: on n'utilise que la couleur
282
  // Ready signal for async load completion
283
  let readyResolve = null;
284
- const ready = new Promise((res)=> { readyResolve = res; });
285
 
286
  // Shared formatter for thousands: 5000 -> 5k, 1500 -> 1.5k (trim .0)
287
  const formatK = (v) => {
@@ -294,7 +433,7 @@
294
  return d3.format('d')(v);
295
  };
296
 
297
- function updateScales(){
298
  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
299
  const axisColor = 'var(--axis-color)';
300
  const tickColor = 'var(--tick-color)';
@@ -303,29 +442,29 @@
303
  const rect = cell.getBoundingClientRect();
304
  width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
305
  height = Math.max(280, Math.round(width / 2.3));
306
- svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','xMidYMid meet');
307
  const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
308
  gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
309
  xScale.range([0, innerWidth]); yScale.range([innerHeight, 0]);
310
 
311
  // Y ticks
312
  let yTicks = [];
313
- if (isRankStrictFlag) { const maxR = Math.max(1, Math.round(rankTickMax)); for (let v=1; v<=maxR; v+=1) yTicks.push(v); }
314
  else { yTicks = yScale.ticks(6); }
315
 
316
  // Grid
317
  gGrid.selectAll('*').remove();
318
  gGrid.selectAll('line').data(yTicks).join('line')
319
- .attr('x1',0).attr('x2',innerWidth).attr('y1',d=>yScale(d)).attr('y2',d=>yScale(d))
320
- .attr('stroke', gridColor).attr('stroke-width',1).attr('shape-rendering','crispEdges');
321
 
322
  // Axes
323
  gAxes.selectAll('*').remove();
324
  let xAxis = d3.axisBottom(xScale).tickSizeOuter(0); xAxis = xAxis.ticks(8);
325
  xAxis = xAxis.tickFormat(formatK);
326
  const yAxis = d3.axisLeft(yScale).tickValues(yTicks).tickSizeOuter(0).tickFormat(isRankStrictFlag ? d3.format('d') : d3.format('.2f'));
327
- gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(xAxis).call(g=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','11px'); });
328
- gAxes.append('g').call(yAxis).call(g=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','11px'); });
329
 
330
  // Axis labels
331
  gAxes.append('text')
@@ -352,16 +491,16 @@
352
  return { innerWidth, innerHeight, tickColor };
353
  }
354
 
355
- function renderMetric(metricKey){
356
  const map = dataByMetric.get(metricKey) || {};
357
  const runs = runOrder;
358
  let minStep = Infinity, maxStep = -Infinity, maxVal = 0, minVal = Infinity;
359
  const isRank = /rank/i.test(metricKey); const isAverage = /average/i.test(metricKey); const isRankStrict = isRank && !isAverage;
360
- runs.forEach(r => { (map[r]||[]).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep=Math.min(minStep,pt.step); maxStep=Math.max(maxStep,pt.step); maxVal=Math.max(maxVal,v); minVal=Math.min(minVal,v); }); });
361
  if (!isFinite(minStep) || !isFinite(maxStep)) return;
362
  xScale.domain([minStep, maxStep]);
363
  if (sharedYConfig && sharedYConfig.type === 'rank_strict') {
364
- rankTickMax = Math.max(1, Math.round(sharedYConfig.maxRank||1));
365
  yScale.domain([rankTickMax, 1]);
366
  isRankStrictFlag = true;
367
  isRankMetricFlag = true;
@@ -385,7 +524,7 @@
385
  const j = (typeof idx === 'number' ? idx : runs.indexOf(run));
386
  return pool[(j >= 0 ? j : 0) % pool.length];
387
  };
388
- const series = runs.map((r, i) => ({ run:r, color: colorForRun(r, i), values:(map[r]||[]).slice().sort((a,b)=>a.step-b.step).map(pt => isRankStrict ? { step: pt.step, value: Math.round(pt.value), stderr: pt.stderr } : pt) }));
389
 
390
  // zones Β± stderr (mΓ©triques non rank)
391
  gAreas.selectAll('*').remove();
@@ -396,9 +535,9 @@
396
  const upper = withErr.map(d => [xScale(d.step), yScale(d.value + d.stderr)]);
397
  const lower = withErr.slice().reverse().map(d => [xScale(d.step), yScale(d.value - d.stderr)]);
398
  const coords = upper.concat(lower);
399
- const pathData = d3.line().x(d=>d[0]).y(d=>d[1]).curve(d3.curveLinearClosed)(coords);
400
  gAreas.append('path')
401
- .attr('class','area')
402
  .attr('data-run', s.run)
403
  .attr('d', pathData)
404
  .attr('fill', s.color)
@@ -409,66 +548,68 @@
409
  });
410
  }
411
 
412
- const paths = gLines.selectAll('path.run-line').data(series, d=>d.run);
413
  paths.enter()
414
  .append('path')
415
- .attr('class','run-line')
416
- .attr('data-run', d=>d.run)
417
- .attr('fill','none')
418
  .attr('stroke-width', 1)
419
- .attr('opacity',0)
420
- .attr('stroke', d=>d.color)
421
- .attr('d', d=>lineGen(d.values))
422
  .transition(tChange || undefined)
423
- .attr('opacity',0.9);
424
  paths
425
  .transition(tChange || undefined)
426
- .attr('stroke', d=>d.color)
427
- .attr('opacity',0.9)
428
- .attr('d', d=>lineGen(d.values));
429
  paths.exit().remove();
430
 
431
  // Draw light point markers at each data sample (subtle)
432
- const allPoints = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value })));
433
- const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d=> `${d.run}-${d.step}`);
434
- ptsSel.enter().append('circle').attr('class','pt')
435
- .attr('data-run', d=>d.run)
436
  .attr('r', 1.5)
437
- .attr('fill', d=>d.color)
438
  .attr('fill-opacity', 0.6)
439
  .attr('stroke', 'none')
440
- .attr('cx', d=>xScale(d.step))
441
- .attr('cy', d=>yScale(d.value))
442
  .merge(ptsSel)
443
- .attr('fill', d=>d.color)
444
  .transition(tChange || undefined)
445
  .attr('r', 2)
446
- .attr('cx', d=>xScale(d.step))
447
- .attr('cy', d=>yScale(d.value));
448
  ptsSel.exit().remove();
449
 
450
  // No per-cell legend content (handled globally)
451
 
452
  // Hover
453
  gHover.selectAll('*').remove();
454
- const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight);
455
- const hoverLine = gHover.append('line').style('stroke','var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none');
456
- const stepSet = new Set(); series.forEach(s=>s.values.forEach(v=>stepSet.add(v.step))); const steps = Array.from(stepSet).sort((a,b)=>a-b);
457
- function onMove(ev){ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx,my]=d3.pointer(ev, overlay.node()); const nearest = steps.reduce((best,s)=> Math.abs(s - xScale.invert(mx)) < Math.abs(best - xScale.invert(mx)) ? s : best, steps[0]); const xpx = xScale(nearest); hoverLine.attr('x1',xpx).attr('x2',xpx).style('display',null);
 
458
  let html = `<div><strong>${titleText}</strong></div><div><strong>step</strong> ${formatK(nearest)}</div>`;
459
- const entries = series.map(s=>{ const map = new Map(s.values.map(v=>[v.step, v])); const pt = map.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null);
460
- entries.sort((a,b)=> (a.pt.value - b.pt.value));
461
- const fmt = (vv)=> (isRankStrictFlag? d3.format('d')(vv) : (+vv).toFixed(4));
462
  entries.forEach(e => {
463
- const err = (e.pt.stderr!=null && isFinite(e.pt.stderr) && e.pt.stderr>0) ? ` Β± ${fmt(e.pt.stderr)}` : '';
464
  html += `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}${err}</span></div>`;
465
  });
466
- tipInner.innerHTML = html; const offsetX=12, offsetY=12; tip.style.opacity='1'; tip.style.transform=`translate(${Math.round(mx+offsetX+margin.left)}px, ${Math.round(my+offsetY+margin.top)}px)`; }
467
- function onLeave(){ hideTipTimer = setTimeout(()=>{ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)'; hoverLine.style('display','none'); }, 100); }
 
468
  overlay.on('mousemove', onMove).on('mouseleave', onLeave);
469
  }
470
 
471
- async function load(){
472
  try {
473
  const file = (csvPath || '').split('/').pop();
474
  const CANDIDATES = [
@@ -479,18 +620,18 @@
479
  `../../assets/data/${file}`
480
  ].filter(Boolean);
481
  let text = null;
482
- for (const p of CANDIDATES){
483
- try { const r = await fetch(p, { cache:'no-cache' }); if (r.ok) { text = await r.text(); break; } } catch(e){}
484
  }
485
  if (text == null) throw new Error(`CSV not found: ${file}`);
486
- const rows = d3.csvParse(text, d => ({ run:(d.run||'').trim(), step:+d.step, metric:(d.metric||'').trim(), value:+d.value, stderr: (d.stderr!=null && d.stderr!=='') ? +d.stderr : null }));
487
- metricList = Array.from(new Set(rows.map(r=>r.metric))).sort();
488
- runList = Array.from(new Set(rows.map(r=>r.run))).sort(); runOrder = runList;
489
- metricList.forEach(m => { const map={}; runList.forEach(r=>map[r]=[]); rows.filter(r=>r.metric===m).forEach(r=>{ if(!isNaN(r.step)&&!isNaN(r.value)) map[r.run].push({ step:r.step, value:r.value, stderr:r.stderr }); }); dataByMetric.set(m, map); });
490
  const preferred = metricList.find(m => m === 'ai2d_exact_match') || metricList.find(m => /average_rank/i.test(m));
491
  const def = preferred || metricList[0];
492
  renderMetric(def);
493
- const ro = window.ResizeObserver ? new ResizeObserver(()=>renderMetric(def)) : null; if (ro) ro.observe(cell);
494
  if (typeof readyResolve === 'function') readyResolve();
495
  } catch (e) {
496
  const pre = document.createElement('pre'); pre.textContent = 'CSV load error: ' + (e && e.message ? e.message : e);
@@ -499,7 +640,7 @@
499
  }
500
  }
501
  load();
502
-
503
  return {
504
  ready,
505
  getMetrics: () => metricList.slice(),
@@ -508,7 +649,7 @@
508
  const key = m; const map = dataByMetric.get(key) || {}; const runs = runOrder;
509
  let maxVal = 0, minVal = Infinity; let minStep = Infinity, maxStep = -Infinity;
510
  const isRank = /rank/i.test(key); const isAverage = /average/i.test(key); const isRankStrict = isRank && !isAverage;
511
- runs.forEach(r => { (map[r]||[]).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep=Math.min(minStep,pt.step); maxStep=Math.max(maxStep,pt.step); maxVal=Math.max(maxVal,v); minVal=Math.min(minVal,v); }); });
512
  const rankMax = isRank ? Math.max(1, Math.round(maxVal)) : null;
513
  return { isRank, isRankStrict, min: maxVal === 0 && minVal === Infinity ? null : minVal, max: maxVal, rankMax };
514
  },
@@ -560,7 +701,7 @@
560
  const select = (headerEl || header).querySelector('.controls select');
561
  if (select) {
562
  select.innerHTML = '';
563
- metrics.forEach(m => { const o=document.createElement('option'); o.value=m; o.textContent=prettyMetricLabel(m); select.appendChild(o); });
564
  if (def) select.value = def;
565
  }
566
 
@@ -577,7 +718,7 @@
577
  const max = Math.max(...infos.map(info => info.max));
578
  instances.forEach(i => i && typeof i.setSharedY === 'function' && i.setSharedY({ type: 'value', min, max, key: metric }));
579
  }
580
- } catch (_) {}
581
  };
582
 
583
  const applyAll = (v) => { computeAndApplySharedY(v); instances.forEach(i => i && typeof i.setMetric === 'function' && i.setMetric(v)); };
@@ -589,14 +730,14 @@
589
  if (legendItemsHost) {
590
  try {
591
  const f = '/data/formatting_filters.csv';
592
- const r = await fetch(f, { cache:'no-cache' });
593
  if (r.ok && window.d3 && window.d3.csvParse) {
594
  const txt = await r.text();
595
  const rows = window.d3.csvParse(txt);
596
- const runList = Array.from(new Set(rows.map(row => String(row.run||'').trim()).filter(Boolean))).sort();
597
- const poolLegend = (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function')
598
  ? window.ColorPalettes.getColors('categorical', runList.length)
599
- : (()=>{ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB'; return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...((window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'])]; })();
600
  // Build shared run->color map once
601
  SHARED_RUN_COLOR = {};
602
  runList.forEach((name, i) => { SHARED_RUN_COLOR[name] = poolLegend[i % poolLegend.length]; });
@@ -608,7 +749,7 @@
608
  try {
609
  const currentMetric = (select && select.value) || def;
610
  if (currentMetric) applyAll(currentMetric);
611
- } catch {}
612
  // Legend hover ghosting across all cells
613
  legendItemsHost.querySelectorAll('.item').forEach(el => {
614
  el.addEventListener('mouseenter', () => {
@@ -632,14 +773,11 @@
632
  });
633
  });
634
  }
635
- } catch {}
636
  }
637
  })();
638
  };
639
 
640
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
641
  })();
642
- </script>
643
-
644
-
645
-
 
1
  <div class="line-quad">
2
+
3
  <div class="line-quad__grid">
4
  <div class="quad-cell" data-title="Formatting Filter" data-csv="/data/formatting_filters.csv"></div>
5
  <div class="quad-cell" data-title="Relevance Filter" data-csv="/data/relevance_filters.csv"></div>
6
  <div class="quad-cell" data-title="Visual Dependency Filter" data-csv="/data/visual_dependency_filters.csv"></div>
7
+ <div class="quad-cell" data-title="Image Correspondence Filter" data-csv="/data/image_correspondence_filters.csv">
8
+ </div>
9
  </div>
10
  <noscript>JavaScript is required to render these charts.</noscript>
11
 
12
  </div>
13
  <style>
14
+ .line-quad {
15
+ position: relative;
16
+ }
17
+
18
  /* Axis/tick/grid use global variables from _variables.css */
19
  /* Apply axis/tick/grid purely via CSS */
20
  .line-quad .axes path,
21
+ .line-quad .axes line {
22
+ stroke: var(--axis-color);
23
+ }
24
+
25
+ .line-quad .axes text {
26
+ fill: var(--tick-color);
27
+ }
28
+
29
+ .line-quad .grid line {
30
+ stroke: var(--grid-color);
31
+ }
32
+
33
+ .line-quad__grid {
34
+ display: grid;
35
+ grid-template-columns: repeat(2, minmax(0, 1fr));
36
+ gap: 12px;
37
+ }
38
+
39
+ @media (max-width: 980px) {
40
+ .line-quad__grid {
41
+ grid-template-columns: 1fr;
42
+ }
43
+ }
44
 
 
45
 
46
+
47
+ .quad-cell {
48
+ border: 1px solid var(--border-color);
49
+ border-radius: 10px;
50
+ background: var(--surface-bg);
51
+ display: flex;
52
+ flex-direction: column;
53
+ position: relative;
54
+ }
55
+
56
  /* Stacking order to ensure hover/tooltip overlays are not hidden by neighbors */
57
+ .line-quad__grid .quad-cell:nth-child(1) {
58
+ z-index: 4;
59
+ }
60
+
61
+ /* top-left */
62
+ .line-quad__grid .quad-cell:nth-child(3) {
63
+ z-index: 3;
64
+ }
65
+
66
+ /* bottom-left */
67
+ .line-quad__grid .quad-cell:nth-child(2) {
68
+ z-index: 2;
69
+ }
70
+
71
+ /* top-right */
72
+ .line-quad__grid .quad-cell:nth-child(4) {
73
+ z-index: 1;
74
+ }
75
+
76
+ /* bottom-right */
77
+ .quad-cell .cell-header {
78
+ padding: 8px 10px;
79
+ border-bottom: 1px solid var(--border-color);
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: space-between;
83
+ gap: 8px;
84
+ }
85
+
86
+ .quad-cell .cell-title {
87
+ font-size: 13px;
88
+ font-weight: 700;
89
+ color: var(--text-color);
90
+ }
91
+
92
+ .quad-cell .cell-controls {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 12px;
96
+ }
97
+
98
+ .quad-cell .cell-controls label {
99
+ font-size: 12px;
100
+ color: var(--muted-color);
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 6px;
104
+ white-space: nowrap;
105
+ }
106
+
107
  .quad-cell select {
108
+ font-size: 12px;
109
+ padding: 6px 28px 6px 10px;
110
+ border: 1px solid var(--border-color);
111
+ border-radius: 8px;
112
+ background-color: var(--surface-bg);
113
+ color: var(--text-color);
114
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
115
+ background-repeat: no-repeat;
116
+ background-position: right 8px center;
117
+ background-size: 12px;
118
+ -webkit-appearance: none;
119
+ appearance: none;
120
+ cursor: pointer;
121
+ transition: border-color .15s ease, box-shadow .15s ease;
122
  }
123
+
124
  [data-theme="dark"] .quad-cell select {
125
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
126
  }
127
+
128
+ .quad-cell select:hover {
129
+ border-color: var(--primary-color);
130
+ }
131
+
132
+ .quad-cell select:focus {
133
+ border-color: var(--primary-color);
134
+ box-shadow: 0 0 0 3px rgba(232, 137, 171, .25);
135
+ outline: none;
136
+ }
137
+
138
+ .quad-cell .cell-body {
139
+ position: relative;
140
+ }
141
+
142
+ .quad-cell .cell-body {
143
+ width: 100%;
144
+ overflow: hidden;
145
+ }
146
+
147
+ .quad-cell .cell-body svg {
148
+ max-width: 100%;
149
+ height: auto;
150
+ }
151
+
152
+ .line-quad.hovering .lines path.ghost {
153
+ opacity: .25;
154
+ }
155
+
156
+ .line-quad.hovering .points circle.ghost {
157
+ opacity: .25;
158
+ }
159
+
160
+ .line-quad.hovering .areas path.ghost {
161
+ opacity: .08;
162
+ }
163
+
164
+ .line-quad.hovering .legend-bottom .item.ghost {
165
+ opacity: .35;
166
+ }
167
+
168
  /* Tooltip refined styling */
169
  .line-quad .d3-tooltip {
170
  z-index: 20;
171
  backdrop-filter: saturate(1.12) blur(8px);
172
  }
173
+
174
  .line-quad .d3-tooltip__inner {
175
  display: flex;
176
  flex-direction: column;
177
  gap: 6px;
178
  min-width: 220px;
179
  }
180
+
181
+ .line-quad .d3-tooltip__inner>div:first-child {
182
  font-weight: 800;
183
  letter-spacing: 0.1px;
184
  margin-bottom: 0;
185
  }
186
+
187
+ .line-quad .d3-tooltip__inner>div:nth-child(2) {
188
  font-size: 11px;
189
  color: var(--muted-color);
190
  display: block;
 
192
  margin-bottom: 2px;
193
  letter-spacing: 0.1px;
194
  }
195
+
196
+ .line-quad .d3-tooltip__inner>div:nth-child(n+3) {
197
  padding-top: 6px;
198
  border-top: 1px solid var(--border-color);
199
  }
200
+
201
  .line-quad .d3-tooltip__inner svg {
202
  display: inline-block;
203
  vertical-align: middle;
204
  margin-right: 2px;
205
  }
206
+
207
  .line-quad .d3-tooltip__inner strong {
208
  margin-right: 6px;
209
  }
210
+
211
  .line-quad .d3-tooltip__color-dot {
212
  display: inline-block;
213
  width: 12px;
 
215
  border-radius: 3px;
216
  border: 1px solid var(--border-color);
217
  }
218
+
219
  /* Header layout (like d3-line-simple) */
220
  .line-quad__header {
221
  display: flex;
 
225
  margin: 8px 0 0 0;
226
  flex-wrap: wrap;
227
  }
228
+
229
  .line-quad__header .legend-bottom {
230
  display: flex;
231
  flex-direction: column;
 
234
  font-size: 12px;
235
  color: var(--text-color);
236
  }
237
+
238
  .line-quad__header .legend-bottom .legend-title {
239
  font-size: 12px;
240
  font-weight: 700;
241
  color: var(--text-color);
242
  }
243
+
244
  .line-quad__header .legend-bottom .items {
245
  display: flex;
246
  flex-wrap: wrap;
247
  gap: 8px 14px;
248
  }
249
+
250
  .line-quad__header .legend-bottom .item {
251
  display: inline-flex;
252
  align-items: center;
253
  gap: 6px;
254
  white-space: nowrap;
255
  }
256
+
257
  .line-quad__header .legend-bottom .swatch {
258
  width: 14px;
259
  height: 14px;
 
261
  border: 1px solid var(--border-color);
262
  display: inline-block;
263
  }
264
+
265
  .line-quad .controls {
266
  margin-top: 0;
267
  display: flex;
 
271
  width: auto;
272
  flex-wrap: wrap;
273
  }
274
+
275
  .line-quad .controls .control-group {
276
  display: flex;
277
  flex-direction: column;
278
  align-items: flex-start;
279
  gap: 6px;
280
  }
281
+
282
  .line-quad .controls label {
283
  font-size: 12px;
284
  color: var(--text-color);
 
288
  white-space: nowrap;
289
  font-weight: 700;
290
  }
291
+
292
  .line-quad .controls select {
293
  font-size: 12px;
294
  padding: 8px 28px 8px 10px;
 
305
  cursor: pointer;
306
  transition: border-color .15s ease, box-shadow .15s ease;
307
  }
308
+
309
  [data-theme="dark"] .line-quad .controls select {
310
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
311
  }
312
+
313
+ .line-quad .controls select:hover {
314
+ border-color: var(--primary-color);
315
+ }
316
+
317
+ .line-quad .controls select:focus {
318
+ border-color: var(--primary-color);
319
+ box-shadow: 0 0 0 3px rgba(232, 137, 171, .25);
320
+ outline: none;
321
+ }
322
  </style>
323
  <script>
324
  (() => {
 
347
  s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
348
  };
349
 
350
+ function initRunLine(cell) {
351
  const d3 = window.d3;
352
  const csvPath = cell.getAttribute('data-csv');
353
  const titleText = cell.getAttribute('data-title') || '';
 
360
 
361
  // Body & SVG
362
  const body = document.createElement('div'); body.className = 'cell-body'; cell.appendChild(body);
363
+ const svg = d3.select(body).append('svg').attr('width', '100%').style('display', 'block');
364
  const gRoot = svg.append('g');
365
+ const gGrid = gRoot.append('g').attr('class', 'grid');
366
+ const gAxes = gRoot.append('g').attr('class', 'axes');
367
+ const gAreas = gRoot.append('g').attr('class', 'areas');
368
+ const gLines = gRoot.append('g').attr('class', 'lines');
369
+ const gPoints = gRoot.append('g').attr('class', 'points');
370
+ const gHover = gRoot.append('g').attr('class', 'hover');
371
  // Removed per-cell legend; using global footer legend
372
 
373
  // Tooltip
 
377
  tip = document.createElement('div');
378
  tip.className = 'd3-tooltip';
379
  Object.assign(tip.style, {
380
+ position: 'absolute',
381
+ top: '0',
382
+ left: '0',
383
+ transform: 'translate(-9999px,-9999px)',
384
+ pointerEvents: 'none',
385
+ padding: '10px 12px',
386
+ borderRadius: '12px',
387
+ fontSize: '12px',
388
+ lineHeight: '1.35',
389
+ border: '1px solid var(--border-color)',
390
+ background: 'var(--surface-bg)',
391
+ color: 'var(--text-color)',
392
+ boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
393
+ opacity: '0',
394
+ transition: 'opacity .12s ease',
395
+ backdropFilter: 'saturate(1.12) blur(8px)'
396
  });
397
  tipInner = document.createElement('div');
398
  tipInner.className = 'd3-tooltip__inner';
399
+ tipInner.style.textAlign = 'left';
400
  tip.appendChild(tipInner);
401
  cell.appendChild(tip);
402
  } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
 
409
  let isRankStrictFlag = false; let isRankMetricFlag = false; let rankTickMax = 1;
410
  let sharedYConfig = null; // { type: 'rank_strict', maxRank } | { type: 'value', min, max }
411
  let axisLabelY = 'Value';
412
+
413
  // Colors and markers (match original embeds)
414
  const getRunColors = (n) => {
415
+ try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch (_) { }
416
+ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
417
+ return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...(d3.schemeTableau10 || [])].slice(0, n);
418
  };
419
  const pool = getRunColors(12);
420
+ // Shapes removed: we only use color
421
  // Ready signal for async load completion
422
  let readyResolve = null;
423
+ const ready = new Promise((res) => { readyResolve = res; });
424
 
425
  // Shared formatter for thousands: 5000 -> 5k, 1500 -> 1.5k (trim .0)
426
  const formatK = (v) => {
 
433
  return d3.format('d')(v);
434
  };
435
 
436
+ function updateScales() {
437
  const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
438
  const axisColor = 'var(--axis-color)';
439
  const tickColor = 'var(--tick-color)';
 
442
  const rect = cell.getBoundingClientRect();
443
  width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
444
  height = Math.max(280, Math.round(width / 2.3));
445
+ svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio', 'xMidYMid meet');
446
  const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
447
  gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
448
  xScale.range([0, innerWidth]); yScale.range([innerHeight, 0]);
449
 
450
  // Y ticks
451
  let yTicks = [];
452
+ if (isRankStrictFlag) { const maxR = Math.max(1, Math.round(rankTickMax)); for (let v = 1; v <= maxR; v += 1) yTicks.push(v); }
453
  else { yTicks = yScale.ticks(6); }
454
 
455
  // Grid
456
  gGrid.selectAll('*').remove();
457
  gGrid.selectAll('line').data(yTicks).join('line')
458
+ .attr('x1', 0).attr('x2', innerWidth).attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
459
+ .attr('stroke', gridColor).attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
460
 
461
  // Axes
462
  gAxes.selectAll('*').remove();
463
  let xAxis = d3.axisBottom(xScale).tickSizeOuter(0); xAxis = xAxis.ticks(8);
464
  xAxis = xAxis.tickFormat(formatK);
465
  const yAxis = d3.axisLeft(yScale).tickValues(yTicks).tickSizeOuter(0).tickFormat(isRankStrictFlag ? d3.format('d') : d3.format('.2f'));
466
+ gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(xAxis).call(g => { g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size', '11px'); });
467
+ gAxes.append('g').call(yAxis).call(g => { g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size', '11px'); });
468
 
469
  // Axis labels
470
  gAxes.append('text')
 
491
  return { innerWidth, innerHeight, tickColor };
492
  }
493
 
494
+ function renderMetric(metricKey) {
495
  const map = dataByMetric.get(metricKey) || {};
496
  const runs = runOrder;
497
  let minStep = Infinity, maxStep = -Infinity, maxVal = 0, minVal = Infinity;
498
  const isRank = /rank/i.test(metricKey); const isAverage = /average/i.test(metricKey); const isRankStrict = isRank && !isAverage;
499
+ runs.forEach(r => { (map[r] || []).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); maxVal = Math.max(maxVal, v); minVal = Math.min(minVal, v); }); });
500
  if (!isFinite(minStep) || !isFinite(maxStep)) return;
501
  xScale.domain([minStep, maxStep]);
502
  if (sharedYConfig && sharedYConfig.type === 'rank_strict') {
503
+ rankTickMax = Math.max(1, Math.round(sharedYConfig.maxRank || 1));
504
  yScale.domain([rankTickMax, 1]);
505
  isRankStrictFlag = true;
506
  isRankMetricFlag = true;
 
524
  const j = (typeof idx === 'number' ? idx : runs.indexOf(run));
525
  return pool[(j >= 0 ? j : 0) % pool.length];
526
  };
527
+ const series = runs.map((r, i) => ({ run: r, color: colorForRun(r, i), values: (map[r] || []).slice().sort((a, b) => a.step - b.step).map(pt => isRankStrict ? { step: pt.step, value: Math.round(pt.value), stderr: pt.stderr } : pt) }));
528
 
529
  // zones Β± stderr (mΓ©triques non rank)
530
  gAreas.selectAll('*').remove();
 
535
  const upper = withErr.map(d => [xScale(d.step), yScale(d.value + d.stderr)]);
536
  const lower = withErr.slice().reverse().map(d => [xScale(d.step), yScale(d.value - d.stderr)]);
537
  const coords = upper.concat(lower);
538
+ const pathData = d3.line().x(d => d[0]).y(d => d[1]).curve(d3.curveLinearClosed)(coords);
539
  gAreas.append('path')
540
+ .attr('class', 'area')
541
  .attr('data-run', s.run)
542
  .attr('d', pathData)
543
  .attr('fill', s.color)
 
548
  });
549
  }
550
 
551
+ const paths = gLines.selectAll('path.run-line').data(series, d => d.run);
552
  paths.enter()
553
  .append('path')
554
+ .attr('class', 'run-line')
555
+ .attr('data-run', d => d.run)
556
+ .attr('fill', 'none')
557
  .attr('stroke-width', 1)
558
+ .attr('opacity', 0)
559
+ .attr('stroke', d => d.color)
560
+ .attr('d', d => lineGen(d.values))
561
  .transition(tChange || undefined)
562
+ .attr('opacity', 0.9);
563
  paths
564
  .transition(tChange || undefined)
565
+ .attr('stroke', d => d.color)
566
+ .attr('opacity', 0.9)
567
+ .attr('d', d => lineGen(d.values));
568
  paths.exit().remove();
569
 
570
  // Draw light point markers at each data sample (subtle)
571
+ const allPoints = series.flatMap(s => s.values.map(v => ({ run: s.run, color: s.color, step: v.step, value: v.value })));
572
+ const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d => `${d.run}-${d.step}`);
573
+ ptsSel.enter().append('circle').attr('class', 'pt')
574
+ .attr('data-run', d => d.run)
575
  .attr('r', 1.5)
576
+ .attr('fill', d => d.color)
577
  .attr('fill-opacity', 0.6)
578
  .attr('stroke', 'none')
579
+ .attr('cx', d => xScale(d.step))
580
+ .attr('cy', d => yScale(d.value))
581
  .merge(ptsSel)
582
+ .attr('fill', d => d.color)
583
  .transition(tChange || undefined)
584
  .attr('r', 2)
585
+ .attr('cx', d => xScale(d.step))
586
+ .attr('cy', d => yScale(d.value));
587
  ptsSel.exit().remove();
588
 
589
  // No per-cell legend content (handled globally)
590
 
591
  // Hover
592
  gHover.selectAll('*').remove();
593
+ const overlay = gHover.append('rect').attr('fill', 'transparent').style('cursor', 'crosshair').attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
594
+ const hoverLine = gHover.append('line').style('stroke', 'var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width', 1).attr('y1', 0).attr('y2', innerHeight).style('display', 'none');
595
+ const stepSet = new Set(); series.forEach(s => s.values.forEach(v => stepSet.add(v.step))); const steps = Array.from(stepSet).sort((a, b) => a - b);
596
+ function onMove(ev) {
597
+ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx, my] = d3.pointer(ev, overlay.node()); const nearest = steps.reduce((best, s) => Math.abs(s - xScale.invert(mx)) < Math.abs(best - xScale.invert(mx)) ? s : best, steps[0]); const xpx = xScale(nearest); hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
598
  let html = `<div><strong>${titleText}</strong></div><div><strong>step</strong> ${formatK(nearest)}</div>`;
599
+ const entries = series.map(s => { const map = new Map(s.values.map(v => [v.step, v])); const pt = map.get(nearest); return { run: s.run, color: s.color, pt }; }).filter(e => e.pt && e.pt.value != null);
600
+ entries.sort((a, b) => (a.pt.value - b.pt.value));
601
+ const fmt = (vv) => (isRankStrictFlag ? d3.format('d')(vv) : (+vv).toFixed(4));
602
  entries.forEach(e => {
603
+ const err = (e.pt.stderr != null && isFinite(e.pt.stderr) && e.pt.stderr > 0) ? ` Β± ${fmt(e.pt.stderr)}` : '';
604
  html += `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}${err}</span></div>`;
605
  });
606
+ tipInner.innerHTML = html; const offsetX = 12, offsetY = 12; tip.style.opacity = '1'; tip.style.transform = `translate(${Math.round(mx + offsetX + margin.left)}px, ${Math.round(my + offsetY + margin.top)}px)`;
607
+ }
608
+ function onLeave() { hideTipTimer = setTimeout(() => { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px, -9999px)'; hoverLine.style('display', 'none'); }, 100); }
609
  overlay.on('mousemove', onMove).on('mouseleave', onLeave);
610
  }
611
 
612
+ async function load() {
613
  try {
614
  const file = (csvPath || '').split('/').pop();
615
  const CANDIDATES = [
 
620
  `../../assets/data/${file}`
621
  ].filter(Boolean);
622
  let text = null;
623
+ for (const p of CANDIDATES) {
624
+ try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) { text = await r.text(); break; } } catch (e) { }
625
  }
626
  if (text == null) throw new Error(`CSV not found: ${file}`);
627
+ const rows = d3.csvParse(text, d => ({ run: (d.run || '').trim(), step: +d.step, metric: (d.metric || '').trim(), value: +d.value, stderr: (d.stderr != null && d.stderr !== '') ? +d.stderr : null }));
628
+ metricList = Array.from(new Set(rows.map(r => r.metric))).sort();
629
+ runList = Array.from(new Set(rows.map(r => r.run))).sort(); runOrder = runList;
630
+ metricList.forEach(m => { const map = {}; runList.forEach(r => map[r] = []); rows.filter(r => r.metric === m).forEach(r => { if (!isNaN(r.step) && !isNaN(r.value)) map[r.run].push({ step: r.step, value: r.value, stderr: r.stderr }); }); dataByMetric.set(m, map); });
631
  const preferred = metricList.find(m => m === 'ai2d_exact_match') || metricList.find(m => /average_rank/i.test(m));
632
  const def = preferred || metricList[0];
633
  renderMetric(def);
634
+ const ro = window.ResizeObserver ? new ResizeObserver(() => renderMetric(def)) : null; if (ro) ro.observe(cell);
635
  if (typeof readyResolve === 'function') readyResolve();
636
  } catch (e) {
637
  const pre = document.createElement('pre'); pre.textContent = 'CSV load error: ' + (e && e.message ? e.message : e);
 
640
  }
641
  }
642
  load();
643
+
644
  return {
645
  ready,
646
  getMetrics: () => metricList.slice(),
 
649
  const key = m; const map = dataByMetric.get(key) || {}; const runs = runOrder;
650
  let maxVal = 0, minVal = Infinity; let minStep = Infinity, maxStep = -Infinity;
651
  const isRank = /rank/i.test(key); const isAverage = /average/i.test(key); const isRankStrict = isRank && !isAverage;
652
+ runs.forEach(r => { (map[r] || []).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); maxVal = Math.max(maxVal, v); minVal = Math.min(minVal, v); }); });
653
  const rankMax = isRank ? Math.max(1, Math.round(maxVal)) : null;
654
  return { isRank, isRankStrict, min: maxVal === 0 && minVal === Infinity ? null : minVal, max: maxVal, rankMax };
655
  },
 
701
  const select = (headerEl || header).querySelector('.controls select');
702
  if (select) {
703
  select.innerHTML = '';
704
+ metrics.forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = prettyMetricLabel(m); select.appendChild(o); });
705
  if (def) select.value = def;
706
  }
707
 
 
718
  const max = Math.max(...infos.map(info => info.max));
719
  instances.forEach(i => i && typeof i.setSharedY === 'function' && i.setSharedY({ type: 'value', min, max, key: metric }));
720
  }
721
+ } catch (_) { }
722
  };
723
 
724
  const applyAll = (v) => { computeAndApplySharedY(v); instances.forEach(i => i && typeof i.setMetric === 'function' && i.setMetric(v)); };
 
730
  if (legendItemsHost) {
731
  try {
732
  const f = '/data/formatting_filters.csv';
733
+ const r = await fetch(f, { cache: 'no-cache' });
734
  if (r.ok && window.d3 && window.d3.csvParse) {
735
  const txt = await r.text();
736
  const rows = window.d3.csvParse(txt);
737
+ const runList = Array.from(new Set(rows.map(row => String(row.run || '').trim()).filter(Boolean))).sort();
738
+ const poolLegend = (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function')
739
  ? window.ColorPalettes.getColors('categorical', runList.length)
740
+ : (() => { const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...((window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab'])]; })();
741
  // Build shared run->color map once
742
  SHARED_RUN_COLOR = {};
743
  runList.forEach((name, i) => { SHARED_RUN_COLOR[name] = poolLegend[i % poolLegend.length]; });
 
749
  try {
750
  const currentMetric = (select && select.value) || def;
751
  if (currentMetric) applyAll(currentMetric);
752
+ } catch { }
753
  // Legend hover ghosting across all cells
754
  legendItemsHost.querySelectorAll('.item').forEach(el => {
755
  el.addEventListener('mouseenter', () => {
 
773
  });
774
  });
775
  }
776
+ } catch { }
777
  }
778
  })();
779
  };
780
 
781
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
782
  })();
783
+ </script>
 
 
 
app/src/content/embeds/d3-matrix.html CHANGED
@@ -1,18 +1,21 @@
1
- <div class="d3-matrix" ></div>
2
  <style>
3
  .d3-matrix {
4
  position: relative;
5
  }
 
6
  .d3-matrix .panels {
7
  display: flex;
8
  flex-wrap: wrap;
9
  gap: 16px;
10
  margin-bottom: 4px;
11
  }
 
12
  .d3-matrix .panel {
13
  flex: 1 1 320px;
14
  min-width: 280px;
15
  }
 
16
  .d3-matrix .panel__title {
17
  color: var(--text-color);
18
  font-size: 12px;
@@ -20,22 +23,31 @@
20
  margin: 0 0 6px 0;
21
  font-weight: 600;
22
  }
 
23
  .d3-matrix .axis-label {
24
  fill: var(--text-color);
25
  font-size: 11px;
26
  font-weight: 700;
27
  }
 
28
  .d3-matrix .cell-border {
29
  stroke: var(--border-color);
30
  stroke-width: 1px;
31
  fill: none;
32
  }
 
33
  .d3-matrix .cell-text {
34
  fill: var(--muted-color);
35
  font-size: 11px;
36
  pointer-events: none;
37
  }
38
- .d3-matrix .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; }
 
 
 
 
 
 
39
  </style>
40
  <script>
41
  (() => {
@@ -57,7 +69,7 @@
57
  const bootstrap = () => {
58
  const scriptEl = document.currentScript;
59
  let container = scriptEl ? scriptEl.previousElementSibling : null;
60
- if (!(container && container.classList && container.classList.contains('d3-matrix'))){
61
  const cs = Array.from(document.querySelectorAll('.d3-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
62
  container = cs[cs.length - 1] || null;
63
  }
@@ -131,30 +143,30 @@
131
 
132
  // Demo data (two distinct 10x10 matrices: Baseline vs Improved)
133
  // Rows / Columns are generic class labels
134
- const classes = ['0','1','2','3','4','5','6','7','8','9'];
135
  const matrixA = [
136
- [90, 2, 1, 0, 0, 0, 1, 0, 5, 1],
137
- [ 3, 85, 5, 1, 0, 1, 2, 1, 1, 1],
138
- [ 1, 6, 70, 10, 4, 4, 1, 1, 1, 2],
139
- [ 0, 1, 8, 65, 10, 10, 2, 1, 1, 2],
140
- [ 0, 0, 2, 6, 83, 3, 1, 1, 3, 1],
141
- [ 0, 1, 2, 12, 4, 70, 5, 2, 2, 2],
142
- [ 1, 2, 1, 0, 1, 2, 88, 1, 3, 1],
143
- [ 0, 1, 1, 1, 1, 1, 2, 90, 1, 2],
144
- [ 6, 2, 2, 4, 6, 3, 3, 2, 70, 2],
145
- [ 1, 1, 1, 1, 2, 1, 1, 2, 1, 89]
146
  ];
147
  const matrixB = [
148
- [94, 1, 0, 0, 0, 0, 1, 0, 3, 1],
149
- [ 2, 90, 3, 1, 0, 0, 1, 1, 1, 1],
150
- [ 1, 4, 78, 7, 3, 3, 1, 1, 1, 1],
151
- [ 0, 1, 5, 74, 7, 8, 1, 1, 1, 2],
152
- [ 0, 0, 1, 4, 88, 2, 1, 1, 2, 1],
153
- [ 0, 1, 1, 9, 3, 78, 3, 1, 2, 2],
154
- [ 1, 1, 1, 0, 1, 1, 91, 1, 2, 1],
155
- [ 0, 1, 1, 1, 1, 1, 1, 92, 1, 1],
156
- [ 4, 1, 1, 3, 4, 2, 2, 2, 79, 2],
157
- [ 1, 1, 1, 1, 2, 1, 1, 1, 1, 90]
158
  ];
159
 
160
  // Colors: sequential palette via window.ColorPalettes with graceful fallback
@@ -163,7 +175,7 @@
163
  if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
164
  return window.ColorPalettes.getColors('sequential', count);
165
  }
166
- } catch (_) {}
167
  // Fallback: generate a monochrome scale using the primary color with varying opacity
168
  const arr = [];
169
  for (let i = 0; i < count; i++) arr.push('var(--primary-color)');
@@ -176,13 +188,13 @@
176
  if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
177
  return window.ColorPalettes.getColors('diverging', count);
178
  }
179
- } catch (_) {}
180
- const steps = Math.max(3, count|0);
181
  const arr = [];
182
  for (let i = 0; i < steps; i++) {
183
  const t = i / (steps - 1);
184
  const pct = Math.round(t * 100);
185
- arr.push(`color-mix(in srgb, #D64545 ${100-pct}%, #3A7BD5 ${pct}%)`);
186
  }
187
  return arr;
188
  };
@@ -248,19 +260,19 @@
248
  }
249
 
250
  // Compute a fixed readable text color from a CSS rgb()/rgba() string
251
- function chooseFixedReadableTextOnBg(bgCss){
252
  try {
253
- const m = String(bgCss||'').match(/rgba?\(([^)]+)\)/);
254
  if (!m) return '#0e1116';
255
  const parts = m[1].split(',').map(s => parseFloat(s.trim()));
256
  const [r, g, b] = parts;
257
  // sRGB β†’ relative luminance
258
  const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255);
259
- const linear = srgb.map(c => (c <= 0.03928 ? c/12.92 : Math.pow((c + 0.055)/1.055, 2.4)));
260
- const L = 0.2126*linear[0] + 0.7152*linear[1] + 0.0722*linear[2];
261
  // Threshold ~ 0.5 for readability; darker BG β†’ white text, else near-black
262
  return L < 0.5 ? '#ffffff' : '#0e1116';
263
- } catch(_) { return '#0e1116'; }
264
  }
265
 
266
  function render() {
@@ -319,7 +331,7 @@
319
  .attr('x', d => x(d.c) + x.bandwidth() / 2)
320
  .attr('y', d => y(d.r) + y.bandwidth() / 2)
321
  .text(d => `${Math.round(d.value * 100)}`)
322
- .style('fill', function(d){
323
  try {
324
  const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
325
  const bg = rect ? getComputedStyle(rect).fill : colorA(d.value);
@@ -382,7 +394,7 @@
382
  const delta = dataB.data.map(d => ({ r: d.r, c: d.c, count: d.count, value: (d.value - (mapA.get(d.r + '-' + d.c) || 0)) }));
383
  // Symmetric domain around 0 (in proportions), express later as pp in labels
384
  const maxAbsDelta = Math.max(0.01, d3.max(delta, d => Math.abs(d.value)) || 0.01);
385
- const colorB = d3.scaleQuantize().domain([-maxAbsDelta, maxAbsDelta]).range(diverging);
386
 
387
  gCellsB.selectAll('rect.cell-bg')
388
  .data([0])
@@ -408,12 +420,12 @@
408
  .attr('ry', 2)
409
  .on('mousemove', (event, d) => {
410
  const [px, py] = d3.pointer(event, container);
411
- const a = dataA.data.find(x => x.r===d.r && x.c===d.c);
412
- const b = dataB.data.find(x => x.r===d.r && x.c===d.c);
413
  const dv = ((b ? b.value : 0) - (a ? a.value : 0)) * 100;
414
  tipInner.innerHTML = `<strong>${classes[d.r]}</strong> β†’ <strong>${classes[d.c]}</strong>` +
415
- `<br/>baseline ${(a ? a.value*100 : 0).toFixed(1)}%` +
416
- `<br/>improved ${(b ? b.value*100 : 0).toFixed(1)}%` +
417
  `<br/>delta ${dv.toFixed(1)} pp`;
418
  tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;
419
  tip.style.opacity = '1';
@@ -434,18 +446,18 @@
434
  .attr('y', d => y(d.r))
435
  .attr('width', Math.max(1, x.bandwidth()))
436
  .attr('height', Math.max(1, y.bandwidth()))
437
- .attr('fill', d => colorB(delta.find(x => x.r===d.r && x.c===d.c).value));
438
 
439
  cellsMergedB.select('text')
440
  .attr('x', d => x(d.c) + x.bandwidth() / 2)
441
  .attr('y', d => y(d.r) + y.bandwidth() / 2)
442
  .text(d => {
443
- const dv = delta.find(x => x.r===d.r && x.c===d.c).value; return `${Math.round(dv * 100)}`;
444
  })
445
- .style('fill', function(d){
446
  try {
447
  const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
448
- const dv = delta.find(x => x.r===d.r && x.c===d.c).value;
449
  const bg = rect ? getComputedStyle(rect).fill : colorB(dv);
450
  return chooseFixedReadableTextOnBg(bg);
451
  } catch (_) {
@@ -509,7 +521,4 @@
509
  ensureD3(bootstrap);
510
  }
511
  })();
512
- </script>
513
-
514
-
515
-
 
1
+ <div class="d3-matrix"></div>
2
  <style>
3
  .d3-matrix {
4
  position: relative;
5
  }
6
+
7
  .d3-matrix .panels {
8
  display: flex;
9
  flex-wrap: wrap;
10
  gap: 16px;
11
  margin-bottom: 4px;
12
  }
13
+
14
  .d3-matrix .panel {
15
  flex: 1 1 320px;
16
  min-width: 280px;
17
  }
18
+
19
  .d3-matrix .panel__title {
20
  color: var(--text-color);
21
  font-size: 12px;
 
23
  margin: 0 0 6px 0;
24
  font-weight: 600;
25
  }
26
+
27
  .d3-matrix .axis-label {
28
  fill: var(--text-color);
29
  font-size: 11px;
30
  font-weight: 700;
31
  }
32
+
33
  .d3-matrix .cell-border {
34
  stroke: var(--border-color);
35
  stroke-width: 1px;
36
  fill: none;
37
  }
38
+
39
  .d3-matrix .cell-text {
40
  fill: var(--muted-color);
41
  font-size: 11px;
42
  pointer-events: none;
43
  }
44
+
45
+ .d3-matrix .chart-card {
46
+ background: var(--surface-bg);
47
+ border: 1px solid var(--border-color);
48
+ border-radius: 10px;
49
+ padding: 8px;
50
+ }
51
  </style>
52
  <script>
53
  (() => {
 
69
  const bootstrap = () => {
70
  const scriptEl = document.currentScript;
71
  let container = scriptEl ? scriptEl.previousElementSibling : null;
72
+ if (!(container && container.classList && container.classList.contains('d3-matrix'))) {
73
  const cs = Array.from(document.querySelectorAll('.d3-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
74
  container = cs[cs.length - 1] || null;
75
  }
 
143
 
144
  // Demo data (two distinct 10x10 matrices: Baseline vs Improved)
145
  // Rows / Columns are generic class labels
146
+ const classes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
147
  const matrixA = [
148
+ [90, 2, 1, 0, 0, 0, 1, 0, 5, 1],
149
+ [3, 85, 5, 1, 0, 1, 2, 1, 1, 1],
150
+ [1, 6, 70, 10, 4, 4, 1, 1, 1, 2],
151
+ [0, 1, 8, 65, 10, 10, 2, 1, 1, 2],
152
+ [0, 0, 2, 6, 83, 3, 1, 1, 3, 1],
153
+ [0, 1, 2, 12, 4, 70, 5, 2, 2, 2],
154
+ [1, 2, 1, 0, 1, 2, 88, 1, 3, 1],
155
+ [0, 1, 1, 1, 1, 1, 2, 90, 1, 2],
156
+ [6, 2, 2, 4, 6, 3, 3, 2, 70, 2],
157
+ [1, 1, 1, 1, 2, 1, 1, 2, 1, 89]
158
  ];
159
  const matrixB = [
160
+ [94, 1, 0, 0, 0, 0, 1, 0, 3, 1],
161
+ [2, 90, 3, 1, 0, 0, 1, 1, 1, 1],
162
+ [1, 4, 78, 7, 3, 3, 1, 1, 1, 1],
163
+ [0, 1, 5, 74, 7, 8, 1, 1, 1, 2],
164
+ [0, 0, 1, 4, 88, 2, 1, 1, 2, 1],
165
+ [0, 1, 1, 9, 3, 78, 3, 1, 2, 2],
166
+ [1, 1, 1, 0, 1, 1, 91, 1, 2, 1],
167
+ [0, 1, 1, 1, 1, 1, 1, 92, 1, 1],
168
+ [4, 1, 1, 3, 4, 2, 2, 2, 79, 2],
169
+ [1, 1, 1, 1, 2, 1, 1, 1, 1, 90]
170
  ];
171
 
172
  // Colors: sequential palette via window.ColorPalettes with graceful fallback
 
175
  if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
176
  return window.ColorPalettes.getColors('sequential', count);
177
  }
178
+ } catch (_) { }
179
  // Fallback: generate a monochrome scale using the primary color with varying opacity
180
  const arr = [];
181
  for (let i = 0; i < count; i++) arr.push('var(--primary-color)');
 
188
  if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
189
  return window.ColorPalettes.getColors('diverging', count);
190
  }
191
+ } catch (_) { }
192
+ const steps = Math.max(3, count | 0);
193
  const arr = [];
194
  for (let i = 0; i < steps; i++) {
195
  const t = i / (steps - 1);
196
  const pct = Math.round(t * 100);
197
+ arr.push(`color-mix(in srgb, #D64545 ${100 - pct}%, #3A7BD5 ${pct}%)`);
198
  }
199
  return arr;
200
  };
 
260
  }
261
 
262
  // Compute a fixed readable text color from a CSS rgb()/rgba() string
263
+ function chooseFixedReadableTextOnBg(bgCss) {
264
  try {
265
+ const m = String(bgCss || '').match(/rgba?\(([^)]+)\)/);
266
  if (!m) return '#0e1116';
267
  const parts = m[1].split(',').map(s => parseFloat(s.trim()));
268
  const [r, g, b] = parts;
269
  // sRGB β†’ relative luminance
270
  const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255);
271
+ const linear = srgb.map(c => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
272
+ const L = 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
273
  // Threshold ~ 0.5 for readability; darker BG β†’ white text, else near-black
274
  return L < 0.5 ? '#ffffff' : '#0e1116';
275
+ } catch (_) { return '#0e1116'; }
276
  }
277
 
278
  function render() {
 
331
  .attr('x', d => x(d.c) + x.bandwidth() / 2)
332
  .attr('y', d => y(d.r) + y.bandwidth() / 2)
333
  .text(d => `${Math.round(d.value * 100)}`)
334
+ .style('fill', function (d) {
335
  try {
336
  const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
337
  const bg = rect ? getComputedStyle(rect).fill : colorA(d.value);
 
394
  const delta = dataB.data.map(d => ({ r: d.r, c: d.c, count: d.count, value: (d.value - (mapA.get(d.r + '-' + d.c) || 0)) }));
395
  // Symmetric domain around 0 (in proportions), express later as pp in labels
396
  const maxAbsDelta = Math.max(0.01, d3.max(delta, d => Math.abs(d.value)) || 0.01);
397
+ const colorB = d3.scaleQuantize().domain([-maxAbsDelta / 2, maxAbsDelta]).range(diverging);
398
 
399
  gCellsB.selectAll('rect.cell-bg')
400
  .data([0])
 
420
  .attr('ry', 2)
421
  .on('mousemove', (event, d) => {
422
  const [px, py] = d3.pointer(event, container);
423
+ const a = dataA.data.find(x => x.r === d.r && x.c === d.c);
424
+ const b = dataB.data.find(x => x.r === d.r && x.c === d.c);
425
  const dv = ((b ? b.value : 0) - (a ? a.value : 0)) * 100;
426
  tipInner.innerHTML = `<strong>${classes[d.r]}</strong> β†’ <strong>${classes[d.c]}</strong>` +
427
+ `<br/>baseline ${(a ? a.value * 100 : 0).toFixed(1)}%` +
428
+ `<br/>improved ${(b ? b.value * 100 : 0).toFixed(1)}%` +
429
  `<br/>delta ${dv.toFixed(1)} pp`;
430
  tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;
431
  tip.style.opacity = '1';
 
446
  .attr('y', d => y(d.r))
447
  .attr('width', Math.max(1, x.bandwidth()))
448
  .attr('height', Math.max(1, y.bandwidth()))
449
+ .attr('fill', d => colorB(delta.find(x => x.r === d.r && x.c === d.c).value));
450
 
451
  cellsMergedB.select('text')
452
  .attr('x', d => x(d.c) + x.bandwidth() / 2)
453
  .attr('y', d => y(d.r) + y.bandwidth() / 2)
454
  .text(d => {
455
+ const dv = delta.find(x => x.r === d.r && x.c === d.c).value; return `${Math.round(dv * 100)}`;
456
  })
457
+ .style('fill', function (d) {
458
  try {
459
  const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
460
+ const dv = delta.find(x => x.r === d.r && x.c === d.c).value;
461
  const bg = rect ? getComputedStyle(rect).fill : colorB(dv);
462
  return chooseFixedReadableTextOnBg(bg);
463
  } catch (_) {
 
521
  ensureD3(bootstrap);
522
  }
523
  })();
524
+ </script>
 
 
 
app/src/content/embeds/d3-neural-network.html CHANGED
@@ -1,33 +1,172 @@
1
  <div class="d3-neural"></div>
2
  <style>
3
- .d3-neural { position: relative; width:100%;margin:0;}
4
- .d3-neural .controls { margin-top: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
5
- .d3-neural .controls label { font-size: 12px; color: var(--muted-color); display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 6px 10px; }
6
- .d3-neural .controls input[type="range"]{ width: 160px; }
7
- .d3-neural .panel { display:flex; gap:8px; align-items:stretch; flex-wrap: nowrap; }
8
- .d3-neural .left { flex: 0 0 33.333%; max-width: 33.333%; min-width: 160px; display:flex; flex-direction:column; gap:8px; }
9
- .d3-neural .right { flex: 1 1 66.666%; max-width: 66.666%; min-width: 280px; display:flex; }
10
- .d3-neural .right > svg { flex: 1 1 auto; height: 100%; }
11
- .d3-neural .arrow-sep { flex: 0 0 18px; max-width: 18px; display:flex; align-items:center; justify-content:center; color: var(--muted-color); }
12
- .d3-neural .arrow-sep svg { display:block; width: 16px; height: 16px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  @media (max-width: 800px) {
14
- .d3-neural .panel { flex-direction: column; }
 
 
 
15
  .d3-neural .left,
16
- .d3-neural .right { flex: 0 0 100%; max-width: 100%; min-width: 0; }
17
- .d3-neural .arrow-sep { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  }
19
- .d3-neural canvas { width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border-color); background: var(--surface-bg); display:block; }
20
- .d3-neural .preview28 { display:grid; grid-template-columns: repeat(28, 1fr); gap: 1px; width: 100%; }
21
- .d3-neural .preview28 span { display:block; aspect-ratio:1/1; border-radius:2px; }
22
- .d3-neural .legend { font-size: 12px; color: var(--text-color); line-height:1.35; }
23
- .d3-neural .probs { display:flex; gap:6px; align-items:flex-end; height: 64px; }
24
- .d3-neural .probs .bar { width: 10px; border-radius:2px 2px 0 0; background: var(--border-color); transition: height .15s ease, background-color .15s ease; }
25
- .d3-neural .probs .bar.active { background: var(--primary-color); }
26
- .d3-neural .probs .tick { font-size: 10px; color: var(--muted-color); text-align:center; margin-top: 2px; }
27
- .d3-neural .canvas-wrap { position: relative; }
28
- .d3-neural .erase-btn { position: absolute; top: 8px; right: 8px; width: 32px; height: 32px; display:flex; align-items:center; justify-content:center; border: 1px solid var(--border-color); }
29
- .d3-neural .canvas-hint { position: absolute; top: 8px; left: 12px; font-size: 12px; font-weight: 700; color: rgba(0,0,0,.9); pointer-events: none; transition: opacity .12s ease; }
30
-
31
  </style>
32
  <script>
33
  (() => {
@@ -40,14 +179,7 @@
40
  if (window.d3) onReady();
41
  };
42
 
43
- const ensureTF = (cb) => {
44
- if (window.tf && typeof window.tf.tensor === 'function') return cb();
45
- let s = document.getElementById('tfjs-cdn-script');
46
- if (!s) { s = document.createElement('script'); s.id = 'tfjs-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js'; document.head.appendChild(s); }
47
- const onReady = () => { if (window.tf && typeof window.tf.tensor === 'function') cb(); };
48
- s.addEventListener('load', onReady, { once: true });
49
- if (window.tf) onReady();
50
- };
51
 
52
  const bootstrap = () => {
53
  const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
@@ -72,18 +204,18 @@
72
  const canvas = document.createElement('canvas'); canvas.width = CANVAS_PX; canvas.height = CANVAS_PX;
73
  const ctx = canvas.getContext('2d');
74
  // init white bg
75
- ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX);
76
  const canvasWrap = document.createElement('div'); canvasWrap.className = 'canvas-wrap';
77
  canvasWrap.appendChild(canvas);
78
  // Erase icon button (top-right)
79
- const eraseBtn = document.createElement('button'); eraseBtn.className='erase-btn button--ghost'; eraseBtn.type='button'; eraseBtn.setAttribute('aria-label','Clear');
80
  // Hidden until the user interacts with the canvas
81
  eraseBtn.style.display = 'none';
82
  eraseBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>';
83
  eraseBtn.addEventListener('click', () => clearCanvas());
84
  canvasWrap.appendChild(eraseBtn);
85
  // Hint (top-left)
86
- const hint = document.createElement('div'); hint.className='canvas-hint'; hint.textContent='Draw a digit here';
87
  canvasWrap.appendChild(hint);
88
  left.appendChild(canvasWrap);
89
 
@@ -94,133 +226,133 @@
94
  // (prediction panel removed; predictions rendered next to output nodes)
95
 
96
  // SVG network on right
97
- const svg = d3.select(right).append('svg').attr('width','100%').style('display','block');
98
  const defs = svg.append('defs');
99
  const gRoot = svg.append('g');
100
- const gInput = gRoot.append('g').attr('class','input');
101
- const gInputLinks = gRoot.append('g').attr('class','input-links');
102
- const gLinks = gRoot.append('g').attr('class','links');
103
- const gNodes = gRoot.append('g').attr('class','nodes');
104
- const gLabels = gRoot.append('g').attr('class','labels');
105
- const gOutText = gRoot.append('g').attr('class','out-probs');
106
 
107
  // Network structure (compact: 8 -> 8 -> 10)
108
  const layerSizes = [8, 8, 10];
109
- const layers = layerSizes.map((n, li)=> Array.from({length:n}, (_, i)=>({ id:`L${li}N${i}`, layer: li, index: i, a:0 })));
110
  // Links only between hidden->hidden and hidden->output
111
  const links = [];
112
- for (let i=0;i<layerSizes[0];i++){
113
- for (let j=0;j<layerSizes[1];j++) links.push({ s:{l:0,i}, t:{l:1,j}, w: (Math.sin(i*17+j*31)+1)/2 });
114
  }
115
- for (let i=0;i<layerSizes[1];i++){
116
- for (let j=0;j<layerSizes[2];j++) links.push({ s:{l:1,i}, t:{l:2,j}, w: (Math.cos(i*7+j*13)+1)/2 });
117
  }
118
 
119
  // Linear classifier: logits = W * feats + b, feats in [0,1]
120
  // features: [total, cx, cy, lr, tb, htrans, vtrans, loopiness]
121
  const W = [
122
  // 0 1 2 3 4 5 6 7
123
- [ 0.3, 0.0, 0.0, 0.0, 0.0, -0.8, -0.6, 1.2], // 0
124
- [-0.2, 0.9, 0.2, 0.8, 0.1, -0.2, 0.2, -1.1], // 1
125
- [ 0.1, 0.4, 0.2, 0.5, 0.2, 0.9, 0.1, -0.6], // 2
126
- [ 0.2, 0.3, 0.2, 0.2, 0.2, 0.9, 0.0, -0.2], // 3
127
- [ 0.0,-0.3, 0.2,-0.6, 0.4, 0.2, 0.8, -0.6], // 4
128
- [ 0.1,-0.4, 0.2,-0.5, 0.5, 0.9, 0.1, -0.6], // 5
129
- [ 0.2,-0.2, 0.6,-0.2, 0.8, -0.3, 0.2, 0.6], // 6
130
- [ 0.0, 0.6,-0.2, 0.6,-0.8, 0.6, 0.0, -0.8], // 7
131
- [ 0.4, 0.0, 0.0, 0.1, 0.1, 0.6, 0.6, 1.0], // 8
132
- [ 0.2, 0.2,-0.6, 0.2,-0.8, 0.2, 0.6, 0.5], // 9
133
  ];
134
  const b = [-0.2, -0.1, -0.05, -0.05, -0.05, -0.05, -0.05, -0.1, -0.15, -0.1];
135
 
136
- function computeFeatures(x28){
137
  // x28: Float32Array length 784, values in [0,1] (1 = black/ink)
138
- let sum=0, cx=0, cy=0; const w=28, h=28;
139
  const rowSum = new Array(h).fill(0); const colSum = new Array(w).fill(0);
140
- let hTransitions=0, vTransitions=0;
141
- for (let y=0;y<h;y++){
142
- for (let x=0;x<w;x++){
143
- const v = x28[y*w+x]; sum += v; cx += x*v; cy += y*v; rowSum[y]+=v; colSum[x]+=v;
144
- if (x>0){ const v0=x28[y*w+(x-1)], v1=v; if ((v0>0.25)!==(v1>0.25)) hTransitions+=1; }
145
- if (y>0){ const v0=x28[(y-1)*w+x], v1=v; if ((v0>0.25)!==(v1>0.25)) vTransitions+=1; }
146
  }
147
  }
148
- const total = sum/(w*h); // [0,1]
149
- const cxn = sum>1e-6 ? (cx/sum)/(w-1) : 0.5; // [0,1]
150
- const cyn = sum>1e-6 ? (cy/sum)/(h-1) : 0.5; // [0,1]
151
- let left=0,right=0,top=0,bottom=0;
152
- for (let y=0;y<h;y++){ for (let x=0;x<w;x++){ const v=x28[y*w+x]; if (x<w/2) left+=v; else right+=v; if (y<h/2) top+=v; else bottom+=v; }}
153
- const lr = (right/(right+left+1e-6));
154
- const tb = (bottom/(bottom+top+1e-6));
155
- const htn = Math.min(1, hTransitions/(w*h*0.35));
156
- const vtn = Math.min(1, vTransitions/(w*h*0.35));
157
  // Loopiness proxy: ink near perimeter low vs center high
158
- let perimeter=0, center=0; const m=5;
159
- for (let y=0;y<h;y++){
160
- for (let x=0;x<w;x++){
161
- const v=x28[y*w+x];
162
- const isBorder = (x<m||x>=w-m||y<m||y>=h-m);
163
- if (isBorder) perimeter+=v; else center+=v;
164
  }
165
  }
166
- const loopiness = Math.min(1, center/(perimeter+center+1e-6)*1.8);
167
  return [total, cxn, cyn, lr, tb, htn, vtn, loopiness];
168
  }
169
 
170
- function softmax(arr){ const m=Math.max(...arr); const ex=arr.map(v=>Math.exp(v-m)); const s=ex.reduce((a,b)=>a+b,0)+1e-12; return ex.map(v=>v/s); }
171
- function l2norm(a){ return Math.hypot(...a) || 0; }
172
- function normalize(a){ const n=l2norm(a); return n>0 ? a.map(v=>v/n) : a.slice(); }
173
- function cosine(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; const na=l2norm(a), nb=l2norm(b)||1; return na>0 ? s/(na*nb) : 0; }
174
 
175
  // MNIST-like normalization: crop to tight bbox, scale into 20x20, center in 28x28
176
- function normalize28(x28){
177
- const w=28,h=28, thr=0.2;
178
- let minX=29,minY=29,maxX=-1,maxY=-1, sum=0, cx=0, cy=0;
179
- for (let y=0;y<h;y++){
180
- for (let x=0;x<w;x++){
181
- const v = x28[y*w+x];
182
- if (v>thr){ if (x<minX) minX=x; if (x>maxX) maxX=x; if (y<minY) minY=y; if (y>maxY) maxY=y; }
183
- sum += v; cx += x*v; cy += y*v;
184
  }
185
  }
186
- if (sum < 1e-3 || maxX<0){ return x28; }
187
- const comX = cx/sum, comY = cy/sum;
188
- const bw = Math.max(1, maxX-minX+1), bh = Math.max(1, maxY-minY+1);
189
- const scale = 20/Math.max(bw, bh);
190
- const out = new Float32Array(w*h);
191
  // center of canvas
192
- const cxOut = (w-1)/2, cyOut = (h-1)/2;
193
- for (let y=0;y<h;y++){
194
- for (let x=0;x<w;x++){
195
  // map output pixel to source space around COM
196
- const sx = (x - cxOut)/scale + comX;
197
- const sy = (y - cyOut)/scale + comY;
198
- out[y*w+x] = bilinearSample(x28, w, h, sx, sy);
199
  }
200
  }
201
  return out;
202
  }
203
- function bilinearSample(img, w, h, x, y){
204
  const x0 = Math.floor(x), y0 = Math.floor(y);
205
- const x1 = x0+1, y1 = y0+1;
206
  const tx = x - x0, ty = y - y0;
207
- function at(ix,iy){ if (ix<0||iy<0||ix>=w||iy>=h) return 0; return img[iy*w+ix]; }
208
- const v00 = at(x0,y0), v10 = at(x1,y0), v01 = at(x0,y1), v11 = at(x1,y1);
209
- const a = v00*(1-tx)+v10*tx; const b = v01*(1-tx)+v11*tx; return a*(1-ty)+b*ty;
210
  }
211
  // Simple dilation (max-pooling 3x3) to thicken strokes
212
- function dilate28(x){
213
- const w=28,h=28; const out=new Float32Array(w*h);
214
- for (let y=0;y<h;y++){
215
- for (let x0=0;x0<w;x0++){
216
- let m=0;
217
- for (let dy=-1;dy<=1;dy++){
218
- for (let dx=-1;dx<=1;dx++){
219
- const xx=x0+dx, yy=y+dy; if (xx<0||yy<0||xx>=w||yy>=h) continue;
220
- const v = x[yy*w+xx]; if (v>m) m=v;
221
  }
222
  }
223
- out[y*w+x0]=m;
224
  }
225
  }
226
  return out;
@@ -228,44 +360,44 @@
228
 
229
  // Glyph-based 28x28 prototypes for digits 0-9 (normalized)
230
  const protoGlyphs28 = [];
231
- (function buildGlyphProtos(){
232
  const off = document.createElement('canvas'); off.width = CANVAS_PX; off.height = CANVAS_PX;
233
  const c = off.getContext('2d');
234
- for (let d=0; d<10; d++){
235
- c.fillStyle = '#ffffff'; c.fillRect(0,0,off.width,off.height);
236
- c.fillStyle = '#000000'; c.textAlign='center'; c.textBaseline='middle';
237
  c.font = 'bold 180px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
238
- c.fillText(String(d), off.width/2, off.height*0.56);
239
- const src = c.getImageData(0,0,off.width,off.height).data; const block = off.width/28;
240
- const vec = new Float32Array(28*28);
241
- for (let gy=0; gy<28; gy++){
242
- for (let gx=0; gx<28; gx++){
243
- let acc=0, cnt=0; const x0=Math.floor(gx*block), y0=Math.floor(gy*block);
244
- for (let yy=y0; yy<y0+block; yy++){
245
- for (let xx=x0; xx<x0+block; xx++){
246
- const idx=(yy*off.width+xx)*4; const r=src[idx], g=src[idx+1], b=src[idx+2];
247
- const gray=(r+g+b)/3/255; acc += (1-gray); cnt++;
248
  }
249
  }
250
- vec[gy*28+gx] = acc/(cnt||1);
251
  }
252
  }
253
  const normed = normalize28(vec);
254
- const n = l2norm(normed)||1; protoGlyphs28.push(normed.map(v=>v/n));
255
  }
256
  })();
257
- function dot(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; return s; }
258
 
259
  // Resize handling and node layout
260
- let width=640, height=360; const margin = { top: 16, right: 8, bottom: 24, left: 8 };
261
  let inputGrid = { cell: 0, x: 0, y: 0, width: 0, height: 0 };
262
- function layoutNodes(){
263
  // Right panel width, and a non-square aspect ratio for clarity
264
  width = Math.max(280, Math.round(right.clientWidth || 640));
265
  height = Math.max(260, Math.round(width * 0.56));
266
  svg.attr('width', width).attr('height', height);
267
  // Match canvas height to SVG height so both columns align vertically
268
- try { canvas.style.height = '100%'; canvasWrap.style.height = height + 'px'; } catch(_) {}
269
  const innerW = width - margin.left - margin.right; const innerH = height - margin.top - margin.bottom;
270
  gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
271
  // Input grid layout (28x28) at left β€” cap width to a fraction of innerW
@@ -273,8 +405,8 @@
273
  const cellByHeight = Math.floor(innerH / 28);
274
  const cellByWidth = Math.floor((innerW * maxGridFrac) / 28);
275
  let cell = Math.max(3, Math.min(cellByHeight, cellByWidth));
276
- let gridH = cell * 28; let gridY = Math.floor((innerH - gridH)/2);
277
- inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };
278
  // Equal horizontal gaps: grid -> L0 -> L1 -> L2
279
  const nLayers = layerSizes.length; // 3
280
  const rightLabelPad = 36; // smaller pad; use more width for spreading layers
@@ -283,30 +415,30 @@
283
  const desiredMinFree = rightLabelPad + nLayers * minGap; // 3 equal gaps
284
  if (inputGrid.width + desiredMinFree > innerW) {
285
  cell = Math.max(3, Math.floor((innerW - desiredMinFree) / 28));
286
- gridH = cell * 28; gridY = Math.floor((innerH - gridH)/2);
287
- inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };
288
  }
289
  const gridRight = inputGrid.x + inputGrid.width;
290
  const freeW = Math.max(nLayers * minGap, innerW - gridRight - rightLabelPad);
291
  const gapX = Math.min(maxGap, Math.max(minGap, Math.floor(freeW / nLayers)));
292
  const xs = Array.from({ length: nLayers }, (_, li) => gridRight + gapX * (li + 1));
293
  // Y positions evenly spaced per layer
294
- layers.forEach((nodes, li)=>{
295
  const n = nodes.length;
296
  if (n <= 1) {
297
- nodes.forEach((nd)=>{ nd.x = xs[li]; nd.y = innerH/2; });
298
  } else {
299
  const occupancy = 0.9; // use 90% of vertical space
300
  const span = innerH * occupancy;
301
  const topPad = (innerH - span) / 2;
302
  const spacing = span / (n - 1);
303
- nodes.forEach((nd, i)=>{ nd.x = xs[li]; nd.y = topPad + i*spacing; });
304
  }
305
  });
306
  }
307
 
308
- let lastX28 = new Float32Array(28*28);
309
- function nodeRadiusForNode(n){
310
  const a = Math.max(0, Math.min(1, (n && typeof n.a === 'number') ? n.a : 0));
311
  if (n && n.layer === 2) {
312
  // Output nodes: variable radius based on activation
@@ -315,14 +447,14 @@
315
  // Hidden/feature nodes: variable radius based on activation
316
  return 5 + 5 * a; // ~5–10
317
  }
318
- function renderInputGrid(){
319
  if (!inputGrid || inputGrid.cell <= 0) return;
320
- const data = Array.from({ length: 28*28 }, (_, i) => ({ i, v: lastX28[i] || 0 }));
321
- const sel = gInput.selectAll('rect.input-px').data(data, d=>d.i);
322
  const gap = Math.max(1, Math.floor(inputGrid.cell * 0.10));
323
  const inner = Math.max(1, inputGrid.cell - gap);
324
  const offset = Math.floor(gap / 2);
325
- sel.enter().append('rect').attr('class','input-px')
326
  .attr('width', inner).attr('height', inner)
327
  .merge(sel)
328
  .attr('x', d => inputGrid.x + (d.i % 28) * inputGrid.cell + offset)
@@ -338,26 +470,26 @@
338
 
339
  // Border around the input grid area
340
  const borderSel = gInput.selectAll('rect.input-border').data([0]);
341
- borderSel.enter().append('rect').attr('class','input-border')
342
- .attr('fill','none')
343
  .attr('rx', 0).attr('ry', 0)
344
- .attr('stroke','var(--text-color)')
345
  .attr('stroke-opacity', 0.25)
346
  .attr('stroke-width', 1)
347
  .lower()
348
  .merge(borderSel)
349
- .attr('x', inputGrid.x-1)
350
- .attr('y', inputGrid.y-1)
351
- .attr('width', inputGrid.width+1)
352
- .attr('height', inputGrid.height+1);
353
 
354
  // Centered label above the input grid
355
  const labelSel = gInput.selectAll('text.input-label').data([0]);
356
- labelSel.enter().append('text').attr('class','input-label')
357
- .attr('text-anchor','middle')
358
- .style('font-size','12px')
359
- .style('font-weight','700')
360
- .style('fill','var(--muted-color)')
361
  .merge(labelSel)
362
  .attr('x', inputGrid.x + inputGrid.width / 2)
363
  .attr('y', Math.max(12, inputGrid.y - 10))
@@ -365,7 +497,7 @@
365
  }
366
 
367
  // Compute link path between two layered nodes using their current radii
368
- function computeLinkD(d){
369
  const s = layers[d.s.l][d.s.i];
370
  const t = layers[d.t.l][d.t.j];
371
  if (!s || !t) return '';
@@ -375,10 +507,10 @@
375
  const x1 = s.x + rs, y1 = s.y; // right edge of source circle
376
  const x2 = t.x - rt, y2 = t.y; // left edge of target circle
377
  const dx = (x2 - x1) * 0.45;
378
- return `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`;
379
  }
380
 
381
- function renderInputLinks(){
382
  // Draw bundle-like links from input grid right edge to first layer nodes (features)
383
  const firstLayer = layers[0];
384
  if (!firstLayer || !inputGrid || inputGrid.cell <= 0) { gInputLinks.selectAll('path').remove(); return; }
@@ -400,20 +532,20 @@
400
  return { x0, y0, x1, y1, c1x: x0 + dx, c1y: y0, c2x: x1 - dx, c2y: y1, idx };
401
  });
402
  const sel = gInputLinks.selectAll('path.input-link').data(paths);
403
- sel.enter().append('path').attr('class','input-link')
404
- .attr('fill','none')
405
- .attr('stroke','var(--text-color)')
406
  .attr('stroke-opacity', 0.25)
407
  .attr('stroke-width', 1)
408
- .attr('stroke-linecap','round')
409
  .merge(sel)
410
  .attr('d', d => `M${d.x0},${d.y0} C${d.c1x},${d.c1y} ${d.c2x},${d.c2y} ${d.x1},${d.y1}`)
411
- .attr('stroke','var(--text-color)');
412
  sel.exit().remove();
413
  }
414
 
415
  // Recompute input link path on the fly (used when node radii change)
416
- function computeInputLinkD(idx){
417
  const firstLayer = layers[0];
418
  const n = firstLayer[idx]; if (!n) return '';
419
  const x0 = inputGrid.x + inputGrid.width;
@@ -433,66 +565,66 @@
433
  return `M${x0},${y0} C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1}`;
434
  }
435
 
436
- function renderGraph(showEdges){
437
  layoutNodes();
438
  renderInputGrid();
439
  renderInputLinks();
440
  // Nodes
441
  const allNodes = layers.flat();
442
- const nodeSel = gNodes.selectAll('circle.node').data(allNodes, d=>d.id);
443
- nodeSel.enter().append('circle').attr('class','node')
444
  .attr('r', 10)
445
- .attr('cx', d=>d.x).attr('cy', d=>d.y)
446
- .attr('fill', d=> d.layer===2 ? 'var(--page-bg)' : 'var(--primary-color)')
447
- .attr('fill-opacity', d=> d.layer===2 ? 1 : 0.12)
448
- .attr('stroke', d=> d.layer===2 ? 'var(--border-color)' : 'var(--border-color)')
449
- .attr('stroke-width',1)
450
- .attr('stroke-linejoin','round')
451
  .merge(nodeSel)
452
- .attr('cx', d=>d.x).attr('cy', d=>d.y)
453
  .attr('opacity', 1);
454
  nodeSel.exit().remove();
455
 
456
  // Labels for first hidden layer only (avoid stacking with output probs)
457
  const labels = [];
458
- layers[0].forEach((n,i)=> labels.push({ x:n.x-30, y:n.y+4, txt:`f${i+1}` }));
459
  const labSel = gLabels.selectAll('text').data(labels);
460
  labSel.enter().append('text')
461
- .style('font-size','10px')
462
- .style('fill','var(--muted-color)')
463
- .style('paint-order','stroke')
464
- .style('stroke','var(--page-bg)')
465
- .style('stroke-width','3px')
466
- .attr('x', d=>d.x)
467
- .attr('y', d=>d.y)
468
- .text(d=>d.txt)
469
  .merge(labSel)
470
- .style('paint-order','stroke')
471
- .style('stroke','var(--page-bg)')
472
- .style('stroke-width','5px')
473
- .attr('x', d=>d.x)
474
- .attr('y', d=>d.y)
475
- .text(d=>d.txt);
476
  labSel.exit().remove();
477
 
478
  // Links as smooth curves
479
- const linkSel = gLinks.selectAll('path.link').data(links, d=> `${d.s.l}-${d.s.i}-${d.t.l}-${d.t.j}`);
480
- linkSel.enter().append('path').attr('class','link')
481
  .attr('d', computeLinkD)
482
- .attr('fill','none')
483
- .attr('stroke','var(--text-color)')
484
  .attr('stroke-opacity', 0.25)
485
- .attr('stroke-width', d=> 0.5 + d.w*1.2)
486
- .attr('stroke-linecap','round')
487
  .merge(linkSel)
488
  .attr('d', computeLinkD)
489
- .attr('stroke','var(--text-color)')
490
- .attr('stroke-width', d=> 0.5 + d.w*1.2);
491
  linkSel.exit().remove();
492
 
493
  // Ensure output labels remain aligned with the last layer on resize
494
  gOutText.selectAll('g.out-label')
495
- .attr('transform', function(d){
496
  if (!d || typeof d.digit !== 'number') return d3.select(this).attr('transform');
497
  const n = layers[2][d.digit];
498
  if (!n) return d3.select(this).attr('transform');
@@ -501,73 +633,73 @@
501
  });
502
  // Ensure clip-path circles are updated on resize
503
  if (defs) {
504
- const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d=>d.id);
505
- const ce = clips.enter().append('clipPath').attr('class','clip-node').attr('clipPathUnits','userSpaceOnUse').attr('id', d=>`clip-${d.id}`);
506
  ce.append('circle');
507
- clips.merge(ce).select('circle').attr('cx', d=>d.x).attr('cy', d=>d.y).attr('r', d=>nodeRadiusForNode(d));
508
  clips.exit().remove();
509
  }
510
  }
511
 
512
- function setNodeActivations(h1, h2, out){
513
- layers[0].forEach((n,i)=> n.a = h1[i] || 0);
514
- layers[1].forEach((n,i)=> n.a = h2[i] || 0);
515
- layers[2].forEach((n,i)=> n.a = out[i] || 0);
516
  // Determine top prediction (for ghosting others)
517
  let argmaxIdx = 0; let bestProb = -1;
518
  if (Array.isArray(out)) {
519
- for (let i=0;i<out.length;i++){ if (out[i] > bestProb){ bestProb = out[i]; argmaxIdx = i; } }
520
  }
521
  // Color/size by activation with smooth transitions
522
  gNodes.selectAll('circle.node')
523
  .transition().duration(180).ease(d3.easeCubicOut)
524
- .attr('fill', d=> d.layer===2 ? 'var(--page-bg)' : 'var(--primary-color)')
525
- .attr('fill-opacity', d=> d.layer===2 ? 1 : (0.12 + 0.58*Math.min(1, d.a||0)))
526
  .attr('stroke', 'var(--primary-color)')
527
- .attr('stroke-opacity', d=> (d.layer===2 ? 0.9 : (0.45 + 0.45*Math.min(1, d.a||0))))
528
- .attr('opacity', d=> 0.55 + 0.45*Math.min(1, d.a||0))
529
- .attr('r', d=> nodeRadiusForNode(d));
530
  // Link opacity by activation flow
531
  gLinks.selectAll('path.link')
532
  .transition().duration(180).ease(d3.easeCubicOut)
533
  .attr('d', computeLinkD)
534
- .attr('stroke','var(--text-color)')
535
- .attr('stroke-opacity', d=>{
536
  const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
537
  return Math.min(1, 0.15 + 0.85 * (aS * aT));
538
  })
539
- .attr('stroke-width', d=>{
540
  const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
541
- return 0.6 + 2.2*(aS*aT);
542
  });
543
  // Theme-aware and activation-aware input links
544
  gInputLinks.selectAll('path.input-link')
545
  .transition().duration(180).ease(d3.easeCubicOut)
546
- .attr('d', (d)=> computeInputLinkD(d.idx))
547
- .attr('stroke','var(--text-color)')
548
  .attr('stroke-opacity', 0.25)
549
- .attr('stroke-width', d=> 0.6 + 2.0*(layers[0][d.idx] ? (layers[0][d.idx].a||0) : 0));
550
  // Update clip-path circles to match new radii/positions of output nodes
551
  if (defs) {
552
- const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d=>d.id);
553
  clips.select('circle')
554
  .transition().duration(180).ease(d3.easeCubicOut)
555
- .attr('cx', d=>d.x)
556
- .attr('cy', d=>d.y)
557
- .attr('r', d=> nodeRadiusForNode(d));
558
  }
559
  // Theme-aware input links on updates handled above via transition
560
  // Output labels: digit placed to the right of the node
561
- const outs = layers[2].map((n,i)=>({ x:n.x + nodeRadiusForNode(n) + 8, y:n.y, digit: i, prob: (out[i]||0), isTop: i===argmaxIdx }));
562
- const gSel = gOutText.selectAll('g.out-label').data(outs, d=>d.digit);
563
- const gEnter = gSel.enter().append('g').attr('class','out-label');
564
- gEnter.append('text').attr('class','out-digit')
565
- .style('font-size','12px').style('font-weight','800').style('fill','var(--text-color)')
566
- .attr('text-anchor','start').attr('dominant-baseline','middle')
567
- .style('paint-order','stroke').style('stroke','var(--transparent-page-contrast)').style('stroke-width','3px');
568
  const merged = gEnter.merge(gSel)
569
- .attr('transform', d=>`translate(${d.x},${d.y})`)
570
- .each(function(d){
571
  const sel = d3.select(this);
572
  sel.select('text.out-digit')
573
  .attr('x', 0).attr('y', 0)
@@ -581,28 +713,28 @@
581
  gSel.exit().remove();
582
 
583
  // Output liquid fill using clipPath + rect from bottom
584
- const rects = gNodes.selectAll('rect.out-liquid').data(layers[2], d=>d.id);
585
- const rectEnter = rects.enter().append('rect').attr('class','out-liquid')
586
- .attr('fill','var(--primary-color)')
587
  .attr('fill-opacity', 0.55)
588
  .attr('clip-path', d => `url(#clip-${d.id})`);
589
  rectEnter.merge(rects)
590
  .transition().duration(180).ease(d3.easeCubicOut)
591
- .attr('x', d=> d.x - nodeRadiusForNode(d))
592
- .attr('width', d=> 2 * nodeRadiusForNode(d))
593
- .attr('y', d=> {
594
  const r = nodeRadiusForNode(d);
595
- const h = 2 * r * Math.max(0, Math.min(1, d.a||0));
596
  return d.y + r - h;
597
  })
598
- .attr('height', d=> 2 * nodeRadiusForNode(d) * Math.max(0, Math.min(1, d.a||0)))
599
  .attr('fill-opacity', 0.55);
600
  rects.exit().remove();
601
  }
602
 
603
  // (no separate updateBars; bars are rendered next to nodes)
604
 
605
- function runPipeline(){
606
  const x28raw = downsample28();
607
  const x28 = dilate28(normalize28(x28raw));
608
  // Update input grid data
@@ -615,14 +747,14 @@
615
  // Hidden 1 = raw features
616
  const h1 = feats;
617
  // Hidden 2 = simple non-linear mix for visualization only
618
- const h2 = layers[1].map((_, j)=>{
619
- let s=0; for (let i=0;i<layers[0].length;i++){ const w = (Math.sin(i*17+j*31)+1)/2 * 0.8 + 0.1; s += w*h1[i]; }
620
- return Math.tanh(s*0.8);
621
  });
622
  let prob;
623
- if (inkMass < 0.03){
624
  // Too little ink: return near-uniform distribution
625
- prob = Array.from({length:10}, ()=> 1/10);
626
  } else {
627
  // Prefer TFJS model if available
628
  const tfProbs = predictTfjs(x28);
@@ -632,115 +764,85 @@
632
  // Fallback: rely mostly on glyph similarity
633
  const x28n = normalize(x28);
634
  const logitsGlyph = protoGlyphs28.map(p => 8.0 * cosine(x28n, p));
635
- const logitsLinear = W.map((row, k)=> dot(row, h1) + b[k]);
636
- const logits = logitsGlyph.map((v,k)=> v + 0.2*logitsLinear[k]);
637
  prob = softmax(logits);
638
  }
639
  }
640
- setNodeActivations(h1, h2.map(v => (v+1)/2), prob);
641
  }
642
 
643
- function downsample28(){
644
  // From canvas (224x224) to 28x28 by average pooling in 8x8 blocks
645
- const block = CANVAS_PX/28; // 8
646
- const src = ctx.getImageData(0,0,CANVAS_PX,CANVAS_PX).data;
647
- const out = new Float32Array(28*28);
648
- for (let gy=0; gy<28; gy++){
649
- for (let gx=0; gx<28; gx++){
650
- let acc=0; let cnt=0;
651
- const x0 = Math.floor(gx*block), y0 = Math.floor(gy*block);
652
- for (let y=y0; y<y0+block; y++){
653
- for (let x=x0; x<x0+block; x++){
654
- const idx = (y*CANVAS_PX + x)*4; // RGBA
655
- const r=src[idx], g=src[idx+1], b=src[idx+2];
656
- const gray = (r+g+b)/3/255; // 1: white, 0: black
657
- const ink = 1-gray; // 1: ink/black
658
  acc += ink; cnt++;
659
  }
660
  }
661
- out[gy*28+gx] = acc/(cnt||1);
662
  }
663
  }
664
  return out;
665
  }
666
 
667
- function clearCanvas(){ ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX); runPipeline(); }
668
 
669
  // Drawing interactions
670
- let drawing=false; let last=null;
671
- let hasInteracted=false;
672
  const getPos = (ev) => {
673
  const rect = canvas.getBoundingClientRect();
674
- const sx = CANVAS_PX/rect.width; const sy = CANVAS_PX/rect.height;
675
- const x = (('touches' in ev)? ev.touches[0].clientX : ev.clientX) - rect.left;
676
- const y = (('touches' in ev)? ev.touches[0].clientY : ev.clientY) - rect.top;
677
- return { x: x*sx, y: y*sy };
678
  };
679
- function drawTo(p){
680
  const size = 24;
681
- ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#000000'; ctx.lineWidth=size;
682
  if (!last) last = p;
683
  ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
684
  last = p; runPipeline();
685
  }
686
- function onDown(ev){
687
- drawing=true; last=null;
688
- if (!hasInteracted){ hasInteracted=true; try { eraseBtn.style.display = 'flex'; } catch(_) {} }
689
  drawTo(getPos(ev)); ev.preventDefault();
690
  }
691
- function onMove(ev){ if (!drawing) return; drawTo(getPos(ev)); ev.preventDefault(); }
692
- function onUp(){ drawing=false; last=null; }
693
  canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
694
- canvas.addEventListener('touchstart', onDown, { passive:false }); canvas.addEventListener('touchmove', onMove, { passive:false }); window.addEventListener('touchend', onUp);
695
 
696
  // (erase button handled as overlay)
697
  const rerender = () => { renderGraph(true); };
698
  if (window.ResizeObserver) {
699
- const ro = new ResizeObserver(()=>rerender());
700
  ro.observe(right);
701
  ro.observe(canvas);
702
  } else { window.addEventListener('resize', rerender); }
703
 
704
- // TFJS model (optional)
705
  let tfModel = null;
706
  const tryLoadModel = async () => {
707
- await new Promise((res)=> ensureTF(res));
708
- const candidates = [
709
- // Prefer public path via symlink to assets/data
710
- '/data/mnist-variant-model.json',
711
- // Fallbacks to relative copies under content assets (shards must be colocated)
712
- './assets/data/mnist-variant-model.json',
713
- '../assets/data/mnist-variant-model.json',
714
- '/assets/data/mnist-variant-model.json',
715
- // Fallback to public TFJS MNIST
716
- 'https://storage.googleapis.com/tfjs-models/tfjs/mnist/model.json'
717
- ];
718
- for (const u of candidates){
719
- try { tfModel = await tf.loadLayersModel(u); return; } catch(_) { /* try next */ }
720
- }
721
  tfModel = null;
722
  };
723
 
724
- function predictTfjs(x28){
725
- if (!tfModel || !window.tf) return null;
726
- const run = (arr) => {
727
- const t = tf.tidy(()=> tf.tensor(arr, [28,28,1]).expandDims(0));
728
- try { const y = tfModel.predict(t); const p = y.softmax(); const out = Array.from(p.dataSync()); tf.dispose([y,p,t]); return out; } catch(e){ tf.dispose(t); return null; }
729
- };
730
- // Try both orientations and keep the one with higher confidence
731
- const p1 = run(x28);
732
- const inv = x28.map(v=>1-v);
733
- const p2 = run(inv);
734
- let probs = p1 || p2;
735
- if (p1 && p2){
736
- const m1 = Math.max(...p1), m2 = Math.max(...p2);
737
- probs = m2>m1 ? p2 : p1;
738
- }
739
- if (!probs) return null;
740
- // Normalize output size to 10 classes (pad or slice)
741
- if (probs.length < 10){ probs = probs.concat(Array(10 - probs.length).fill(0)); }
742
- if (probs.length > 10){ probs = probs.slice(0,10); }
743
- return probs;
744
  }
745
 
746
  // Initial render
@@ -751,8 +853,4 @@
751
 
752
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
753
  })();
754
- </script>
755
-
756
-
757
-
758
-
 
1
  <div class="d3-neural"></div>
2
  <style>
3
+ .d3-neural {
4
+ position: relative;
5
+ width: 100%;
6
+ margin: 0;
7
+ }
8
+
9
+ .d3-neural .controls {
10
+ margin-top: 12px;
11
+ display: flex;
12
+ gap: 12px;
13
+ align-items: center;
14
+ flex-wrap: wrap;
15
+ }
16
+
17
+ .d3-neural .controls label {
18
+ font-size: 12px;
19
+ color: var(--muted-color);
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ white-space: nowrap;
24
+ padding: 6px 10px;
25
+ }
26
+
27
+ .d3-neural .controls input[type="range"] {
28
+ width: 160px;
29
+ }
30
+
31
+ .d3-neural .panel {
32
+ display: flex;
33
+ gap: 8px;
34
+ align-items: stretch;
35
+ flex-wrap: nowrap;
36
+ }
37
+
38
+ .d3-neural .left {
39
+ flex: 0 0 33.333%;
40
+ max-width: 33.333%;
41
+ min-width: 160px;
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 8px;
45
+ }
46
+
47
+ .d3-neural .right {
48
+ flex: 1 1 66.666%;
49
+ max-width: 66.666%;
50
+ min-width: 280px;
51
+ display: flex;
52
+ }
53
+
54
+ .d3-neural .right>svg {
55
+ flex: 1 1 auto;
56
+ height: 100%;
57
+ }
58
+
59
+ .d3-neural .arrow-sep {
60
+ flex: 0 0 18px;
61
+ max-width: 18px;
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ color: var(--muted-color);
66
+ }
67
+
68
+ .d3-neural .arrow-sep svg {
69
+ display: block;
70
+ width: 16px;
71
+ height: 16px;
72
+ }
73
+
74
  @media (max-width: 800px) {
75
+ .d3-neural .panel {
76
+ flex-direction: column;
77
+ }
78
+
79
  .d3-neural .left,
80
+ .d3-neural .right {
81
+ flex: 0 0 100%;
82
+ max-width: 100%;
83
+ min-width: 0;
84
+ }
85
+
86
+ .d3-neural .arrow-sep {
87
+ display: none;
88
+ }
89
+ }
90
+
91
+ .d3-neural canvas {
92
+ width: 100%;
93
+ height: auto;
94
+ border-radius: 8px;
95
+ border: 1px solid var(--border-color);
96
+ background: var(--surface-bg);
97
+ display: block;
98
+ }
99
+
100
+ .d3-neural .preview28 {
101
+ display: grid;
102
+ grid-template-columns: repeat(28, 1fr);
103
+ gap: 1px;
104
+ width: 100%;
105
+ }
106
+
107
+ .d3-neural .preview28 span {
108
+ display: block;
109
+ aspect-ratio: 1/1;
110
+ border-radius: 2px;
111
+ }
112
+
113
+ .d3-neural .legend {
114
+ font-size: 12px;
115
+ color: var(--text-color);
116
+ line-height: 1.35;
117
+ }
118
+
119
+ .d3-neural .probs {
120
+ display: flex;
121
+ gap: 6px;
122
+ align-items: flex-end;
123
+ height: 64px;
124
+ }
125
+
126
+ .d3-neural .probs .bar {
127
+ width: 10px;
128
+ border-radius: 2px 2px 0 0;
129
+ background: var(--border-color);
130
+ transition: height .15s ease, background-color .15s ease;
131
+ }
132
+
133
+ .d3-neural .probs .bar.active {
134
+ background: var(--primary-color);
135
+ }
136
+
137
+ .d3-neural .probs .tick {
138
+ font-size: 10px;
139
+ color: var(--muted-color);
140
+ text-align: center;
141
+ margin-top: 2px;
142
+ }
143
+
144
+ .d3-neural .canvas-wrap {
145
+ position: relative;
146
+ }
147
+
148
+ .d3-neural .erase-btn {
149
+ position: absolute;
150
+ top: 8px;
151
+ right: 8px;
152
+ width: 32px;
153
+ height: 32px;
154
+ display: flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ border: 1px solid var(--border-color);
158
+ }
159
+
160
+ .d3-neural .canvas-hint {
161
+ position: absolute;
162
+ top: 8px;
163
+ left: 12px;
164
+ font-size: 12px;
165
+ font-weight: 700;
166
+ color: rgba(0, 0, 0, .9);
167
+ pointer-events: none;
168
+ transition: opacity .12s ease;
169
  }
 
 
 
 
 
 
 
 
 
 
 
 
170
  </style>
171
  <script>
172
  (() => {
 
179
  if (window.d3) onReady();
180
  };
181
 
182
+ // TensorFlow.js loading removed - not needed for glyph-based fallback
 
 
 
 
 
 
 
183
 
184
  const bootstrap = () => {
185
  const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
 
204
  const canvas = document.createElement('canvas'); canvas.width = CANVAS_PX; canvas.height = CANVAS_PX;
205
  const ctx = canvas.getContext('2d');
206
  // init white bg
207
+ ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_PX, CANVAS_PX);
208
  const canvasWrap = document.createElement('div'); canvasWrap.className = 'canvas-wrap';
209
  canvasWrap.appendChild(canvas);
210
  // Erase icon button (top-right)
211
+ const eraseBtn = document.createElement('button'); eraseBtn.className = 'erase-btn button--ghost'; eraseBtn.type = 'button'; eraseBtn.setAttribute('aria-label', 'Clear');
212
  // Hidden until the user interacts with the canvas
213
  eraseBtn.style.display = 'none';
214
  eraseBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>';
215
  eraseBtn.addEventListener('click', () => clearCanvas());
216
  canvasWrap.appendChild(eraseBtn);
217
  // Hint (top-left)
218
+ const hint = document.createElement('div'); hint.className = 'canvas-hint'; hint.textContent = 'Draw a digit here';
219
  canvasWrap.appendChild(hint);
220
  left.appendChild(canvasWrap);
221
 
 
226
  // (prediction panel removed; predictions rendered next to output nodes)
227
 
228
  // SVG network on right
229
+ const svg = d3.select(right).append('svg').attr('width', '100%').style('display', 'block');
230
  const defs = svg.append('defs');
231
  const gRoot = svg.append('g');
232
+ const gInput = gRoot.append('g').attr('class', 'input');
233
+ const gInputLinks = gRoot.append('g').attr('class', 'input-links');
234
+ const gLinks = gRoot.append('g').attr('class', 'links');
235
+ const gNodes = gRoot.append('g').attr('class', 'nodes');
236
+ const gLabels = gRoot.append('g').attr('class', 'labels');
237
+ const gOutText = gRoot.append('g').attr('class', 'out-probs');
238
 
239
  // Network structure (compact: 8 -> 8 -> 10)
240
  const layerSizes = [8, 8, 10];
241
+ const layers = layerSizes.map((n, li) => Array.from({ length: n }, (_, i) => ({ id: `L${li}N${i}`, layer: li, index: i, a: 0 })));
242
  // Links only between hidden->hidden and hidden->output
243
  const links = [];
244
+ for (let i = 0; i < layerSizes[0]; i++) {
245
+ for (let j = 0; j < layerSizes[1]; j++) links.push({ s: { l: 0, i }, t: { l: 1, j }, w: (Math.sin(i * 17 + j * 31) + 1) / 2 });
246
  }
247
+ for (let i = 0; i < layerSizes[1]; i++) {
248
+ for (let j = 0; j < layerSizes[2]; j++) links.push({ s: { l: 1, i }, t: { l: 2, j }, w: (Math.cos(i * 7 + j * 13) + 1) / 2 });
249
  }
250
 
251
  // Linear classifier: logits = W * feats + b, feats in [0,1]
252
  // features: [total, cx, cy, lr, tb, htrans, vtrans, loopiness]
253
  const W = [
254
  // 0 1 2 3 4 5 6 7
255
+ [0.3, 0.0, 0.0, 0.0, 0.0, -0.8, -0.6, 1.2], // 0
256
+ [-0.2, 0.9, 0.2, 0.8, 0.1, -0.2, 0.2, -1.1], // 1
257
+ [0.1, 0.4, 0.2, 0.5, 0.2, 0.9, 0.1, -0.6], // 2
258
+ [0.2, 0.3, 0.2, 0.2, 0.2, 0.9, 0.0, -0.2], // 3
259
+ [0.0, -0.3, 0.2, -0.6, 0.4, 0.2, 0.8, -0.6], // 4
260
+ [0.1, -0.4, 0.2, -0.5, 0.5, 0.9, 0.1, -0.6], // 5
261
+ [0.2, -0.2, 0.6, -0.2, 0.8, -0.3, 0.2, 0.6], // 6
262
+ [0.0, 0.6, -0.2, 0.6, -0.8, 0.6, 0.0, -0.8], // 7
263
+ [0.4, 0.0, 0.0, 0.1, 0.1, 0.6, 0.6, 1.0], // 8
264
+ [0.2, 0.2, -0.6, 0.2, -0.8, 0.2, 0.6, 0.5], // 9
265
  ];
266
  const b = [-0.2, -0.1, -0.05, -0.05, -0.05, -0.05, -0.05, -0.1, -0.15, -0.1];
267
 
268
+ function computeFeatures(x28) {
269
  // x28: Float32Array length 784, values in [0,1] (1 = black/ink)
270
+ let sum = 0, cx = 0, cy = 0; const w = 28, h = 28;
271
  const rowSum = new Array(h).fill(0); const colSum = new Array(w).fill(0);
272
+ let hTransitions = 0, vTransitions = 0;
273
+ for (let y = 0; y < h; y++) {
274
+ for (let x = 0; x < w; x++) {
275
+ const v = x28[y * w + x]; sum += v; cx += x * v; cy += y * v; rowSum[y] += v; colSum[x] += v;
276
+ if (x > 0) { const v0 = x28[y * w + (x - 1)], v1 = v; if ((v0 > 0.25) !== (v1 > 0.25)) hTransitions += 1; }
277
+ if (y > 0) { const v0 = x28[(y - 1) * w + x], v1 = v; if ((v0 > 0.25) !== (v1 > 0.25)) vTransitions += 1; }
278
  }
279
  }
280
+ const total = sum / (w * h); // [0,1]
281
+ const cxn = sum > 1e-6 ? (cx / sum) / (w - 1) : 0.5; // [0,1]
282
+ const cyn = sum > 1e-6 ? (cy / sum) / (h - 1) : 0.5; // [0,1]
283
+ let left = 0, right = 0, top = 0, bottom = 0;
284
+ for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const v = x28[y * w + x]; if (x < w / 2) left += v; else right += v; if (y < h / 2) top += v; else bottom += v; } }
285
+ const lr = (right / (right + left + 1e-6));
286
+ const tb = (bottom / (bottom + top + 1e-6));
287
+ const htn = Math.min(1, hTransitions / (w * h * 0.35));
288
+ const vtn = Math.min(1, vTransitions / (w * h * 0.35));
289
  // Loopiness proxy: ink near perimeter low vs center high
290
+ let perimeter = 0, center = 0; const m = 5;
291
+ for (let y = 0; y < h; y++) {
292
+ for (let x = 0; x < w; x++) {
293
+ const v = x28[y * w + x];
294
+ const isBorder = (x < m || x >= w - m || y < m || y >= h - m);
295
+ if (isBorder) perimeter += v; else center += v;
296
  }
297
  }
298
+ const loopiness = Math.min(1, center / (perimeter + center + 1e-6) * 1.8);
299
  return [total, cxn, cyn, lr, tb, htn, vtn, loopiness];
300
  }
301
 
302
+ function softmax(arr) { const m = Math.max(...arr); const ex = arr.map(v => Math.exp(v - m)); const s = ex.reduce((a, b) => a + b, 0) + 1e-12; return ex.map(v => v / s); }
303
+ function l2norm(a) { return Math.hypot(...a) || 0; }
304
+ function normalize(a) { const n = l2norm(a); return n > 0 ? a.map(v => v / n) : a.slice(); }
305
+ function cosine(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; const na = l2norm(a), nb = l2norm(b) || 1; return na > 0 ? s / (na * nb) : 0; }
306
 
307
  // MNIST-like normalization: crop to tight bbox, scale into 20x20, center in 28x28
308
+ function normalize28(x28) {
309
+ const w = 28, h = 28, thr = 0.2;
310
+ let minX = 29, minY = 29, maxX = -1, maxY = -1, sum = 0, cx = 0, cy = 0;
311
+ for (let y = 0; y < h; y++) {
312
+ for (let x = 0; x < w; x++) {
313
+ const v = x28[y * w + x];
314
+ if (v > thr) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; }
315
+ sum += v; cx += x * v; cy += y * v;
316
  }
317
  }
318
+ if (sum < 1e-3 || maxX < 0) { return x28; }
319
+ const comX = cx / sum, comY = cy / sum;
320
+ const bw = Math.max(1, maxX - minX + 1), bh = Math.max(1, maxY - minY + 1);
321
+ const scale = 20 / Math.max(bw, bh);
322
+ const out = new Float32Array(w * h);
323
  // center of canvas
324
+ const cxOut = (w - 1) / 2, cyOut = (h - 1) / 2;
325
+ for (let y = 0; y < h; y++) {
326
+ for (let x = 0; x < w; x++) {
327
  // map output pixel to source space around COM
328
+ const sx = (x - cxOut) / scale + comX;
329
+ const sy = (y - cyOut) / scale + comY;
330
+ out[y * w + x] = bilinearSample(x28, w, h, sx, sy);
331
  }
332
  }
333
  return out;
334
  }
335
+ function bilinearSample(img, w, h, x, y) {
336
  const x0 = Math.floor(x), y0 = Math.floor(y);
337
+ const x1 = x0 + 1, y1 = y0 + 1;
338
  const tx = x - x0, ty = y - y0;
339
+ function at(ix, iy) { if (ix < 0 || iy < 0 || ix >= w || iy >= h) return 0; return img[iy * w + ix]; }
340
+ const v00 = at(x0, y0), v10 = at(x1, y0), v01 = at(x0, y1), v11 = at(x1, y1);
341
+ const a = v00 * (1 - tx) + v10 * tx; const b = v01 * (1 - tx) + v11 * tx; return a * (1 - ty) + b * ty;
342
  }
343
  // Simple dilation (max-pooling 3x3) to thicken strokes
344
+ function dilate28(x) {
345
+ const w = 28, h = 28; const out = new Float32Array(w * h);
346
+ for (let y = 0; y < h; y++) {
347
+ for (let x0 = 0; x0 < w; x0++) {
348
+ let m = 0;
349
+ for (let dy = -1; dy <= 1; dy++) {
350
+ for (let dx = -1; dx <= 1; dx++) {
351
+ const xx = x0 + dx, yy = y + dy; if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
352
+ const v = x[yy * w + xx]; if (v > m) m = v;
353
  }
354
  }
355
+ out[y * w + x0] = m;
356
  }
357
  }
358
  return out;
 
360
 
361
  // Glyph-based 28x28 prototypes for digits 0-9 (normalized)
362
  const protoGlyphs28 = [];
363
+ (function buildGlyphProtos() {
364
  const off = document.createElement('canvas'); off.width = CANVAS_PX; off.height = CANVAS_PX;
365
  const c = off.getContext('2d');
366
+ for (let d = 0; d < 10; d++) {
367
+ c.fillStyle = '#ffffff'; c.fillRect(0, 0, off.width, off.height);
368
+ c.fillStyle = '#000000'; c.textAlign = 'center'; c.textBaseline = 'middle';
369
  c.font = 'bold 180px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
370
+ c.fillText(String(d), off.width / 2, off.height * 0.56);
371
+ const src = c.getImageData(0, 0, off.width, off.height).data; const block = off.width / 28;
372
+ const vec = new Float32Array(28 * 28);
373
+ for (let gy = 0; gy < 28; gy++) {
374
+ for (let gx = 0; gx < 28; gx++) {
375
+ let acc = 0, cnt = 0; const x0 = Math.floor(gx * block), y0 = Math.floor(gy * block);
376
+ for (let yy = y0; yy < y0 + block; yy++) {
377
+ for (let xx = x0; xx < x0 + block; xx++) {
378
+ const idx = (yy * off.width + xx) * 4; const r = src[idx], g = src[idx + 1], b = src[idx + 2];
379
+ const gray = (r + g + b) / 3 / 255; acc += (1 - gray); cnt++;
380
  }
381
  }
382
+ vec[gy * 28 + gx] = acc / (cnt || 1);
383
  }
384
  }
385
  const normed = normalize28(vec);
386
+ const n = l2norm(normed) || 1; protoGlyphs28.push(normed.map(v => v / n));
387
  }
388
  })();
389
+ function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; }
390
 
391
  // Resize handling and node layout
392
+ let width = 640, height = 360; const margin = { top: 16, right: 8, bottom: 24, left: 8 };
393
  let inputGrid = { cell: 0, x: 0, y: 0, width: 0, height: 0 };
394
+ function layoutNodes() {
395
  // Right panel width, and a non-square aspect ratio for clarity
396
  width = Math.max(280, Math.round(right.clientWidth || 640));
397
  height = Math.max(260, Math.round(width * 0.56));
398
  svg.attr('width', width).attr('height', height);
399
  // Match canvas height to SVG height so both columns align vertically
400
+ try { canvas.style.height = '100%'; canvasWrap.style.height = height + 'px'; } catch (_) { }
401
  const innerW = width - margin.left - margin.right; const innerH = height - margin.top - margin.bottom;
402
  gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
403
  // Input grid layout (28x28) at left β€” cap width to a fraction of innerW
 
405
  const cellByHeight = Math.floor(innerH / 28);
406
  const cellByWidth = Math.floor((innerW * maxGridFrac) / 28);
407
  let cell = Math.max(3, Math.min(cellByHeight, cellByWidth));
408
+ let gridH = cell * 28; let gridY = Math.floor((innerH - gridH) / 2);
409
+ inputGrid = { cell, x: 0, y: gridY, width: cell * 28, height: gridH };
410
  // Equal horizontal gaps: grid -> L0 -> L1 -> L2
411
  const nLayers = layerSizes.length; // 3
412
  const rightLabelPad = 36; // smaller pad; use more width for spreading layers
 
415
  const desiredMinFree = rightLabelPad + nLayers * minGap; // 3 equal gaps
416
  if (inputGrid.width + desiredMinFree > innerW) {
417
  cell = Math.max(3, Math.floor((innerW - desiredMinFree) / 28));
418
+ gridH = cell * 28; gridY = Math.floor((innerH - gridH) / 2);
419
+ inputGrid = { cell, x: 0, y: gridY, width: cell * 28, height: gridH };
420
  }
421
  const gridRight = inputGrid.x + inputGrid.width;
422
  const freeW = Math.max(nLayers * minGap, innerW - gridRight - rightLabelPad);
423
  const gapX = Math.min(maxGap, Math.max(minGap, Math.floor(freeW / nLayers)));
424
  const xs = Array.from({ length: nLayers }, (_, li) => gridRight + gapX * (li + 1));
425
  // Y positions evenly spaced per layer
426
+ layers.forEach((nodes, li) => {
427
  const n = nodes.length;
428
  if (n <= 1) {
429
+ nodes.forEach((nd) => { nd.x = xs[li]; nd.y = innerH / 2; });
430
  } else {
431
  const occupancy = 0.9; // use 90% of vertical space
432
  const span = innerH * occupancy;
433
  const topPad = (innerH - span) / 2;
434
  const spacing = span / (n - 1);
435
+ nodes.forEach((nd, i) => { nd.x = xs[li]; nd.y = topPad + i * spacing; });
436
  }
437
  });
438
  }
439
 
440
+ let lastX28 = new Float32Array(28 * 28);
441
+ function nodeRadiusForNode(n) {
442
  const a = Math.max(0, Math.min(1, (n && typeof n.a === 'number') ? n.a : 0));
443
  if (n && n.layer === 2) {
444
  // Output nodes: variable radius based on activation
 
447
  // Hidden/feature nodes: variable radius based on activation
448
  return 5 + 5 * a; // ~5–10
449
  }
450
+ function renderInputGrid() {
451
  if (!inputGrid || inputGrid.cell <= 0) return;
452
+ const data = Array.from({ length: 28 * 28 }, (_, i) => ({ i, v: lastX28[i] || 0 }));
453
+ const sel = gInput.selectAll('rect.input-px').data(data, d => d.i);
454
  const gap = Math.max(1, Math.floor(inputGrid.cell * 0.10));
455
  const inner = Math.max(1, inputGrid.cell - gap);
456
  const offset = Math.floor(gap / 2);
457
+ sel.enter().append('rect').attr('class', 'input-px')
458
  .attr('width', inner).attr('height', inner)
459
  .merge(sel)
460
  .attr('x', d => inputGrid.x + (d.i % 28) * inputGrid.cell + offset)
 
470
 
471
  // Border around the input grid area
472
  const borderSel = gInput.selectAll('rect.input-border').data([0]);
473
+ borderSel.enter().append('rect').attr('class', 'input-border')
474
+ .attr('fill', 'none')
475
  .attr('rx', 0).attr('ry', 0)
476
+ .attr('stroke', 'var(--text-color)')
477
  .attr('stroke-opacity', 0.25)
478
  .attr('stroke-width', 1)
479
  .lower()
480
  .merge(borderSel)
481
+ .attr('x', inputGrid.x - 1)
482
+ .attr('y', inputGrid.y - 1)
483
+ .attr('width', inputGrid.width + 1)
484
+ .attr('height', inputGrid.height + 1);
485
 
486
  // Centered label above the input grid
487
  const labelSel = gInput.selectAll('text.input-label').data([0]);
488
+ labelSel.enter().append('text').attr('class', 'input-label')
489
+ .attr('text-anchor', 'middle')
490
+ .style('font-size', '12px')
491
+ .style('font-weight', '700')
492
+ .style('fill', 'var(--muted-color)')
493
  .merge(labelSel)
494
  .attr('x', inputGrid.x + inputGrid.width / 2)
495
  .attr('y', Math.max(12, inputGrid.y - 10))
 
497
  }
498
 
499
  // Compute link path between two layered nodes using their current radii
500
+ function computeLinkD(d) {
501
  const s = layers[d.s.l][d.s.i];
502
  const t = layers[d.t.l][d.t.j];
503
  if (!s || !t) return '';
 
507
  const x1 = s.x + rs, y1 = s.y; // right edge of source circle
508
  const x2 = t.x - rt, y2 = t.y; // left edge of target circle
509
  const dx = (x2 - x1) * 0.45;
510
+ return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
511
  }
512
 
513
+ function renderInputLinks() {
514
  // Draw bundle-like links from input grid right edge to first layer nodes (features)
515
  const firstLayer = layers[0];
516
  if (!firstLayer || !inputGrid || inputGrid.cell <= 0) { gInputLinks.selectAll('path').remove(); return; }
 
532
  return { x0, y0, x1, y1, c1x: x0 + dx, c1y: y0, c2x: x1 - dx, c2y: y1, idx };
533
  });
534
  const sel = gInputLinks.selectAll('path.input-link').data(paths);
535
+ sel.enter().append('path').attr('class', 'input-link')
536
+ .attr('fill', 'none')
537
+ .attr('stroke', 'var(--text-color)')
538
  .attr('stroke-opacity', 0.25)
539
  .attr('stroke-width', 1)
540
+ .attr('stroke-linecap', 'round')
541
  .merge(sel)
542
  .attr('d', d => `M${d.x0},${d.y0} C${d.c1x},${d.c1y} ${d.c2x},${d.c2y} ${d.x1},${d.y1}`)
543
+ .attr('stroke', 'var(--text-color)');
544
  sel.exit().remove();
545
  }
546
 
547
  // Recompute input link path on the fly (used when node radii change)
548
+ function computeInputLinkD(idx) {
549
  const firstLayer = layers[0];
550
  const n = firstLayer[idx]; if (!n) return '';
551
  const x0 = inputGrid.x + inputGrid.width;
 
565
  return `M${x0},${y0} C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1}`;
566
  }
567
 
568
+ function renderGraph(showEdges) {
569
  layoutNodes();
570
  renderInputGrid();
571
  renderInputLinks();
572
  // Nodes
573
  const allNodes = layers.flat();
574
+ const nodeSel = gNodes.selectAll('circle.node').data(allNodes, d => d.id);
575
+ nodeSel.enter().append('circle').attr('class', 'node')
576
  .attr('r', 10)
577
+ .attr('cx', d => d.x).attr('cy', d => d.y)
578
+ .attr('fill', d => d.layer === 2 ? 'var(--page-bg)' : 'var(--primary-color)')
579
+ .attr('fill-opacity', d => d.layer === 2 ? 1 : 0.12)
580
+ .attr('stroke', d => d.layer === 2 ? 'var(--border-color)' : 'var(--border-color)')
581
+ .attr('stroke-width', 1)
582
+ .attr('stroke-linejoin', 'round')
583
  .merge(nodeSel)
584
+ .attr('cx', d => d.x).attr('cy', d => d.y)
585
  .attr('opacity', 1);
586
  nodeSel.exit().remove();
587
 
588
  // Labels for first hidden layer only (avoid stacking with output probs)
589
  const labels = [];
590
+ layers[0].forEach((n, i) => labels.push({ x: n.x - 30, y: n.y + 4, txt: `f${i + 1}` }));
591
  const labSel = gLabels.selectAll('text').data(labels);
592
  labSel.enter().append('text')
593
+ .style('font-size', '10px')
594
+ .style('fill', 'var(--muted-color)')
595
+ .style('paint-order', 'stroke')
596
+ .style('stroke', 'var(--page-bg)')
597
+ .style('stroke-width', '3px')
598
+ .attr('x', d => d.x)
599
+ .attr('y', d => d.y)
600
+ .text(d => d.txt)
601
  .merge(labSel)
602
+ .style('paint-order', 'stroke')
603
+ .style('stroke', 'var(--page-bg)')
604
+ .style('stroke-width', '5px')
605
+ .attr('x', d => d.x)
606
+ .attr('y', d => d.y)
607
+ .text(d => d.txt);
608
  labSel.exit().remove();
609
 
610
  // Links as smooth curves
611
+ const linkSel = gLinks.selectAll('path.link').data(links, d => `${d.s.l}-${d.s.i}-${d.t.l}-${d.t.j}`);
612
+ linkSel.enter().append('path').attr('class', 'link')
613
  .attr('d', computeLinkD)
614
+ .attr('fill', 'none')
615
+ .attr('stroke', 'var(--text-color)')
616
  .attr('stroke-opacity', 0.25)
617
+ .attr('stroke-width', d => 0.5 + d.w * 1.2)
618
+ .attr('stroke-linecap', 'round')
619
  .merge(linkSel)
620
  .attr('d', computeLinkD)
621
+ .attr('stroke', 'var(--text-color)')
622
+ .attr('stroke-width', d => 0.5 + d.w * 1.2);
623
  linkSel.exit().remove();
624
 
625
  // Ensure output labels remain aligned with the last layer on resize
626
  gOutText.selectAll('g.out-label')
627
+ .attr('transform', function (d) {
628
  if (!d || typeof d.digit !== 'number') return d3.select(this).attr('transform');
629
  const n = layers[2][d.digit];
630
  if (!n) return d3.select(this).attr('transform');
 
633
  });
634
  // Ensure clip-path circles are updated on resize
635
  if (defs) {
636
+ const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d => d.id);
637
+ const ce = clips.enter().append('clipPath').attr('class', 'clip-node').attr('clipPathUnits', 'userSpaceOnUse').attr('id', d => `clip-${d.id}`);
638
  ce.append('circle');
639
+ clips.merge(ce).select('circle').attr('cx', d => d.x).attr('cy', d => d.y).attr('r', d => nodeRadiusForNode(d));
640
  clips.exit().remove();
641
  }
642
  }
643
 
644
+ function setNodeActivations(h1, h2, out) {
645
+ layers[0].forEach((n, i) => n.a = h1[i] || 0);
646
+ layers[1].forEach((n, i) => n.a = h2[i] || 0);
647
+ layers[2].forEach((n, i) => n.a = out[i] || 0);
648
  // Determine top prediction (for ghosting others)
649
  let argmaxIdx = 0; let bestProb = -1;
650
  if (Array.isArray(out)) {
651
+ for (let i = 0; i < out.length; i++) { if (out[i] > bestProb) { bestProb = out[i]; argmaxIdx = i; } }
652
  }
653
  // Color/size by activation with smooth transitions
654
  gNodes.selectAll('circle.node')
655
  .transition().duration(180).ease(d3.easeCubicOut)
656
+ .attr('fill', d => d.layer === 2 ? 'var(--page-bg)' : 'var(--primary-color)')
657
+ .attr('fill-opacity', d => d.layer === 2 ? 1 : (0.12 + 0.58 * Math.min(1, d.a || 0)))
658
  .attr('stroke', 'var(--primary-color)')
659
+ .attr('stroke-opacity', d => (d.layer === 2 ? 0.9 : (0.45 + 0.45 * Math.min(1, d.a || 0))))
660
+ .attr('opacity', d => 0.55 + 0.45 * Math.min(1, d.a || 0))
661
+ .attr('r', d => nodeRadiusForNode(d));
662
  // Link opacity by activation flow
663
  gLinks.selectAll('path.link')
664
  .transition().duration(180).ease(d3.easeCubicOut)
665
  .attr('d', computeLinkD)
666
+ .attr('stroke', 'var(--text-color)')
667
+ .attr('stroke-opacity', d => {
668
  const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
669
  return Math.min(1, 0.15 + 0.85 * (aS * aT));
670
  })
671
+ .attr('stroke-width', d => {
672
  const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
673
+ return 0.6 + 2.2 * (aS * aT);
674
  });
675
  // Theme-aware and activation-aware input links
676
  gInputLinks.selectAll('path.input-link')
677
  .transition().duration(180).ease(d3.easeCubicOut)
678
+ .attr('d', (d) => computeInputLinkD(d.idx))
679
+ .attr('stroke', 'var(--text-color)')
680
  .attr('stroke-opacity', 0.25)
681
+ .attr('stroke-width', d => 0.6 + 2.0 * (layers[0][d.idx] ? (layers[0][d.idx].a || 0) : 0));
682
  // Update clip-path circles to match new radii/positions of output nodes
683
  if (defs) {
684
+ const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d => d.id);
685
  clips.select('circle')
686
  .transition().duration(180).ease(d3.easeCubicOut)
687
+ .attr('cx', d => d.x)
688
+ .attr('cy', d => d.y)
689
+ .attr('r', d => nodeRadiusForNode(d));
690
  }
691
  // Theme-aware input links on updates handled above via transition
692
  // Output labels: digit placed to the right of the node
693
+ const outs = layers[2].map((n, i) => ({ x: n.x + nodeRadiusForNode(n) + 8, y: n.y, digit: i, prob: (out[i] || 0), isTop: i === argmaxIdx }));
694
+ const gSel = gOutText.selectAll('g.out-label').data(outs, d => d.digit);
695
+ const gEnter = gSel.enter().append('g').attr('class', 'out-label');
696
+ gEnter.append('text').attr('class', 'out-digit')
697
+ .style('font-size', '12px').style('font-weight', '800').style('fill', 'var(--text-color)')
698
+ .attr('text-anchor', 'start').attr('dominant-baseline', 'middle')
699
+ .style('paint-order', 'stroke').style('stroke', 'var(--transparent-page-contrast)').style('stroke-width', '3px');
700
  const merged = gEnter.merge(gSel)
701
+ .attr('transform', d => `translate(${d.x},${d.y})`)
702
+ .each(function (d) {
703
  const sel = d3.select(this);
704
  sel.select('text.out-digit')
705
  .attr('x', 0).attr('y', 0)
 
713
  gSel.exit().remove();
714
 
715
  // Output liquid fill using clipPath + rect from bottom
716
+ const rects = gNodes.selectAll('rect.out-liquid').data(layers[2], d => d.id);
717
+ const rectEnter = rects.enter().append('rect').attr('class', 'out-liquid')
718
+ .attr('fill', 'var(--primary-color)')
719
  .attr('fill-opacity', 0.55)
720
  .attr('clip-path', d => `url(#clip-${d.id})`);
721
  rectEnter.merge(rects)
722
  .transition().duration(180).ease(d3.easeCubicOut)
723
+ .attr('x', d => d.x - nodeRadiusForNode(d))
724
+ .attr('width', d => 2 * nodeRadiusForNode(d))
725
+ .attr('y', d => {
726
  const r = nodeRadiusForNode(d);
727
+ const h = 2 * r * Math.max(0, Math.min(1, d.a || 0));
728
  return d.y + r - h;
729
  })
730
+ .attr('height', d => 2 * nodeRadiusForNode(d) * Math.max(0, Math.min(1, d.a || 0)))
731
  .attr('fill-opacity', 0.55);
732
  rects.exit().remove();
733
  }
734
 
735
  // (no separate updateBars; bars are rendered next to nodes)
736
 
737
+ function runPipeline() {
738
  const x28raw = downsample28();
739
  const x28 = dilate28(normalize28(x28raw));
740
  // Update input grid data
 
747
  // Hidden 1 = raw features
748
  const h1 = feats;
749
  // Hidden 2 = simple non-linear mix for visualization only
750
+ const h2 = layers[1].map((_, j) => {
751
+ let s = 0; for (let i = 0; i < layers[0].length; i++) { const w = (Math.sin(i * 17 + j * 31) + 1) / 2 * 0.8 + 0.1; s += w * h1[i]; }
752
+ return Math.tanh(s * 0.8);
753
  });
754
  let prob;
755
+ if (inkMass < 0.03) {
756
  // Too little ink: return near-uniform distribution
757
+ prob = Array.from({ length: 10 }, () => 1 / 10);
758
  } else {
759
  // Prefer TFJS model if available
760
  const tfProbs = predictTfjs(x28);
 
764
  // Fallback: rely mostly on glyph similarity
765
  const x28n = normalize(x28);
766
  const logitsGlyph = protoGlyphs28.map(p => 8.0 * cosine(x28n, p));
767
+ const logitsLinear = W.map((row, k) => dot(row, h1) + b[k]);
768
+ const logits = logitsGlyph.map((v, k) => v + 0.2 * logitsLinear[k]);
769
  prob = softmax(logits);
770
  }
771
  }
772
+ setNodeActivations(h1, h2.map(v => (v + 1) / 2), prob);
773
  }
774
 
775
+ function downsample28() {
776
  // From canvas (224x224) to 28x28 by average pooling in 8x8 blocks
777
+ const block = CANVAS_PX / 28; // 8
778
+ const src = ctx.getImageData(0, 0, CANVAS_PX, CANVAS_PX).data;
779
+ const out = new Float32Array(28 * 28);
780
+ for (let gy = 0; gy < 28; gy++) {
781
+ for (let gx = 0; gx < 28; gx++) {
782
+ let acc = 0; let cnt = 0;
783
+ const x0 = Math.floor(gx * block), y0 = Math.floor(gy * block);
784
+ for (let y = y0; y < y0 + block; y++) {
785
+ for (let x = x0; x < x0 + block; x++) {
786
+ const idx = (y * CANVAS_PX + x) * 4; // RGBA
787
+ const r = src[idx], g = src[idx + 1], b = src[idx + 2];
788
+ const gray = (r + g + b) / 3 / 255; // 1: white, 0: black
789
+ const ink = 1 - gray; // 1: ink/black
790
  acc += ink; cnt++;
791
  }
792
  }
793
+ out[gy * 28 + gx] = acc / (cnt || 1);
794
  }
795
  }
796
  return out;
797
  }
798
 
799
+ function clearCanvas() { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_PX, CANVAS_PX); runPipeline(); }
800
 
801
  // Drawing interactions
802
+ let drawing = false; let last = null;
803
+ let hasInteracted = false;
804
  const getPos = (ev) => {
805
  const rect = canvas.getBoundingClientRect();
806
+ const sx = CANVAS_PX / rect.width; const sy = CANVAS_PX / rect.height;
807
+ const x = (('touches' in ev) ? ev.touches[0].clientX : ev.clientX) - rect.left;
808
+ const y = (('touches' in ev) ? ev.touches[0].clientY : ev.clientY) - rect.top;
809
+ return { x: x * sx, y: y * sy };
810
  };
811
+ function drawTo(p) {
812
  const size = 24;
813
+ ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#000000'; ctx.lineWidth = size;
814
  if (!last) last = p;
815
  ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
816
  last = p; runPipeline();
817
  }
818
+ function onDown(ev) {
819
+ drawing = true; last = null;
820
+ if (!hasInteracted) { hasInteracted = true; try { eraseBtn.style.display = 'flex'; } catch (_) { } }
821
  drawTo(getPos(ev)); ev.preventDefault();
822
  }
823
+ function onMove(ev) { if (!drawing) return; drawTo(getPos(ev)); ev.preventDefault(); }
824
+ function onUp() { drawing = false; last = null; }
825
  canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
826
+ canvas.addEventListener('touchstart', onDown, { passive: false }); canvas.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onUp);
827
 
828
  // (erase button handled as overlay)
829
  const rerender = () => { renderGraph(true); };
830
  if (window.ResizeObserver) {
831
+ const ro = new ResizeObserver(() => rerender());
832
  ro.observe(right);
833
  ro.observe(canvas);
834
  } else { window.addEventListener('resize', rerender); }
835
 
836
+ // TFJS model disabled - using glyph-based fallback only
837
  let tfModel = null;
838
  const tryLoadModel = async () => {
839
+ // Model loading disabled to avoid 404 errors
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  tfModel = null;
841
  };
842
 
843
+ function predictTfjs(x28) {
844
+ // Always return null to use glyph-based fallback
845
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
  }
847
 
848
  // Initial render
 
853
 
854
  if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
855
  })();
856
+ </script>
 
 
 
 
app/src/content/embeds/d3-pie-quad.html CHANGED
@@ -21,7 +21,7 @@
21
  .d3-pie-quad.hovering .slice.ghost {
22
  opacity: .25;
23
  }
24
- /* Layout HTML (pas JS) pour la grille et les cellules */
25
  .d3-pie-quad .plots-grid {
26
  display: flex;
27
  flex-wrap: wrap;
@@ -33,21 +33,21 @@
33
  margin-right: auto;
34
  width: 100%;
35
  }
36
- /* Par dΓ©faut (flux ~1280): 2 colonnes centrΓ©es */
37
  .content-grid .d3-pie-quad .plots-grid { width: 100%; }
38
  .content-grid .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 20px)/2); }
39
- /* En wrappers larges: viser 4 colonnes si l'espace le permet */
40
  .wide .d3-pie-quad .plots-grid,
41
  .full-width .d3-pie-quad .plots-grid { width: 100%; }
42
  .wide .d3-pie-quad .pie-cell,
43
  .full-width .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 60px)/4); }
44
- /* Forcer 2 colonnes dans le flux lorsque le parent ~1280px */
45
  .content-grid .d3-pie-quad .plots-grid { width: min(740px, 100%); }
46
  .d3-pie-quad .pie-cell {
47
  display: flex;
48
  flex-direction: column;
49
  align-items: center;
50
- flex: 0 0 360px; /* 2 colonnes fixes dans le flux Γ  1280px */
51
  }
52
  /* 4/2/1 colonnes en fonction de la largeur du parent */
53
  /* @container (min-width: 740px) {
@@ -179,10 +179,10 @@
179
  const CAPTION_GAP = 36; // espace entre titre et donut
180
  const GAP_X = 20; // espace entre colonnes
181
  const GAP_Y = 12; // espace entre lignes
182
- const TOP_OFFSET = 4; // dΓ©calage vertical supplΓ©mentaire pour aΓ©rer le haut
183
  const DONUT_INNER_RATIO = 0.58; // ratio du trou central (0 = pie plein, 0.5 = moitiΓ©)
184
  // LEGEND_GAP supprimΓ©: l'espacement est dΓ©sormais gΓ©rΓ© en CSS via .d3-pie-quad .legend { margin-bottom }
185
- const SVG_VPAD = 16; // padding vertical supplΓ©mentaire Γ  l'intΓ©rieur des SVG pour Γ©viter la coupe
186
 
187
  const updateSize = () => {
188
  width = container.clientWidth || 800;
@@ -199,7 +199,7 @@
199
  function drawPies(rows){
200
  const { innerWidth } = updateSize();
201
 
202
- // CatΓ©gories (triΓ©es) + Γ©chelle de couleurs harmonisΓ©e avec banner.html
203
  const categories = Array.from(new Set(rows.map(r => r.eagle_cathegory || 'Unknown'))).sort();
204
  const getCatColors = (n) => {
205
  try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch(_) {}
 
21
  .d3-pie-quad.hovering .slice.ghost {
22
  opacity: .25;
23
  }
24
+ /* HTML layout (not JS) for grid and cells */
25
  .d3-pie-quad .plots-grid {
26
  display: flex;
27
  flex-wrap: wrap;
 
33
  margin-right: auto;
34
  width: 100%;
35
  }
36
+ /* Default (flow ~1280): 2 centered columns */
37
  .content-grid .d3-pie-quad .plots-grid { width: 100%; }
38
  .content-grid .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 20px)/2); }
39
+ /* In wide wrappers: aim for 4 columns if space allows */
40
  .wide .d3-pie-quad .plots-grid,
41
  .full-width .d3-pie-quad .plots-grid { width: 100%; }
42
  .wide .d3-pie-quad .pie-cell,
43
  .full-width .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 60px)/4); }
44
+ /* Force 2 columns in flow when parent ~1280px */
45
  .content-grid .d3-pie-quad .plots-grid { width: min(740px, 100%); }
46
  .d3-pie-quad .pie-cell {
47
  display: flex;
48
  flex-direction: column;
49
  align-items: center;
50
+ flex: 0 0 360px; /* 2 fixed columns in flow at 1280px */
51
  }
52
  /* 4/2/1 colonnes en fonction de la largeur du parent */
53
  /* @container (min-width: 740px) {
 
179
  const CAPTION_GAP = 36; // espace entre titre et donut
180
  const GAP_X = 20; // espace entre colonnes
181
  const GAP_Y = 12; // espace entre lignes
182
+ const TOP_OFFSET = 4; // additional vertical offset to air out the top
183
  const DONUT_INNER_RATIO = 0.58; // ratio du trou central (0 = pie plein, 0.5 = moitiΓ©)
184
  // LEGEND_GAP supprimΓ©: l'espacement est dΓ©sormais gΓ©rΓ© en CSS via .d3-pie-quad .legend { margin-bottom }
185
+ const SVG_VPAD = 16; // additional vertical padding inside SVGs to avoid cropping
186
 
187
  const updateSize = () => {
188
  width = container.clientWidth || 800;
 
199
  function drawPies(rows){
200
  const { innerWidth } = updateSize();
201
 
202
+ // Categories (sorted) + color scale harmonized with banner.html
203
  const categories = Array.from(new Set(rows.map(r => r.eagle_cathegory || 'Unknown'))).sort();
204
  const getCatColors = (n) => {
205
  try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch(_) {}
app/src/content/embeds/d3-scatter.html CHANGED
@@ -80,7 +80,7 @@
80
  function getDotStrokeColor(fillColor = null){
81
  if (!fillColor) return 'var(--muted-color)';
82
 
83
- // RΓ©soudre les variables CSS en couleurs rΓ©elles
84
  let resolvedColor = fillColor;
85
  if (fillColor.startsWith('var(')) {
86
  const tempEl = document.createElement('div');
 
80
  function getDotStrokeColor(fillColor = null){
81
  if (!fillColor) return 'var(--muted-color)';
82
 
83
+ // Resolve CSS variables to actual colors
84
  let resolvedColor = fillColor;
85
  if (fillColor.startsWith('var(')) {
86
  const tempEl = document.createElement('div');
app/src/content/embeds/d3-umap-typography.html ADDED
@@ -0,0 +1,804 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="typography-umap-2d">
2
+ <svg class="main-svg" xmlns="http://www.w3.org/2000/svg">
3
+ <defs id="font-defs"></defs>
4
+ <g class="viewport-group"></g>
5
+ <g class="ui-group"></g>
6
+ </svg>
7
+ <div class="ui-layer"></div>
8
+ </div>
9
+ <style>
10
+ .typography-umap-2d {
11
+ position: relative;
12
+ min-height: 280px;
13
+ max-height: 450px;
14
+ width: 100%;
15
+ overflow: hidden;
16
+ cursor: grab;
17
+ contain: layout style paint;
18
+ }
19
+
20
+ .main-svg {
21
+ width: 100%;
22
+ height: 100%;
23
+ display: block;
24
+ cursor: grab;
25
+ /* Rendering optimizations */
26
+ shape-rendering: optimizeLegibility;
27
+ text-rendering: optimizeLegibility;
28
+ image-rendering: optimizeLegibility;
29
+ /* GPU layer */
30
+ will-change: transform;
31
+ transform: translateZ(0);
32
+ }
33
+
34
+ .typography-umap-2d:active {
35
+ cursor: grabbing;
36
+ }
37
+
38
+ /* Viewport - THE only element that receives transforms */
39
+ .viewport {
40
+ position: absolute;
41
+ top: 0;
42
+ left: 0;
43
+ width: 100%;
44
+ height: 100%;
45
+ will-change: transform;
46
+ }
47
+
48
+ /* Font glyphs with expanded hit area */
49
+ .font-glyph-group {
50
+ cursor: pointer;
51
+ }
52
+
53
+ .font-hit-area {
54
+ cursor: pointer;
55
+ }
56
+
57
+ .font-glyph {
58
+ pointer-events: none;
59
+ /* The glyph itself no longer intercepts events */
60
+ }
61
+
62
+ /* Native SVG centroid labels */
63
+ .centroid-label {
64
+ font-weight: 700;
65
+ font-size: 14px;
66
+ pointer-events: none;
67
+ paint-order: stroke fill;
68
+ stroke: var(--page-bg);
69
+ stroke-width: 3px;
70
+ stroke-linejoin: round;
71
+ stroke-linecap: round;
72
+ }
73
+
74
+ /* UI Layer - static */
75
+ .ui-layer {
76
+ position: absolute;
77
+ top: 0;
78
+ left: 0;
79
+ width: 100%;
80
+ height: 100%;
81
+ pointer-events: none;
82
+ z-index: 100;
83
+ }
84
+
85
+ /* HTML tooltip in foreignObject */
86
+ .svg-tooltip {
87
+ pointer-events: none;
88
+ opacity: 0;
89
+ transition: opacity 0.2s ease;
90
+ }
91
+
92
+ .tooltip-html {
93
+ background: var(--surface-bg);
94
+ border: 1px solid var(--border-color);
95
+ border-radius: 8px;
96
+ padding: 12px;
97
+ font-size: 12px;
98
+ line-height: 1.4;
99
+ color: var(--text-color);
100
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
101
+ white-space: nowrap;
102
+ font-family: var(--font-sans);
103
+ min-width: 140px;
104
+ filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.08));
105
+ }
106
+
107
+ .tooltip-title {
108
+ font-weight: 600;
109
+ font-size: 13px;
110
+ margin-bottom: 4px;
111
+ color: var(--text-color);
112
+ }
113
+
114
+ .tooltip-category {
115
+ font-size: 11px;
116
+ color: var(--muted-color);
117
+ margin-bottom: 2px;
118
+ }
119
+
120
+ /* Zoom controls */
121
+ .zoom-controls {
122
+ position: absolute;
123
+ top: 10px;
124
+ right: 10px;
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 4px;
128
+ pointer-events: all;
129
+ }
130
+
131
+ .zoom-controls button {
132
+ background: var(--surface-bg);
133
+ border: 1px solid var(--border-color);
134
+ border-radius: 6px;
135
+ width: 32px;
136
+ height: 32px;
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ cursor: pointer;
141
+ font-size: 16px;
142
+ font-weight: bold;
143
+ color: var(--text-color);
144
+ transition: all 0.2s ease;
145
+ }
146
+
147
+ .zoom-controls button:hover {
148
+ background: var(--primary-color);
149
+ color: white;
150
+ transform: scale(1.1);
151
+ }
152
+ </style>
153
+
154
+ <script>
155
+ (() => {
156
+ // Ensure D3 is loaded
157
+ const ensureD3 = (cb) => {
158
+ if (window.d3) return cb();
159
+ const script = document.createElement('script');
160
+ script.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
161
+ script.onload = cb;
162
+ document.head.appendChild(script);
163
+ };
164
+
165
+ const bootstrap = () => {
166
+ // Get container
167
+ const containers = document.querySelectorAll('.typography-umap-2d');
168
+ const container = containers[containers.length - 1];
169
+ if (!container || container.dataset.mounted) return;
170
+ container.dataset.mounted = 'true';
171
+
172
+ // SVG elements - Native architecture
173
+ const mainSvg = container.querySelector('.main-svg');
174
+ let fontDefs = container.querySelector('#font-defs');
175
+ let viewportGroup = container.querySelector('.viewport-group');
176
+ const uiGroup = container.querySelector('.ui-group');
177
+ const uiLayer = container.querySelector('.ui-layer');
178
+
179
+ // Create viewportGroup if necessary (will be used as zoomGroup)
180
+ if (!viewportGroup) {
181
+ viewportGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
182
+ viewportGroup.className = 'viewport-group';
183
+ mainSvg.appendChild(viewportGroup);
184
+ }
185
+
186
+ // Check and create fontDefs if necessary
187
+ if (!fontDefs) {
188
+ fontDefs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
189
+ fontDefs.id = 'font-defs';
190
+ mainSvg.insertBefore(fontDefs, mainSvg.firstChild);
191
+ }
192
+
193
+ // State
194
+ let width = 800, height = 500;
195
+ const margin = 20;
196
+ let data = [];
197
+ let visibleGlyphs = new Map();
198
+ let centroids = new Map();
199
+ let transform = d3.zoomIdentity;
200
+ let isZooming = false;
201
+
202
+ // Native SVG tooltip
203
+ let tooltipGroup = null;
204
+ let currentTooltipFont = null;
205
+ let hoverTimeout = null;
206
+
207
+ // Scales
208
+ const x = d3.scaleLinear();
209
+ const y = d3.scaleLinear();
210
+ const color = d3.scaleOrdinal(d3.schemeTableau10);
211
+
212
+ // Typography families
213
+ const families = {
214
+ 'serif': 'Serif',
215
+ 'sans-serif': 'Sans Serif',
216
+ 'monospace': 'Monospace',
217
+ 'display': 'Display',
218
+ 'handwriting': 'Handwriting',
219
+ 'geometric': 'Geometric'
220
+ };
221
+
222
+ // Mapping fonts to sprite IDs
223
+ let fontMapping = {};
224
+
225
+ // Load mapping and inject sprite into defs - SIMPLIFIED APPROACH
226
+ const initSprite = async () => {
227
+ try {
228
+ // Load mapping
229
+ const mappingPaths = ['/data/font-sprite-mapping.json', './assets/data/font-sprite-mapping.json', '../assets/data/font-sprite-mapping.json'];
230
+ let mappingResponse;
231
+ for (const path of mappingPaths) {
232
+ try {
233
+ mappingResponse = await fetch(path);
234
+ if (mappingResponse.ok) break;
235
+ } catch (e) { }
236
+ }
237
+ if (!mappingResponse?.ok) throw new Error('Mapping not found');
238
+ fontMapping = await mappingResponse.json();
239
+
240
+ // Load SVG sprite
241
+ const spritePaths = ['/data/font-sprite.svg', './assets/sprites/font-sprite.svg', '../assets/sprites/font-sprite.svg'];
242
+ let spriteResponse;
243
+ for (const path of spritePaths) {
244
+ try {
245
+ spriteResponse = await fetch(path);
246
+ if (spriteResponse.ok) break;
247
+ } catch (e) { }
248
+ }
249
+ if (!spriteResponse?.ok) throw new Error('Sprite not found');
250
+ const spriteContent = await spriteResponse.text();
251
+
252
+ // SIMPLIFIED APPROACH: Inject complete sprite at beginning of document
253
+ // This makes symbols available globally via <use>
254
+ if (!document.getElementById('global-font-sprite')) {
255
+ const spriteContainer = document.createElement('div');
256
+ spriteContainer.id = 'global-font-sprite';
257
+ spriteContainer.innerHTML = spriteContent;
258
+ spriteContainer.style.display = 'none';
259
+ spriteContainer.style.position = 'absolute';
260
+ spriteContainer.style.width = '0';
261
+ spriteContainer.style.height = '0';
262
+ document.body.insertBefore(spriteContainer, document.body.firstChild);
263
+ }
264
+
265
+ } catch (error) {
266
+ console.error('Sprite loading error:', error);
267
+ }
268
+ };
269
+
270
+ const getFontSymbolId = (fontName) => {
271
+ const mapped = fontMapping[fontName];
272
+ if (mapped) return mapped;
273
+
274
+ // Fallback: generate ID from name
275
+ return fontName.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase() + '_a';
276
+ };
277
+
278
+ const createFontUse = (fontName, x, y) => {
279
+ const symbolId = getFontSymbolId(fontName);
280
+
281
+ // Group containing glyph and its hit area
282
+ const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
283
+ group.setAttribute('class', 'font-glyph-group');
284
+ group.setAttribute('data-font', fontName);
285
+
286
+ // Invisible hit area (larger square)
287
+ const hitArea = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
288
+ hitArea.setAttribute('x', x - 12);
289
+ hitArea.setAttribute('y', y - 12);
290
+ hitArea.setAttribute('width', '24');
291
+ hitArea.setAttribute('height', '24');
292
+ hitArea.setAttribute('fill', 'transparent');
293
+ hitArea.setAttribute('class', 'font-hit-area');
294
+ hitArea.setAttribute('data-font', fontName);
295
+
296
+ // The visible glyph
297
+ const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
298
+ use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', `#${symbolId}`);
299
+ use.setAttribute('x', x - 8);
300
+ use.setAttribute('y', y - 8);
301
+ use.setAttribute('width', '16');
302
+ use.setAttribute('height', '16');
303
+ use.setAttribute('class', 'font-glyph');
304
+ use.setAttribute('data-font', fontName);
305
+
306
+ // Simple click handler with delay to avoid conflicts with D3
307
+ group.addEventListener('click', (e) => {
308
+ if (!isZooming) {
309
+ // Small delay to ensure it's not a drag
310
+ setTimeout(() => {
311
+ if (!isZooming) {
312
+ e.preventDefault();
313
+ e.stopPropagation();
314
+
315
+ const fontData = data.find(d => d.label === fontName);
316
+ if (fontData && fontData.googleFontsUrl) {
317
+ window.open(fontData.googleFontsUrl, '_blank');
318
+ }
319
+ }
320
+ }, 10);
321
+ }
322
+ });
323
+
324
+ group.appendChild(hitArea);
325
+ group.appendChild(use);
326
+
327
+ return group;
328
+ };
329
+
330
+ // TOOLTIP SYSTEM with foreignObject + HTML
331
+ const createTooltipGroup = () => {
332
+ if (tooltipGroup) return tooltipGroup;
333
+
334
+ tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
335
+ tooltipGroup.setAttribute('class', 'svg-tooltip');
336
+
337
+ // foreignObject to contain HTML
338
+ const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
339
+ foreignObject.setAttribute('width', '160');
340
+ foreignObject.setAttribute('height', '60');
341
+
342
+ // HTML content in the foreignObject
343
+ const htmlDiv = document.createElement('div');
344
+ htmlDiv.className = 'tooltip-html';
345
+ htmlDiv.innerHTML = `
346
+ <div class="tooltip-title"></div>
347
+ <div class="tooltip-category">
348
+ <span class="tooltip-category-text"></span>
349
+ </div>
350
+ `;
351
+
352
+ foreignObject.appendChild(htmlDiv);
353
+ tooltipGroup.appendChild(foreignObject);
354
+
355
+ uiGroup.appendChild(tooltipGroup);
356
+ return tooltipGroup;
357
+ };
358
+
359
+ const showTooltip = (fontData, mouseX, mouseY) => {
360
+ if (isZooming) return;
361
+
362
+ // Clear pending hide
363
+ if (hoverTimeout) {
364
+ clearTimeout(hoverTimeout);
365
+ hoverTimeout = null;
366
+ }
367
+
368
+ // Same font, just update position
369
+ if (currentTooltipFont === fontData.label) {
370
+ updateTooltipPosition(mouseX, mouseY);
371
+ return;
372
+ }
373
+
374
+ currentTooltipFont = fontData.label;
375
+ const tooltip = createTooltipGroup();
376
+ const clr = color(fontData.group);
377
+
378
+ // Update HTML content
379
+ const titleEl = tooltip.querySelector('.tooltip-title');
380
+ const categoryTextEl = tooltip.querySelector('.tooltip-category-text');
381
+
382
+ titleEl.textContent = fontData.label;
383
+ categoryTextEl.textContent = families[fontData.group] || fontData.group;
384
+
385
+ // Position tooltip in screen coordinates (SVG viewport coordinates)
386
+ const { x: svgX, y: svgY } = screenToSvg(mouseX, mouseY);
387
+
388
+ // Fixed size for foreignObject (smaller without preview)
389
+ const tooltipWidth = 160;
390
+ const tooltipHeight = 60;
391
+
392
+ let tooltipX = svgX + 15;
393
+ let tooltipY = svgY - tooltipHeight - 10;
394
+
395
+ // Keep tooltip in bounds
396
+ if (tooltipX + tooltipWidth > width) tooltipX = svgX - tooltipWidth - 15;
397
+ if (tooltipY < 0) tooltipY = svgY + 15;
398
+
399
+ tooltip.setAttribute('transform', `translate(${tooltipX}, ${tooltipY})`);
400
+ tooltip.style.opacity = '1';
401
+ };
402
+
403
+ const hideTooltip = () => {
404
+ if (!tooltipGroup || hoverTimeout) return;
405
+
406
+ hoverTimeout = setTimeout(() => {
407
+ if (tooltipGroup) {
408
+ tooltipGroup.style.opacity = '0';
409
+ }
410
+ currentTooltipFont = null;
411
+ hoverTimeout = null;
412
+ }, 100);
413
+ };
414
+
415
+ const updateTooltipPosition = (mouseX, mouseY) => {
416
+ if (!tooltipGroup || !currentTooltipFont) return;
417
+
418
+ const { x: svgX, y: svgY } = screenToSvg(mouseX, mouseY);
419
+
420
+ const tooltipWidth = 160;
421
+ const tooltipHeight = 60;
422
+
423
+ let tooltipX = svgX + 15;
424
+ let tooltipY = svgY - tooltipHeight - 10;
425
+
426
+ if (tooltipX + tooltipWidth > width) tooltipX = svgX - tooltipWidth - 15;
427
+ if (tooltipY < 0) tooltipY = svgY + 15;
428
+
429
+ tooltipGroup.setAttribute('transform', `translate(${tooltipX}, ${tooltipY})`);
430
+ };
431
+
432
+ const createFallbackSvg = () => {
433
+ return `
434
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="16" height="16">
435
+ <rect width="80" height="80" fill="var(--surface-bg)" stroke="var(--border-color)" stroke-width="1"/>
436
+ <text x="40" y="50" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="32" fill="currentColor">A</text>
437
+ </svg>
438
+ `;
439
+ };
440
+
441
+ // CORE: Update viewport transform - SVG NATIVE
442
+ const updateTransform = () => {
443
+ const { k, x: tx, y: ty } = transform;
444
+ viewportGroup.setAttribute('transform', `translate(${tx}, ${ty}) scale(${k})`);
445
+
446
+ // Adjust centroid label size to remain constant on screen
447
+ // centroids.forEach((labelElement, family) => {
448
+ // const inverseScale = 1 / k;
449
+ // // Get original centroid position
450
+ // const originalX = parseFloat(labelElement.getAttribute('data-x'));
451
+ // const originalY = parseFloat(labelElement.getAttribute('data-y'));
452
+
453
+ // // Apply inverse scale centered on label position
454
+ // labelElement.setAttribute('transform', `translate(${originalX}, ${originalY}) scale(${inverseScale}) translate(${-originalX}, ${-originalY})`);
455
+ // });
456
+ };
457
+
458
+ // Native SVG render - ULTRA PERFORMANCE
459
+ const render = () => {
460
+ if (!data.length) return;
461
+
462
+ // Create all glyphs as <use> elements in SVG
463
+ data.forEach((d, i) => {
464
+ if (visibleGlyphs.has(i)) return; // Already created
465
+
466
+ const useElement = createFontUse(d.label, x(d.x), y(d.y));
467
+ viewportGroup.appendChild(useElement);
468
+ visibleGlyphs.set(i, useElement);
469
+ });
470
+
471
+ // Native SVG centroids with density-weighted position
472
+ // if (centroids.size === 0) {
473
+ // const groups = d3.rollup(data, v => {
474
+ // // Calculate centroid weighted by local density
475
+ // const positions = v.map(d => ({ x: d.x, y: d.y }));
476
+
477
+ // // For each point, calculate its local density (number of neighbors within radius)
478
+ // const radius = 2.0; // Radius to calculate local density
479
+ // const densityWeights = positions.map(pos => {
480
+ // const neighbors = positions.filter(other => {
481
+ // const dist = Math.sqrt(Math.pow(pos.x - other.x, 2) + Math.pow(pos.y - other.y, 2));
482
+ // return dist <= radius;
483
+ // });
484
+ // return neighbors.length; // Weight = number of neighbors
485
+ // });
486
+
487
+ // // Calculate weighted centroid
488
+ // const totalWeight = d3.sum(densityWeights);
489
+ // const weightedX = d3.sum(positions, (d, i) => d.x * densityWeights[i]) / totalWeight;
490
+ // const weightedY = d3.sum(positions, (d, i) => d.y * densityWeights[i]) / totalWeight;
491
+
492
+ // return {
493
+ // x: weightedX,
494
+ // y: weightedY,
495
+ // count: v.length
496
+ // };
497
+ // }, d => d.group);
498
+
499
+ // groups.forEach((info, family) => {
500
+ // if (info.count < 3) return; // Show groups with 3+ fonts
501
+
502
+ // const posX = x(info.x);
503
+ // const posY = y(info.y);
504
+
505
+ // const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
506
+ // text.setAttribute('x', posX);
507
+ // text.setAttribute('y', posY);
508
+ // text.setAttribute('data-x', posX); // Store original position
509
+ // text.setAttribute('data-y', posY); // Store original position
510
+ // text.setAttribute('class', 'centroid-label');
511
+ // text.setAttribute('text-anchor', 'middle');
512
+ // text.setAttribute('dominant-baseline', 'middle');
513
+ // text.setAttribute('fill', color(family));
514
+ // text.textContent = families[family] || family;
515
+
516
+ // viewportGroup.appendChild(text);
517
+ // centroids.set(family, text);
518
+ // });
519
+ // }
520
+ };
521
+
522
+ // Zoom handler - ONLY transform viewport
523
+ const handleZoom = (event) => {
524
+ transform = event.transform;
525
+ updateTransform();
526
+ };
527
+
528
+ // Professional approach for zoom/pan constraints
529
+ const getConstraints = () => {
530
+ // No data = generic default constraints
531
+ if (!data.length) {
532
+ return {
533
+ scaleExtent: [0.25, 10],
534
+ translateExtent: [[-width * 0.5, -height * 0.5], [width * 1.5, height * 1.5]]
535
+ };
536
+ }
537
+
538
+ // Calculate actual data bounds in screen space
539
+ const xExtent = d3.extent(data, d => d.x);
540
+ const yExtent = d3.extent(data, d => d.y);
541
+
542
+ // Convert to screen coordinates
543
+ const screenLeft = x(xExtent[0]);
544
+ const screenRight = x(xExtent[1]);
545
+ const screenTop = y(yExtent[1]); // y inverted
546
+ const screenBottom = y(yExtent[0]); // y inverted
547
+
548
+ const contentWidth = screenRight - screenLeft;
549
+ const contentHeight = screenBottom - screenTop;
550
+
551
+ // Minimum scale: see all content with comfortable padding
552
+ const paddingFactor = 1.1; // 10% padding
553
+ const minScaleX = width / (contentWidth * paddingFactor);
554
+ const minScaleY = height / (contentHeight * paddingFactor);
555
+ const minScale = Math.min(minScaleX, minScaleY) * 0.9; // Small safety margin
556
+
557
+ // Maximum scale: fine details visible
558
+ const maxScale = 12;
559
+
560
+ // Translation bounds: "rubber band" strategy - can go out but not too much
561
+ // Allow centering any part of content in view
562
+ const buffer = Math.min(width, height) * 0.3; // 30% flexible buffer
563
+
564
+ const translateExtent = [
565
+ // Top-left bounds: can go quite far left/up
566
+ [screenLeft - width + buffer, screenTop - height + buffer],
567
+ // Bottom-right bounds: can go quite far right/down
568
+ [screenRight - buffer, screenBottom - buffer]
569
+ ];
570
+
571
+ const constraints = {
572
+ scaleExtent: [Math.max(0.1, minScale), maxScale],
573
+ translateExtent
574
+ };
575
+
576
+ return constraints;
577
+ };
578
+
579
+ // Setup zoom with D3 best practices
580
+ // 1. Element that receives the behavior (mainSvg)
581
+ // 2. Element that will be transformed (zoomGroup in the SVG)
582
+ // 3. Visual content (in zoomGroup)
583
+
584
+ const zoom = d3.zoom()
585
+ .scaleExtent([1, 4])
586
+ // Constraints will be applied after data loading
587
+ .on('start', () => {
588
+ isZooming = true;
589
+ if (tooltipGroup) {
590
+ tooltipGroup.style.opacity = '0';
591
+ }
592
+ currentTooltipFont = null;
593
+ })
594
+ .on('zoom', (event) => {
595
+ const { transform } = event;
596
+
597
+ // Apply transformation directly (WITHOUT updateTransform which interferes)
598
+ d3.select(viewportGroup).attr('transform', transform.toString());
599
+ })
600
+ .on('end', () => {
601
+ setTimeout(() => { isZooming = false; }, 100);
602
+ });
603
+ d3.select(mainSvg).call(zoom);
604
+
605
+ // Convert screen coordinates to SVG
606
+ const screenToSvg = (clientX, clientY) => {
607
+ const rect = mainSvg.getBoundingClientRect();
608
+ const svgX = ((clientX - rect.left) / rect.width) * width;
609
+ const svgY = ((clientY - rect.top) / rect.height) * height;
610
+ return { x: svgX, y: svgY };
611
+ };
612
+
613
+ // TOOLTIP EVENTS - Screen coordinates
614
+ mainSvg.addEventListener('mousemove', (e) => {
615
+ if (isZooming) return;
616
+
617
+ // Find element under mouse
618
+ const element = document.elementFromPoint(e.clientX, e.clientY);
619
+
620
+ // Check if it's a glyph, hit area, or in a glyph group
621
+ let fontName = null;
622
+ if (element && (element.classList.contains('font-glyph') || element.classList.contains('font-hit-area'))) {
623
+ fontName = element.getAttribute('data-font');
624
+ } else if (element && element.closest('.font-glyph-group')) {
625
+ fontName = element.closest('.font-glyph-group').getAttribute('data-font');
626
+ }
627
+
628
+ if (fontName) {
629
+ const fontData = data.find(d => d.label === fontName);
630
+ if (fontData) {
631
+ showTooltip(fontData, e.clientX, e.clientY);
632
+ }
633
+ } else {
634
+ hideTooltip();
635
+ }
636
+ });
637
+
638
+ mainSvg.addEventListener('mouseleave', () => {
639
+ hideTooltip();
640
+ });
641
+
642
+
643
+ // Zoom controls
644
+ const controls = document.createElement('div');
645
+ controls.className = 'zoom-controls';
646
+ controls.innerHTML = `
647
+ <button title="Zoom In">+</button>
648
+ <button title="Zoom Out">βˆ’</button>
649
+ <button title="Reset">βŒ‚</button>
650
+ `;
651
+
652
+ const [zoomIn, zoomOut, reset] = controls.querySelectorAll('button');
653
+ zoomIn.onclick = () => d3.select(mainSvg).transition().call(zoom.scaleBy, 1.5);
654
+ zoomOut.onclick = () => d3.select(mainSvg).transition().call(zoom.scaleBy, 1 / 1.5);
655
+
656
+ // Smart reset: return to optimal data view
657
+ reset.onclick = () => {
658
+ if (!data.length) {
659
+ d3.select(mainSvg).transition().call(zoom.transform, d3.zoomIdentity);
660
+ return;
661
+ }
662
+
663
+ const constraints = getConstraints();
664
+ const optimalScale = constraints.scaleExtent[0] * 1.05; // Slightly above minimum
665
+
666
+ // Calculate translation to center content
667
+ const xExtent = d3.extent(data, d => d.x);
668
+ const yExtent = d3.extent(data, d => d.y);
669
+ const centerX = (x(xExtent[0]) + x(xExtent[1])) / 2;
670
+ const centerY = (y(yExtent[0]) + y(yExtent[1])) / 2;
671
+
672
+ const targetX = width / 2 - centerX * optimalScale;
673
+ const targetY = height / 2 - centerY * optimalScale;
674
+
675
+ const resetTransform = d3.zoomIdentity.translate(targetX, targetY).scale(optimalScale);
676
+
677
+ d3.select(mainSvg).transition().duration(750).call(zoom.transform, resetTransform);
678
+ };
679
+
680
+ uiLayer.appendChild(controls);
681
+
682
+ // Setup scales - Compact size inspired by scatter
683
+ const updateScales = () => {
684
+ width = container.clientWidth || 800;
685
+ height = Math.max(280, Math.min(450, Math.round(width * 0.4))); // More compact: 2.5:1 max ratio
686
+
687
+ // Update main SVG viewBox
688
+ mainSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
689
+
690
+ const xExtent = d3.extent(data, d => d.x);
691
+ const yExtent = d3.extent(data, d => d.y);
692
+
693
+ x.domain(xExtent).range([margin, width - margin]);
694
+ y.domain(yExtent).range([height - margin, margin]);
695
+ };
696
+
697
+ // Color palette
698
+ const updateColors = () => {
699
+ try {
700
+ if (window.ColorPalettes?.getColors) {
701
+ const colors = window.ColorPalettes.getColors('categorical', 6);
702
+ if (colors?.length) color.range(colors);
703
+ }
704
+ } catch (e) { }
705
+ };
706
+
707
+ // Load data
708
+ const loadData = async () => {
709
+ try {
710
+ // Load SVG sprite first
711
+ await initSprite();
712
+
713
+ const paths = ['/data/typography_data.json', './assets/data/typography_data.json', '../assets/data/typography_data.json'];
714
+ let response;
715
+ for (const path of paths) {
716
+ try {
717
+ response = await fetch(path);
718
+ if (response.ok) break;
719
+ } catch (e) { }
720
+ }
721
+
722
+ if (!response?.ok) throw new Error('Data not found');
723
+
724
+ const raw = await response.json();
725
+ const fontsData = raw.fonts || raw; // Support both formats
726
+ data = fontsData.map((d, i) => ({
727
+ id: i,
728
+ originalId: d.id,
729
+ googleFontsUrl: d.google_fonts_url, // Pre-generated URL
730
+ x: d.x,
731
+ y: d.y,
732
+ group: d.family || 'sans-serif',
733
+ label: d.name
734
+ })).filter(d => Number.isFinite(d.x) && Number.isFinite(d.y));
735
+
736
+ color.domain([...new Set(data.map(d => d.group))]);
737
+ updateColors();
738
+ updateScales();
739
+
740
+ // Apply professional constraints based on data
741
+ const xExtent = d3.extent(data, d => d.x);
742
+ const yExtent = d3.extent(data, d => d.y);
743
+
744
+ // Convert to screen coordinates
745
+ const contentLeft = x(xExtent[0]);
746
+ const contentRight = x(xExtent[1]);
747
+ const contentTop = y(yExtent[1]);
748
+ const contentBottom = y(yExtent[0]);
749
+
750
+ const contentWidth = contentRight - contentLeft;
751
+ const contentHeight = contentBottom - contentTop;
752
+
753
+ // Professional pan area: slightly larger than content
754
+ const padding = 1; // padding in pixels
755
+ const panLeft = contentLeft - padding;
756
+ const panRight = contentRight + padding;
757
+ const panTop = contentTop - padding;
758
+ const panBottom = contentBottom + padding;
759
+
760
+ // Apply constraints to zoom
761
+ zoom.translateExtent([[panLeft, panTop], [panRight, panBottom]]);
762
+
763
+ render();
764
+ } catch (e) {
765
+ console.error('Failed to load data:', e);
766
+ container.innerHTML = '<div style="color:red;padding:20px;">Failed to load typography data</div>';
767
+ }
768
+ };
769
+
770
+ // Initialize
771
+ updateColors();
772
+ document.addEventListener('palettes:updated', updateColors);
773
+
774
+ // Resize handler
775
+ let resizeTimer;
776
+ const handleResize = () => {
777
+ clearTimeout(resizeTimer);
778
+ resizeTimer = setTimeout(() => {
779
+ updateScales();
780
+
781
+ // TEMPORARILY DISABLED: Recalculate constraints after resize
782
+ // if (data.length) {
783
+ // const constraints = getConstraints();
784
+ // zoom.scaleExtent(constraints.scaleExtent)
785
+ // .translateExtent(constraints.translateExtent);
786
+ // }
787
+
788
+ render();
789
+ }, 100);
790
+ };
791
+
792
+ new ResizeObserver(handleResize).observe(container);
793
+
794
+ loadData();
795
+ };
796
+
797
+ // Start when ready
798
+ if (document.readyState === 'loading') {
799
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap));
800
+ } else {
801
+ ensureD3(bootstrap);
802
+ }
803
+ })();
804
+ </script>
app/src/content/embeds/demo/color-picker.html DELETED
@@ -1,226 +0,0 @@
1
- <div class="color-picker" style="width:100%; margin: 10px 0;">
2
- <style>
3
- .color-picker .picker__stack { display:flex; flex-direction:column; gap:12px; }
4
- .color-picker .current-card { display:grid; grid-template-columns: 30% 70%; align-items: center; gap:14px; padding:14px 32px 14px 16px; border:1px solid var(--border-color); background: var(--surface-bg); border-radius: 12px; }
5
- .color-picker .current-left { display:flex; flex-direction: column; gap:8px; min-width: 0; }
6
- .color-picker .current-right { display:flex; flex-direction: column; gap:8px; padding-left: 14px; border-left: 1px solid var(--border-color); }
7
- .color-picker .current-main { display:flex; align-items:center; gap:12px; min-width: 0; }
8
- .color-picker .current-swatch { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); }
9
- .color-picker .current-text { display:flex; flex-direction: column; line-height: 1.2; min-width: 0; }
10
- .color-picker .current-name { font-size: 14px; font-weight: 800; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
11
- .color-picker .current-hex, .color-picker .current-extra { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
12
- /* theme preview styles removed */
13
- .color-picker .picker__label { font-weight:700; font-size: 12px; color: var(--muted-color); text-transform: uppercase; letter-spacing: .02em; }
14
- .color-picker .hue-slider { position:relative; height:16px; 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; flex: 1 1 auto; min-width: 200px; }
15
- .color-picker .hue-knob { position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2; box-shadow: 0 0 0 1px rgba(0,0,0,.05); }
16
- .color-picker .hue-slider:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
17
- .color-picker .hue-value { font-variant-numeric: tabular-nums; color: var(--muted-color); font-size: 12px; }
18
- @media (max-width: 720px) { .color-picker .current-card { grid-template-columns: 1fr; } .color-picker .current-right { padding-left: 0; border-left: none; } }
19
- </style>
20
- <div class="picker__stack">
21
- <div class="current-card">
22
- <div class="current-left">
23
- <div class="current-main">
24
- <div class="current-swatch" aria-label="Current color" title="Current color"></div>
25
- <div class="current-text">
26
- <div class="current-name">β€”</div>
27
- <div class="current-hex">β€”</div>
28
- <div class="current-extra current-lch">β€”</div>
29
- <div class="current-extra current-rgb">β€”</div>
30
- </div>
31
- </div>
32
- </div>
33
- <div class="current-right">
34
- <div class="picker__label">Hue</div>
35
- <div class="hue-slider" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="337" tabindex="0">
36
- <div class="hue-knob"></div>
37
- </div>
38
- <div class="hue-value">337Β°</div>
39
- </div>
40
- </div>
41
- </div>
42
- </div>
43
- <script>
44
- (() => {
45
- // Ensure chroma.js is loaded once
46
- const ensureChroma = (next) => {
47
- if (window.chroma) return next();
48
- const loadScript = (id, src, onload, onerror) => {
49
- let s = document.getElementById(id);
50
- if (s) { return onload && onload(); }
51
- s = document.createElement('script');
52
- s.id = id; s.src = src; s.async = true;
53
- if (onload) s.addEventListener('load', onload, { once: true });
54
- if (onerror) s.addEventListener('error', onerror, { once: true });
55
- document.head.appendChild(s);
56
- };
57
- loadScript('chroma-cdn', 'https://unpkg.com/chroma-js@2.4.2/dist/chroma.min.js', next, () => {
58
- loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', next);
59
- });
60
- };
61
-
62
- // Minimal embedded color-name list (same as palettes)
63
- const COLOR_NAMES = [{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Big bang Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Pink","hex":"#ff0099"},{"name":"Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
64
- if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
65
-
66
- // Shared event bus so multiple instances stay in sync
67
- if (!window.__colorPickerBus) {
68
- window.__colorPickerBus = (() => {
69
- let hue = 337; // shared initial hue
70
- let adjusting = false;
71
- const listeners = new Set();
72
- return {
73
- get: () => ({ hue, adjusting }),
74
- publish: (sourceId, nextHue, isAdjusting) => {
75
- hue = ((nextHue % 360) + 360) % 360;
76
- adjusting = !!isAdjusting;
77
- listeners.forEach((fn) => { try { fn({ sourceId, hue, adjusting }); } catch {} });
78
- },
79
- subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }
80
- };
81
- })();
82
- }
83
-
84
- const bootstrap = () => {
85
- const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
86
- const root = mount && mount.closest('.color-picker') ? mount.closest('.color-picker') : document.querySelector('.color-picker');
87
- if (!root || root.dataset.mounted) return; root.dataset.mounted = 'true';
88
-
89
- const slider = root.querySelector('.hue-slider');
90
- const knob = root.querySelector('.hue-knob');
91
- const hueValue = root.querySelector('.hue-value');
92
- const currentSwatch = root.querySelector('.current-swatch');
93
- const currentName = root.querySelector('.current-name');
94
- const currentHex = root.querySelector('.current-hex');
95
- const currentLch = root.querySelector('.current-lch');
96
- const currentRgb = root.querySelector('.current-rgb');
97
-
98
- const bus = window.__colorPickerBus;
99
- const instanceId = Math.random().toString(36).slice(2);
100
-
101
- const getKnobRadius = () => {
102
- try { const w = knob ? knob.getBoundingClientRect().width : 0; return w ? w / 2 : 8; } catch { return 8; }
103
- };
104
-
105
- const getName = (hex) => {
106
- const list = (window.__colorNames && window.__colorNames.length) ? window.__colorNames : COLOR_NAMES;
107
- if (list && window.chroma) {
108
- let bestName = null; let best = Infinity;
109
- for (let i = 0; i < list.length; i++) {
110
- const item = list[i];
111
- const d = (chroma.deltaE ? chroma.deltaE(hex, item.hex) : chroma.distance(hex, item.hex, 'lab'));
112
- if (d < best) { best = d; bestName = item.name; }
113
- }
114
- if (bestName) return bestName;
115
- }
116
- const hh = chroma(hex).get('hsl.h') || 0;
117
- const labels = ['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta'];
118
- const idx = Math.round(((hh % 360) / 360) * (labels.length - 1));
119
- return labels[idx];
120
- };
121
-
122
- const updateUI = (h, adjusting) => {
123
- const rect = slider.getBoundingClientRect();
124
- const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
125
- const t = Math.max(0, Math.min(1, (h / 360)));
126
- const leftPx = r + t * Math.max(0, (rect.width - 2 * r));
127
- if (knob) knob.style.left = (leftPx / rect.width * 100) + '%';
128
- if (hueValue) hueValue.textContent = `${Math.round(h)}Β°`;
129
- if (slider) slider.setAttribute('aria-valuenow', String(Math.round(h)));
130
- // Use LCH for consistent chroma across hues
131
- const L = 70; // lightness
132
- const C = 60; // chroma kept within sRGB-friendly range
133
- const base = chroma.lch(L, C, h);
134
- const baseHex = base.hex();
135
- if (currentSwatch) currentSwatch.style.background = baseHex;
136
- if (currentName) currentName.textContent = getName(baseHex.toUpperCase());
137
- if (currentHex) currentHex.textContent = baseHex.toUpperCase();
138
- if (currentLch) {
139
- const lc = base.lch();
140
- const L = Math.round((lc[0] || 0));
141
- const C = Math.round((lc[1] || 0));
142
- const H = Math.round(((lc[2] || 0) % 360 + 360) % 360);
143
- currentLch.textContent = `LCH ${L}, ${C}, ${H}Β°`;
144
- }
145
- if (currentRgb) {
146
- const rgb = base.rgb().map(v => Math.round(v));
147
- currentRgb.textContent = `RGB ${rgb[0]}, ${rgb[1]}, ${rgb[2]}`;
148
- }
149
- // Apply to theme (always, to reflect the selection)
150
- const hoverL = Math.max(0, Math.min(100, L - 8));
151
- const hoverHex = chroma.lch(hoverL, C, h).hex();
152
- const rootEl = document.documentElement;
153
- rootEl.style.setProperty('--primary-color', baseHex);
154
- rootEl.style.setProperty('--primary-color-hover', hoverHex);
155
- };
156
-
157
- const getHueFromEvent = (ev) => {
158
- const rect = slider.getBoundingClientRect();
159
- const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
160
- const x = clientX - rect.left;
161
- const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
162
- const effX = Math.max(r, Math.min(rect.width - r, x));
163
- const denom = Math.max(1, rect.width - 2 * r);
164
- const t = (effX - r) / denom;
165
- return t * 360;
166
- };
167
-
168
- // Subscribe to bus to sync multiple instances
169
- const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => {
170
- if (sourceId === instanceId) return; // avoid feedback
171
- updateUI(hue, adjusting);
172
- });
173
-
174
- // Init from theme color if available
175
- try {
176
- const cssPrimary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
177
- if (cssPrimary) {
178
- const initH = chroma(cssPrimary).get('hsl.h') || 0;
179
- updateUI(initH, false);
180
- bus.publish(instanceId, initH, false);
181
- } else {
182
- const { hue: sharedHue } = bus.get();
183
- updateUI(sharedHue, false);
184
- }
185
- } catch {
186
- const { hue: sharedHue } = bus.get();
187
- updateUI(sharedHue, false);
188
- }
189
-
190
- const onDown = (ev) => {
191
- ev.preventDefault();
192
- const h = getHueFromEvent(ev);
193
- updateUI(h, true);
194
- bus.publish(instanceId, h, true);
195
- const move = (e) => { e.preventDefault && e.preventDefault(); const hh = getHueFromEvent(e); updateUI(hh, true); bus.publish(instanceId, hh, true); };
196
- const up = () => { bus.publish(instanceId, getHueFromEvent(ev), false); window.removeEventListener('mousemove', move); window.removeEventListener('touchmove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up); };
197
- window.addEventListener('mousemove', move, { passive: false });
198
- window.addEventListener('touchmove', move, { passive: false });
199
- window.addEventListener('mouseup', up, { once: true });
200
- window.addEventListener('touchend', up, { once: true });
201
- };
202
-
203
- if (slider) {
204
- slider.addEventListener('mousedown', onDown);
205
- slider.addEventListener('touchstart', onDown, { passive: false });
206
- // Minimal keyboard support (←/β†’, Shift for larger steps)
207
- slider.addEventListener('keydown', (e) => {
208
- const step = e.shiftKey ? 10 : 2;
209
- if (e.key === 'ArrowLeft') { e.preventDefault(); const { hue } = bus.get(); const h = hue - step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); }
210
- if (e.key === 'ArrowRight') { e.preventDefault(); const { hue } = bus.get(); const h = hue + step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); }
211
- });
212
- }
213
-
214
- // Clean up on detach (best-effort)
215
- const ro = new MutationObserver(() => {
216
- if (!document.body.contains(root)) { unsubscribe && unsubscribe(); ro.disconnect(); }
217
- });
218
- ro.observe(document.body, { childList: true, subtree: true });
219
- };
220
-
221
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureChroma(bootstrap), { once: true });
222
- else ensureChroma(bootstrap);
223
- })();
224
- </script>
225
-
226
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/content/embeds/demo/palettes.html DELETED
@@ -1,219 +0,0 @@
1
- <div class="palettes" style="width:100%; margin: 10px 0;">
2
- <style>
3
- .palettes { box-sizing: border-box; overflow-x: hidden; }
4
- .palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; max-width: 100%; }
5
- .palettes .palette-card { position: relative; display: grid; grid-template-columns: auto 1fr minmax(0, 220px); align-items: stretch; gap: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; }
6
- /* removed circular badge */
7
- .palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 2px; margin: 0; min-height: 20px; }
8
- .palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 0; border: 1px solid var(--border-color); }
9
- .palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
10
- .palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
11
- .palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 0; }
12
- .palettes .palette-card__content__info { display: flex; flex-direction: column; }
13
- .palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
14
- .palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
15
- .palettes .palette-card__actions { display: flex; align-items: center; justify-content: flex-start; justify-self: start; align-self: stretch; }
16
- /* .palettes .copy-btn { margin: 0; padding: 0 10px; height: 100%; border-radius: 8px; } */
17
- /* .palettes .copy-btn:hover { background: var(--primary-color); color: var(--on-primary)!important; border-color: transparent; }
18
- .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */
19
- .palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
20
- /* Simulation UI */
21
- .palettes .palettes__select { width: 100%; max-width: 100%; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
22
- .palettes .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
23
- .palettes .palettes__controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 8px 0 14px; }
24
- .palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1 1 280px; max-width: 100%; }
25
- .palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; }
26
- .palettes .palettes__label-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
27
- .palettes .ghost-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-color); color: var(--muted-color); background: transparent; font-variant-numeric: tabular-nums; }
28
- .palettes .palettes__count { display: flex; align-items: center; gap: 8px; max-width: 100%; }
29
- .palettes .palettes__count input[type="range"] { width: 100%; }
30
- .palettes .palettes__count output { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted-color); }
31
- /* Slider styling */
32
- .palettes input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; cursor: pointer; accent-color: var(--primary-color); }
33
- .palettes input[type="range"]:focus { outline: none; }
34
- /* WebKit */
35
- .palettes input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: var(--border-color); border-radius: 999px; }
36
- .palettes input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; margin-top: -6px; width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
37
- /* Firefox */
38
- .palettes input[type="range"]::-moz-range-track { height: 6px; background: var(--border-color); border: none; border-radius: 999px; }
39
- .palettes input[type="range"]::-moz-range-progress { height: 6px; background: var(--primary-color); border-radius: 999px; }
40
- .palettes input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
41
- /* Page-wide color vision simulation classes */
42
- html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
43
- html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
44
- html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) !important; }
45
- html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) !important; }
46
- html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) !important; }
47
- @media (max-width: 640px) {
48
- .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
49
- .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); }
50
- .palettes .palette-card__content { border-right: none; padding-right: 0; }
51
- .palettes .palette-card__actions { justify-self: start; }
52
-
53
- }
54
- </style>
55
- <div class="palettes__controls">
56
- <div class="palettes__field">
57
- <label class="palettes__label" for="cb-select">Color vision simulation</label>
58
- <select id="cb-select" class="palettes__select">
59
- <option value="none">Normal color vision β€” typical for most people</option>
60
- <option value="achromatopsia">Achromatopsia β€” no color at all</option>
61
- <option value="protanopia">Protanopia β€” reduced/absent reds</option>
62
- <option value="deuteranopia">Deuteranopia β€” reduced/absent greens</option>
63
- <option value="tritanopia">Tritanopia β€” reduced/absent blues</option>
64
- </select>
65
- </div>
66
- <div class="palettes__field">
67
- <div class="palettes__label-row">
68
- <label class="palettes__label" for="color-count">Number of colors</label>
69
- <output id="color-count-out" for="color-count" class="ghost-badge">8</output>
70
- </div>
71
- <div class="palettes__count">
72
- <input id="color-count" type="range" min="6" max="10" step="1" value="8" aria-label="Number of colors" />
73
- </div>
74
- </div>
75
- </div>
76
- <div class="palettes__grid"></div>
77
- <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
78
- <!-- Hidden SVG filters used by the page-wide simulation classes -->
79
- <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
80
- <defs>
81
- <!-- Matrices from common color vision deficiency simulations -->
82
- <filter id="cb-protanopia">
83
- <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"/>
84
- </filter>
85
- <filter id="cb-deuteranopia">
86
- <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"/>
87
- </filter>
88
- <filter id="cb-tritanopia">
89
- <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"/>
90
- </filter>
91
- <filter id="cb-achromatopsia">
92
- <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"/>
93
- </filter>
94
- </defs>
95
- </svg>
96
- </div>
97
- </div>
98
- <script>
99
- (() => {
100
- const cards = [
101
- { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. The more you have the more likely they are to look similar.' },
102
- { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
103
- { key: 'diverging', title: 'Diverging', desc: 'Opposing extremes via <strong>base β†’ white β†’ complement</strong>; smooth contrast around a neutral midpoint.' }
104
- ];
105
-
106
- const getPaletteColors = (key, count) => {
107
- const total = Number(count) || 6;
108
- if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
109
- return window.ColorPalettes.getColors(key, total) || [];
110
- }
111
- return [];
112
- };
113
-
114
- const render = () => {
115
- const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
116
- const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
117
- if (!root) return;
118
- const grid = root.querySelector('.palettes__grid');
119
- if (!grid) return;
120
- const input = document.getElementById('color-count');
121
- const total = input ? Number(input.value) || 6 : 6;
122
- const html = cards.map((c) => {
123
- const colors = getPaletteColors(c.key, total);
124
- const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
125
- return `
126
- <div class="palette-card" data-colors="${colors.join(',')}">
127
- <div class="palette-card__content">
128
- <div class="palette-card__content__info">
129
- <div class="palette-card__title">${c.title}</div>
130
- <div class="palette-card__desc">${c.desc}</div>
131
- </div>
132
- <button class="copy-btn button--ghost" type="button" aria-label="Copy palette">
133
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
134
- </button>
135
- </div>
136
- <div class="palette-card__actions"></div>
137
- <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
138
- </div>
139
- `;
140
- }).join('');
141
- grid.innerHTML = html;
142
- };
143
-
144
- const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
145
- const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
146
- const clearCbClasses = () => {
147
- const rootEl = document.documentElement;
148
- CLEAR_CLASSES.forEach(cls => rootEl.classList.remove(cls));
149
- };
150
- const applyCbClass = (mode) => {
151
- clearCbClasses();
152
- const cls = MODE_TO_CLASS[mode];
153
- if (cls) document.documentElement.classList.add(cls);
154
- };
155
- const currentCbMode = () => {
156
- const rootEl = document.documentElement;
157
- for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { if (rootEl.classList.contains(cls)) return mode; }
158
- return 'none';
159
- };
160
- const setupCbSim = () => {
161
- const select = document.getElementById('cb-select');
162
- if (!select) return;
163
- try { select.value = currentCbMode(); } catch {}
164
- select.addEventListener('change', () => applyCbClass(select.value));
165
- };
166
-
167
- const setupCountControl = () => {
168
- const input = document.getElementById('color-count');
169
- const out = document.getElementById('color-count-out');
170
- if (!input) return;
171
- const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
172
- const read = () => clamp(Number(input.value) || 6, 6, 10);
173
- const syncOut = () => { if (out) out.textContent = String(read()); };
174
- const onChange = () => { syncOut(); render(); };
175
- syncOut();
176
- input.addEventListener('input', onChange);
177
- document.addEventListener('palettes:updated', () => { syncOut(); render(); });
178
- };
179
-
180
- let copyDelegationSetup = false;
181
- const setupCopyDelegation = () => {
182
- if (copyDelegationSetup) return;
183
- const root = document.querySelector('.palettes');
184
- if (!root) return;
185
- const grid = root.querySelector('.palettes__grid');
186
- if (!grid) return;
187
- grid.addEventListener('click', async (e) => {
188
- const target = e.target.closest ? e.target.closest('.copy-btn') : null;
189
- if (!target) return;
190
- const card = target.closest('.palette-card');
191
- if (!card) return;
192
- const colors = (card.dataset.colors || '').split(',').filter(Boolean);
193
- const json = JSON.stringify(colors, null, 2);
194
- try {
195
- await navigator.clipboard.writeText(json);
196
- const old = target.innerHTML;
197
- target.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
198
- setTimeout(() => target.innerHTML = old, 900);
199
- } catch {
200
- window.prompt('Copy palette', json);
201
- }
202
- });
203
- copyDelegationSetup = true;
204
- };
205
-
206
- const bootstrap = () => {
207
- setupCbSim();
208
- setupCountControl();
209
- render();
210
- setupCopyDelegation();
211
- // Re-render when primary color changes
212
- document.addEventListener('palettes:updated', render);
213
- };
214
-
215
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
216
- else bootstrap();
217
- })();
218
- </script>
219
-