thibaud frere commited on
Commit
d61f156
·
1 Parent(s): b6281fd
app/.astro/astro/content.d.ts CHANGED
@@ -1,204 +0,0 @@
1
- declare module 'astro:content' {
2
- interface Render {
3
- '.mdx': Promise<{
4
- Content: import('astro').MarkdownInstance<{}>['Content'];
5
- headings: import('astro').MarkdownHeading[];
6
- remarkPluginFrontmatter: Record<string, any>;
7
- components: import('astro').MDXInstance<{}>['components'];
8
- }>;
9
- }
10
- }
11
-
12
- declare module 'astro:content' {
13
- interface RenderResult {
14
- Content: import('astro/runtime/server/index.js').AstroComponentFactory;
15
- headings: import('astro').MarkdownHeading[];
16
- remarkPluginFrontmatter: Record<string, any>;
17
- }
18
- interface Render {
19
- '.md': Promise<RenderResult>;
20
- }
21
-
22
- export interface RenderedContent {
23
- html: string;
24
- metadata?: {
25
- imagePaths: Array<string>;
26
- [key: string]: unknown;
27
- };
28
- }
29
- }
30
-
31
- declare module 'astro:content' {
32
- type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
33
-
34
- export type CollectionKey = keyof AnyEntryMap;
35
- export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
36
-
37
- export type ContentCollectionKey = keyof ContentEntryMap;
38
- export type DataCollectionKey = keyof DataEntryMap;
39
-
40
- type AllValuesOf<T> = T extends any ? T[keyof T] : never;
41
- type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
42
- ContentEntryMap[C]
43
- >['slug'];
44
-
45
- /** @deprecated Use `getEntry` instead. */
46
- export function getEntryBySlug<
47
- C extends keyof ContentEntryMap,
48
- E extends ValidContentEntrySlug<C> | (string & {}),
49
- >(
50
- collection: C,
51
- // Note that this has to accept a regular string too, for SSR
52
- entrySlug: E,
53
- ): E extends ValidContentEntrySlug<C>
54
- ? Promise<CollectionEntry<C>>
55
- : Promise<CollectionEntry<C> | undefined>;
56
-
57
- /** @deprecated Use `getEntry` instead. */
58
- export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
59
- collection: C,
60
- entryId: E,
61
- ): Promise<CollectionEntry<C>>;
62
-
63
- export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
64
- collection: C,
65
- filter?: (entry: CollectionEntry<C>) => entry is E,
66
- ): Promise<E[]>;
67
- export function getCollection<C extends keyof AnyEntryMap>(
68
- collection: C,
69
- filter?: (entry: CollectionEntry<C>) => unknown,
70
- ): Promise<CollectionEntry<C>[]>;
71
-
72
- export function getEntry<
73
- C extends keyof ContentEntryMap,
74
- E extends ValidContentEntrySlug<C> | (string & {}),
75
- >(entry: {
76
- collection: C;
77
- slug: E;
78
- }): E extends ValidContentEntrySlug<C>
79
- ? Promise<CollectionEntry<C>>
80
- : Promise<CollectionEntry<C> | undefined>;
81
- export function getEntry<
82
- C extends keyof DataEntryMap,
83
- E extends keyof DataEntryMap[C] | (string & {}),
84
- >(entry: {
85
- collection: C;
86
- id: E;
87
- }): E extends keyof DataEntryMap[C]
88
- ? Promise<DataEntryMap[C][E]>
89
- : Promise<CollectionEntry<C> | undefined>;
90
- export function getEntry<
91
- C extends keyof ContentEntryMap,
92
- E extends ValidContentEntrySlug<C> | (string & {}),
93
- >(
94
- collection: C,
95
- slug: E,
96
- ): E extends ValidContentEntrySlug<C>
97
- ? Promise<CollectionEntry<C>>
98
- : Promise<CollectionEntry<C> | undefined>;
99
- export function getEntry<
100
- C extends keyof DataEntryMap,
101
- E extends keyof DataEntryMap[C] | (string & {}),
102
- >(
103
- collection: C,
104
- id: E,
105
- ): E extends keyof DataEntryMap[C]
106
- ? Promise<DataEntryMap[C][E]>
107
- : Promise<CollectionEntry<C> | undefined>;
108
-
109
- /** Resolve an array of entry references from the same collection */
110
- export function getEntries<C extends keyof ContentEntryMap>(
111
- entries: {
112
- collection: C;
113
- slug: ValidContentEntrySlug<C>;
114
- }[],
115
- ): Promise<CollectionEntry<C>[]>;
116
- export function getEntries<C extends keyof DataEntryMap>(
117
- entries: {
118
- collection: C;
119
- id: keyof DataEntryMap[C];
120
- }[],
121
- ): Promise<CollectionEntry<C>[]>;
122
-
123
- export function render<C extends keyof AnyEntryMap>(
124
- entry: AnyEntryMap[C][string],
125
- ): Promise<RenderResult>;
126
-
127
- export function reference<C extends keyof AnyEntryMap>(
128
- collection: C,
129
- ): import('astro/zod').ZodEffects<
130
- import('astro/zod').ZodString,
131
- C extends keyof ContentEntryMap
132
- ? {
133
- collection: C;
134
- slug: ValidContentEntrySlug<C>;
135
- }
136
- : {
137
- collection: C;
138
- id: keyof DataEntryMap[C];
139
- }
140
- >;
141
- // Allow generic `string` to avoid excessive type errors in the config
142
- // if `dev` is not running to update as you edit.
143
- // Invalid collection names will be caught at build time.
144
- export function reference<C extends string>(
145
- collection: C,
146
- ): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
147
-
148
- type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
149
- type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
150
- ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
151
- >;
152
-
153
- type ContentEntryMap = {
154
- "chapters": {
155
- "available-blocks.mdx": {
156
- id: "available-blocks.mdx";
157
- slug: "available-blocks";
158
- body: string;
159
- collection: "chapters";
160
- data: any
161
- } & { render(): Render[".mdx"] };
162
- "best-pratices.mdx": {
163
- id: "best-pratices.mdx";
164
- slug: "best-pratices";
165
- body: string;
166
- collection: "chapters";
167
- data: any
168
- } & { render(): Render[".mdx"] };
169
- "getting-started.mdx": {
170
- id: "getting-started.mdx";
171
- slug: "getting-started";
172
- body: string;
173
- collection: "chapters";
174
- data: any
175
- } & { render(): Render[".mdx"] };
176
- "writing-your-content.mdx": {
177
- id: "writing-your-content.mdx";
178
- slug: "writing-your-content";
179
- body: string;
180
- collection: "chapters";
181
- data: any
182
- } & { render(): Render[".mdx"] };
183
- };
184
-
185
- };
186
-
187
- type DataEntryMap = {
188
- "assets": Record<string, {
189
- id: string;
190
- collection: "assets";
191
- data: any;
192
- }>;
193
- "embeds": Record<string, {
194
- id: string;
195
- collection: "embeds";
196
- data: any;
197
- }>;
198
-
199
- };
200
-
201
- type AnyEntryMap = ContentEntryMap & DataEntryMap;
202
-
203
- export type ContentConfig = never;
204
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/package.json CHANGED
Binary files a/app/package.json and b/app/package.json differ
 
app/public/data/against_baselines.csv DELETED
@@ -1 +0,0 @@
1
- ../../src/content/assets/data/against_baselines.csv
 
 
app/public/data/finevision.csv DELETED
@@ -1 +0,0 @@
1
- ../../src/content/assets/data/finevision.csv
 
 
app/src/content/assets/data/mnist-variant-model.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7dca86e85be46c1fca6a4e2503786e88e3f8d4609fb7284c8a1479620a5827da
3
+ size 4315
app/src/content/chapters/available-blocks.mdx CHANGED
@@ -328,35 +328,45 @@ Props (optional)
328
  - `frameless`: removes the card background and border for seamless embeds.
329
  - `align`: aligns the title/description text. One of `left` (default), `center`, `right`.
330
 
 
331
  <HtmlEmbed src="d3-line.html" title="D3 Line" desc="Simple time series" />
332
  ---
333
- <HtmlEmbed
334
- src="d3-bar.html"
335
- title="Memory usage with recomputation"
336
- desc={`Memory usage with recomputation <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}
337
- />
338
  ---
339
  <FullWidth>
340
- <HtmlEmbed
341
- src="d3-pie.html"
342
- title="Category distribution (4 metrics)"
343
- desc="Pie charts by category"
344
- align="center"
345
- />
346
  </FullWidth>
347
  ---
348
  <HtmlEmbed src="line.html" title="Plotly Line" desc="Interactive time series" />
349
 
 
 
 
350
  <small className="muted">Example</small>
351
  ```mdx
352
  import HtmlEmbed from '../components/HtmlEmbed.astro'
353
 
 
 
 
 
 
 
 
 
 
 
 
354
  <HtmlEmbed src="line.html" title="Plotly Line" desc="Interactive time series" />
355
  ```
356
 
357
  ### Iframes
358
 
359
- You can embed external content in your article using **iframes**. For example, **TrackIO or github code embeds** can be used this way.
360
 
361
  <small className="muted">Github code embed</small>
362
  <iframe frameborder="0" scrolling="no" style="width:100%; height:292px;" allow="clipboard-write" src="https://emgithub.com/iframe.html?target=https%3A%2F%2Fgithub.com%2Fhuggingface%2Fpicotron%2Fblob%2F1004ae37b87887cde597c9060fb067faa060bafe%2Fsetup.py&style=default&type=code&showBorder=on&showLineNumbers=on"></iframe>
 
328
  - `frameless`: removes the card background and border for seamless embeds.
329
  - `align`: aligns the title/description text. One of `left` (default), `center`, `right`.
330
 
331
+ {/* <HtmlEmbed src="d3-scatter.html" frameless title="" desc="" /> */}
332
  <HtmlEmbed src="d3-line.html" title="D3 Line" desc="Simple time series" />
333
  ---
334
+ <HtmlEmbed src="d3-bar.html" title="D3 Memory usage with recomputation" desc={`Memory usage with recomputation — <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}/>
335
+ ---
336
+ <Wide>
337
+ <HtmlEmbed src="d3-neural.html" title="D3 Interactive neural network (MNIST-like)" desc="Draw a digit and visualize activations and class probabilities (0–9)." align="center" />
338
+ </Wide>
339
  ---
340
  <FullWidth>
341
+ <HtmlEmbed src="d3-pie.html" desc="D3 Pie charts by category" align="center" />
 
 
 
 
 
342
  </FullWidth>
343
  ---
344
  <HtmlEmbed src="line.html" title="Plotly Line" desc="Interactive time series" />
345
 
346
+ <br/><br/>
347
+ Here are some examples of the two **libraries** in the template
348
+
349
  <small className="muted">Example</small>
350
  ```mdx
351
  import HtmlEmbed from '../components/HtmlEmbed.astro'
352
 
353
+ <HtmlEmbed src="d3-line.html" title="D3 Line" desc="Simple time series" />
354
+ <HtmlEmbed src="d3-bar.html" title="D3 Memory usage with recomputation" desc={`Memory usage with recomputation — <a href="https://huggingface.co/spaces/nanotron/ultrascale-playbook?section=activation_recomputation" target="_blank">from the ultrascale playbook</a>`}/>
355
+
356
+ <Wide>
357
+ <HtmlEmbed src="d3-neural.html" title="D3 Interactive neural network (MNIST-like)" desc="Draw a digit and visualize activations and class probabilities (0–9)." align="center" />
358
+ </Wide>
359
+
360
+ <FullWidth>
361
+ <HtmlEmbed src="d3-pie.html" desc="D3 Pie charts by category" align="center" />
362
+ </FullWidth>
363
+
364
  <HtmlEmbed src="line.html" title="Plotly Line" desc="Interactive time series" />
365
  ```
366
 
367
  ### Iframes
368
 
369
+ You can embed external content in your article using **iframes**. For example, **TrackIO**, **Gradio** or even **Github code embeds** can be used this way.
370
 
371
  <small className="muted">Github code embed</small>
372
  <iframe frameborder="0" scrolling="no" style="width:100%; height:292px;" allow="clipboard-write" src="https://emgithub.com/iframe.html?target=https%3A%2F%2Fgithub.com%2Fhuggingface%2Fpicotron%2Fblob%2F1004ae37b87887cde597c9060fb067faa060bafe%2Fsetup.py&style=default&type=code&showBorder=on&showLineNumbers=on"></iframe>
app/src/content/embeds/d3-line.html CHANGED
@@ -232,25 +232,20 @@
232
  xScale.range([0, innerWidth]);
233
  yScale.range([innerHeight, 0]);
234
 
235
- // Compute integer ticks for Y
236
- let yIntTicks = [];
237
  if (isRankStrictFlag) {
238
  const maxR = Math.max(1, Math.round(rankTickMax));
239
- for (let v = 1; v <= maxR; v += 1) yIntTicks.push(v);
240
  } else {
241
- const yDomain = yScale.domain();
242
- const yMin = Math.min(yDomain[0], yDomain[1]);
243
- const yMax = Math.max(yDomain[0], yDomain[1]);
244
- let yStep = Math.max(1, Math.round((yMax - yMin) / 6));
245
- if (!isFinite(yStep) || yStep <= 0) yStep = 1;
246
- for (let v = Math.ceil(yMin); v <= Math.floor(yMax); v += yStep) { yIntTicks.push(v); }
247
- if (yIntTicks.length === 0) { yIntTicks = [Math.round(yMin), Math.round(yMax)]; }
248
  }
249
 
250
  // Grid (horizontal)
251
  gGrid.selectAll('*').remove();
252
  gGrid.selectAll('line')
253
- .data(yIntTicks)
254
  .join('line')
255
  .attr('x1', 0)
256
  .attr('x2', innerWidth)
@@ -274,7 +269,10 @@
274
  } else {
275
  xAxis = xAxis.ticks(8);
276
  }
277
- const yAxis = d3.axisLeft(yScale).tickValues(yIntTicks).tickSizeOuter(0).tickFormat(d3.format('d'));
 
 
 
278
  gAxes.append('g')
279
  .attr('transform', `translate(0,${innerHeight})`)
280
  .call(xAxis)
 
232
  xScale.range([0, innerWidth]);
233
  yScale.range([innerHeight, 0]);
234
 
235
+ // Compute Y ticks
236
+ let yTicks = [];
237
  if (isRankStrictFlag) {
238
  const maxR = Math.max(1, Math.round(rankTickMax));
239
+ for (let v = 1; v <= maxR; v += 1) yTicks.push(v);
240
  } else {
241
+ // Use D3's tick generator to produce nice floating-point ticks
242
+ yTicks = yScale.ticks(6);
 
 
 
 
 
243
  }
244
 
245
  // Grid (horizontal)
246
  gGrid.selectAll('*').remove();
247
  gGrid.selectAll('line')
248
+ .data(yTicks)
249
  .join('line')
250
  .attr('x1', 0)
251
  .attr('x2', innerWidth)
 
269
  } else {
270
  xAxis = xAxis.ticks(8);
271
  }
272
+ const yAxis = d3.axisLeft(yScale)
273
+ .tickValues(yTicks)
274
+ .tickSizeOuter(0)
275
+ .tickFormat(isRankStrictFlag ? d3.format('d') : d3.format('.2f'));
276
  gAxes.append('g')
277
  .attr('transform', `translate(0,${innerHeight})`)
278
  .call(xAxis)
app/src/content/embeds/d3-neural.html ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-neural" style="width:100%;margin:10px 0;"></div>
2
+ <style>
3
+ .d3-neural .controls { margin-top: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
4
+ .d3-neural .controls label { font-size: 12px; color: var(--muted-color); display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 6px 10px; }
5
+ .d3-neural .controls input[type="range"]{ width: 160px; }
6
+ .d3-neural .panel { display:flex; gap:16px; align-items:flex-start; }
7
+ .d3-neural .left { flex: 0 0 320px; display:flex; flex-direction:column; gap:8px; }
8
+ .d3-neural .right { flex: 1 1 auto; min-width: 0; }
9
+ .d3-neural canvas { width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border-color); background: var(--surface-bg); display:block; }
10
+ .d3-neural .preview28 { display:grid; grid-template-columns: repeat(28, 1fr); gap: 1px; width: 100%; }
11
+ .d3-neural .preview28 span { display:block; aspect-ratio:1/1; border-radius:2px; }
12
+ .d3-neural .legend { font-size: 12px; color: var(--text-color); line-height:1.35; }
13
+ .d3-neural .probs { display:flex; gap:6px; align-items:flex-end; height: 64px; }
14
+ .d3-neural .probs .bar { width: 10px; border-radius:2px 2px 0 0; background: var(--border-color); transition: height .15s ease, background-color .15s ease; }
15
+ .d3-neural .probs .bar.active { background: var(--primary-color); }
16
+ .d3-neural .probs .tick { font-size: 10px; color: var(--muted-color); text-align:center; margin-top: 2px; }
17
+ .d3-neural .canvas-wrap { position: relative; }
18
+ .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); }
19
+ .d3-neural .canvas-hint { position: absolute; top: 8px; left: 8px; font-size: 11px; color: var(--muted-color); pointer-events: none; }
20
+ </style>
21
+ <script>
22
+ (() => {
23
+ const ensureD3 = (cb) => {
24
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
25
+ let s = document.getElementById('d3-cdn-script');
26
+ 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); }
27
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
28
+ s.addEventListener('load', onReady, { once: true });
29
+ if (window.d3) onReady();
30
+ };
31
+
32
+ const ensureTF = (cb) => {
33
+ if (window.tf && typeof window.tf.tensor === 'function') return cb();
34
+ let s = document.getElementById('tfjs-cdn-script');
35
+ if (!s) { s = document.createElement('script'); s.id = 'tfjs-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/@tensorflow/[email protected]/dist/tf.min.js'; document.head.appendChild(s); }
36
+ const onReady = () => { if (window.tf && typeof window.tf.tensor === 'function') cb(); };
37
+ s.addEventListener('load', onReady, { once: true });
38
+ if (window.tf) onReady();
39
+ };
40
+
41
+ const bootstrap = () => {
42
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
43
+ const container = (mount && mount.querySelector && mount.querySelector('.d3-neural')) || document.querySelector('.d3-neural');
44
+ if (!container) return;
45
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
46
+
47
+ // Layout: left (canvas + preview + controls), right (svg network)
48
+ const panel = document.createElement('div');
49
+ panel.className = 'panel';
50
+ const left = document.createElement('div'); left.className = 'left';
51
+ const right = document.createElement('div'); right.className = 'right';
52
+ panel.appendChild(left); panel.appendChild(right);
53
+ container.appendChild(panel);
54
+
55
+ // Canvas for drawing
56
+ const CANVAS_PX = 224; // canvas pixels (square)
57
+ const canvas = document.createElement('canvas'); canvas.width = CANVAS_PX; canvas.height = CANVAS_PX;
58
+ const ctx = canvas.getContext('2d');
59
+ // init white bg
60
+ ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX);
61
+ const canvasWrap = document.createElement('div'); canvasWrap.className = 'canvas-wrap';
62
+ canvasWrap.appendChild(canvas);
63
+ // Erase icon button (top-right)
64
+ const eraseBtn = document.createElement('button'); eraseBtn.className='erase-btn button--ghost'; eraseBtn.type='button'; eraseBtn.setAttribute('aria-label','Clear');
65
+ 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>';
66
+ eraseBtn.addEventListener('click', () => clearCanvas());
67
+ canvasWrap.appendChild(eraseBtn);
68
+ // Hint (top-left)
69
+ const hint = document.createElement('div'); hint.className='canvas-hint'; hint.textContent='Draw a digit';
70
+ canvasWrap.appendChild(hint);
71
+ left.appendChild(canvasWrap);
72
+
73
+ // (preview grid removed)
74
+
75
+ // (controls removed; erase button is overlayed on canvas)
76
+
77
+ // (prediction panel removed; predictions rendered next to output nodes)
78
+
79
+ // SVG network on right
80
+ const svg = d3.select(right).append('svg').attr('width','100%').style('display','block');
81
+ const gRoot = svg.append('g');
82
+ const gInput = gRoot.append('g').attr('class','input');
83
+ const gInputLinks = gRoot.append('g').attr('class','input-links');
84
+ const gLinks = gRoot.append('g').attr('class','links');
85
+ const gNodes = gRoot.append('g').attr('class','nodes');
86
+ const gLabels = gRoot.append('g').attr('class','labels');
87
+ const gOutText = gRoot.append('g').attr('class','out-probs');
88
+
89
+ // Network structure (compact: 8 -> 8 -> 10)
90
+ const layerSizes = [8, 8, 10];
91
+ const layers = layerSizes.map((n, li)=> Array.from({length:n}, (_, i)=>({ id:`L${li}N${i}`, layer: li, index: i, a:0 })));
92
+ // Links only between hidden->hidden and hidden->output
93
+ const links = [];
94
+ for (let i=0;i<layerSizes[0];i++){
95
+ 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 });
96
+ }
97
+ for (let i=0;i<layerSizes[1];i++){
98
+ 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 });
99
+ }
100
+
101
+ // Linear classifier: logits = W * feats + b, feats in [0,1]
102
+ // features: [total, cx, cy, lr, tb, htrans, vtrans, loopiness]
103
+ const W = [
104
+ // 0 1 2 3 4 5 6 7
105
+ [ 0.3, 0.0, 0.0, 0.0, 0.0, -0.8, -0.6, 1.2], // 0
106
+ [-0.2, 0.9, 0.2, 0.8, 0.1, -0.2, 0.2, -1.1], // 1
107
+ [ 0.1, 0.4, 0.2, 0.5, 0.2, 0.9, 0.1, -0.6], // 2
108
+ [ 0.2, 0.3, 0.2, 0.2, 0.2, 0.9, 0.0, -0.2], // 3
109
+ [ 0.0,-0.3, 0.2,-0.6, 0.4, 0.2, 0.8, -0.6], // 4
110
+ [ 0.1,-0.4, 0.2,-0.5, 0.5, 0.9, 0.1, -0.6], // 5
111
+ [ 0.2,-0.2, 0.6,-0.2, 0.8, -0.3, 0.2, 0.6], // 6
112
+ [ 0.0, 0.6,-0.2, 0.6,-0.8, 0.6, 0.0, -0.8], // 7
113
+ [ 0.4, 0.0, 0.0, 0.1, 0.1, 0.6, 0.6, 1.0], // 8
114
+ [ 0.2, 0.2,-0.6, 0.2,-0.8, 0.2, 0.6, 0.5], // 9
115
+ ];
116
+ const b = [-0.2, -0.1, -0.05, -0.05, -0.05, -0.05, -0.05, -0.1, -0.15, -0.1];
117
+
118
+ function computeFeatures(x28){
119
+ // x28: Float32Array length 784, values in [0,1] (1 = black/ink)
120
+ let sum=0, cx=0, cy=0; const w=28, h=28;
121
+ const rowSum = new Array(h).fill(0); const colSum = new Array(w).fill(0);
122
+ let hTransitions=0, vTransitions=0;
123
+ for (let y=0;y<h;y++){
124
+ for (let x=0;x<w;x++){
125
+ const v = x28[y*w+x]; sum += v; cx += x*v; cy += y*v; rowSum[y]+=v; colSum[x]+=v;
126
+ if (x>0){ const v0=x28[y*w+(x-1)], v1=v; if ((v0>0.25)!==(v1>0.25)) hTransitions+=1; }
127
+ if (y>0){ const v0=x28[(y-1)*w+x], v1=v; if ((v0>0.25)!==(v1>0.25)) vTransitions+=1; }
128
+ }
129
+ }
130
+ const total = sum/(w*h); // [0,1]
131
+ const cxn = sum>1e-6 ? (cx/sum)/(w-1) : 0.5; // [0,1]
132
+ const cyn = sum>1e-6 ? (cy/sum)/(h-1) : 0.5; // [0,1]
133
+ let left=0,right=0,top=0,bottom=0;
134
+ 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; }}
135
+ const lr = (right/(right+left+1e-6));
136
+ const tb = (bottom/(bottom+top+1e-6));
137
+ const htn = Math.min(1, hTransitions/(w*h*0.35));
138
+ const vtn = Math.min(1, vTransitions/(w*h*0.35));
139
+ // Loopiness proxy: ink near perimeter low vs center high
140
+ let perimeter=0, center=0; const m=5;
141
+ for (let y=0;y<h;y++){
142
+ for (let x=0;x<w;x++){
143
+ const v=x28[y*w+x];
144
+ const isBorder = (x<m||x>=w-m||y<m||y>=h-m);
145
+ if (isBorder) perimeter+=v; else center+=v;
146
+ }
147
+ }
148
+ const loopiness = Math.min(1, center/(perimeter+center+1e-6)*1.8);
149
+ return [total, cxn, cyn, lr, tb, htn, vtn, loopiness];
150
+ }
151
+
152
+ 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); }
153
+ function l2norm(a){ return Math.hypot(...a) || 0; }
154
+ function normalize(a){ const n=l2norm(a); return n>0 ? a.map(v=>v/n) : a.slice(); }
155
+ 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; }
156
+
157
+ // MNIST-like normalization: crop to tight bbox, scale into 20x20, center in 28x28
158
+ function normalize28(x28){
159
+ const w=28,h=28, thr=0.2;
160
+ let minX=29,minY=29,maxX=-1,maxY=-1, sum=0, cx=0, cy=0;
161
+ for (let y=0;y<h;y++){
162
+ for (let x=0;x<w;x++){
163
+ const v = x28[y*w+x];
164
+ if (v>thr){ if (x<minX) minX=x; if (x>maxX) maxX=x; if (y<minY) minY=y; if (y>maxY) maxY=y; }
165
+ sum += v; cx += x*v; cy += y*v;
166
+ }
167
+ }
168
+ if (sum < 1e-3 || maxX<0){ return x28; }
169
+ const comX = cx/sum, comY = cy/sum;
170
+ const bw = Math.max(1, maxX-minX+1), bh = Math.max(1, maxY-minY+1);
171
+ const scale = 20/Math.max(bw, bh);
172
+ const out = new Float32Array(w*h);
173
+ // center of canvas
174
+ const cxOut = (w-1)/2, cyOut = (h-1)/2;
175
+ for (let y=0;y<h;y++){
176
+ for (let x=0;x<w;x++){
177
+ // map output pixel to source space around COM
178
+ const sx = (x - cxOut)/scale + comX;
179
+ const sy = (y - cyOut)/scale + comY;
180
+ out[y*w+x] = bilinearSample(x28, w, h, sx, sy);
181
+ }
182
+ }
183
+ return out;
184
+ }
185
+ function bilinearSample(img, w, h, x, y){
186
+ const x0 = Math.floor(x), y0 = Math.floor(y);
187
+ const x1 = x0+1, y1 = y0+1;
188
+ const tx = x - x0, ty = y - y0;
189
+ function at(ix,iy){ if (ix<0||iy<0||ix>=w||iy>=h) return 0; return img[iy*w+ix]; }
190
+ const v00 = at(x0,y0), v10 = at(x1,y0), v01 = at(x0,y1), v11 = at(x1,y1);
191
+ const a = v00*(1-tx)+v10*tx; const b = v01*(1-tx)+v11*tx; return a*(1-ty)+b*ty;
192
+ }
193
+ // Simple dilation (max-pooling 3x3) to thicken strokes
194
+ function dilate28(x){
195
+ const w=28,h=28; const out=new Float32Array(w*h);
196
+ for (let y=0;y<h;y++){
197
+ for (let x0=0;x0<w;x0++){
198
+ let m=0;
199
+ for (let dy=-1;dy<=1;dy++){
200
+ for (let dx=-1;dx<=1;dx++){
201
+ const xx=x0+dx, yy=y+dy; if (xx<0||yy<0||xx>=w||yy>=h) continue;
202
+ const v = x[yy*w+xx]; if (v>m) m=v;
203
+ }
204
+ }
205
+ out[y*w+x0]=m;
206
+ }
207
+ }
208
+ return out;
209
+ }
210
+
211
+ // Glyph-based 28x28 prototypes for digits 0-9 (normalized)
212
+ const protoGlyphs28 = [];
213
+ (function buildGlyphProtos(){
214
+ const off = document.createElement('canvas'); off.width = CANVAS_PX; off.height = CANVAS_PX;
215
+ const c = off.getContext('2d');
216
+ for (let d=0; d<10; d++){
217
+ c.fillStyle = '#ffffff'; c.fillRect(0,0,off.width,off.height);
218
+ c.fillStyle = '#000000'; c.textAlign='center'; c.textBaseline='middle';
219
+ c.font = 'bold 180px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
220
+ c.fillText(String(d), off.width/2, off.height*0.56);
221
+ const src = c.getImageData(0,0,off.width,off.height).data; const block = off.width/28;
222
+ const vec = new Float32Array(28*28);
223
+ for (let gy=0; gy<28; gy++){
224
+ for (let gx=0; gx<28; gx++){
225
+ let acc=0, cnt=0; const x0=Math.floor(gx*block), y0=Math.floor(gy*block);
226
+ for (let yy=y0; yy<y0+block; yy++){
227
+ for (let xx=x0; xx<x0+block; xx++){
228
+ const idx=(yy*off.width+xx)*4; const r=src[idx], g=src[idx+1], b=src[idx+2];
229
+ const gray=(r+g+b)/3/255; acc += (1-gray); cnt++;
230
+ }
231
+ }
232
+ vec[gy*28+gx] = acc/(cnt||1);
233
+ }
234
+ }
235
+ const normed = normalize28(vec);
236
+ const n = l2norm(normed)||1; protoGlyphs28.push(normed.map(v=>v/n));
237
+ }
238
+ })();
239
+ function dot(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; return s; }
240
+
241
+ // Resize handling and node layout
242
+ let width=800, height=360; const margin = { top: 16, right: 24, bottom: 24, left: 24 };
243
+ let inputGrid = { cell: 0, x: 0, y: 0, width: 0, height: 0 };
244
+ function layoutNodes(){
245
+ // Right panel width, and a non-square aspect ratio for clarity
246
+ width = Math.max(300, Math.round(right.clientWidth || 800));
247
+ height = Math.max(260, Math.round(width * 0.45));
248
+ svg.attr('width', width).attr('height', height);
249
+ const innerW = width - margin.left - margin.right; const innerH = height - margin.top - margin.bottom;
250
+ gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
251
+ // Input grid layout (28x28) at left — cap width to a fraction of innerW
252
+ const maxGridFrac = 0.28; // at most 28% of available width
253
+ const cellByHeight = Math.floor(innerH / 28);
254
+ const cellByWidth = Math.floor((innerW * maxGridFrac) / 28);
255
+ let cell = Math.max(3, Math.min(cellByHeight, cellByWidth));
256
+ let gridH = cell * 28; let gridY = Math.floor((innerH - gridH)/2);
257
+ inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };
258
+ // Ensure there is always space for layers to the right
259
+ const minRightPad = 40; // minimal free space at right
260
+ let startX = inputGrid.width + 24;
261
+ if (startX > innerW - minRightPad) {
262
+ // Shrink grid to free horizontal room
263
+ cell = Math.max(3, Math.floor((innerW - minRightPad - 24) / 28));
264
+ gridH = cell * 28; gridY = Math.floor((innerH - gridH)/2);
265
+ inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };
266
+ startX = inputGrid.width + 24;
267
+ }
268
+ const nLayers = layerSizes.length;
269
+ // Reserve space at right for output labels/bars so they don't get cut off
270
+ const rightLabelPad = 100; // px reserved for digit label + bar
271
+ const availableW = Math.max(100, innerW - startX - rightLabelPad);
272
+ // Reduce inter-layer spacing slightly; keep a sane min/max
273
+ const stepX = nLayers > 1 ? Math.min(200, Math.max(28, availableW / (nLayers - 1))) : 0;
274
+ const xs = Array.from({ length: nLayers }, (_, li) => startX + stepX * li);
275
+ // Y positions evenly spaced per layer
276
+ layers.forEach((nodes, li)=>{
277
+ const n = nodes.length; const spacing = innerH/(n+1);
278
+ nodes.forEach((nd, i)=>{ nd.x = xs[li]; nd.y = spacing*(i+1); });
279
+ });
280
+ }
281
+
282
+ let lastX28 = new Float32Array(28*28);
283
+ function renderInputGrid(){
284
+ if (!inputGrid || inputGrid.cell <= 0) return;
285
+ const data = Array.from({ length: 28*28 }, (_, i) => ({ i, v: lastX28[i] || 0 }));
286
+ const sel = gInput.selectAll('rect.input-px').data(data, d=>d.i);
287
+ const gap = Math.max(1, Math.floor(inputGrid.cell * 0.10));
288
+ const inner = Math.max(1, inputGrid.cell - gap);
289
+ const offset = Math.floor(gap / 2);
290
+ sel.enter().append('rect').attr('class','input-px')
291
+ .attr('width', inner).attr('height', inner)
292
+ .merge(sel)
293
+ .attr('x', d => inputGrid.x + (d.i % 28) * inputGrid.cell + offset)
294
+ .attr('y', d => inputGrid.y + Math.floor(d.i / 28) * inputGrid.cell + offset)
295
+ .attr('fill', d => { const g = 255 - Math.round(d.v * 255); return `rgb(${g},${g},${g})`; })
296
+ .attr('stroke', 'none');
297
+ sel.exit().remove();
298
+ }
299
+
300
+ function renderInputLinks(){
301
+ // Draw bundle-like links from input grid right edge to first layer nodes (features)
302
+ const firstLayer = layers[0];
303
+ if (!firstLayer || !inputGrid || inputGrid.cell <= 0) { gInputLinks.selectAll('path').remove(); return; }
304
+ const innerH = height - margin.top - margin.bottom;
305
+ const x0 = inputGrid.x + inputGrid.width;
306
+ const paths = firstLayer.map((n, idx) => {
307
+ const yTarget = n.y;
308
+ // source y roughly aligned to node y, clamped within the grid
309
+ const y0 = Math.max(inputGrid.y, Math.min(inputGrid.y + inputGrid.height, yTarget));
310
+ const dx = (n.x - x0) * 0.35;
311
+ return { x0, y0, x1: n.x - 12, y1: yTarget, c1x: x0 + dx, c1y: y0, c2x: n.x - dx, c2y: yTarget };
312
+ });
313
+ const sel = gInputLinks.selectAll('path.input-link').data(paths);
314
+ sel.enter().append('path').attr('class','input-link')
315
+ .attr('fill','none')
316
+ .attr('stroke','rgba(0,0,0,0.25)')
317
+ .attr('stroke-width', 1)
318
+ .merge(sel)
319
+ .attr('d', d => `M${d.x0},${d.y0} C${d.c1x},${d.c1y} ${d.c2x},${d.c2y} ${d.x1},${d.y1}`);
320
+ sel.exit().remove();
321
+ }
322
+
323
+ function renderGraph(showEdges){
324
+ layoutNodes();
325
+ renderInputGrid();
326
+ renderInputLinks();
327
+ // Nodes
328
+ const allNodes = layers.flat();
329
+ const nodeSel = gNodes.selectAll('circle.node').data(allNodes, d=>d.id);
330
+ nodeSel.enter().append('circle').attr('class','node')
331
+ .attr('r', 10)
332
+ .attr('cx', d=>d.x).attr('cy', d=>d.y)
333
+ .attr('fill', d=> d.layer===2 ? 'var(--primary-color)' : 'var(--surface-bg)')
334
+ .attr('stroke','var(--border-color)').attr('stroke-width',1)
335
+ .merge(nodeSel)
336
+ .attr('cx', d=>d.x).attr('cy', d=>d.y)
337
+ .attr('opacity', 1);
338
+ nodeSel.exit().remove();
339
+
340
+ // Labels for first hidden layer only (avoid stacking with output probs)
341
+ const labels = [];
342
+ layers[0].forEach((n,i)=> labels.push({ x:n.x, y:n.y-16, txt:`f${i+1}` }));
343
+ const labSel = gLabels.selectAll('text').data(labels);
344
+ labSel.enter().append('text').style('font-size','12px').style('fill','var(--muted-color)')
345
+ .attr('x', d=>d.x).attr('y', d=>d.y)
346
+ .text(d=>d.txt)
347
+ .merge(labSel)
348
+ .attr('x', d=>d.x).attr('y', d=>d.y).text(d=>d.txt);
349
+ labSel.exit().remove();
350
+
351
+ // Links as smooth curves
352
+ const pathFor = (d) => {
353
+ const x1 = layers[d.s.l][d.s.i].x, y1 = layers[d.s.l][d.s.i].y;
354
+ const x2 = layers[d.t.l][d.t.j].x, y2 = layers[d.t.l][d.t.j].y;
355
+ const dx = (x2 - x1) * 0.45;
356
+ return `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`;
357
+ };
358
+ const linkSel = gLinks.selectAll('path.link').data(links, d=> `${d.s.l}-${d.s.i}-${d.t.l}-${d.t.j}`);
359
+ linkSel.enter().append('path').attr('class','link')
360
+ .attr('d', pathFor)
361
+ .attr('fill','none')
362
+ .attr('stroke','rgba(0,0,0,0.25)')
363
+ .attr('stroke-width', d=> 0.5 + d.w*1.2)
364
+ .merge(linkSel)
365
+ .attr('d', pathFor)
366
+ .attr('stroke-width', d=> 0.5 + d.w*1.2);
367
+ linkSel.exit().remove();
368
+ }
369
+
370
+ function setNodeActivations(h1, h2, out){
371
+ layers[0].forEach((n,i)=> n.a = h1[i] || 0);
372
+ layers[1].forEach((n,i)=> n.a = h2[i] || 0);
373
+ layers[2].forEach((n,i)=> n.a = out[i] || 0);
374
+ // Determine top prediction (for ghosting others)
375
+ let argmaxIdx = 0; let bestProb = -1;
376
+ if (Array.isArray(out)) {
377
+ for (let i=0;i<out.length;i++){ if (out[i] > bestProb){ bestProb = out[i]; argmaxIdx = i; } }
378
+ }
379
+ // Color/opacity by activation
380
+ gNodes.selectAll('circle.node')
381
+ .attr('fill', d=> d.layer===2 ? 'var(--primary-color)' : `rgba(0,0,0,${0.06 + 0.44*d.a})`)
382
+ .attr('stroke', d=> d.layer===2 ? 'var(--primary-color)' : 'var(--border-color)')
383
+ .attr('opacity', d=> 0.25 + 0.75*Math.min(1, d.a))
384
+ .attr('r', d=> 8 + 6*Math.min(1, d.a));
385
+ // Link opacity by activation flow
386
+ gLinks.selectAll('path.link')
387
+ .attr('stroke', d=>{
388
+ const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
389
+ const alpha = Math.min(1, 0.08 + 0.85 * (aS * aT));
390
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
391
+ const base = isDark ? 255 : 0;
392
+ return `rgba(${base},${base},${base},${alpha})`;
393
+ });
394
+ // Output labels: bold digit + small horizontal bar for probability
395
+ const outs = layers[2].map((n,i)=>({ x:n.x+18, y:n.y, digit: i, prob: (out[i]||0), isTop: i===argmaxIdx }));
396
+ const gSel = gOutText.selectAll('g.out-label').data(outs, d=>d.digit);
397
+ const gEnter = gSel.enter().append('g').attr('class','out-label');
398
+ gEnter.append('text').attr('class','out-digit')
399
+ .style('font-size','12px').style('font-weight','700').style('fill','var(--text-color)');
400
+ gEnter.append('rect').attr('class','out-bar-bg').attr('rx',2).attr('ry',2)
401
+ .attr('height', 4).attr('fill', 'var(--border-color)');
402
+ gEnter.append('rect').attr('class','out-bar').attr('rx',2).attr('ry',2)
403
+ .attr('height', 4);
404
+ const BAR_MAX = 64;
405
+ gEnter.merge(gSel)
406
+ .attr('transform', d=>`translate(${d.x},${d.y})`)
407
+ .each(function(d){
408
+ const sel = d3.select(this);
409
+ sel.select('text.out-digit')
410
+ .attr('x', 0).attr('y', -2)
411
+ .text(String(d.digit));
412
+ sel.select('rect.out-bar-bg')
413
+ .attr('x', 0).attr('y', 6)
414
+ .attr('width', BAR_MAX);
415
+ sel.select('rect.out-bar')
416
+ .attr('x', 0).attr('y', 6)
417
+ .attr('width', Math.max(1, Math.round(d.prob * BAR_MAX)))
418
+ .attr('fill', d.isTop ? 'var(--primary-color)' : 'var(--border-color)');
419
+ // Ghost non-top predictions
420
+ sel.style('opacity', d.isTop ? 1 : 0.35);
421
+ });
422
+ gSel.exit().remove();
423
+ }
424
+
425
+ // (no separate updateBars; bars are rendered next to nodes)
426
+
427
+ function runPipeline(){
428
+ const x28raw = downsample28();
429
+ const x28 = dilate28(normalize28(x28raw));
430
+ // Update input grid data
431
+ lastX28 = x28;
432
+ renderInputGrid();
433
+ const feats = computeFeatures(x28); // 8D in [0,1]
434
+ const inkMass = feats[0];
435
+ // Hidden 1 = raw features
436
+ const h1 = feats;
437
+ // Hidden 2 = simple non-linear mix for visualization only
438
+ const h2 = layers[1].map((_, j)=>{
439
+ 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]; }
440
+ return Math.tanh(s*0.8);
441
+ });
442
+ let prob;
443
+ if (inkMass < 0.03){
444
+ // Too little ink: return near-uniform distribution
445
+ prob = Array.from({length:10}, ()=> 1/10);
446
+ } else {
447
+ // Prefer TFJS model if available
448
+ const tfProbs = predictTfjs(x28);
449
+ if (tfProbs && tfProbs.length === 10) {
450
+ prob = tfProbs;
451
+ } else {
452
+ // Fallback: rely mostly on glyph similarity
453
+ const x28n = normalize(x28);
454
+ const logitsGlyph = protoGlyphs28.map(p => 8.0 * cosine(x28n, p));
455
+ const logitsLinear = W.map((row, k)=> dot(row, h1) + b[k]);
456
+ const logits = logitsGlyph.map((v,k)=> v + 0.2*logitsLinear[k]);
457
+ prob = softmax(logits);
458
+ }
459
+ }
460
+ setNodeActivations(h1, h2.map(v => (v+1)/2), prob);
461
+ }
462
+
463
+ function downsample28(){
464
+ // From canvas (224x224) to 28x28 by average pooling in 8x8 blocks
465
+ const block = CANVAS_PX/28; // 8
466
+ const src = ctx.getImageData(0,0,CANVAS_PX,CANVAS_PX).data;
467
+ const out = new Float32Array(28*28);
468
+ for (let gy=0; gy<28; gy++){
469
+ for (let gx=0; gx<28; gx++){
470
+ let acc=0; let cnt=0;
471
+ const x0 = Math.floor(gx*block), y0 = Math.floor(gy*block);
472
+ for (let y=y0; y<y0+block; y++){
473
+ for (let x=x0; x<x0+block; x++){
474
+ const idx = (y*CANVAS_PX + x)*4; // RGBA
475
+ const r=src[idx], g=src[idx+1], b=src[idx+2];
476
+ const gray = (r+g+b)/3/255; // 1: white, 0: black
477
+ const ink = 1-gray; // 1: ink/black
478
+ acc += ink; cnt++;
479
+ }
480
+ }
481
+ out[gy*28+gx] = acc/(cnt||1);
482
+ }
483
+ }
484
+ return out;
485
+ }
486
+
487
+ function clearCanvas(){ ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX); runPipeline(); }
488
+
489
+ // Drawing interactions
490
+ let drawing=false; let last=null;
491
+ const getPos = (ev) => {
492
+ const rect = canvas.getBoundingClientRect();
493
+ const sx = CANVAS_PX/rect.width; const sy = CANVAS_PX/rect.height;
494
+ const x = (('touches' in ev)? ev.touches[0].clientX : ev.clientX) - rect.left;
495
+ const y = (('touches' in ev)? ev.touches[0].clientY : ev.clientY) - rect.top;
496
+ return { x: x*sx, y: y*sy };
497
+ };
498
+ function drawTo(p){
499
+ const size = 24;
500
+ ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#000000'; ctx.lineWidth=size;
501
+ if (!last) last = p;
502
+ ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
503
+ last = p; runPipeline();
504
+ }
505
+ function onDown(ev){ drawing=true; last=null; drawTo(getPos(ev)); ev.preventDefault(); }
506
+ function onMove(ev){ if (!drawing) return; drawTo(getPos(ev)); ev.preventDefault(); }
507
+ function onUp(){ drawing=false; last=null; }
508
+ canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
509
+ canvas.addEventListener('touchstart', onDown, { passive:false }); canvas.addEventListener('touchmove', onMove, { passive:false }); window.addEventListener('touchend', onUp);
510
+
511
+ // (erase button handled as overlay)
512
+ const rerender = () => { renderGraph(true); };
513
+ if (window.ResizeObserver) {
514
+ const ro = new ResizeObserver(()=>rerender());
515
+ ro.observe(right);
516
+ ro.observe(canvas);
517
+ } else { window.addEventListener('resize', rerender); }
518
+
519
+ // TFJS model (optional)
520
+ let tfModel = null;
521
+ const tryLoadModel = async () => {
522
+ await new Promise((res)=> ensureTF(res));
523
+ const candidates = [
524
+ // Prefer local variant in assets (weights shards must be colocated)
525
+ './assets/data/mnist-variant-model.json',
526
+ '../assets/data/mnist-variant-model.json',
527
+ '/assets/data/mnist-variant-model.json',
528
+ // Fallback to public TFJS MNIST
529
+ 'https://storage.googleapis.com/tfjs-models/tfjs/mnist/model.json'
530
+ ];
531
+ for (const u of candidates){
532
+ try { tfModel = await tf.loadLayersModel(u); return; } catch(_) { /* try next */ }
533
+ }
534
+ tfModel = null;
535
+ };
536
+
537
+ function predictTfjs(x28){
538
+ if (!tfModel || !window.tf) return null;
539
+ const run = (arr) => {
540
+ const t = tf.tidy(()=> tf.tensor(arr, [28,28,1]).expandDims(0));
541
+ 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; }
542
+ };
543
+ // Try both orientations and keep the one with higher confidence
544
+ const p1 = run(x28);
545
+ const inv = x28.map(v=>1-v);
546
+ const p2 = run(inv);
547
+ let probs = p1 || p2;
548
+ if (p1 && p2){
549
+ const m1 = Math.max(...p1), m2 = Math.max(...p2);
550
+ probs = m2>m1 ? p2 : p1;
551
+ }
552
+ if (!probs) return null;
553
+ // Normalize output size to 10 classes (pad or slice)
554
+ if (probs.length < 10){ probs = probs.concat(Array(10 - probs.length).fill(0)); }
555
+ if (probs.length > 10){ probs = probs.slice(0,10); }
556
+ return probs;
557
+ }
558
+
559
+ // Initial render
560
+ renderGraph(true);
561
+ clearCanvas();
562
+ tryLoadModel();
563
+ };
564
+
565
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
566
+ })();
567
+ </script>
568
+
569
+
570
+
571
+
app/src/content/embeds/d3-scatter.html ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-scatter" style="width:100%;margin:10px 0;"></div>
2
+ <style>
3
+ .d3-scatter .controls { margin-top: 12px; display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
4
+ .d3-scatter .controls label { font-size: 12px; color: var(--muted-color); display: flex; align-items: center; gap: 8px; white-space: nowrap; padding: 6px 10px; }
5
+ .d3-scatter .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; }
6
+ [data-theme="dark"] .d3-scatter .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"); }
7
+ .d3-scatter .point { opacity: .9; }
8
+ .d3-scatter .point:hover { opacity: 1; }
9
+ </style>
10
+ <script>
11
+ (() => {
12
+ const ensureD3 = (cb) => {
13
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
14
+ let s = document.getElementById('d3-cdn-script');
15
+ 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); }
16
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
17
+ s.addEventListener('load', onReady, { once: true });
18
+ if (window.d3) onReady();
19
+ };
20
+
21
+ const bootstrap = () => {
22
+ const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
23
+ const container = (mount && mount.querySelector && mount.querySelector('.d3-scatter')) || document.querySelector('.d3-scatter');
24
+ if (!container) return;
25
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
26
+
27
+ // Try multiple paths: prefer public path then relative copies under content assets
28
+ const JSON_PATHS = [
29
+ '/data/data.json',
30
+ './assets/data/data.json',
31
+ '../assets/data/data.json',
32
+ '../../assets/data/data.json'
33
+ ];
34
+ const fetchFirstAvailable = async (paths) => {
35
+ for (const p of paths) {
36
+ try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return await r.json(); } catch(e) {}
37
+ }
38
+ throw new Error('JSON not found: data.json');
39
+ };
40
+
41
+ // SVG scaffolding
42
+ const svg = d3.select(container).append('svg').attr('width','100%').style('display','block');
43
+ const gRoot = svg.append('g');
44
+ const gGrid = gRoot.append('g').attr('class','grid');
45
+ const gAxes = gRoot.append('g').attr('class','axes');
46
+ const gPoints = gRoot.append('g').attr('class','points');
47
+ const gHover = gRoot.append('g').attr('class','hover');
48
+
49
+ // Tooltip
50
+ container.style.position = container.style.position || 'relative';
51
+ let tip = container.querySelector('.d3-tooltip'); let tipInner;
52
+ 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; }
53
+
54
+ // Layout & scales
55
+ let width=800, height=360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
56
+ const x = d3.scaleLinear();
57
+ const y = d3.scaleLinear();
58
+ const color = d3.scaleOrdinal(d3.schemeTableau10);
59
+
60
+ const overlay = gHover.append('rect').attr('fill', 'transparent').style('cursor', 'crosshair');
61
+
62
+ function updateScales(domainX, domainY){
63
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
64
+ const axisColor = isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)';
65
+ const tickColor = isDark ? 'rgba(255,255,255,0.70)' : 'rgba(0,0,0,0.55)';
66
+ const gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)';
67
+
68
+ width = container.clientWidth || 800; height = Math.max(260, Math.round(width/3)); svg.attr('width', width).attr('height', height);
69
+ const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
70
+
71
+ x.domain(domainX).range([0, innerWidth]).nice();
72
+ y.domain(domainY).range([innerHeight, 0]).nice();
73
+
74
+ // Grid
75
+ gGrid.selectAll('*').remove();
76
+ gGrid.selectAll('line').data(y.ticks(6)).join('line')
77
+ .attr('x1', 0).attr('x2', innerWidth).attr('y1', (d)=>y(d)).attr('y2', (d)=>y(d))
78
+ .attr('stroke', gridColor).attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
79
+
80
+ // Axes
81
+ gAxes.selectAll('*').remove();
82
+ gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x).ticks(8)).call((g)=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','12px'); });
83
+ gAxes.append('g').call(d3.axisLeft(y).ticks(6)).call((g)=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','12px'); });
84
+
85
+ // Axis labels
86
+ gAxes.append('text').attr('class','axis-label axis-label--x').attr('x', innerWidth/2).attr('y', innerHeight + 44).attr('text-anchor','middle').style('font-size','12px').style('fill', tickColor).text('x');
87
+ gAxes.append('text').attr('class','axis-label axis-label--y').attr('text-anchor','middle').attr('transform', `translate(${-36},${innerHeight/2}) rotate(-90)`).style('font-size','12px').style('fill', tickColor).text('y');
88
+
89
+ overlay.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
90
+
91
+ return { innerWidth, innerHeight };
92
+ }
93
+
94
+ function normalizeData(raw){
95
+ // Accepts: [{x,y,label?}] or [[x,y,label?]] or objects with other keys (will try first two numeric fields)
96
+ const out = [];
97
+ if (Array.isArray(raw)) {
98
+ raw.forEach((row) => {
99
+ if (row == null) return;
100
+ if (Array.isArray(row)) {
101
+ const x = +row[0]; const y = +row[1]; const label = row.length > 2 ? row[2] : undefined; if (Number.isFinite(x) && Number.isFinite(y)) out.push({ x, y, label });
102
+ } else if (typeof row === 'object') {
103
+ if ('x' in row && 'y' in row) { const x = +row.x; const y = +row.y; const label = row.label; if (Number.isFinite(x) && Number.isFinite(y)) out.push({ x, y, label }); }
104
+ else {
105
+ const vals = Object.values(row).filter(v => typeof v === 'number' || (typeof v === 'string' && v.trim() !== ''));
106
+ const nums = vals.map(v => +v).filter(Number.isFinite);
107
+ if (nums.length >= 2) { out.push({ x: nums[0], y: nums[1], label: vals.length > 2 ? vals[2] : undefined }); }
108
+ }
109
+ }
110
+ });
111
+ }
112
+ return out;
113
+ }
114
+
115
+ function render(points){
116
+ if (!points || points.length === 0) { gPoints.selectAll('*').remove(); return; }
117
+ const xExtent = d3.extent(points, d=>d.x);
118
+ const yExtent = d3.extent(points, d=>d.y);
119
+ updateScales(xExtent, yExtent);
120
+
121
+ const sel = gPoints.selectAll('circle.point').data(points);
122
+ sel.enter().append('circle').attr('class','point')
123
+ .attr('r', 3)
124
+ .attr('cx', d=>x(d.x))
125
+ .attr('cy', d=>y(d.y))
126
+ .attr('fill', d=> d.label != null ? color(d.label) : 'var(--primary-color)')
127
+ .on('mouseenter', function(ev, d){
128
+ tipInner.innerHTML = `<div><strong>x</strong> ${(+d.x).toFixed(4)}</div><div><strong>y</strong> ${(+d.y).toFixed(4)}</div>${d.label!=null?`<div><strong>label</strong> ${d.label}</div>`:''}`;
129
+ tip.style.opacity = '1';
130
+ })
131
+ .on('mousemove', function(ev){ const [mx,my] = d3.pointer(ev, container); const offsetX=12, offsetY=12; tip.style.transform = `translate(${Math.round(mx+offsetX)}px, ${Math.round(my+offsetY)}px)`; })
132
+ .on('mouseleave', function(){ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)'; })
133
+ .merge(sel)
134
+ .attr('cx', d=>x(d.x))
135
+ .attr('cy', d=>y(d.y))
136
+ .attr('fill', d=> d.label != null ? color(d.label) : 'var(--primary-color)');
137
+ sel.exit().remove();
138
+ }
139
+
140
+ (async () => {
141
+ try {
142
+ const raw = await fetchFirstAvailable(JSON_PATHS);
143
+ const points = normalizeData(raw);
144
+ render(points);
145
+ const rerender = () => { render(points); };
146
+ if (window.ResizeObserver) { const ro = new ResizeObserver(()=>rerender()); ro.observe(container); } else { window.addEventListener('resize', rerender); }
147
+ } catch (e) {
148
+ const pre = document.createElement('pre'); pre.textContent = 'JSON load error: ' + (e && e.message ? e.message : e);
149
+ pre.style.color = 'var(--danger, #b00020)'; pre.style.fontSize = '12px'; pre.style.whiteSpace = 'pre-wrap';
150
+ container.appendChild(pre);
151
+ }
152
+ })();
153
+ };
154
+
155
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
156
+ })();
157
+ </script>
158
+
159
+
160
+
app/src/content/embeds/palettes.html CHANGED
@@ -15,8 +15,24 @@
15
  .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */
16
  .palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
17
  /* Simulation UI */
18
- .palettes .palettes__select { width: 100%; max-width: 260px; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
19
  .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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  /* Page-wide color vision simulation classes */
21
  html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
22
  html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
@@ -28,21 +44,32 @@
28
  .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); }
29
  .palettes .palette-card__content { border-right: none; padding-right: 0; }
30
  .palettes .palette-card__actions { justify-self: start; }
 
31
  }
32
  </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  <div class="palettes__grid"></div>
34
  <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
35
  <br/>
36
  <p ><strong>Use color with care.</strong> Color should rarely be the only channel of meaning. Always pair it with text, icons, shape or position. The simulation below helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies. Toggle modes while checking charts, legends and interactions to ensure sufficient contrast and redundant cues.</p>
37
- <label for="cb-select">Color vision simulation</label>
38
- <select id="cb-select" class="palettes__select">
39
- <option value="none">None — full color</option>
40
- <option value="grayscale">Grayscale — no hue (luminance only)</option>
41
- <option value="protanopia">Protanopia — reduced/absent reds</option>
42
- <option value="deuteranopia">Deuteranopia — reduced/absent greens</option>
43
- <option value="tritanopia">Tritanopia — reduced/absent blues</option>
44
- <option value="achromatopsia">Achromatopsia — no color at all</option>
45
- </select>
46
  <!-- Hidden SVG filters used by the page-wide simulation classes -->
47
  <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
48
  <defs>
@@ -84,7 +111,7 @@
84
  };
85
 
86
  const cards = [
87
- { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors. <strong>Up to 6</strong>.', generator: (baseHex) => {
88
  const base = chroma(baseHex);
89
  const lc = base.lch();
90
  const baseH = base.get('hsl.h') || 0;
@@ -116,17 +143,18 @@
116
  // Base en premier
117
  pushHex(base);
118
 
119
- // Cinq couleurs supplémentaires, espacées de 60°
120
- const angles = [60, 120, 180, 240, 300];
121
- const hueOffsets = [0, 20, -20, 40, -40, 60, -60, 80, -80];
122
  const lVariants = [L0, Math.max(40, L0 - 6), Math.min(85, L0 + 6)];
123
 
124
- angles.forEach(step => {
 
125
  let accepted = false;
126
  for (let li = 0; li < lVariants.length && !accepted; li++) {
127
  for (let oi = 0; oi < hueOffsets.length && !accepted; oi++) {
128
- let h = (baseH + step + hueOffsets[oi] + 360) % 360;
129
- let col = makeSafe(h, lVariants[li], C0);
130
  const hex = col.hex();
131
  if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
132
  pushHex(col);
@@ -135,12 +163,11 @@
135
  }
136
  }
137
  if (!accepted) {
138
- // Réduction de C si nécessaire
139
  let cTry = C0 - 10;
140
- let h = (baseH + step + 360) % 360;
141
  let trials = 0;
142
  while (!accepted && cTry >= 30 && trials < 6) {
143
- const col = makeSafe(h, L0, cTry);
144
  const hex = col.hex();
145
  if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
146
  pushHex(col);
@@ -150,11 +177,10 @@
150
  cTry -= 5;
151
  trials++;
152
  }
153
- // Dernier recours: choisir la teinte la plus éloignée possible même si < seuil
154
  if (!accepted) {
155
  let bestHex = null; let bestMin = -1;
156
  hueOffsets.forEach(off => {
157
- const hh = (baseH + step + off + 360) % 360;
158
  const cand = makeSafe(hh, L0, C0).hex();
159
  const minD = results.reduce((m, prev) => Math.min(m, chroma.distance(cand, prev, 'lab')), Infinity);
160
  if (minD > bestMin && !seen.has(cand.toLowerCase())) { bestMin = minD; bestHex = cand; }
@@ -162,23 +188,22 @@
162
  if (bestHex) { seen.add(bestHex.toLowerCase()); results.push(bestHex); }
163
  }
164
  }
165
- });
166
 
167
- return results.slice(0, 6);
168
  }},
169
- { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.', generator: (baseHex) => {
 
170
  const c = chroma(baseHex).saturate(0.3);
171
- return chroma.scale([c.darken(2), c, c.brighten(2)]).mode('lab').correctLightness(true).colors(6);
172
  }},
173
- { key: 'diverging', title: 'Diverging', desc: 'For <strong>centered ranges</strong> with <strong>two extremes</strong> around a <strong>baseline</strong>. (e.g., negatives/positives)', generator: (baseHex) => {
 
174
  const baseH = chroma(baseHex).get('hsl.h');
175
  const compH = (baseH + 180) % 360;
176
  const left = chroma.hsl(baseH, 0.75, 0.55);
177
  const right = chroma.hsl(compH, 0.75, 0.55);
178
- const center = '#ffffff';
179
- const leftRamp = chroma.scale([left, center]).mode('lch').correctLightness(true).colors(4);
180
- const rightRamp = chroma.scale([center, right]).mode('lch').correctLightness(true).colors(4);
181
- return [leftRamp[0], leftRamp[1], leftRamp[2], rightRamp[1], rightRamp[2], rightRamp[3]];
182
  }}
183
  ];
184
 
@@ -186,6 +211,13 @@
186
  try { return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); } catch { return ''; }
187
  };
188
 
 
 
 
 
 
 
 
189
  const render = () => {
190
  const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
191
  const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
@@ -195,9 +227,10 @@
195
  grid.innerHTML = '';
196
  const css = getCssPrimary();
197
  const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
 
198
 
199
  const html = cards.map((c) => {
200
- const colors = c.generator(baseHex).slice(0, 6);
201
  const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
202
  return `
203
  <div class="palette-card" data-colors="${colors.join(',')}">
@@ -211,14 +244,14 @@
211
  </button>
212
  </div>
213
  <div class="palette-card__actions"></div>
214
- <div class="palette-card__swatches">${swatches}</div>
215
  </div>
216
  `;
217
  }).join('');
218
  grid.innerHTML = html;
219
  };
220
 
221
- const MODE_TO_CLASS = { grayscale: 'cb-grayscale', protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
222
  const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
223
  const clearCbClasses = () => {
224
  const rootEl = document.documentElement;
@@ -241,6 +274,15 @@
241
  select.addEventListener('change', () => applyCbClass(select.value));
242
  };
243
 
 
 
 
 
 
 
 
 
 
244
  let copyDelegationSetup = false;
245
  const setupCopyDelegation = () => {
246
  if (copyDelegationSetup) return;
@@ -268,8 +310,9 @@
268
  };
269
 
270
  const bootstrap = () => {
271
- render();
272
  setupCbSim();
 
 
273
  setupCopyDelegation();
274
  const mo = new MutationObserver(() => render());
275
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
 
15
  .palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */
16
  .palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
17
  /* Simulation UI */
18
+ .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; }
19
  .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; }
20
+ .palettes .palettes__controls { display: flex; flex-wrap: nowrap; gap: 16px; align-items: center; margin: 8px 0 14px; }
21
+ .palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 0 0 50%; max-width: 50%; }
22
+ .palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; }
23
+ .palettes .palettes__count { display: flex; align-items: center; gap: 8px; max-width: 100%; }
24
+ .palettes .palettes__count input[type="range"] { width: 100%; }
25
+ .palettes .palettes__count output { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted-color); }
26
+ /* Slider styling */
27
+ .palettes input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; cursor: pointer; accent-color: var(--primary-color); }
28
+ .palettes input[type="range"]:focus { outline: none; }
29
+ /* WebKit */
30
+ .palettes input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: var(--border-color); border-radius: 999px; }
31
+ .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%; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
32
+ /* Firefox */
33
+ .palettes input[type="range"]::-moz-range-track { height: 6px; background: var(--border-color); border: none; border-radius: 999px; }
34
+ .palettes input[type="range"]::-moz-range-progress { height: 6px; background: var(--primary-color); border-radius: 999px; }
35
+ .palettes input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; box-shadow: 0 1px 2px rgba(0,0,0,.15); }
36
  /* Page-wide color vision simulation classes */
37
  html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
38
  html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
 
44
  .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); }
45
  .palettes .palette-card__content { border-right: none; padding-right: 0; }
46
  .palettes .palette-card__actions { justify-self: start; }
47
+
48
  }
49
  </style>
50
+ <div class="palettes__controls">
51
+ <div class="palettes__field">
52
+ <label class="palettes__label" for="cb-select">Color vision simulation</label>
53
+ <select id="cb-select" class="palettes__select">
54
+ <option value="none">Normal color vision — typical for most people</option>
55
+ <option value="achromatopsia">Achromatopsia — no color at all</option>
56
+ <option value="protanopia">Protanopia — reduced/absent reds</option>
57
+ <option value="deuteranopia">Deuteranopia — reduced/absent greens</option>
58
+ <option value="tritanopia">Tritanopia — reduced/absent blues</option>
59
+ </select>
60
+ </div>
61
+ <div class="palettes__field">
62
+ <label class="palettes__label" for="color-count">Number of colors</label>
63
+ <div class="palettes__count">
64
+ <input id="color-count" type="range" min="6" max="10" step="1" value="6" aria-label="Number of colors" />
65
+ <output id="color-count-out" for="color-count">6</output>
66
+ </div>
67
+ </div>
68
+ </div>
69
  <div class="palettes__grid"></div>
70
  <div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
71
  <br/>
72
  <p ><strong>Use color with care.</strong> Color should rarely be the only channel of meaning. Always pair it with text, icons, shape or position. The simulation below helps you spot palettes and states that become indistinguishable for people with color‑vision deficiencies. Toggle modes while checking charts, legends and interactions to ensure sufficient contrast and redundant cues.</p>
 
 
 
 
 
 
 
 
 
73
  <!-- Hidden SVG filters used by the page-wide simulation classes -->
74
  <svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
75
  <defs>
 
111
  };
112
 
113
  const cards = [
114
+ { key: 'categorical', title: 'Categorical', desc: 'For <strong>non‑numeric categories</strong>; <strong>visually distinct</strong> colors.', generator: (baseHex, count) => {
115
  const base = chroma(baseHex);
116
  const lc = base.lch();
117
  const baseH = base.get('hsl.h') || 0;
 
143
  // Base en premier
144
  pushHex(base);
145
 
146
+ const total = Math.max(6, Math.min(10, count || 6));
147
+ const hueStep = 360 / total;
148
+ const hueOffsets = [0, 18, -18, 36, -36, 54, -54, 72, -72];
149
  const lVariants = [L0, Math.max(40, L0 - 6), Math.min(85, L0 + 6)];
150
 
151
+ for (let idx = 1; results.length < total && idx < total + 12; idx++) {
152
+ let stepHue = (baseH + idx * hueStep) % 360;
153
  let accepted = false;
154
  for (let li = 0; li < lVariants.length && !accepted; li++) {
155
  for (let oi = 0; oi < hueOffsets.length && !accepted; oi++) {
156
+ const h = (stepHue + hueOffsets[oi] + 360) % 360;
157
+ const col = makeSafe(h, lVariants[li], C0);
158
  const hex = col.hex();
159
  if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
160
  pushHex(col);
 
163
  }
164
  }
165
  if (!accepted) {
166
+ // Réduction de chroma si nécessaire
167
  let cTry = C0 - 10;
 
168
  let trials = 0;
169
  while (!accepted && cTry >= 30 && trials < 6) {
170
+ const col = makeSafe(stepHue, L0, cTry);
171
  const hex = col.hex();
172
  if (!seen.has(hex.toLowerCase()) && isFarEnough(hex)) {
173
  pushHex(col);
 
177
  cTry -= 5;
178
  trials++;
179
  }
 
180
  if (!accepted) {
181
  let bestHex = null; let bestMin = -1;
182
  hueOffsets.forEach(off => {
183
+ const hh = (stepHue + off + 360) % 360;
184
  const cand = makeSafe(hh, L0, C0).hex();
185
  const minD = results.reduce((m, prev) => Math.min(m, chroma.distance(cand, prev, 'lab')), Infinity);
186
  if (minD > bestMin && !seen.has(cand.toLowerCase())) { bestMin = minD; bestHex = cand; }
 
188
  if (bestHex) { seen.add(bestHex.toLowerCase()); results.push(bestHex); }
189
  }
190
  }
191
+ }
192
 
193
+ return results.slice(0, total);
194
  }},
195
+ { key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.', generator: (baseHex, count) => {
196
+ const total = Math.max(6, Math.min(10, count || 6));
197
  const c = chroma(baseHex).saturate(0.3);
198
+ return chroma.scale([c.darken(2), c, c.brighten(2)]).mode('lab').correctLightness(true).colors(total);
199
  }},
200
+ { key: 'diverging', title: 'Diverging', desc: 'For <strong>centered ranges</strong> with <strong>two extremes</strong> around a <strong>baseline</strong>. (e.g., negatives/positives)', generator: (baseHex, count) => {
201
+ const total = Math.max(6, Math.min(10, count || 6));
202
  const baseH = chroma(baseHex).get('hsl.h');
203
  const compH = (baseH + 180) % 360;
204
  const left = chroma.hsl(baseH, 0.75, 0.55);
205
  const right = chroma.hsl(compH, 0.75, 0.55);
206
+ return chroma.scale([left, '#ffffff', right]).mode('lch').correctLightness(true).colors(total);
 
 
 
207
  }}
208
  ];
209
 
 
211
  try { return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); } catch { return ''; }
212
  };
213
 
214
+ const getDesiredCount = () => {
215
+ const input = document.getElementById('color-count');
216
+ let v = input ? parseInt(input.value, 10) : 6;
217
+ if (Number.isNaN(v)) v = 6;
218
+ return Math.max(6, Math.min(10, v));
219
+ };
220
+
221
  const render = () => {
222
  const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
223
  const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
 
227
  grid.innerHTML = '';
228
  const css = getCssPrimary();
229
  const baseHex = css && /^#|rgb|hsl/i.test(css) ? chroma(css).hex() : '#E889AB';
230
+ const count = getDesiredCount();
231
 
232
  const html = cards.map((c) => {
233
+ const colors = c.generator(baseHex, count).slice(0, count);
234
  const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
235
  return `
236
  <div class="palette-card" data-colors="${colors.join(',')}">
 
244
  </button>
245
  </div>
246
  <div class="palette-card__actions"></div>
247
+ <div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
248
  </div>
249
  `;
250
  }).join('');
251
  grid.innerHTML = html;
252
  };
253
 
254
+ const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
255
  const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
256
  const clearCbClasses = () => {
257
  const rootEl = document.documentElement;
 
274
  select.addEventListener('change', () => applyCbClass(select.value));
275
  };
276
 
277
+ const setupCountControl = () => {
278
+ const input = document.getElementById('color-count');
279
+ const out = document.getElementById('color-count-out');
280
+ if (!input) return;
281
+ const sync = () => { if (out) out.textContent = String(getDesiredCount()); render(); };
282
+ input.addEventListener('input', sync);
283
+ try { if (out) out.textContent = String(getDesiredCount()); } catch {}
284
+ };
285
+
286
  let copyDelegationSetup = false;
287
  const setupCopyDelegation = () => {
288
  if (copyDelegationSetup) return;
 
310
  };
311
 
312
  const bootstrap = () => {
 
313
  setupCbSim();
314
+ setupCountControl();
315
+ render();
316
  setupCopyDelegation();
317
  const mo = new MutationObserver(() => render());
318
  mo.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
app/src/env.d.ts DELETED
@@ -1,9 +0,0 @@
1
- /// <reference path="../.astro/types.d.ts" />
2
- /// <reference types="vite/client" />
3
-
4
- declare module '*.png?url' {
5
- const src: string;
6
- export default src;
7
- }
8
-
9
- // (Global window typings for Plotly/D3 are intentionally omitted; components handle typing inline.)