thibaud frere commited on
Commit
7914ed2
·
1 Parent(s): 2da6ea7
README.md CHANGED
@@ -12,18 +12,19 @@ thumbnail: https://huggingface.co/spaces/tfrere/research-paper-template/thumb.jp
12
 
13
  ## Find recent duplicated Spaces
14
 
15
- This repository includes a small utility to list public Spaces created in the last N days that were duplicated from a given source Space.
16
 
17
- Prerequisites:
18
 
19
  ```bash
20
- pip install huggingface_hub requests
 
21
  ```
22
 
23
  Usage:
24
 
25
  ```bash
26
- python app/scripts/find_duplicated_spaces.py --source owner/space-name --days 14
27
  ```
28
 
29
  Options:
@@ -40,11 +41,11 @@ Examples:
40
  export HF_TOKEN=hf_xxx
41
 
42
  # Find Spaces duplicated from tfrere/my-space in the last 14 days
43
- python app/scripts/find_duplicated_spaces.py --source tfrere/my-space
44
 
45
  # Use a 7-day window and explicit token
46
- python app/scripts/find_duplicated_spaces.py --source tfrere/my-space --days 7 --token $HF_TOKEN
47
  ```
48
 
49
- The script first checks card metadata (e.g., `duplicated_from`) and optionally falls back to parsing the README frontmatter for robustness.
50
 
 
12
 
13
  ## Find recent duplicated Spaces
14
 
15
+ This repository includes a small Poetry tool under `tools/duplicated-spaces` to list public Spaces created in the last N days that were duplicated from a given source Space.
16
 
17
+ Setup:
18
 
19
  ```bash
20
+ cd tools/duplicated-spaces
21
+ poetry install --no-root
22
  ```
23
 
24
  Usage:
25
 
26
  ```bash
27
+ poetry run find-duplicated-spaces --source owner/space-name --days 14
28
  ```
29
 
30
  Options:
 
41
  export HF_TOKEN=hf_xxx
42
 
43
  # Find Spaces duplicated from tfrere/my-space in the last 14 days
44
+ poetry run find-duplicated-spaces --source tfrere/my-space
45
 
46
  # Use a 7-day window and explicit token
47
+ poetry run find-duplicated-spaces --source tfrere/my-space --days 7 --token $HF_TOKEN
48
  ```
49
 
50
+ The tool first checks card metadata (e.g., `duplicated_from`) and optionally falls back to parsing the README frontmatter for robustness.
51
 
app/src/components/ColorPicker.astro CHANGED
@@ -82,7 +82,27 @@
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
- const getName = (hex) => { const h = hexToHsl(hex).h || 0; const labels=['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta']; const idx=Math.round(((h%360)/360)*(labels.length-1)); return labels[idx]; };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  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); };
87
  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; };
88
  const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => { if (sourceId === instanceId) return; updateUI(hue, adjusting); });
 
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); });
app/src/content/chapters/vibe-coding-charts.mdx CHANGED
@@ -1,7 +1,10 @@
1
  import HtmlEmbed from '../../components/HtmlEmbed.astro';
 
2
 
3
  ## Vibe coding charts
4
 
 
 
5
  ### Prompt
6
 
7
  This page explains how to use the directives to author D3 charts as self‑contained HTML fragments.
@@ -62,7 +65,9 @@ They can be found in the `app/src/content/embeds` folder and you can also use th
62
  ---
63
  <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>`}/>
64
  ---
65
- <HtmlEmbed src="d3-pie.html" title="d3-pie: Pie charts by category" align="center" frameless 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>' />
 
 
66
  ---
67
  <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" />
68
  ---
 
1
  import HtmlEmbed from '../../components/HtmlEmbed.astro';
2
+ import Note from '../../components/Note.astro';
3
 
4
  ## Vibe coding charts
5
 
6
+ <Note emoji="⚠️" variant="danger">This is a work in progress. It may change quickly.</Note>
7
+
8
  ### Prompt
9
 
10
  This page explains how to use the directives to author D3 charts as self‑contained HTML fragments.
 
65
  ---
66
  <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>`}/>
67
  ---
68
+ <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>' />
69
+ ---
70
+ <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.'} />
71
  ---
72
  <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" />
73
  ---
app/src/content/embeds/d3-pie-quad.html ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-pie-quad"></div>
2
+ <style>
3
+ /* Layout piloté par container queries (par rapport au parent) */
4
+ .d3-pie-quad { container-type: inline-size; }
5
+ .d3-pie-quad .legend { width: 80%;margin: 0 auto; font-size: 12px; line-height: 1.35; color: var(--text-color); }
6
+ .d3-pie-quad .legend { margin-bottom: 32px; }
7
+ .d3-pie-quad .legend .items { display:flex; flex-wrap:wrap; gap:8px 14px; align-items:center; justify-content:center; }
8
+ .d3-pie-quad .legend .item { display:flex; align-items:center; gap:8px; white-space:nowrap; }
9
+ .d3-pie-quad .legend .swatch { width:14px; height:14px; border-radius:3px; display:inline-block; border: 1px solid var(--border-color); }
10
+ .d3-pie-quad .legend .title { display:block; text-align:center; font-weight:800; margin-bottom:6px; }
11
+ .d3-pie-quad .caption { font-size: 14px; font-weight: 800; fill: var(--text-color); }
12
+ .d3-pie-quad .caption-subtitle { font-size: 11px; font-weight: 400; fill: var(--muted-color); }
13
+ .d3-pie-quad .nodata { font-size: 12px; fill: var(--muted-color); }
14
+ /* Ghost legend items when hovering slices */
15
+ .d3-pie-quad.hovering .legend .item.ghost { opacity: .35; }
16
+ .d3-pie-quad .slice-label { font-size: 11px; font-weight: 700; fill: var(--text-color); paint-order: stroke; stroke: var(--transparent-page-contrast); stroke-width: 3px; }
17
+ /* Effet ghost synchronisé */
18
+ .d3-pie-quad .slice {
19
+ transition: opacity .15s ease;
20
+ }
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;
28
+ justify-content: center;
29
+ align-items: flex-start;
30
+ gap: 12px 20px;
31
+ margin-top: 4px;
32
+ margin-left: auto;
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) {
54
+ .d3-pie-quad .plots-grid { width: 740px; }
55
+ }
56
+ @container (max-width: 739.98px) {
57
+ .d3-pie-quad .plots-grid { width: 100%; }
58
+ } */
59
+ @media (max-width: 500px) {
60
+ .d3-pie-quad .pie-cell { flex: 0 0 100%; }
61
+ }
62
+ /* Tooltip styling aligned with filters-quad */
63
+ .d3-pie-quad .d3-tooltip {
64
+ z-index: var(--z-elevated);
65
+ backdrop-filter: saturate(1.12) blur(8px);
66
+ }
67
+ .d3-pie-quad .d3-tooltip__inner {
68
+ display: flex;
69
+ flex-direction: column;
70
+ gap: 6px;
71
+ min-width: 220px;
72
+ }
73
+ .d3-pie-quad .d3-tooltip__inner > div:first-child {
74
+ font-weight: 800;
75
+ letter-spacing: 0.1px;
76
+ margin-bottom: 0;
77
+ }
78
+ .d3-pie-quad .d3-tooltip__inner > div:nth-child(2) {
79
+ font-size: 11px;
80
+ color: var(--muted-color);
81
+ display: block;
82
+ margin-top: -4px;
83
+ margin-bottom: 2px;
84
+ letter-spacing: 0.1px;
85
+ }
86
+ .d3-pie-quad .d3-tooltip__inner > div:nth-child(n+3) {
87
+ padding-top: 6px;
88
+ border-top: 1px solid var(--border-color);
89
+ }
90
+ .d3-pie-quad .d3-tooltip__color-dot {
91
+ display: inline-block;
92
+ width: 12px;
93
+ height: 12px;
94
+ border-radius: 3px;
95
+ border: 1px solid var(--border-color);
96
+ }
97
+ </style>
98
+ <script>
99
+ (() => {
100
+ const THIS_SCRIPT = document.currentScript;
101
+ const ensureD3 = (cb) => {
102
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
103
+ let s = document.getElementById('d3-cdn-script');
104
+ 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); }
105
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
106
+ s.addEventListener('load', onReady, { once: true });
107
+ if (window.d3) onReady();
108
+ };
109
+
110
+ const bootstrap = () => {
111
+ const scriptEl = THIS_SCRIPT;
112
+ const host = scriptEl && scriptEl.parentElement;
113
+ let container = null;
114
+ if (host && host.querySelector) {
115
+ container = host.querySelector('.d3-pie-quad');
116
+ }
117
+ if (!container) {
118
+ let sib = scriptEl && scriptEl.previousElementSibling;
119
+ while (sib && !(sib.classList && sib.classList.contains('d3-pie-quad'))) {
120
+ sib = sib.previousElementSibling;
121
+ }
122
+ container = sib || document.querySelector('.d3-pie-quad');
123
+ }
124
+ if (!container) return;
125
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
126
+
127
+ // Tooltip
128
+ container.style.position = container.style.position || 'relative';
129
+ let tip = container.querySelector('.d3-tooltip'); let tipInner;
130
+ if (!tip) {
131
+ tip = document.createElement('div'); tip.className = 'd3-tooltip';
132
+ Object.assign(tip.style, {
133
+ position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none',
134
+ padding:'10px 12px', borderRadius:'12px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)',
135
+ 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',
136
+ zIndex: 'var(--z-elevated)', backdropFilter: 'saturate(1.12) blur(8px)'
137
+ });
138
+ tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip);
139
+ } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
140
+
141
+ // HTML scaffolding: legend and plots grid as HTML; only pies are SVG
142
+ const legendHost = document.createElement('div'); legendHost.className = 'legend'; container.appendChild(legendHost);
143
+ const plotsHost = document.createElement('div'); plotsHost.className = 'plots-grid'; container.appendChild(plotsHost);
144
+
145
+ // Metrics (order and labels as in the Python script)
146
+ const METRICS = [
147
+ { key:'answer_total_tokens', name:'Answer Tokens', title:'Weighted by ', letter:'a' },
148
+ { key:'total_samples', name:'Number of Samples', title:'Weighted by ', letter:'b' },
149
+ { key:'total_turns', name:'Number of Turns', title:'Weighted by ', letter:'c' },
150
+ { key:'total_images', name:'Number of Images', title:'Weighted by ', letter:'d' }
151
+ ];
152
+
153
+ // CSV: load from public path
154
+ const CSV_PATHS = [
155
+ '/data/vision.csv'
156
+ ];
157
+
158
+ const fetchFirstAvailable = async (paths) => {
159
+ for (const p of paths) {
160
+ try {
161
+ const res = await fetch(p, { cache: 'no-cache' });
162
+ if (res.ok) { return await res.text(); }
163
+ } catch (_) { /* try next */ }
164
+ }
165
+ throw new Error('CSV not found: vision.csv');
166
+ };
167
+
168
+ const parseCsv = (text) => d3.csvParse(text, (d) => ({
169
+ subset_name: (d['subset_name']||'').trim(),
170
+ eagle_cathegory: (d['eagle_cathegory']||'').trim(),
171
+ answer_total_tokens: +((d['answer_total_tokens']||'0').toString().trim()) || 0,
172
+ total_samples: +((d['total_samples']||'0').toString().trim()) || 0,
173
+ total_turns: +((d['total_turns']||'0').toString().trim()) || 0,
174
+ total_images: +((d['total_images']||'0').toString().trim()) || 0
175
+ }));
176
+
177
+ // Layout
178
+ let width=800; const margin = { top: 8, right: 24, bottom: 0, left: 24 };
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;
189
+ return { innerWidth: width - margin.left - margin.right };
190
+ };
191
+
192
+ function renderLegend(categories, colorOf){
193
+ legendHost.style.display = 'flex';
194
+ legendHost.style.alignItems = 'center';
195
+ legendHost.style.justifyContent = 'center';
196
+ legendHost.innerHTML = `<div class="items">${categories.map(c => `<div class="item" data-category="${c}"><span class=\"swatch\" style=\"background:${colorOf(c)}\"></span><span style=\"font-weight:500\">${c}</span></div>`).join('')}</div>`;
197
+ }
198
+
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(_) {}
206
+ return (d3.schemeTableau10 ? d3.schemeTableau10.slice(0, n) : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'].slice(0, n));
207
+ };
208
+ const color = d3.scaleOrdinal().domain(categories).range(getCatColors(categories.length));
209
+ const colorOf = (cat) => color(cat || 'Unknown');
210
+
211
+ // Clear plots grid
212
+ plotsHost.innerHTML = '';
213
+
214
+ // Légende au-dessus, centrée
215
+ renderLegend(categories, colorOf);
216
+
217
+ // Rayon fixé selon la largeur cible d'une cellule (gérée par CSS)
218
+ const CELL_BASIS = 360; // doit correspondre à .pie-cell { flex-basis }
219
+ const radius = Math.max(80, Math.min(120, Math.floor(CELL_BASIS * 0.42)));
220
+ const innerR = Math.round(radius * DONUT_INNER_RATIO);
221
+ // Placement géré par CSS; ici on ne fait que l'espacement vertical minimal
222
+ plotsHost.style.position = 'relative';
223
+ plotsHost.style.marginTop = (TOP_OFFSET) + 'px';
224
+
225
+ const pie = d3.pie().sort(null).value(d => d.value).padAngle(0.02);
226
+ const arc = d3.arc().innerRadius(innerR).outerRadius(radius).cornerRadius(3);
227
+ const arcLabel = d3.arc().innerRadius((innerR + radius) / 2).outerRadius((innerR + radius) / 2);
228
+
229
+ // Légende déjà rendue au-dessus
230
+
231
+ METRICS.forEach((metric, idx) => {
232
+ // Aggregate by category
233
+ const totals = new Map(); categories.forEach(c => totals.set(c, 0));
234
+ rows.forEach(r => { totals.set(r.eagle_cathegory, totals.get(r.eagle_cathegory) + (r[metric.key] || 0)); });
235
+ const values = categories.map(c => ({ category: c, value: totals.get(c) || 0 }));
236
+ const nonZeroValues = values.filter(v => (v.value || 0) > 0);
237
+ const totalSum = d3.sum(nonZeroValues, d => d.value);
238
+
239
+ // Create HTML cell container
240
+ const cell = document.createElement('div');
241
+ cell.className = 'pie-cell';
242
+ cell.style.width = (radius * 2) + 'px';
243
+ cell.style.height = (radius * 2 + SVG_VPAD * 2 + CAPTION_GAP + 24) + 'px';
244
+ cell.style.display = 'flex';
245
+ cell.style.flexDirection = 'column';
246
+ cell.style.alignItems = 'center';
247
+ cell.style.justifyContent = 'flex-start';
248
+ plotsHost.appendChild(cell);
249
+
250
+ // SVG pie inside cell
251
+ const svg = d3.select(cell).append('svg').attr('width', radius * 2).attr('height', radius * 2 + SVG_VPAD * 2).style('display','block');
252
+ const gCell = svg.append('g').attr('transform', `translate(${radius},${radius + SVG_VPAD})`);
253
+
254
+ if (!totalSum || totalSum <= 0 || nonZeroValues.length === 0) {
255
+ gCell.append('text').attr('class','nodata').attr('text-anchor','middle').attr('dy','0').text('No data for this metric');
256
+ } else {
257
+ const data = pie(nonZeroValues);
258
+ const percent = (v) => (v / totalSum) * 100;
259
+
260
+ // Slices
261
+ const slices = gCell.selectAll('path.slice').data(data).enter().append('path').attr('class','slice')
262
+ .attr('d', arc)
263
+ .attr('fill', d => colorOf(d.data.category))
264
+ .attr('stroke', 'var(--surface-bg)')
265
+ .attr('stroke-width', 1.2)
266
+ .attr('data-category', d => d.data.category)
267
+ .on('mouseenter', function(ev, d){
268
+ const hoveredCategory = d.data.category;
269
+ d3.select(container).classed('hovering', true);
270
+ d3.select(container).selectAll('path.slice').classed('ghost', s => (s.data && s.data.category) !== hoveredCategory);
271
+ // Ghost legend items that are not hovered
272
+ d3.select(legendHost).selectAll('.item').classed('ghost', function(){ return this.dataset && this.dataset.category !== hoveredCategory; });
273
+ d3.select(this).attr('stroke', 'rgba(0,0,0,0.85)').attr('stroke-width', 1);
274
+ const p = percent(d.data.value);
275
+ const catColor = colorOf(d.data.category);
276
+ let html = `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${catColor}\"></span><strong>${d.data.category}</strong></div>`;
277
+ html += `<div>${metric.name}</div>`;
278
+ html += `<div style="display:flex;align-items:center;gap:6px;white-space:nowrap;"><strong>Value</strong><span style="margin-left:auto;text-align:right;">${d.data.value.toLocaleString()}</span></div>`;
279
+ /* Share row removed per request */
280
+ tipInner.innerHTML = html;
281
+ tip.style.opacity = '1';
282
+ })
283
+ .on('mousemove', function(ev){
284
+ 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)`;
285
+ })
286
+ .on('mouseleave', function(){
287
+ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)';
288
+ d3.select(container).classed('hovering', false);
289
+ d3.select(container).selectAll('path.slice').classed('ghost', false);
290
+ d3.select(legendHost).selectAll('.item').classed('ghost', false);
291
+ d3.select(this).attr('stroke','var(--surface-bg)');
292
+ });
293
+
294
+ // Percentage labels (>= 3%)
295
+ gCell.selectAll('text.slice-label').data(data.filter(d => percent(d.data.value) >= 3)).enter()
296
+ .append('text').attr('class','slice-label').style('pointer-events','none')
297
+ .attr('transform', d => `translate(${arcLabel.centroid(d)})`)
298
+ .attr('text-anchor','middle')
299
+ .text(d => `${percent(d.data.value).toFixed(1)}%`);
300
+ }
301
+
302
+ // HTML captions under the SVG (keep design)
303
+ const subtitleEl = document.createElement('div'); subtitleEl.className = 'caption-subtitle'; subtitleEl.textContent = metric.title; subtitleEl.style.textAlign = 'center'; cell.appendChild(subtitleEl);
304
+ const titleEl = document.createElement('div'); titleEl.className = 'caption'; titleEl.textContent = metric.name; titleEl.style.textAlign = 'center'; cell.appendChild(titleEl);
305
+ });
306
+
307
+ // Container height flows naturally with HTML; nothing to do
308
+
309
+ // Reset global hover/ghost when leaving the plots area
310
+ plotsHost.onmouseleave = () => {
311
+ tip.style.opacity='0';
312
+ tip.style.transform='translate(-9999px, -9999px)';
313
+ d3.select(container).classed('hovering', false);
314
+ d3.select(container).selectAll('path.slice').classed('ghost', false);
315
+ d3.select(legendHost).selectAll('.item').classed('ghost', false);
316
+ };
317
+ }
318
+
319
+ async function init(){
320
+ try {
321
+ const text = await fetchFirstAvailable(CSV_PATHS);
322
+ const rows = parseCsv(text);
323
+ drawPies(rows);
324
+
325
+ // Resize handling
326
+ const rerender = () => drawPies(rows);
327
+ if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); }
328
+ else { window.addEventListener('resize', rerender); }
329
+ } catch (err) {
330
+ const pre = document.createElement('pre'); pre.textContent = (err && err.message) ? err.message : String(err);
331
+ pre.style.color = 'var(--danger, #b00020)'; pre.style.fontSize = '12px'; pre.style.whiteSpace = 'pre-wrap';
332
+ container.appendChild(pre);
333
+ }
334
+ }
335
+
336
+ init();
337
+ };
338
+
339
+ if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
340
+ })();
341
+ </script>
342
+
343
+
344
+
345
+
346
+
347
+
app/src/content/embeds/d3-pie.html CHANGED
@@ -1,345 +1,154 @@
1
  <div class="d3-pie"></div>
2
  <style>
3
- /* Layout piloté par container queries (par rapport au parent) */
4
- .d3-pie { container-type: inline-size; }
5
- .d3-pie .legend { width: 80%;margin: 0 auto; font-size: 12px; line-height: 1.35; color: var(--text-color); }
6
- .d3-pie .legend { margin-bottom: 32px; }
7
- .d3-pie .legend .items { display:flex; flex-wrap:wrap; gap:8px 14px; align-items:center; justify-content:center; }
8
- .d3-pie .legend .item { display:flex; align-items:center; gap:8px; white-space:nowrap; }
9
- .d3-pie .legend .swatch { width:14px; height:14px; border-radius:3px; display:inline-block; border: 1px solid var(--border-color); }
10
- .d3-pie .legend .title { display:block; text-align:center; font-weight:800; margin-bottom:6px; }
11
- .d3-pie .caption { font-size: 14px; font-weight: 800; fill: var(--text-color); }
12
- .d3-pie .caption-subtitle { font-size: 11px; font-weight: 400; fill: var(--muted-color); }
13
- .d3-pie .nodata { font-size: 12px; fill: var(--muted-color); }
14
  /* Ghost legend items when hovering slices */
15
  .d3-pie.hovering .legend .item.ghost { opacity: .35; }
 
 
 
 
16
  .d3-pie .slice-label { font-size: 11px; font-weight: 700; fill: var(--text-color); paint-order: stroke; stroke: var(--transparent-page-contrast); stroke-width: 3px; }
17
- /* Effet ghost synchronisé */
18
- .d3-pie .slice {
19
- transition: opacity .15s ease;
20
- }
21
- .d3-pie.hovering .slice.ghost {
22
- opacity: .25;
23
- }
24
- /* Layout HTML (pas JS) pour la grille et les cellules */
25
- .d3-pie .plots-grid {
26
- display: flex;
27
- flex-wrap: wrap;
28
- justify-content: center;
29
- align-items: flex-start;
30
- gap: 12px 20px;
31
- margin-top: 4px;
32
- margin-left: auto;
33
- margin-right: auto;
34
- width: 100%;
35
- }
36
- /* Par défaut (flux ~1280): 2 colonnes centrées */
37
- .content-grid .d3-pie .plots-grid { width: 100%; }
38
- .content-grid .d3-pie .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 .plots-grid,
41
- .full-width .d3-pie .plots-grid { width: 100%; }
42
- .wide .d3-pie .pie-cell,
43
- .full-width .d3-pie .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 .plots-grid { width: min(740px, 100%); }
46
- .d3-pie .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) {
54
- .d3-pie .plots-grid { width: 740px; }
55
- }
56
- @container (max-width: 739.98px) {
57
- .d3-pie .plots-grid { width: 100%; }
58
- } */
59
- @media (max-width: 500px) {
60
- .d3-pie .pie-cell { flex: 0 0 100%; }
61
- }
62
- /* Tooltip styling aligned with filters-quad */
63
- .d3-pie .d3-tooltip {
64
- z-index: var(--z-elevated);
65
- backdrop-filter: saturate(1.12) blur(8px);
66
- }
67
- .d3-pie .d3-tooltip__inner {
68
- display: flex;
69
- flex-direction: column;
70
- gap: 6px;
71
- min-width: 220px;
72
- }
73
- .d3-pie .d3-tooltip__inner > div:first-child {
74
- font-weight: 800;
75
- letter-spacing: 0.1px;
76
- margin-bottom: 0;
77
- }
78
- .d3-pie .d3-tooltip__inner > div:nth-child(2) {
79
- font-size: 11px;
80
- color: var(--muted-color);
81
- display: block;
82
- margin-top: -4px;
83
- margin-bottom: 2px;
84
- letter-spacing: 0.1px;
85
- }
86
- .d3-pie .d3-tooltip__inner > div:nth-child(n+3) {
87
- padding-top: 6px;
88
- border-top: 1px solid var(--border-color);
89
- }
90
- .d3-pie .d3-tooltip__color-dot {
91
- display: inline-block;
92
- width: 12px;
93
- height: 12px;
94
- border-radius: 3px;
95
- border: 1px solid var(--border-color);
96
- }
97
  </style>
98
  <script>
99
  (() => {
100
- const THIS_SCRIPT = document.currentScript;
101
  const ensureD3 = (cb) => {
102
  if (window.d3 && typeof window.d3.select === 'function') return cb();
103
  let s = document.getElementById('d3-cdn-script');
104
- 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); }
105
  const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
106
- s.addEventListener('load', onReady, { once: true });
107
- if (window.d3) onReady();
108
  };
109
 
110
  const bootstrap = () => {
111
- const scriptEl = THIS_SCRIPT;
112
- const host = scriptEl && scriptEl.parentElement;
113
- let container = null;
114
- if (host && host.querySelector) {
115
- container = host.querySelector('.d3-pie');
116
- }
117
- if (!container) {
118
- let sib = scriptEl && scriptEl.previousElementSibling;
119
- while (sib && !(sib.classList && sib.classList.contains('d3-pie'))) {
120
- sib = sib.previousElementSibling;
121
- }
122
- container = sib || document.querySelector('.d3-pie');
123
  }
124
  if (!container) return;
125
- if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
126
 
127
- // Tooltip
128
  container.style.position = container.style.position || 'relative';
129
  let tip = container.querySelector('.d3-tooltip'); let tipInner;
130
- if (!tip) {
131
- tip = document.createElement('div'); tip.className = 'd3-tooltip';
132
- Object.assign(tip.style, {
133
- position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none',
134
- padding:'10px 12px', borderRadius:'12px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)',
135
- 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',
136
- zIndex: 'var(--z-elevated)', backdropFilter: 'saturate(1.12) blur(8px)'
137
- });
138
- tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip);
139
- } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
140
 
141
- // HTML scaffolding: legend and plots grid as HTML; only pies are SVG
142
- const legendHost = document.createElement('div'); legendHost.className = 'legend'; container.appendChild(legendHost);
143
- const plotsHost = document.createElement('div'); plotsHost.className = 'plots-grid'; container.appendChild(plotsHost);
144
 
145
- // Metrics (order and labels as in the Python script)
146
- const METRICS = [
147
- { key:'answer_total_tokens', name:'Answer Tokens', title:'Weighted by ', letter:'a' },
148
- { key:'total_samples', name:'Number of Samples', title:'Weighted by ', letter:'b' },
149
- { key:'total_turns', name:'Number of Turns', title:'Weighted by ', letter:'c' },
150
- { key:'total_images', name:'Number of Images', title:'Weighted by ', letter:'d' }
151
- ];
152
-
153
- // CSV: load from public path
154
- const CSV_PATHS = [
155
- '/data/vision.csv'
156
- ];
157
 
 
158
  const fetchFirstAvailable = async (paths) => {
159
- for (const p of paths) {
160
- try {
161
- const res = await fetch(p, { cache: 'no-cache' });
162
- if (res.ok) { return await res.text(); }
163
- } catch (_) { /* try next */ }
164
- }
165
  throw new Error('CSV not found: vision.csv');
166
  };
 
 
 
 
 
 
167
 
168
- const parseCsv = (text) => d3.csvParse(text, (d) => ({
169
- subset_name: (d['subset_name']||'').trim(),
170
- eagle_cathegory: (d['eagle_cathegory']||'').trim(),
171
- answer_total_tokens: +((d['answer_total_tokens']||'0').toString().trim()) || 0,
172
- total_samples: +((d['total_samples']||'0').toString().trim()) || 0,
173
- total_turns: +((d['total_turns']||'0').toString().trim()) || 0,
174
- total_images: +((d['total_images']||'0').toString().trim()) || 0
175
- }));
176
-
177
- // Layout
178
- let width=800; const margin = { top: 8, right: 24, bottom: 0, left: 24 };
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 .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;
189
- return { innerWidth: width - margin.left - margin.right };
190
- };
191
-
192
- function renderLegend(categories, colorOf){
193
- legendHost.style.display = 'flex';
194
- legendHost.style.alignItems = 'center';
195
- legendHost.style.justifyContent = 'center';
196
- legendHost.innerHTML = `<div class="items">${categories.map(c => `<div class="item" data-category="${c}"><span class="swatch" style="background:${colorOf(c)}"></span><span style="font-weight:500">${c}</span></div>`).join('')}</div>`;
197
  }
198
 
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(_) {}
206
- return (d3.schemeTableau10 ? d3.schemeTableau10.slice(0, n) : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'].slice(0, n));
207
- };
208
- const color = d3.scaleOrdinal().domain(categories).range(getCatColors(categories.length));
209
- const colorOf = (cat) => color(cat || 'Unknown');
210
 
211
- // Clear plots grid
212
- plotsHost.innerHTML = '';
213
 
214
- // Légende au-dessus, centrée
215
- renderLegend(categories, colorOf);
 
216
 
217
- // Rayon fixé selon la largeur cible d'une cellule (gérée par CSS)
218
- const CELL_BASIS = 360; // doit correspondre à .pie-cell { flex-basis }
219
- const radius = Math.max(80, Math.min(120, Math.floor(CELL_BASIS * 0.42)));
220
  const innerR = Math.round(radius * DONUT_INNER_RATIO);
221
- // Placement géré par CSS; ici on ne fait que l'espacement vertical minimal
222
- plotsHost.style.position = 'relative';
223
- plotsHost.style.marginTop = (TOP_OFFSET) + 'px';
224
-
225
- const pie = d3.pie().sort(null).value(d => d.value).padAngle(0.02);
226
  const arc = d3.arc().innerRadius(innerR).outerRadius(radius).cornerRadius(3);
227
- const arcLabel = d3.arc().innerRadius((innerR + radius) / 2).outerRadius((innerR + radius) / 2);
228
-
229
- // Légende déjà rendue au-dessus
230
-
231
- METRICS.forEach((metric, idx) => {
232
- // Aggregate by category
233
- const totals = new Map(); categories.forEach(c => totals.set(c, 0));
234
- rows.forEach(r => { totals.set(r.eagle_cathegory, totals.get(r.eagle_cathegory) + (r[metric.key] || 0)); });
235
- const values = categories.map(c => ({ category: c, value: totals.get(c) || 0 }));
236
- const nonZeroValues = values.filter(v => (v.value || 0) > 0);
237
- const totalSum = d3.sum(nonZeroValues, d => d.value);
238
-
239
- // Create HTML cell container
240
- const cell = document.createElement('div');
241
- cell.className = 'pie-cell';
242
- cell.style.width = (radius * 2) + 'px';
243
- cell.style.height = (radius * 2 + SVG_VPAD * 2 + CAPTION_GAP + 24) + 'px';
244
- cell.style.display = 'flex';
245
- cell.style.flexDirection = 'column';
246
- cell.style.alignItems = 'center';
247
- cell.style.justifyContent = 'flex-start';
248
- plotsHost.appendChild(cell);
249
-
250
- // SVG pie inside cell
251
- const svg = d3.select(cell).append('svg').attr('width', radius * 2).attr('height', radius * 2 + SVG_VPAD * 2).style('display','block');
252
- const gCell = svg.append('g').attr('transform', `translate(${radius},${radius + SVG_VPAD})`);
253
-
254
- if (!totalSum || totalSum <= 0 || nonZeroValues.length === 0) {
255
- gCell.append('text').attr('class','nodata').attr('text-anchor','middle').attr('dy','0').text('No data for this metric');
256
- } else {
257
- const data = pie(nonZeroValues);
258
- const percent = (v) => (v / totalSum) * 100;
259
-
260
- // Slices
261
- const slices = gCell.selectAll('path.slice').data(data).enter().append('path').attr('class','slice')
262
- .attr('d', arc)
263
- .attr('fill', d => colorOf(d.data.category))
264
- .attr('stroke', 'var(--surface-bg)')
265
- .attr('stroke-width', 1.2)
266
- .attr('data-category', d => d.data.category)
267
- .on('mouseenter', function(ev, d){
268
- const hoveredCategory = d.data.category;
269
- d3.select(container).classed('hovering', true);
270
- d3.select(container).selectAll('path.slice').classed('ghost', s => (s.data && s.data.category) !== hoveredCategory);
271
- // Ghost legend items that are not hovered
272
- d3.select(legendHost).selectAll('.item').classed('ghost', function(){ return this.dataset && this.dataset.category !== hoveredCategory; });
273
- d3.select(this).attr('stroke', 'rgba(0,0,0,0.85)').attr('stroke-width', 1);
274
- const p = percent(d.data.value);
275
- const catColor = colorOf(d.data.category);
276
- let html = `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${catColor}\"></span><strong>${d.data.category}</strong></div>`;
277
- html += `<div>${metric.name}</div>`;
278
- html += `<div style="display:flex;align-items:center;gap:6px;white-space:nowrap;"><strong>Value</strong><span style="margin-left:auto;text-align:right;">${d.data.value.toLocaleString()}</span></div>`;
279
- /* Share row removed per request */
280
- tipInner.innerHTML = html;
281
- tip.style.opacity = '1';
282
- })
283
- .on('mousemove', function(ev){
284
- 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)`;
285
- })
286
- .on('mouseleave', function(){
287
- tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)';
288
- d3.select(container).classed('hovering', false);
289
- d3.select(container).selectAll('path.slice').classed('ghost', false);
290
- d3.select(legendHost).selectAll('.item').classed('ghost', false);
291
- d3.select(this).attr('stroke','var(--surface-bg)');
292
- });
293
-
294
- // Percentage labels (>= 3%)
295
- gCell.selectAll('text.slice-label').data(data.filter(d => percent(d.data.value) >= 3)).enter()
296
- .append('text').attr('class','slice-label').style('pointer-events','none')
297
- .attr('transform', d => `translate(${arcLabel.centroid(d)})`)
298
- .attr('text-anchor','middle')
299
- .text(d => `${percent(d.data.value).toFixed(1)}%`);
300
- }
301
-
302
- // HTML captions under the SVG (keep design)
303
- const subtitleEl = document.createElement('div'); subtitleEl.className = 'caption-subtitle'; subtitleEl.textContent = metric.title; subtitleEl.style.textAlign = 'center'; cell.appendChild(subtitleEl);
304
- const titleEl = document.createElement('div'); titleEl.className = 'caption'; titleEl.textContent = metric.name; titleEl.style.textAlign = 'center'; cell.appendChild(titleEl);
305
- });
306
-
307
- // Container height flows naturally with HTML; nothing to do
308
-
309
- // Reset global hover/ghost when leaving the plots area
310
- plotsHost.onmouseleave = () => {
311
- tip.style.opacity='0';
312
- tip.style.transform='translate(-9999px, -9999px)';
313
- d3.select(container).classed('hovering', false);
314
- d3.select(container).selectAll('path.slice').classed('ghost', false);
315
- d3.select(legendHost).selectAll('.item').classed('ghost', false);
316
- };
317
  }
318
 
319
- async function init(){
320
  try {
321
- const text = await fetchFirstAvailable(CSV_PATHS);
322
  const rows = parseCsv(text);
323
- drawPies(rows);
324
-
325
- // Resize handling
326
- const rerender = () => drawPies(rows);
327
- if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); }
328
- else { window.addEventListener('resize', rerender); }
329
- } catch (err) {
330
- const pre = document.createElement('pre'); pre.textContent = (err && err.message) ? err.message : String(err);
331
- pre.style.color = 'var(--danger, #b00020)'; pre.style.fontSize = '12px'; pre.style.whiteSpace = 'pre-wrap';
332
- container.appendChild(pre);
333
  }
334
- }
335
-
336
- init();
337
  };
338
 
339
- if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
340
  })();
341
  </script>
342
 
343
-
344
-
345
-
 
1
  <div class="d3-pie"></div>
2
  <style>
3
+ .d3-pie { position: relative; }
4
+ .d3-pie .legend { display:flex; flex-direction:column; align-items:flex-start; gap:6px; margin: 8px 0 0 0; font-size:12px; color: var(--text-color); }
5
+ .d3-pie .legend .legend-title { font-size:12px; font-weight:700; color: var(--text-color); }
6
+ .d3-pie .legend .items { display:flex; flex-wrap:wrap; gap:8px 14px; }
7
+ .d3-pie .legend .item { display:inline-flex; align-items:center; gap:6px; white-space:nowrap; }
8
+ .d3-pie .legend .swatch { width:14px; height:14px; border-radius:3px; border:1px solid var(--border-color); }
 
 
 
 
 
9
  /* Ghost legend items when hovering slices */
10
  .d3-pie.hovering .legend .item.ghost { opacity: .35; }
11
+ /* Ghost effect on slices */
12
+ .d3-pie .slice { transition: opacity .15s ease; }
13
+ .d3-pie.hovering .slice.ghost { opacity: .25; }
14
+ /* Labels with contrast liseret */
15
  .d3-pie .slice-label { font-size: 11px; font-weight: 700; fill: var(--text-color); paint-order: stroke; stroke: var(--transparent-page-contrast); stroke-width: 3px; }
16
+ .d3-pie .d3-tooltip { position:absolute; top:0; left:0; transform:translate(-9999px,-9999px); pointer-events:none; padding:8px 10px; border-radius:8px; font-size:12px; line-height:1.35; border:1px solid var(--border-color); background:var(--surface-bg); color:var(--text-color); box-shadow:0 4px 24px rgba(0,0,0,.18); opacity:0; transition:opacity .12s ease; }
17
+ .d3-pie .d3-tooltip { z-index: var(--z-elevated); backdrop-filter: saturate(1.12) blur(8px); }
18
+ .d3-pie .d3-tooltip__inner { display:flex; flex-direction:column; gap:6px; min-width: 220px; text-align: left; }
19
+ .d3-pie .d3-tooltip__inner > div:first-child { font-weight: 800; letter-spacing: 0.1px; margin-bottom: 0; }
20
+ .d3-pie .d3-tooltip__inner > div:nth-child(2) { font-size: 11px; color: var(--muted-color); display: block; margin-top: -4px; margin-bottom: 2px; letter-spacing: 0.1px; }
21
+ .d3-pie .d3-tooltip__inner > div:nth-child(n+3) { padding-top: 6px; border-top: 1px solid var(--border-color); }
22
+ .d3-pie .d3-tooltip .swatch { width:12px; height:12px; border-radius:3px; border:1px solid var(--border-color); display:inline-block; margin-right:6px; }
23
+ .d3-pie .chart-card { background: var(--surface-bg); border: 1px solid var(--border-color); border-radius: 10px; padding: 8px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  </style>
25
  <script>
26
  (() => {
 
27
  const ensureD3 = (cb) => {
28
  if (window.d3 && typeof window.d3.select === 'function') return cb();
29
  let s = document.getElementById('d3-cdn-script');
30
+ 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); }
31
  const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
32
+ s.addEventListener('load', onReady, { once:true }); if (window.d3) onReady();
 
33
  };
34
 
35
  const bootstrap = () => {
36
+ const scriptEl = document.currentScript;
37
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
38
+ if (!(container && container.classList && container.classList.contains('d3-pie'))){
39
+ const cs = Array.from(document.querySelectorAll('.d3-pie')).filter(el => !(el.dataset && el.dataset.mounted==='true'));
40
+ container = cs[cs.length-1] || null;
 
 
 
 
 
 
 
41
  }
42
  if (!container) return;
43
+ if (container.dataset) { if (container.dataset.mounted==='true') return; container.dataset.mounted='true'; }
44
 
 
45
  container.style.position = container.style.position || 'relative';
46
  let tip = container.querySelector('.d3-tooltip'); let tipInner;
47
+ if (!tip) { tip = document.createElement('div'); tip.className = 'd3-tooltip'; tipInner = document.createElement('div'); tipInner.className='d3-tooltip__inner'; tip.appendChild(tipInner); container.appendChild(tip); } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
 
 
 
 
 
 
 
 
 
48
 
49
+ const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card);
50
+ const legend = document.createElement('div'); legend.className = 'legend'; legend.innerHTML = '<div class="legend-title">Legend</div><div class="items"></div>'; container.appendChild(legend);
 
51
 
52
+ const svg = d3.select(card).append('svg').attr('width','100%').style('display','block');
53
+ const gRoot = svg.append('g');
 
 
 
 
 
 
 
 
 
 
54
 
55
+ const DEFAULT_CSV = '/data/vision.csv';
56
  const fetchFirstAvailable = async (paths) => {
57
+ for (const p of paths) { try { const r = await fetch(p, { cache:'no-cache' }); if (r.ok) return await r.text(); } catch(_){} }
 
 
 
 
 
58
  throw new Error('CSV not found: vision.csv');
59
  };
60
+ function parseCsv(text){
61
+ return d3.csvParse(text, d => ({
62
+ category: (d['eagle_cathegory']||d['category']||'').trim(),
63
+ value: +((d['total_samples']||d['value']||'0').toString().trim()) || 0
64
+ }));
65
+ }
66
 
67
+ let width=800, height=340; const DONUT_INNER_RATIO = 0.6;
68
+ function updateSize(){
69
+ width = container.clientWidth || 800; height = Math.max(240, Math.round(width/3));
70
+ svg.attr('width', width).attr('height', height);
71
+ gRoot.attr('transform', `translate(${width/2},${height/2})`);
72
+ return { inner: Math.min(width, height) * 0.42 };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
 
75
+ function makeLegend(categories, colorOf){
76
+ const items = legend.querySelector('.items'); items.innerHTML = '';
77
+ categories.forEach(name => { const el = document.createElement('span'); el.className='item'; el.dataset.category=name; const sw=document.createElement('span'); sw.className='swatch'; sw.style.background=colorOf(name); const txt=document.createElement('span'); txt.textContent=name; el.appendChild(sw); el.appendChild(txt); items.appendChild(el); });
78
+ }
79
 
80
+ function render(rows){
81
+ const { inner } = updateSize();
82
+ const categories = Array.from(new Set(rows.map(r => r.category || 'Unknown'))).sort();
83
+ const getColors = (n) => { try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function') return window.ColorPalettes.getColors('categorical', n); } catch(_){} return (window.d3 && d3.schemeTableau10) ? d3.schemeTableau10.slice(0, n) : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'].slice(0,n); };
84
+ const palette = getColors(categories.length);
85
+ const color = d3.scaleOrdinal().domain(categories).range(palette);
86
+ const colorOf = (c) => color(c || 'Unknown');
 
87
 
88
+ makeLegend(categories, colorOf);
 
89
 
90
+ const totals = new Map(); categories.forEach(c => totals.set(c, 0)); rows.forEach(r => totals.set(r.category, (totals.get(r.category)||0) + (r.value||0)));
91
+ const values = categories.map(c => ({ category:c, value: totals.get(c)||0 })).filter(d => d.value > 0);
92
+ const sum = d3.sum(values, d=>d.value) || 1;
93
 
94
+ const radius = Math.max(60, Math.min(inner, 120));
 
 
95
  const innerR = Math.round(radius * DONUT_INNER_RATIO);
96
+ const pie = d3.pie().sort(null).value(d=>d.value).padAngle(0.02);
 
 
 
 
97
  const arc = d3.arc().innerRadius(innerR).outerRadius(radius).cornerRadius(3);
98
+ const arcLabel = d3.arc().innerRadius((innerR + radius)/2).outerRadius((innerR + radius)/2);
99
+
100
+ const data = pie(values);
101
+ const slices = gRoot.selectAll('path.slice').data(data, d=>d.data.category);
102
+ slices.enter().append('path').attr('class','slice')
103
+ .attr('fill', d=>colorOf(d.data.category))
104
+ .attr('stroke','var(--surface-bg)')
105
+ .attr('stroke-width',1)
106
+ .attr('data-category', d => d.data.category)
107
+ .on('mouseenter', (ev, d) => {
108
+ const pct = (d.data.value / sum) * 100;
109
+ container.classList.add('hovering');
110
+ gRoot.selectAll('path.slice').classed('ghost', s => (s && s.data && s.data.category) !== d.data.category);
111
+ try { const items = legend.querySelectorAll('.item'); items.forEach(it => it.classList.toggle('ghost', it.dataset.category !== d.data.category)); } catch(_) {}
112
+ const colorSw = colorOf(d.data.category);
113
+ tipInner.innerHTML = `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"swatch\" style=\"background:${colorSw}\"></span><strong>${d.data.category}</strong></div>` +
114
+ `<div>Value</div>` +
115
+ `<div style=\"display:flex;align-items:center;gap:6px;white-space:nowrap;\"><strong>Total</strong><span style=\"margin-left:auto;text-align:right;\">${d.data.value.toLocaleString()} (${pct.toFixed(1)}%)</span></div>`;
116
+ tip.style.opacity='1';
117
+ })
118
+ .on('mousemove', (ev) => { const [mx,my] = d3.pointer(ev, container); tip.style.transform = `translate(${Math.round(mx+12)}px, ${Math.round(my+12)}px)`; })
119
+ .on('mouseleave', () => {
120
+ tip.style.opacity='0'; tip.style.transform='translate(-9999px, -9999px)';
121
+ container.classList.remove('hovering');
122
+ gRoot.selectAll('path.slice').classed('ghost', false);
123
+ try { const items = legend.querySelectorAll('.item'); items.forEach(it => it.classList.remove('ghost')); } catch(_) {}
124
+ })
125
+ .merge(slices)
126
+ .attr('d', arc)
127
+ .attr('fill', d=>colorOf(d.data.category));
128
+ slices.exit().remove();
129
+
130
+ const labels = gRoot.selectAll('text.slice-label').data(data.filter(d => (d.data.value/sum) >= 0.03), d=>d.data.category);
131
+ labels.enter().append('text').attr('class','slice-label').attr('text-anchor','middle')
132
+ .merge(labels)
133
+ .attr('transform', d => `translate(${arcLabel.centroid(d)})`)
134
+ .text(d => `${((d.data.value/sum)*100).toFixed(1)}%`);
135
+ labels.exit().remove();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  }
137
 
138
+ (async () => {
139
  try {
140
+ const text = await fetchFirstAvailable([DEFAULT_CSV, './assets/data/vision.csv', '../assets/data/vision.csv']);
141
  const rows = parseCsv(text);
142
+ render(rows);
143
+ const rerender = () => render(rows);
144
+ if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); } else { window.addEventListener('resize', rerender); }
145
+ } catch (e) {
146
+ const pre = document.createElement('pre'); pre.textContent = (e && e.message) ? e.message : String(e); pre.style.color='var(--danger, #b00020)'; pre.style.fontSize='12px'; pre.style.whiteSpace='pre-wrap'; container.appendChild(pre);
 
 
 
 
 
147
  }
148
+ })();
 
 
149
  };
150
 
151
+ if (document.readyState==='loading'){ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once:true }); } else { ensureD3(bootstrap); }
152
  })();
153
  </script>
154
 
 
 
 
app/src/content/embeds/vibe-code-d3-embeds-directives.md CHANGED
@@ -405,6 +405,52 @@ function makeLegend(seriesNames, colorFor) {
405
  - Axes/labels are legible at small widths.
406
  - Code is easy to skim: clear naming, early returns, short functions.
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  ### 15) Example: small bar chart (structure only)
409
  ```html
410
  <div class="d3-mini-bar"></div>
 
405
  - Axes/labels are legible at small widths.
406
  - Code is easy to skim: clear naming, early returns, short functions.
407
 
408
+ ### 14.1) Agent Checklist (operational)
409
+ - Ensure root: one `<div .d3-xyz>` + scoped `<style>` + IIFE `<script>`
410
+ - Gate mount with `data-mounted` and select closest previous sibling instance
411
+ - Load D3 once via `#d3-cdn-script`; verify `window.d3.select`
412
+ - Colors from `window.ColorPalettes` with CSS variable fallbacks
413
+ - Legend present with visible title “Legend”; HTML-based, not SVG
414
+ - Controls in HTML only; if metric select exists, label text must be “Metric”
415
+ - Tooltip is a single absolute `.d3-tooltip` within the container
416
+ - Data load public-first; implement `fetchFirstAvailable([...])` with `cache:'no-cache'`
417
+ - Read optional HtmlEmbed `data-datafiles` and `data-config` per section 6.1
418
+ - Responsiveness: width from container; `ResizeObserver` fallback to `window.resize`
419
+ - Axis/tick/grid use CSS variables (`--axis-color`, `--tick-color`, `--grid-color`)
420
+ - SVG for marks only; UI/legend/controls in HTML
421
+ - No globals leaked; no external runtime deps besides D3/TFJS when necessary
422
+ - Error path: append small red `<pre>` with a readable message inside container
423
+ - Print-friendly: `svg` width 100%, height responsive, avoid heavy bitmaps
424
+
425
+ ### 14.2) Definition of Done (DoD)
426
+ - Implements root structure and mounting guard
427
+ - Uses `ColorPalettes` (with safe fallback) and CSS variables for theming
428
+ - Legend with title “Legend” and consistent swatch style (14×14, r=3, 1px border)
429
+ - Metric select labelled “Metric” when present; accessible markup (`<label for>`)
430
+ - Tooltip works (show on hover, hide on leave, positioned via `d3.pointer`)
431
+ - Public-first data loading + HtmlEmbed prop support when applicable
432
+ - Responsive: resizes smoothly; axes and grid legible at small widths
433
+ - No console errors; graceful error message on load failures
434
+ - File is self‑contained; no globals; lints pass
435
+
436
+ ### 14.3) Prompt modèle (for the agent)
437
+ ```markdown
438
+ You are implementing a self-contained D3 embed fragment.
439
+ Name: d3-<type>.html (root class .d3-<type>)
440
+ Requirements:
441
+ - One root div + scoped style + IIFE script; no globals
442
+ - UI in HTML (legend+controls), chart primitives in SVG
443
+ - Legend title text exactly “Legend”; swatch 14×14, r=3, 1px border
444
+ - If a select toggles metrics, visible label text exactly “Metric”
445
+ - Colors via window.ColorPalettes (categorical/sequential/diverging), fallback to CSS variables or Tableau10
446
+ - Tooltip: single .d3-tooltip inside container, HTML, positioned via d3.pointer
447
+ - Data loading: try `/data/<file>` first, then `./assets/data/<file>`, `../assets/data/<file>`; implement fetchFirstAvailable(paths)
448
+ - Read optional HtmlEmbed attributes `data-datafiles` and `data-config` if present (see section 6.1)
449
+ - Responsiveness: compute width from container, use ResizeObserver; axis/tick/grid via CSS vars
450
+ - Error handling: append small red <pre> inside container on failure
451
+ Deliver one .html file with only the required elements.
452
+ ```
453
+
454
  ### 15) Example: small bar chart (structure only)
455
  ```html
456
  <div class="d3-mini-bar"></div>
tools/duplicated-spaces/README.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # duplicated-spaces
2
+
3
+ Small Poetry project to list public Spaces created in the last N days that were duplicated from a given source Space.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ cd tools/duplicated-spaces
9
+ poetry install --no-root
10
+ ```
11
+
12
+ Optionally export your token:
13
+
14
+ ```bash
15
+ export HF_TOKEN=hf_xxx
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ poetry run find-duplicated-spaces --source owner/space-name --days 14
22
+ ```
23
+
24
+ Options:
25
+ - `--source`: required. The source Space in the form `owner/space-name`.
26
+ - `--days`: optional. Time window in days (default: 14).
27
+ - `--token`: optional. Your HF token. Defaults to `HF_TOKEN` env var if set.
28
+ - `--no-deep`: optional. Disable README/frontmatter fallback detection.
29
+
30
+ The tool checks card metadata and may fallback to README frontmatter parsing for robustness.
31
+
32
+
tools/duplicated-spaces/duplicated_spaces/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .finder import find_duplicated_spaces
2
+
3
+ __all__ = ["find_duplicated_spaces"]
4
+
5
+
tools/duplicated-spaces/duplicated_spaces/cli.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ from typing import Optional
6
+
7
+ from huggingface_hub import HfApi
8
+
9
+ from .finder import find_duplicated_spaces
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ description="List recent Spaces duplicated from a given Space"
15
+ )
16
+ parser.add_argument(
17
+ "--source",
18
+ required=True,
19
+ help="Source Space in the form 'owner/space-name'",
20
+ )
21
+ parser.add_argument(
22
+ "--days",
23
+ type=int,
24
+ default=14,
25
+ help="Time window in days (default: 14)",
26
+ )
27
+ parser.add_argument(
28
+ "--token",
29
+ default=os.environ.get("HF_TOKEN"),
30
+ help="Hugging Face token (optional). Defaults to HF_TOKEN env var if set.",
31
+ )
32
+ parser.add_argument(
33
+ "--no-deep",
34
+ action="store_true",
35
+ help=(
36
+ "Disable deep detection (README/frontmatter fetch) when card metadata is missing."
37
+ ),
38
+ )
39
+ return parser
40
+
41
+
42
+ def main(argv: Optional[list[str]] = None) -> None:
43
+ parser = build_parser()
44
+ args = parser.parse_args(argv)
45
+
46
+ api = HfApi(token=args.token)
47
+ duplicated = find_duplicated_spaces(
48
+ api=api,
49
+ source=args.source,
50
+ days=args.days,
51
+ deep_detection=not args.no_deep,
52
+ )
53
+
54
+ if duplicated:
55
+ print(
56
+ f"Found {len(duplicated)} Space(s) duplicated from {args.source} in the last {args.days} days:\n"
57
+ )
58
+ for sid in duplicated:
59
+ print(f"https://huggingface.co/spaces/{sid}")
60
+ else:
61
+ print(
62
+ f"No public Spaces duplicated from {args.source} in the last {args.days} days."
63
+ )
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
68
+
69
+
app/scripts/find_duplicated_spaces.py → tools/duplicated-spaces/duplicated_spaces/finder.py RENAMED
@@ -1,22 +1,10 @@
1
- #!/usr/bin/env python3
2
- """
3
- Find Spaces created in the last N days that were duplicated from a given source Space.
4
-
5
- This script uses the public Hugging Face Hub APIs via `huggingface_hub` and optionally
6
- falls back to reading README frontmatter for robustness.
7
-
8
- Usage:
9
- python app/scripts/find_duplicated_spaces.py --source owner/space-name [--days 14] [--token <hf_token>] [--no-deep]
10
 
11
- Notes:
12
- - Comments are in English as requested.
13
- - Chat responses remain in French.
14
  """
15
 
16
- from __future__ import annotations
17
-
18
- import argparse
19
- import os
20
  from datetime import datetime, timedelta, timezone
21
  from typing import Iterable, List, Optional
22
 
@@ -24,39 +12,8 @@ import requests
24
  from huggingface_hub import HfApi
25
 
26
 
27
- def parse_args() -> argparse.Namespace:
28
- parser = argparse.ArgumentParser(
29
- description="List recent Spaces duplicated from a given Space"
30
- )
31
- parser.add_argument(
32
- "--source",
33
- required=True,
34
- help="Source Space in the form 'owner/space-name'",
35
- )
36
- parser.add_argument(
37
- "--days",
38
- type=int,
39
- default=14,
40
- help="Time window in days (default: 14)",
41
- )
42
- parser.add_argument(
43
- "--token",
44
- default=os.environ.get("HF_TOKEN"),
45
- help="Hugging Face token (optional). Defaults to HF_TOKEN env var if set.",
46
- )
47
- parser.add_argument(
48
- "--no-deep",
49
- action="store_true",
50
- help=(
51
- "Disable deep detection (README/frontmatter fetch) when card metadata is missing."
52
- ),
53
- )
54
- return parser.parse_args()
55
-
56
-
57
  def iso_to_datetime(value: str) -> datetime:
58
  """Parse ISO 8601 timestamps returned by the Hub to aware datetime in UTC."""
59
- # Examples: "2024-09-01T12:34:56.789Z" or without microseconds
60
  try:
61
  dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
62
  except ValueError:
@@ -66,7 +23,6 @@ def iso_to_datetime(value: str) -> datetime:
66
 
67
  def readme_frontmatter_duplicated_from(space_id: str) -> Optional[str]:
68
  """Fetch README raw and try to extract duplicated_from from YAML frontmatter."""
69
- # Raw README for Spaces is accessible at /spaces/{id}/raw/README.md
70
  url = f"https://huggingface.co/spaces/{space_id}/raw/README.md"
71
  try:
72
  resp = requests.get(url, timeout=10)
@@ -76,43 +32,31 @@ def readme_frontmatter_duplicated_from(space_id: str) -> Optional[str]:
76
  except requests.RequestException:
77
  return None
78
 
79
- # Very light-weight frontmatter scan to find a line like: duplicated_from: owner/space
80
- # Do not parse full YAML to avoid extra deps.
81
  lines = text.splitlines()
82
  in_frontmatter = False
83
  for line in lines:
84
  if line.strip() == "---":
85
  in_frontmatter = not in_frontmatter
86
- # Stop if we closed the frontmatter without finding the key.
87
  if not in_frontmatter:
88
  break
89
  continue
90
  if in_frontmatter and line.strip().startswith("duplicated_from:"):
91
- # Extract value after colon, trim quotes/spaces
92
  value = line.split(":", 1)[1].strip().strip("'\"")
93
  return value or None
94
  return None
95
 
96
 
97
  def get_recent_spaces(api: HfApi, days: int) -> Iterable:
98
- """Yield Spaces created within the last `days` days, iterating newest first.
99
-
100
- Tries to sort by creation date descending; falls back gracefully if not supported.
101
- """
102
  cutoff = datetime.now(timezone.utc) - timedelta(days=days)
103
-
104
- # Primary attempt: request spaces sorted by creation date (newest first)
105
  try:
106
  spaces_iter = api.list_spaces(full=True, sort="created", direction=-1)
107
  except TypeError:
108
- # Fallback: no sort support in current huggingface_hub; get a reasonably large list
109
- # Note: This may include items older than the cutoff; we'll filter below.
110
  spaces_iter = api.list_spaces(full=True)
111
 
112
  for space in spaces_iter:
113
  created_at_raw = getattr(space, "created_at", None) or getattr(space, "createdAt", None)
114
  if not created_at_raw:
115
- # If missing, include conservatively
116
  yield space
117
  continue
118
  created_at = (
@@ -121,23 +65,11 @@ def get_recent_spaces(api: HfApi, days: int) -> Iterable:
121
  if created_at >= cutoff:
122
  yield space
123
  else:
124
- # If we know the iteration is sorted by creation desc, we can break early
125
- # Only do that when we explicitly asked for sort="created"
126
- if "spaces_iter" in locals():
127
- try:
128
- # If we reached here under the sorted branch, short-circuit
129
- # by checking if the generator came from the sorted call
130
- _ = api # keep linter calm
131
- except Exception:
132
- pass
133
- # We can't be certain the iterator is sorted in fallback; just continue
134
- # without breaking to avoid missing any items.
135
  continue
136
 
137
 
138
- def find_duplicated_spaces(
139
- api: HfApi, source: str, days: int, deep_detection: bool
140
- ) -> List[str]:
141
  """Return list of Space IDs that were duplicated from `source` within `days`."""
142
  source = source.strip().strip("/ ")
143
  results: List[str] = []
@@ -146,7 +78,6 @@ def find_duplicated_spaces(
146
  if not space_id:
147
  continue
148
 
149
- # Check card metadata first
150
  card = getattr(space, "cardData", None) or getattr(space, "card_data", None)
151
  duplicated_from_value: Optional[str] = None
152
  if isinstance(card, dict):
@@ -155,7 +86,6 @@ def find_duplicated_spaces(
155
  duplicated_from_value = card[key].strip().strip("/ ")
156
  break
157
 
158
- # Optional deep detection via README frontmatter
159
  if not duplicated_from_value and deep_detection:
160
  duplicated_from_value = readme_frontmatter_duplicated_from(space_id)
161
  if duplicated_from_value:
@@ -167,30 +97,3 @@ def find_duplicated_spaces(
167
  return results
168
 
169
 
170
- def main() -> None:
171
- args = parse_args()
172
- api = HfApi(token=args.token)
173
-
174
- duplicated = find_duplicated_spaces(
175
- api=api,
176
- source=args.source,
177
- days=args.days,
178
- deep_detection=not args.no_deep,
179
- )
180
-
181
- if duplicated:
182
- print(
183
- f"Found {len(duplicated)} Space(s) duplicated from {args.source} in the last {args.days} days:\n"
184
- )
185
- for sid in duplicated:
186
- print(f"https://huggingface.co/spaces/{sid}")
187
- else:
188
- print(
189
- f"No public Spaces duplicated from {args.source} in the last {args.days} days."
190
- )
191
-
192
-
193
- if __name__ == "__main__":
194
- main()
195
-
196
-
 
1
+ from __future__ import annotations
 
 
 
 
 
 
 
 
2
 
3
+ """
4
+ Core logic to find Spaces duplicated from a given source within a time window.
5
+ Comments are in English (per user preference for code comments).
6
  """
7
 
 
 
 
 
8
  from datetime import datetime, timedelta, timezone
9
  from typing import Iterable, List, Optional
10
 
 
12
  from huggingface_hub import HfApi
13
 
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  def iso_to_datetime(value: str) -> datetime:
16
  """Parse ISO 8601 timestamps returned by the Hub to aware datetime in UTC."""
 
17
  try:
18
  dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
19
  except ValueError:
 
23
 
24
  def readme_frontmatter_duplicated_from(space_id: str) -> Optional[str]:
25
  """Fetch README raw and try to extract duplicated_from from YAML frontmatter."""
 
26
  url = f"https://huggingface.co/spaces/{space_id}/raw/README.md"
27
  try:
28
  resp = requests.get(url, timeout=10)
 
32
  except requests.RequestException:
33
  return None
34
 
 
 
35
  lines = text.splitlines()
36
  in_frontmatter = False
37
  for line in lines:
38
  if line.strip() == "---":
39
  in_frontmatter = not in_frontmatter
 
40
  if not in_frontmatter:
41
  break
42
  continue
43
  if in_frontmatter and line.strip().startswith("duplicated_from:"):
 
44
  value = line.split(":", 1)[1].strip().strip("'\"")
45
  return value or None
46
  return None
47
 
48
 
49
  def get_recent_spaces(api: HfApi, days: int) -> Iterable:
50
+ """Yield Spaces created within the last `days` days, iterating newest first if possible."""
 
 
 
51
  cutoff = datetime.now(timezone.utc) - timedelta(days=days)
 
 
52
  try:
53
  spaces_iter = api.list_spaces(full=True, sort="created", direction=-1)
54
  except TypeError:
 
 
55
  spaces_iter = api.list_spaces(full=True)
56
 
57
  for space in spaces_iter:
58
  created_at_raw = getattr(space, "created_at", None) or getattr(space, "createdAt", None)
59
  if not created_at_raw:
 
60
  yield space
61
  continue
62
  created_at = (
 
65
  if created_at >= cutoff:
66
  yield space
67
  else:
68
+ # We cannot guarantee sort order when falling back; continue to be safe.
 
 
 
 
 
 
 
 
 
 
69
  continue
70
 
71
 
72
+ def find_duplicated_spaces(api: HfApi, source: str, days: int, deep_detection: bool) -> List[str]:
 
 
73
  """Return list of Space IDs that were duplicated from `source` within `days`."""
74
  source = source.strip().strip("/ ")
75
  results: List[str] = []
 
78
  if not space_id:
79
  continue
80
 
 
81
  card = getattr(space, "cardData", None) or getattr(space, "card_data", None)
82
  duplicated_from_value: Optional[str] = None
83
  if isinstance(card, dict):
 
86
  duplicated_from_value = card[key].strip().strip("/ ")
87
  break
88
 
 
89
  if not duplicated_from_value and deep_detection:
90
  duplicated_from_value = readme_frontmatter_duplicated_from(space_id)
91
  if duplicated_from_value:
 
97
  return results
98
 
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/duplicated-spaces/pyproject.toml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.poetry]
2
+ name = "duplicated-spaces"
3
+ version = "0.1.0"
4
+ description = "Find recent Hugging Face Spaces duplicated from a given Space"
5
+ authors = ["thibaud frere <>"]
6
+ readme = "README.md"
7
+ packages = [{ include = "duplicated_spaces" }]
8
+
9
+ [tool.poetry.dependencies]
10
+ python = ">=3.9,<4.0"
11
+ huggingface_hub = "^0.24.0"
12
+ requests = "^2.31.0"
13
+
14
+ [tool.poetry.group.dev.dependencies]
15
+
16
+ [tool.poetry.scripts]
17
+ find-duplicated-spaces = "duplicated_spaces.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["poetry-core>=1.7.0"]
21
+ build-backend = "poetry.core.masonry.api"
22
+
23
+