tfrere HF Staff commited on
Commit
ef40dcd
·
1 Parent(s): e44eaff

embed and style improvements

Browse files
app/src/components/Hero.astro CHANGED
@@ -102,9 +102,9 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
102
  <section class="hero">
103
  <h1 class="hero-title" set:html={title} />
104
  <div class="hero-banner">
105
-
106
- <Image src={eiffel_tower_llama} alt="A llama photobombing the Eiffel Tower" width="700"/>
107
-
108
  {description && <p class="hero-desc">{description}</p>}
109
  </div>
110
  </section>
@@ -374,6 +374,29 @@ const pdfFilename = `${slugify(pdfBase)}.pdf`;
374
  align-items: center;
375
  gap: 16px;
376
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  .hero-desc {
378
  color: var(--muted-color);
379
  font-style: italic;
 
102
  <section class="hero">
103
  <h1 class="hero-title" set:html={title} />
104
  <div class="hero-banner">
105
+ <div class="hero-banner-image-wrapper">
106
+ <Image src={eiffel_tower_llama} alt="A llama photobombing the Eiffel Tower" width="700"/>
107
+ </div>
108
  {description && <p class="hero-desc">{description}</p>}
109
  </div>
110
  </section>
 
374
  align-items: center;
375
  gap: 16px;
376
  }
377
+ .hero-banner-image-wrapper {
378
+ width: 100%;
379
+ max-width: 700px;
380
+ height: 320px;
381
+ border-radius: 16px;
382
+ overflow: hidden;
383
+ display: block;
384
+ }
385
+ .hero-banner-image-wrapper :global(.ri-root),
386
+ .hero-banner-image-wrapper :global(.ri-root img) {
387
+ width: 100%;
388
+ height: 100%;
389
+ object-fit: cover;
390
+ border-radius: 16px;
391
+ display: block;
392
+ }
393
+ .hero-banner-image-wrapper :global(img) {
394
+ width: 100%;
395
+ height: 100%;
396
+ object-fit: cover;
397
+ border-radius: 16px;
398
+ display: block;
399
+ }
400
  .hero-desc {
401
  color: var(--muted-color);
402
  font-style: italic;
app/src/components/HtmlEmbed.astro CHANGED
@@ -1,15 +1,44 @@
1
  ---
2
- interface Props { src: string; title?: string; desc?: string; frameless?: boolean; align?: 'left' | 'center' | 'right'; id?: string, data?: string | string[], config?: any }
3
- const { src, title, desc, frameless = false, align = 'left', id, data, config } = Astro.props as Props;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  // Load all .html embeds under src/content/embeds/** as strings (dev & build)
6
- const embeds = (import.meta as any).glob('../content/embeds/**/*.html', { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
 
 
 
 
7
 
8
  function resolveFragment(requested: string): string | null {
9
  // Allow both "banner.html" and "embeds/banner.html"
10
- const needle = requested.replace(/^\/*/, '');
11
  for (const [key, html] of Object.entries(embeds)) {
12
- if (key.endsWith('/' + needle) || key.endsWith('/' + needle.replace(/^embeds\//, ''))) {
 
 
 
13
  return html;
14
  }
15
  }
@@ -18,66 +47,235 @@ function resolveFragment(requested: string): string | null {
18
 
19
  const html = resolveFragment(src);
20
  const mountId = `frag-${Math.random().toString(36).slice(2)}`;
21
- const dataAttr = Array.isArray(data) ? JSON.stringify(data) : (typeof data === 'string' ? data : undefined);
22
- const configAttr = typeof config === 'string' ? config : (config != null ? JSON.stringify(config) : undefined);
 
 
 
 
 
 
 
 
 
23
 
24
  // Apply the ID to the HTML content if provided
25
- const htmlWithId = id && html ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div class="$1" id="${id}">`) : html;
 
 
 
26
  ---
27
- { html ? (
28
- <figure class="html-embed" id={id}>
29
- {title && <figcaption class="html-embed__title" style={`text-align:${align}`}>{title}</figcaption>}
30
- <div class={`html-embed__card${frameless ? ' is-frameless' : ''}`}>
31
- <div id={mountId} data-datafiles={dataAttr} data-config={configAttr} set:html={htmlWithId} />
32
- </div>
33
- {desc && <figcaption class="html-embed__desc" style={`text-align:${align}`} set:html={desc}></figcaption>}
34
- </figure>
35
- ) : (
36
- <div><!-- Fragment not found: {src} --></div>
37
- ) }
38
-
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  <script>
42
  // Re-execute <script> tags inside the injected fragment (innerHTML doesn't run scripts)
43
- const scriptEl = document.currentScript;
44
- const mount = scriptEl ? scriptEl.previousElementSibling : null;
45
- const execute = () => {
46
- if (!mount) return;
47
- const scripts = mount.querySelectorAll('script');
48
- scripts.forEach(old => {
49
- // ignore non-executable types (e.g., application/json)
50
- if (old.type && old.type !== 'text/javascript' && old.type !== 'module' && old.type !== '') return;
51
- if (old.dataset.executed === 'true') return;
52
- old.dataset.executed = 'true';
53
- if (old.src) {
54
- const s = document.createElement('script');
55
- Array.from(old.attributes).forEach(attr => s.setAttribute(attr.name, attr.value));
56
- document.body.appendChild(s);
57
- } else {
58
- try {
59
- // run inline
60
- (0, eval)(old.text || '');
61
- } catch (e) {
62
- console.error('HtmlEmbed inline script error:', e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
- });
66
- };
67
- // Execute after DOM is parsed (ensures deferred module scripts are executed first)
68
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', execute, { once: true });
69
- else execute();
70
- </script>
71
 
72
  <style is:global>
73
- .html-embed { margin: 0 0 var(--block-spacing-y);
 
74
  z-index: var(--z-elevated);
75
  position: relative;
76
  }
77
- .html-embed__title {
78
- text-align: left;
79
- font-weight: 600;
80
- font-size: 0.95rem;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  color: var(--text-color);
82
  margin: 0;
83
  padding: 0;
@@ -89,7 +287,7 @@ const htmlWithId = id && html ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div
89
  z-index: var(--z-elevated);
90
  }
91
  .html-embed__card {
92
- background: var(--code-bg);
93
  border: 1px solid var(--border-color);
94
  border-radius: 10px;
95
  padding: 24px;
@@ -98,14 +296,14 @@ const htmlWithId = id && html ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div
98
  }
99
  .html-embed__card.is-frameless {
100
  background: transparent;
101
- border-color: transparent;
102
  padding: 0;
103
  }
104
- .html-embed__desc {
105
- text-align: left;
106
- font-size: 0.9rem;
107
- color: var(--muted-color);
108
- margin: 0;
109
  padding: 0;
110
  padding-top: var(--spacing-1);
111
  position: relative;
@@ -114,63 +312,255 @@ const htmlWithId = id && html ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div
114
  width: 100%;
115
  background: var(--page-bg);
116
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  /* Plotly – fragments & controls */
118
- .html-embed__card svg text { fill: var(--text-color); }
119
- .html-embed__card label { color: var(--text-color); }
120
- .plotly-graph-div { width: 100%; min-height: 320px; }
121
- @media (max-width: 768px) { .plotly-graph-div { min-height: 260px; } }
122
- [id^="plot-"] { display: flex; flex-direction: column; align-items: center; gap: 15px; }
123
- .plotly_caption { font-style: italic; margin-top: 10px; }
124
- .plotly_controls { display: flex; flex-wrap: wrap; justify-content: center; gap: 30px; }
125
- .plotly_input_container { display: flex; align-items: center; flex-direction: column; gap: 10px; }
126
- .plotly_input_container > select { padding: 2px 4px; line-height: 1.5em; text-align: center; border-radius: 4px; font-size: 12px; background-color: var(--neutral-200); outline: none; border: 1px solid var(--neutral-300); }
127
- .plotly_slider { display: flex; align-items: center; gap: 10px; }
128
- .plotly_slider > input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--neutral-400); border-radius: 5px; outline: none; }
129
- .plotly_slider > input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
130
- .plotly_slider > input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; background: var(--primary-color); cursor: pointer; }
131
- .plotly_slider > span { font-size: 14px; line-height: 1.6em; min-width: 16px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  /* Dark mode overrides for Plotly readability */
133
- [data-theme="dark"] .html-embed__card:not(.is-frameless) { background: #12151b; border-color: rgba(255,255,255,.15); }
134
  [data-theme="dark"] .html-embed__card .xaxislayer-above text,
135
  [data-theme="dark"] .html-embed__card .yaxislayer-above text,
136
  [data-theme="dark"] .html-embed__card .infolayer text,
137
  [data-theme="dark"] .html-embed__card .legend text,
138
  [data-theme="dark"] .html-embed__card .annotation text,
139
  [data-theme="dark"] .html-embed__card .colorbar text,
140
- [data-theme="dark"] .html-embed__card .hoverlayer text { fill: #fff !important; }
 
 
141
  [data-theme="dark"] .html-embed__card .xaxislayer-above path,
142
  [data-theme="dark"] .html-embed__card .yaxislayer-above path,
143
  [data-theme="dark"] .html-embed__card .xlines-above,
144
- [data-theme="dark"] .html-embed__card .ylines-above { stroke: rgba(255,255,255,.35) !important; }
145
- [data-theme="dark"] .html-embed__card .gridlayer path { stroke: rgba(255,255,255,.15) !important; }
146
- [data-theme="dark"] .html-embed__card .legend rect.bg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
147
- [data-theme="dark"] .html-embed__card .hoverlayer .bg { fill: rgba(0,0,0,.8) !important; stroke: rgba(255,255,255,.2) !important; }
148
- [data-theme="dark"] .html-embed__card .colorbar .cbbg { fill: rgba(0,0,0,.25) !important; stroke: rgba(255,255,255,.2) !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  @media print {
150
- .html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; }
151
- .html-embed__card { padding: 6px; }
152
- .html-embed__card.is-frameless { padding: 0; }
 
 
 
 
 
 
 
 
 
 
153
  .html-embed__card svg,
154
  .html-embed__card canvas,
155
- .html-embed__card img { max-width: 100% !important; height: auto !important; }
156
- .html-embed__card > div[id^="frag-"] { width: 100% !important; }
 
 
 
 
 
157
  }
158
  @media print {
159
  /* Avoid breaks inside embeds */
160
- .html-embed, .html-embed__card { break-inside: avoid; page-break-inside: avoid; }
 
 
 
 
161
  /* Constrain width and scale inner content */
162
- .html-embed, .html-embed__card { max-width: 100% !important; width: 100% !important; }
163
- .html-embed__card { padding: 6px; }
164
- .html-embed__card.is-frameless { padding: 0; }
 
 
 
 
 
 
 
 
165
  .html-embed__card svg,
166
  .html-embed__card canvas,
167
  .html-embed__card img,
168
  .html-embed__card video,
169
- .html-embed__card iframe { max-width: 100% !important; height: auto !important; }
170
- .html-embed__card > div[id^="frag-"] { width: 100% !important; max-width: 100% !important; }
171
- /* Center and constrain the banner (galaxy) when printing */
172
- .html-embed .d3-galaxy { width: 100% !important; max-width: 980px !important; margin-left: auto !important; margin-right: auto !important; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  }
174
  </style>
175
-
176
-
 
1
  ---
2
+ interface Props {
3
+ src: string;
4
+ title?: string;
5
+ desc?: string;
6
+ caption?: string;
7
+ frameless?: boolean;
8
+ wide?: boolean;
9
+ align?: "left" | "center" | "right";
10
+ id?: string;
11
+ data?: string | string[];
12
+ config?: any;
13
+ }
14
+ const {
15
+ src,
16
+ title,
17
+ desc,
18
+ caption,
19
+ frameless = false,
20
+ wide = false,
21
+ align = "left",
22
+ id,
23
+ data,
24
+ config,
25
+ } = Astro.props as Props;
26
 
27
  // Load all .html embeds under src/content/embeds/** as strings (dev & build)
28
+ const embeds = (import.meta as any).glob("../content/embeds/**/*.html", {
29
+ query: "?raw",
30
+ import: "default",
31
+ eager: true,
32
+ }) as Record<string, string>;
33
 
34
  function resolveFragment(requested: string): string | null {
35
  // Allow both "banner.html" and "embeds/banner.html"
36
+ const needle = requested.replace(/^\/*/, "");
37
  for (const [key, html] of Object.entries(embeds)) {
38
+ if (
39
+ key.endsWith("/" + needle) ||
40
+ key.endsWith("/" + needle.replace(/^embeds\//, ""))
41
+ ) {
42
  return html;
43
  }
44
  }
 
47
 
48
  const html = resolveFragment(src);
49
  const mountId = `frag-${Math.random().toString(36).slice(2)}`;
50
+ const dataAttr = Array.isArray(data)
51
+ ? JSON.stringify(data)
52
+ : typeof data === "string"
53
+ ? data
54
+ : undefined;
55
+ const configAttr =
56
+ typeof config === "string"
57
+ ? config
58
+ : config != null
59
+ ? JSON.stringify(config)
60
+ : undefined;
61
 
62
  // Apply the ID to the HTML content if provided
63
+ const htmlWithId =
64
+ id && html
65
+ ? html.replace(/<div class="([^"]*)"[^>]*>/, `<div class="$1" id="${id}">`)
66
+ : html;
67
  ---
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ {
70
+ html ? (
71
+ <figure class={`html-embed${wide ? " html-embed--wide" : ""}`} id={id}>
72
+ {title && (
73
+ <figcaption class="html-embed__title" style={`text-align:${align}`}>
74
+ {title}
75
+ </figcaption>
76
+ )}
77
+ <div class={`html-embed__card${frameless ? " is-frameless" : ""}`}>
78
+ <div
79
+ id={mountId}
80
+ data-datafiles={dataAttr}
81
+ data-config={configAttr}
82
+ set:html={htmlWithId}
83
+ />
84
+ </div>
85
+ {(desc || caption) && (
86
+ <figcaption
87
+ class="html-embed__desc"
88
+ style={`text-align:${align}`}
89
+ set:html={caption || desc}
90
+ />
91
+ )}
92
+ </figure>
93
+ ) : (
94
+ <figure class="html-embed html-embed--error" id={id}>
95
+ <div class="html-embed__card html-embed__card--error">
96
+ <div class="html-embed__error">
97
+ <strong>Embed not found</strong>
98
+ <p>
99
+ The requested embed could not be loaded: <code>{src}</code>
100
+ </p>
101
+ </div>
102
+ </div>
103
+ </figure>
104
+ )
105
+ }
106
 
107
  <script>
108
  // Re-execute <script> tags inside the injected fragment (innerHTML doesn't run scripts)
109
+ // Uses IntersectionObserver for lazy loading - only executes when embed is visible
110
+ (() => {
111
+ const scriptEl = document.currentScript;
112
+ const figure = scriptEl?.previousElementSibling?.closest(".html-embed");
113
+ const mount = scriptEl ? scriptEl.previousElementSibling : null;
114
+
115
+ if (!mount || !figure) return;
116
+
117
+ let executed = false;
118
+ const execute = () => {
119
+ if (executed || !mount) return;
120
+ executed = true;
121
+
122
+ const scripts = mount.querySelectorAll("script");
123
+ scripts.forEach((old) => {
124
+ // ignore non-executable types (e.g., application/json)
125
+ if (
126
+ old.type &&
127
+ old.type !== "text/javascript" &&
128
+ old.type !== "module" &&
129
+ old.type !== ""
130
+ )
131
+ return;
132
+ if (old.dataset.executed === "true") return;
133
+ old.dataset.executed = "true";
134
+ if (old.src) {
135
+ const s = document.createElement("script");
136
+ Array.from(old.attributes).forEach((attr) =>
137
+ s.setAttribute(attr.name, attr.value),
138
+ );
139
+ document.body.appendChild(s);
140
+ } else {
141
+ try {
142
+ // run inline
143
+ (0, eval)(old.text || "");
144
+ } catch (e) {
145
+ console.error("HtmlEmbed inline script error:", e);
146
+ }
147
  }
148
+ });
149
+
150
+ // Mark as loaded
151
+ figure.classList.add("html-embed--loaded");
152
+ };
153
+
154
+ // Check if IntersectionObserver is supported
155
+ if ("IntersectionObserver" in window) {
156
+ const observer = new IntersectionObserver(
157
+ (entries) => {
158
+ entries.forEach((entry) => {
159
+ if (entry.isIntersecting && !executed) {
160
+ observer.disconnect();
161
+ // Small delay to ensure DOM is ready
162
+ if (document.readyState === "loading") {
163
+ document.addEventListener("DOMContentLoaded", execute, {
164
+ once: true,
165
+ });
166
+ } else {
167
+ // Use requestAnimationFrame to ensure execution after DOM is fully ready
168
+ requestAnimationFrame(() => {
169
+ setTimeout(execute, 0);
170
+ });
171
+ }
172
+ }
173
+ });
174
+ },
175
+ {
176
+ // Start loading when element is 100px away from viewport
177
+ rootMargin: "100px",
178
+ threshold: 0.01,
179
+ },
180
+ );
181
+
182
+ observer.observe(figure);
183
+
184
+ // Fallback: if still not loaded after 3 seconds, load anyway (for edge cases)
185
+ setTimeout(() => {
186
+ if (!executed) {
187
+ observer.disconnect();
188
+ if (document.readyState === "loading") {
189
+ document.addEventListener("DOMContentLoaded", execute, {
190
+ once: true,
191
+ });
192
+ } else {
193
+ requestAnimationFrame(() => {
194
+ setTimeout(execute, 0);
195
+ });
196
+ }
197
+ }
198
+ }, 3000);
199
+ } else {
200
+ // Fallback for browsers without IntersectionObserver support
201
+ if (document.readyState === "loading") {
202
+ document.addEventListener("DOMContentLoaded", execute, { once: true });
203
+ } else {
204
+ requestAnimationFrame(() => {
205
+ setTimeout(execute, 0);
206
+ });
207
  }
208
+ }
209
+ })();
210
+ </script>
 
 
 
211
 
212
  <style is:global>
213
+ .html-embed {
214
+ margin: 0 0 var(--block-spacing-y);
215
  z-index: var(--z-elevated);
216
  position: relative;
217
  }
218
+
219
+ /* Wide mode - same styling as Wide.astro component */
220
+ .html-embed--wide {
221
+ /* Target up to ~1100px while staying within viewport minus page gutters */
222
+ width: min(1100px, 100vw - var(--content-padding-x) * 4);
223
+ margin-left: 50%;
224
+ transform: translateX(-50%);
225
+ padding: calc(var(--content-padding-x) * 4);
226
+ border-radius: calc(var(--button-radius) * 4);
227
+ background-color: var(--page-bg);
228
+ -webkit-mask: linear-gradient(
229
+ to right,
230
+ transparent 0px,
231
+ black 20px,
232
+ black calc(100% - 20px),
233
+ transparent 100%
234
+ ),
235
+ linear-gradient(
236
+ to bottom,
237
+ transparent 0px,
238
+ black 20px,
239
+ black calc(100% - 20px),
240
+ transparent 100%
241
+ );
242
+ -webkit-mask-composite: intersect;
243
+ mask: linear-gradient(
244
+ to right,
245
+ transparent 0px,
246
+ black 20px,
247
+ black calc(100% - 20px),
248
+ transparent 100%
249
+ ),
250
+ linear-gradient(
251
+ to bottom,
252
+ transparent 0px,
253
+ black 20px,
254
+ black calc(100% - 20px),
255
+ transparent 100%
256
+ );
257
+ mask-composite: intersect;
258
+ }
259
+
260
+ .html-embed--wide > * {
261
+ margin-bottom: 0 !important;
262
+ }
263
+
264
+ /* Responsive adjustments for wide mode */
265
+ @media (max-width: 1100px) {
266
+ .html-embed--wide {
267
+ width: 100%;
268
+ margin-left: 0;
269
+ margin-right: 0;
270
+ padding: 0;
271
+ transform: none;
272
+ }
273
+ }
274
+
275
+ .html-embed__title {
276
+ text-align: left;
277
+ font-weight: 600;
278
+ font-size: 0.95rem;
279
  color: var(--text-color);
280
  margin: 0;
281
  padding: 0;
 
287
  z-index: var(--z-elevated);
288
  }
289
  .html-embed__card {
290
+ background-color: var(--surface-bg);
291
  border: 1px solid var(--border-color);
292
  border-radius: 10px;
293
  padding: 24px;
 
296
  }
297
  .html-embed__card.is-frameless {
298
  background: transparent;
299
+ border: none;
300
  padding: 0;
301
  }
302
+ .html-embed__desc {
303
+ text-align: left;
304
+ font-size: 0.9rem;
305
+ color: var(--muted-color);
306
+ margin: 0;
307
  padding: 0;
308
  padding-top: var(--spacing-1);
309
  position: relative;
 
312
  width: 100%;
313
  background: var(--page-bg);
314
  }
315
+ /* Error state for missing embeds */
316
+ .html-embed__card--error {
317
+ background: #fef2f2;
318
+ border: 2px solid #dc2626;
319
+ border-radius: 8px;
320
+ padding: 20px;
321
+ }
322
+ .html-embed__error {
323
+ text-align: center;
324
+ color: #dc2626;
325
+ }
326
+ .html-embed__error strong {
327
+ display: block;
328
+ font-size: 1.1rem;
329
+ font-weight: 600;
330
+ margin-bottom: 8px;
331
+ }
332
+ .html-embed__error p {
333
+ margin: 0;
334
+ font-size: 0.9rem;
335
+ line-height: 1.5;
336
+ }
337
+ .html-embed__error code {
338
+ background: rgba(220, 38, 38, 0.1);
339
+ padding: 2px 6px;
340
+ border-radius: 4px;
341
+ font-family: var(--font-mono);
342
+ font-size: 0.85rem;
343
+ word-break: break-all;
344
+ }
345
+ /* Dark mode for error state */
346
+ [data-theme="dark"] .html-embed__card--error {
347
+ background: #1f2937;
348
+ border-color: #ef4444;
349
+ }
350
+ [data-theme="dark"] .html-embed__error {
351
+ color: #ef4444;
352
+ }
353
+ [data-theme="dark"] .html-embed__error code {
354
+ background: rgba(239, 68, 68, 0.2);
355
+ }
356
  /* Plotly – fragments & controls */
357
+ .html-embed__card svg text {
358
+ fill: var(--text-color);
359
+ }
360
+ .html-embed__card label {
361
+ color: var(--text-color);
362
+ }
363
+ .plotly-graph-div {
364
+ width: 100%;
365
+ min-height: 320px;
366
+ }
367
+ @media (max-width: 768px) {
368
+ .plotly-graph-div {
369
+ min-height: 260px;
370
+ }
371
+ }
372
+ [id^="plot-"] {
373
+ display: flex;
374
+ flex-direction: column;
375
+ align-items: center;
376
+ gap: 15px;
377
+ }
378
+ .plotly_caption {
379
+ font-style: italic;
380
+ margin-top: 10px;
381
+ }
382
+ .plotly_controls {
383
+ display: flex;
384
+ flex-wrap: wrap;
385
+ justify-content: center;
386
+ gap: 30px;
387
+ }
388
+ .plotly_input_container {
389
+ display: flex;
390
+ align-items: center;
391
+ flex-direction: column;
392
+ gap: 10px;
393
+ }
394
+ .plotly_input_container > select {
395
+ padding: 2px 4px;
396
+ line-height: 1.5em;
397
+ text-align: center;
398
+ border-radius: 4px;
399
+ font-size: 12px;
400
+ background-color: var(--neutral-200);
401
+ outline: none;
402
+ border: 1px solid var(--neutral-300);
403
+ }
404
+ .plotly_slider {
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 10px;
408
+ }
409
+ .plotly_slider > input[type="range"] {
410
+ -webkit-appearance: none;
411
+ appearance: none;
412
+ height: 2px;
413
+ background: var(--neutral-400);
414
+ border-radius: 5px;
415
+ outline: none;
416
+ }
417
+ .plotly_slider > input[type="range"]::-webkit-slider-thumb {
418
+ -webkit-appearance: none;
419
+ width: 18px;
420
+ height: 18px;
421
+ border-radius: 50%;
422
+ background: var(--primary-color);
423
+ cursor: pointer;
424
+ }
425
+ .plotly_slider > input[type="range"]::-moz-range-thumb {
426
+ width: 18px;
427
+ height: 18px;
428
+ border-radius: 50%;
429
+ background: var(--primary-color);
430
+ cursor: pointer;
431
+ }
432
+ .plotly_slider > span {
433
+ font-size: 14px;
434
+ line-height: 1.6em;
435
+ min-width: 16px;
436
+ }
437
  /* Dark mode overrides for Plotly readability */
 
438
  [data-theme="dark"] .html-embed__card .xaxislayer-above text,
439
  [data-theme="dark"] .html-embed__card .yaxislayer-above text,
440
  [data-theme="dark"] .html-embed__card .infolayer text,
441
  [data-theme="dark"] .html-embed__card .legend text,
442
  [data-theme="dark"] .html-embed__card .annotation text,
443
  [data-theme="dark"] .html-embed__card .colorbar text,
444
+ [data-theme="dark"] .html-embed__card .hoverlayer text {
445
+ fill: #fff !important;
446
+ }
447
  [data-theme="dark"] .html-embed__card .xaxislayer-above path,
448
  [data-theme="dark"] .html-embed__card .yaxislayer-above path,
449
  [data-theme="dark"] .html-embed__card .xlines-above,
450
+ [data-theme="dark"] .html-embed__card .ylines-above {
451
+ stroke: rgba(255, 255, 255, 0.35) !important;
452
+ }
453
+ [data-theme="dark"] .html-embed__card .gridlayer path {
454
+ stroke: rgba(255, 255, 255, 0.15) !important;
455
+ }
456
+ [data-theme="dark"] .html-embed__card .legend rect.bg {
457
+ fill: rgba(0, 0, 0, 0.25) !important;
458
+ stroke: rgba(255, 255, 255, 0.2) !important;
459
+ }
460
+ [data-theme="dark"] .html-embed__card .hoverlayer .bg {
461
+ fill: rgba(0, 0, 0, 0.8) !important;
462
+ stroke: rgba(255, 255, 255, 0.2) !important;
463
+ }
464
+ [data-theme="dark"] .html-embed__card .colorbar .cbbg {
465
+ fill: rgba(0, 0, 0, 0.25) !important;
466
+ stroke: rgba(255, 255, 255, 0.2) !important;
467
+ }
468
  @media print {
469
+ .html-embed,
470
+ .html-embed__card {
471
+ max-width: 100% !important;
472
+ width: 100% !important;
473
+ margin-left: 0 !important;
474
+ margin-right: 0 !important;
475
+ }
476
+ .html-embed__card {
477
+ padding: 6px;
478
+ }
479
+ .html-embed__card.is-frameless {
480
+ padding: 0;
481
+ }
482
  .html-embed__card svg,
483
  .html-embed__card canvas,
484
+ .html-embed__card img {
485
+ max-width: 100% !important;
486
+ height: auto !important;
487
+ }
488
+ .html-embed__card > div[id^="frag-"] {
489
+ width: 100% !important;
490
+ }
491
  }
492
  @media print {
493
  /* Avoid breaks inside embeds */
494
+ .html-embed,
495
+ .html-embed__card {
496
+ break-inside: avoid;
497
+ page-break-inside: avoid;
498
+ }
499
  /* Constrain width and scale inner content */
500
+ .html-embed,
501
+ .html-embed__card {
502
+ max-width: 100% !important;
503
+ width: 100% !important;
504
+ }
505
+ .html-embed__card {
506
+ padding: 6px;
507
+ }
508
+ .html-embed__card.is-frameless {
509
+ padding: 0;
510
+ }
511
  .html-embed__card svg,
512
  .html-embed__card canvas,
513
  .html-embed__card img,
514
  .html-embed__card video,
515
+ .html-embed__card iframe {
516
+ max-width: 100% !important;
517
+ height: auto !important;
518
+ }
519
+ .html-embed__card > div[id^="frag-"] {
520
+ width: 100% !important;
521
+ max-width: 100% !important;
522
+ }
523
+ /* Center and constrain all banners when printing */
524
+ .html-embed .d3-galaxy,
525
+ .html-embed .threejs-galaxy,
526
+ .html-embed .d3-latent-space,
527
+ .html-embed .neural-flow,
528
+ .html-embed .molecular-space,
529
+ .html-embed [class*="banner"] {
530
+ width: 100% !important;
531
+ max-width: 980px !important;
532
+ margin-left: auto !important;
533
+ margin-right: auto !important;
534
+ }
535
+ /* Better rendering for d3-loss-curves when printing */
536
+ .html-embed .d3-loss-curves {
537
+ width: 100% !important;
538
+ height: auto !important;
539
+ min-height: 300px !important;
540
+ margin-left: auto !important;
541
+ margin-right: auto !important;
542
+ overflow: visible !important;
543
+ }
544
+ .html-embed .d3-loss-curves svg {
545
+ width: 100% !important;
546
+ height: auto !important;
547
+ max-height: 500px !important;
548
+ }
549
+ /* Ensure legend is visible in print */
550
+ .html-embed .d3-loss-curves .legend {
551
+ position: relative !important;
552
+ display: flex !important;
553
+ flex-direction: column !important;
554
+ align-items: flex-start !important;
555
+ gap: 4px !important;
556
+ margin-top: 10px !important;
557
+ bottom: auto !important;
558
+ left: auto !important;
559
+ max-width: 100% !important;
560
+ }
561
+ /* Hide annotation in print */
562
+ .html-embed .d3-loss-curves .annotation {
563
+ display: none !important;
564
+ }
565
  }
566
  </style>
 
 
app/src/content/article.mdx CHANGED
@@ -11,7 +11,6 @@ affiliations:
11
  - name: "Hugging Face"
12
  url: "https://huggingface.co"
13
  published: "Nov. 18, 2025"
14
- doi: 10.1234/abcd.efgh
15
  licence: >
16
  Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">Hugging Face</a>, unless noted otherwise.
17
  Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
@@ -19,7 +18,7 @@ tags:
19
  - research
20
  - template
21
  tableOfContentsAutoCollapse: true
22
- pdfProOnly: false
23
  ---
24
 
25
  import Image from '../components/Image.astro'
@@ -289,9 +288,9 @@ import activations_magnitude from './assets/image/activations_magnitude.png'
289
 
290
  As we can see, activation norms roughly grow linearly across layers, with a norm being approximately equal to the layer index.
291
  If we want to look for a steering coefficient that is typically less than the original activation vector norm at layer $l$,
292
- we can define a reduced coefficient $\hat{\alpha_l} = (\alpha_l / l)$, and restrict our search to
293
  $$
294
- \hat{\alpha_l} \in [0,1]
295
  $$
296
 
297
 
@@ -302,9 +301,7 @@ For a first grid search, we used the set of 50 prompts, temperature was set to 1
302
  The image below shows the results for each of our six metrics of the sweep over $\alpha$ for the feature 21576 in layer 15.
303
  The left column displays the three LLM-judge metrics, while the right column shows our three auxiliary metrics. On those charts, we can observe several regimes corresponding to essentially three ranges of the steering coefficient.
304
 
305
- import sweep_1D_analysis from './assets/image/sweep_1D_all_metrics.png'
306
-
307
- <Image src={sweep_1D_analysis} alt="1D sweep of steering coefficient" caption="1D sweep of steering coefficient for a single steering vector, with six metrics monitored." />
308
 
309
  First of all, **for low values of the steering coefficient $\alpha < 5$, the steered model behaves almost as the reference model**:
310
  the concept inclusion metric is zero, instruction following and fluency are close to 2.0, equivalent to the reference model.
@@ -324,9 +321,7 @@ Inspection of the answers shows that the model is producing repetitive patterns
324
 
325
  Those metrics show that we face a fundamental trade-off: stronger steering increases concept inclusion but degrades fluency, and finding the balance is the challenge. This is further complicated by the very large standard deviation: for a given steering coefficient, some prompts lead to good results while others completely fail. Even though all metrics somehow tell the same story, we have to decide how to select the optimal steering coefficient. We could simply use the mean of the three LLM judge metrics, but we can easily see that this would lead us to select the unsteered model (low $\alpha$) as the best model, which is not what we want. For that, we can use **the harmonic mean criterion proposed by AxBench**.
326
 
327
- import harmonic_mean_curve from './assets/image/sweep_1D_harmonic_mean.png'
328
-
329
- <Image src={harmonic_mean_curve} alt="Arithmetic (left) and harmonic (right) mean of the three LLM-judge metrics as a function of steering coefficient." caption="Arithmetic (left) and harmonic (right) mean of the three LLM-judge metrics as a function of steering coefficient." />
330
 
331
  First, the results show the harmonic mean curve is very noisy. Despite the fact that we used 50 prompts to evaluate each point, the inherent discreteness of the LLM-judge metrics and the stochasticity of LLM generation leads to a noisy harmonic mean. This is something to keep in mind when trying to optimize steering coefficients.
332
 
@@ -345,7 +340,7 @@ Note that the harmonic mean we obtained here (about 0.45) is higher than the one
345
 
346
  Using the optimal steering coefficient $\alpha=8.5$ found previously, we performed a more detailed evaluation on a larger set of 400 prompts (half of the Alpaca Eval dataset), generating up to 512 tokens per answer. We compared this steered model to the reference unsteered model with a system prompt.
347
 
348
- <HtmlEmbed src="d3-evaluation1-naive.html" data="evaluation_summary.json" />
349
 
350
  We can see that on all metrics, **the baseline prompted model significantly outperforms the steered model.** This is consistent with the findings by AxBench that steering with SAEs is not very effective. However, our numbers are not as dire as theirs. We can see an average score in concept inclusion compared to the reference model (1.03), while maintaining a reasonable level of instruction following (1.35). However, this comes at the price of a fluency drop (0.78 vs. 1.55 for the prompted model), as fluency is impaired by repetitions (0.27) or awkward phrasing.
351
 
@@ -363,9 +358,7 @@ Overall, the harmonic mean of the three LLM-judge metrics is 1.67 for the prompt
363
 
364
  From the results of this sweep, we can compute the correlations between our six metrics to see how they relate to each other.
365
 
366
- import metrics_correlation from './assets/image/sweep_1D_correlation_matrix.png'
367
-
368
- <Image src={metrics_correlation} alt="Correlation matrix between metrics" caption="Correlation matrix between metrics." />
369
 
370
  The matrix above shows several interesting correlations.
371
  First, **LLM instruction following and fluency are highly correlated** (0.8), which is not surprising as both metrics
@@ -399,7 +392,7 @@ This clamping approach was the one used by Anthropic in their Golden Gate demo,
399
 
400
  We tested the impact of clamping on the same steering vector at the optimal steering coefficient found previously ($\alpha=8.5$). We evaluated the model on the same set of prompts with 20 samples each and a maximum output length of 512 tokens.
401
 
402
- <HtmlEmbed src="d3-evaluation2-clamp.html" data="evaluation_summary.json" />
403
 
404
  We can see that **clamping has a positive effect on concept inclusion (both from the LLM score and the explicit reference), while not harming the other metrics**.
405
 
@@ -483,7 +476,7 @@ We performed optimization using 2 features (from layer 15 and layer 19) and then
483
 
484
  Results are shown below and compared to single-layer steering.
485
 
486
- <HtmlEmbed src="d3-evaluation3-multi.html" data="evaluation_summary.json" />
487
 
488
  As we can see on the chart, steering 2 or even 8 features simultaneously leads to **only marginal improvements** compared to steering only one feature. Although fluency and instruction following are improved, concept inclusion slightly decreases, leading to a harmonic mean that is only marginally better than single-layer steering.
489
 
 
11
  - name: "Hugging Face"
12
  url: "https://huggingface.co"
13
  published: "Nov. 18, 2025"
 
14
  licence: >
15
  Diagrams and text are licensed under <a href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC‑BY 4.0</a> with the source available on <a href="https://huggingface.co/spaces/tfrere/research-article-template" target="_blank" rel="noopener noreferrer">Hugging Face</a>, unless noted otherwise.
16
  Figures reused from other sources are excluded and marked in their captions (“Figure from …”).
 
18
  - research
19
  - template
20
  tableOfContentsAutoCollapse: true
21
+ pdfProOnly: true
22
  ---
23
 
24
  import Image from '../components/Image.astro'
 
288
 
289
  As we can see, activation norms roughly grow linearly across layers, with a norm being approximately equal to the layer index.
290
  If we want to look for a steering coefficient that is typically less than the original activation vector norm at layer $l$,
291
+ we can define a reduced coefficient $\hat{\alpha}_l = (\alpha_l / l)$, and restrict our search to
292
  $$
293
+ \hat{\alpha}_l \in [0,1]
294
  $$
295
 
296
 
 
301
  The image below shows the results for each of our six metrics of the sweep over $\alpha$ for the feature 21576 in layer 15.
302
  The left column displays the three LLM-judge metrics, while the right column shows our three auxiliary metrics. On those charts, we can observe several regimes corresponding to essentially three ranges of the steering coefficient.
303
 
304
+ <HtmlEmbed src="d3-sweep-1d-metrics.html" data="stats_L15F21576.csv" />
 
 
305
 
306
  First of all, **for low values of the steering coefficient $\alpha < 5$, the steered model behaves almost as the reference model**:
307
  the concept inclusion metric is zero, instruction following and fluency are close to 2.0, equivalent to the reference model.
 
321
 
322
  Those metrics show that we face a fundamental trade-off: stronger steering increases concept inclusion but degrades fluency, and finding the balance is the challenge. This is further complicated by the very large standard deviation: for a given steering coefficient, some prompts lead to good results while others completely fail. Even though all metrics somehow tell the same story, we have to decide how to select the optimal steering coefficient. We could simply use the mean of the three LLM judge metrics, but we can easily see that this would lead us to select the unsteered model (low $\alpha$) as the best model, which is not what we want. For that, we can use **the harmonic mean criterion proposed by AxBench**.
323
 
324
+ <HtmlEmbed src="d3-harmonic-mean.html" data="stats_L15F21576.csv" />
 
 
325
 
326
  First, the results show the harmonic mean curve is very noisy. Despite the fact that we used 50 prompts to evaluate each point, the inherent discreteness of the LLM-judge metrics and the stochasticity of LLM generation leads to a noisy harmonic mean. This is something to keep in mind when trying to optimize steering coefficients.
327
 
 
340
 
341
  Using the optimal steering coefficient $\alpha=8.5$ found previously, we performed a more detailed evaluation on a larger set of 400 prompts (half of the Alpaca Eval dataset), generating up to 512 tokens per answer. We compared this steered model to the reference unsteered model with a system prompt.
342
 
343
+ <HtmlEmbed src="d3-evaluation-configurable.html" data="evaluation_summary.json" config="naive" />
344
 
345
  We can see that on all metrics, **the baseline prompted model significantly outperforms the steered model.** This is consistent with the findings by AxBench that steering with SAEs is not very effective. However, our numbers are not as dire as theirs. We can see an average score in concept inclusion compared to the reference model (1.03), while maintaining a reasonable level of instruction following (1.35). However, this comes at the price of a fluency drop (0.78 vs. 1.55 for the prompted model), as fluency is impaired by repetitions (0.27) or awkward phrasing.
346
 
 
358
 
359
  From the results of this sweep, we can compute the correlations between our six metrics to see how they relate to each other.
360
 
361
+ <HtmlEmbed src="d3-correlation-matrix.html" caption="Correlation matrix between metrics." />
 
 
362
 
363
  The matrix above shows several interesting correlations.
364
  First, **LLM instruction following and fluency are highly correlated** (0.8), which is not surprising as both metrics
 
392
 
393
  We tested the impact of clamping on the same steering vector at the optimal steering coefficient found previously ($\alpha=8.5$). We evaluated the model on the same set of prompts with 20 samples each and a maximum output length of 512 tokens.
394
 
395
+ <HtmlEmbed src="d3-evaluation-configurable.html" data="evaluation_summary.json" config="clamp" />
396
 
397
  We can see that **clamping has a positive effect on concept inclusion (both from the LLM score and the explicit reference), while not harming the other metrics**.
398
 
 
476
 
477
  Results are shown below and compared to single-layer steering.
478
 
479
+ <HtmlEmbed src="d3-evaluation-configurable.html" data="evaluation_summary.json" config="multi" />
480
 
481
  As we can see on the chart, steering 2 or even 8 features simultaneously leads to **only marginal improvements** compared to steering only one feature. Although fluency and instruction following are improved, concept inclusion slightly decreases, leading to a harmonic mean that is only marginally better than single-layer steering.
482
 
app/src/content/assets/data/stats_L15F21576.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b9d6e8bec82fc1be821a6a36dc673591b87a9542d058e2c149477e5bfb6e2fa7
3
+ size 40437
app/src/content/assets/data/sweep_1d_metrics.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7cfd8216d57a41ab1fa38f787cc245955a73057d4d4cdc793e3a73c5274f2399
3
+ size 1529
app/src/content/embeds/d3-correlation-matrix.html ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="d3-correlation-matrix"></div>
2
+ <style>
3
+ .d3-correlation-matrix {
4
+ position: relative;
5
+ overflow: visible;
6
+ }
7
+ .d3-correlation-matrix .chart-card {
8
+ background: var(--surface-bg);
9
+ border: none;
10
+ border-radius: 10px;
11
+ padding: 0;
12
+ overflow: visible;
13
+ }
14
+ .d3-correlation-matrix .chart-card svg {
15
+ overflow: visible;
16
+ }
17
+ .d3-correlation-matrix .axis-label-x {
18
+ fill: var(--text-color);
19
+ font-size: 11px;
20
+ font-weight: 700;
21
+ }
22
+ .d3-correlation-matrix .axis-label-x text {
23
+ word-break: break-word;
24
+ white-space: normal;
25
+ }
26
+ .d3-correlation-matrix .axis-label {
27
+ fill: var(--text-color);
28
+ font-size: 11px;
29
+ font-weight: 700;
30
+ }
31
+ .d3-correlation-matrix .cell-text {
32
+ fill: var(--muted-color);
33
+ font-size: 11px;
34
+ pointer-events: none;
35
+ }
36
+ .d3-correlation-matrix .colorbar-label {
37
+ fill: var(--text-color);
38
+ font-size: 10px;
39
+ font-weight: 600;
40
+ }
41
+ </style>
42
+ <script>
43
+ (() => {
44
+ // Load D3 from CDN once
45
+ const ensureD3 = (cb) => {
46
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
47
+ let s = document.getElementById('d3-cdn-script');
48
+ if (!s) {
49
+ s = document.createElement('script');
50
+ s.id = 'd3-cdn-script';
51
+ s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
52
+ document.head.appendChild(s);
53
+ }
54
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
55
+ s.addEventListener('load', onReady, { once: true });
56
+ if (window.d3) onReady();
57
+ };
58
+
59
+ const bootstrap = () => {
60
+ const scriptEl = document.currentScript;
61
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
62
+ if (!(container && container.classList && container.classList.contains('d3-correlation-matrix'))){
63
+ const cs = Array.from(document.querySelectorAll('.d3-correlation-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
64
+ container = cs[cs.length - 1] || null;
65
+ }
66
+ if (!container) return;
67
+ if (container.dataset) {
68
+ if (container.dataset.mounted === 'true') return;
69
+ container.dataset.mounted = 'true';
70
+ }
71
+
72
+ // Tooltip (HTML, single instance inside container)
73
+ container.style.position = container.style.position || 'relative';
74
+ let tip = container.querySelector('.d3-tooltip');
75
+ let tipInner;
76
+ if (!tip) {
77
+ tip = document.createElement('div');
78
+ tip.className = 'd3-tooltip';
79
+ Object.assign(tip.style, {
80
+ position: 'absolute',
81
+ top: '0px',
82
+ left: '0px',
83
+ transform: 'translate(-9999px, -9999px)',
84
+ pointerEvents: 'none',
85
+ padding: '8px 10px',
86
+ borderRadius: '8px',
87
+ fontSize: '12px',
88
+ lineHeight: '1.35',
89
+ border: '1px solid var(--border-color)',
90
+ background: 'var(--surface-bg)',
91
+ color: 'var(--text-color)',
92
+ boxShadow: '0 4px 24px rgba(0,0,0,.18)',
93
+ opacity: '0',
94
+ transition: 'opacity .12s ease'
95
+ });
96
+ tipInner = document.createElement('div');
97
+ tipInner.className = 'd3-tooltip__inner';
98
+ tipInner.style.textAlign = 'left';
99
+ tip.appendChild(tipInner);
100
+ container.appendChild(tip);
101
+ } else {
102
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
103
+ }
104
+
105
+ // SVG scaffolding
106
+ const card = document.createElement('div');
107
+ card.className = 'chart-card';
108
+ container.appendChild(card);
109
+ const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
110
+ const gRoot = svg.append('g');
111
+ const gCells = gRoot.append('g');
112
+ const gAxes = gRoot.append('g');
113
+ const gColorbar = gRoot.append('g');
114
+ const gColorbarRects = gColorbar.append('g'); // Separate group for colorbar rectangles
115
+ const gColorbarLabels = gColorbar.append('g'); // Separate group for colorbar labels
116
+
117
+
118
+ // Data: 6x6 correlation matrix
119
+ const metrics = [
120
+ 'LLM score\nconcept',
121
+ 'LLM score\ninstruction',
122
+ 'LLM score\nfluency',
123
+ 'Explicit\nconcept\ninclusion',
124
+ 'Log Prob',
125
+ '3-gram\nrepetition'
126
+ ];
127
+
128
+ // Correlation matrix (exact values from the image)
129
+ const correlationMatrix = [
130
+ [1.00, -0.28, -0.37, 0.45, -0.57, 0.00],
131
+ [-0.28, 1.00, 0.80, 0.068, 0.45, -0.90],
132
+ [-0.37, 0.80, 1.00, 0.015, 0.67, -0.90],
133
+ [0.45, 0.068, 0.015, 1.00, -0.10, -0.093],
134
+ [-0.57, 0.45, 0.67, -0.10, 1.00, -0.53],
135
+ [0.00, -0.90, -0.90, -0.093, -0.53, 1.00]
136
+ ];
137
+
138
+ // Colors: diverging palette via window.ColorPalettes
139
+ const getDivergingColors = (count) => {
140
+ try {
141
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
142
+ const palette = window.ColorPalettes.getColors('diverging', count);
143
+ // Invert the palette: reverse the array
144
+ return palette.slice().reverse();
145
+ }
146
+ } catch (_) {}
147
+ // Fallback: generate diverging scale (blue for negative, red for positive)
148
+ const steps = Math.max(3, count|0);
149
+ const arr = [];
150
+ for (let i = 0; i < steps; i++) {
151
+ const t = i / (steps - 1);
152
+ const pct = Math.round(t * 100);
153
+ // Blue (negative) to Red (positive) via white
154
+ if (t < 0.5) {
155
+ const bluePct = Math.round((0.5 - t) * 200);
156
+ arr.push(`color-mix(in srgb, #3A7BD5 ${bluePct}%, #ffffff ${100-bluePct}%)`);
157
+ } else {
158
+ const redPct = Math.round((t - 0.5) * 200);
159
+ arr.push(`color-mix(in srgb, #ffffff ${100-redPct}%, #D64545 ${redPct}%)`);
160
+ }
161
+ }
162
+ return arr;
163
+ };
164
+
165
+ const divergingPalette = getDivergingColors(21);
166
+
167
+ let width = 800;
168
+ let height = 600;
169
+ const margin = { top: 40, right: 0, bottom: 0, left: 70 };
170
+ const xLabelHeight = 20; // Height reserved for X-axis labels
171
+
172
+ function updateSize() {
173
+ const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
174
+ width = container.clientWidth || 800;
175
+ // Use more of the available width and calculate height based on content
176
+ const availableWidth = width;
177
+ const minGridSize = 400;
178
+ // Reserve space for colorbar (80px) and calculate optimal grid size
179
+ const maxGridSize = Math.min(availableWidth - margin.left - margin.right - 80, 800);
180
+ const gridSize = Math.max(minGridSize, maxGridSize);
181
+ // Height must include: top margin + grid + x labels height + extra padding to ensure visibility
182
+ height = margin.top + gridSize + xLabelHeight + 20;
183
+ // Responsive SVG: width 100%, height auto, preserve aspect via viewBox
184
+ svg
185
+ .attr('viewBox', `0 0 ${width} ${height}`)
186
+ .attr('preserveAspectRatio', 'xMidYMid meet')
187
+ .style('width', '100%')
188
+ .style('height', 'auto');
189
+ gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
190
+ const innerWidth = width - margin.left - margin.right;
191
+ const innerHeight = height - margin.top - margin.bottom;
192
+ return { innerWidth, innerHeight, isDark, gridSize };
193
+ }
194
+
195
+ // Compute a fixed readable text color from a CSS rgb()/rgba() string
196
+ function chooseFixedReadableTextOnBg(bgCss){
197
+ try {
198
+ const m = String(bgCss||'').match(/rgba?\(([^)]+)\)/);
199
+ if (!m) return '#0e1116';
200
+ const parts = m[1].split(',').map(s => parseFloat(s.trim()));
201
+ const [r, g, b] = parts;
202
+ // sRGB → relative luminance
203
+ const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255);
204
+ const linear = srgb.map(c => (c <= 0.03928 ? c/12.92 : Math.pow((c + 0.055)/1.055, 2.4)));
205
+ const L = 0.2126*linear[0] + 0.7152*linear[1] + 0.0722*linear[2];
206
+ // Threshold ~ 0.5 for readability; darker BG → white text, else near-black
207
+ return L < 0.5 ? '#ffffff' : '#0e1116';
208
+ } catch(_) { return '#0e1116'; }
209
+ }
210
+
211
+ function render() {
212
+ const { innerWidth, innerHeight, gridSize } = updateSize();
213
+ const n = metrics.length;
214
+ const cellSize = gridSize / n;
215
+
216
+ // Ensure xLabelHeight is accessible
217
+ const labelHeight = xLabelHeight;
218
+
219
+ const x = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0);
220
+ const y = d3.scaleBand().domain(d3.range(n)).range([0, gridSize]).paddingInner(0);
221
+
222
+ // Flatten correlation data
223
+ const flatData = [];
224
+ for (let r = 0; r < n; r++) {
225
+ for (let c = 0; c < n; c++) {
226
+ flatData.push({ r, c, value: correlationMatrix[r][c] });
227
+ }
228
+ }
229
+
230
+ // Color scale: diverging from -1 to 1
231
+ const colorScale = d3.scaleQuantize()
232
+ .domain([-1, 1])
233
+ .range(divergingPalette);
234
+
235
+ const cells = gCells.selectAll('g.cell')
236
+ .data(flatData, d => `${d.r}-${d.c}`);
237
+
238
+ const cellsEnter = cells.enter()
239
+ .append('g')
240
+ .attr('class', 'cell');
241
+
242
+ cellsEnter.append('rect')
243
+ .attr('rx', 0)
244
+ .attr('ry', 0)
245
+ .on('mousemove', (event, d) => {
246
+ const [px, py] = d3.pointer(event, container);
247
+ tipInner.innerHTML = `<strong>${metrics[d.r]}</strong> × <strong>${metrics[d.c]}</strong><br/>Correlation: ${d.value.toFixed(2)}`;
248
+ tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;
249
+ tip.style.opacity = '1';
250
+ })
251
+ .on('mouseleave', () => {
252
+ tip.style.opacity = '0';
253
+ });
254
+
255
+ cellsEnter.append('text')
256
+ .attr('class', 'cell-text')
257
+ .attr('text-anchor', 'middle')
258
+ .attr('dominant-baseline', 'middle');
259
+
260
+ const cellsMerged = cellsEnter.merge(cells);
261
+
262
+ cellsMerged.select('rect')
263
+ .attr('x', d => x(d.c))
264
+ .attr('y', d => y(d.r))
265
+ .attr('width', Math.max(1, x.bandwidth()))
266
+ .attr('height', Math.max(1, y.bandwidth()))
267
+ .attr('fill', d => colorScale(d.value));
268
+
269
+ cellsMerged.select('text')
270
+ .attr('x', d => x(d.c) + x.bandwidth() / 2)
271
+ .attr('y', d => y(d.r) + y.bandwidth() / 2)
272
+ .text(d => {
273
+ if (d.value === 1.00) return '1';
274
+ const absVal = Math.abs(d.value);
275
+ if (absVal < 0.01) return '0';
276
+ return d.value.toFixed(2);
277
+ })
278
+ .style('fill', function(d){
279
+ try {
280
+ const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
281
+ const bg = rect ? getComputedStyle(rect).fill : colorScale(d.value);
282
+ return chooseFixedReadableTextOnBg(bg);
283
+ } catch (_) {
284
+ return '#0e1116';
285
+ }
286
+ });
287
+
288
+ cells.exit().remove();
289
+
290
+ // Create clipPath for matrix cells to preserve rounded corners
291
+ const matrixClipId = `matrix-clip-${Math.random().toString(36).slice(2)}`;
292
+ let defsMatrix = svg.select('defs');
293
+ if (defsMatrix.empty()) {
294
+ defsMatrix = svg.append('defs');
295
+ }
296
+ const matrixClipPath = defsMatrix.append('clipPath').attr('id', matrixClipId);
297
+ matrixClipPath.append('rect')
298
+ .attr('x', 0)
299
+ .attr('y', 0)
300
+ .attr('width', gridSize)
301
+ .attr('height', gridSize)
302
+ .attr('rx', 8)
303
+ .attr('ry', 8);
304
+
305
+ // Apply clipPath to cells group
306
+ gCells.attr('clip-path', `url(#${matrixClipId})`);
307
+
308
+ // Draw outer border with rounded corners (in a separate group above cells)
309
+ const gBorder = gRoot.append('g').attr('class', 'matrix-border');
310
+ gBorder.selectAll('rect.cell-bg')
311
+ .data([0])
312
+ .join('rect')
313
+ .attr('class', 'cell-bg')
314
+ .attr('x', 0)
315
+ .attr('y', 0)
316
+ .attr('width', gridSize)
317
+ .attr('height', gridSize)
318
+ .attr('rx', 8)
319
+ .attr('ry', 8)
320
+ .attr('fill', 'none')
321
+ .attr('stroke', 'var(--border-color)')
322
+ .attr('stroke-width', 1);
323
+
324
+ // Axes labels
325
+ gAxes.selectAll('*').remove();
326
+
327
+ // X-axis labels (bottom) - using SVG text with manual line breaks
328
+ const xLabelsGroup = gAxes.append('g').attr('class', 'x-labels');
329
+ const xLabels = xLabelsGroup.selectAll('text')
330
+ .data(metrics)
331
+ .join('text')
332
+ .attr('class', 'axis-label')
333
+ .attr('text-anchor', 'middle')
334
+ .attr('x', (_, i) => x(i) + x.bandwidth() / 2)
335
+ .attr('y', gridSize + 16)
336
+ .style('font-size', '11px')
337
+ .style('font-weight', '700')
338
+ .style('fill', 'var(--text-color)')
339
+ .each(function(d) {
340
+ const text = d3.select(this);
341
+ const lines = d.split('\n');
342
+ lines.forEach((line, i) => {
343
+ text.append('tspan')
344
+ .attr('x', text.attr('x'))
345
+ .attr('dy', i === 0 ? '0' : '1.2em')
346
+ .text(line);
347
+ });
348
+ });
349
+
350
+ // Y-axis labels (left) - using SVG text with manual line breaks
351
+ const yLabelsGroup = gAxes.append('g').attr('class', 'y-labels');
352
+ const yLabels = yLabelsGroup.selectAll('text')
353
+ .data(metrics)
354
+ .join('text')
355
+ .attr('class', 'axis-label')
356
+ .attr('text-anchor', 'end')
357
+ .attr('x', -12)
358
+ .attr('y', (_, i) => y(i) + y.bandwidth() / 2)
359
+ .style('font-size', '11px')
360
+ .style('font-weight', '700')
361
+ .style('fill', 'var(--text-color)')
362
+ .each(function(d) {
363
+ const text = d3.select(this);
364
+ const lines = d.split('\n');
365
+ // Center the text vertically around the y position
366
+ const lineHeight = 1.2;
367
+ const totalHeight = (lines.length - 1) * lineHeight;
368
+ const startY = -totalHeight / 2;
369
+ lines.forEach((line, i) => {
370
+ text.append('tspan')
371
+ .attr('x', text.attr('x'))
372
+ .attr('dy', i === 0 ? startY + 'em' : lineHeight + 'em')
373
+ .attr('text-anchor', 'end')
374
+ .text(line);
375
+ });
376
+ });
377
+
378
+ // Title
379
+ gAxes.append('text')
380
+ .attr('class', 'axis-label')
381
+ .attr('text-anchor', 'middle')
382
+ .attr('x', gridSize / 2)
383
+ .attr('y', -20)
384
+ .style('font-size', '14px')
385
+ .text('Correlation Matrix');
386
+
387
+ // Colorbar
388
+ const colorbarWidth = 20;
389
+ const colorbarHeight = gridSize;
390
+ const colorbarX = gridSize + 20;
391
+ const colorbarY = 0;
392
+ const colorbarSteps = divergingPalette.length;
393
+ const colorbarRadius = 8;
394
+
395
+ // Create clipPath for rounded corners (top and bottom only)
396
+ const clipId = `colorbar-clip-${Math.random().toString(36).slice(2)}`;
397
+ let defsColorbar = svg.select('defs');
398
+ if (defsColorbar.empty()) {
399
+ defsColorbar = svg.append('defs');
400
+ }
401
+ const clipPath = defsColorbar.append('clipPath').attr('id', clipId);
402
+ clipPath.append('rect')
403
+ .attr('x', colorbarX)
404
+ .attr('y', colorbarY)
405
+ .attr('width', colorbarWidth)
406
+ .attr('height', colorbarHeight)
407
+ .attr('rx', colorbarRadius)
408
+ .attr('ry', colorbarRadius);
409
+
410
+ // Apply clipPath only to colorbar rectangles group, not labels
411
+ gColorbarRects.attr('clip-path', `url(#${clipId})`);
412
+
413
+ gColorbarRects.selectAll('rect.colorbar-rect')
414
+ .data(d3.range(colorbarSteps))
415
+ .join('rect')
416
+ .attr('class', 'colorbar-rect')
417
+ .attr('x', colorbarX)
418
+ .attr('y', (_, i) => colorbarY + (colorbarHeight / colorbarSteps) * i)
419
+ .attr('width', colorbarWidth)
420
+ .attr('height', colorbarHeight / colorbarSteps)
421
+ .attr('fill', (_, i) => divergingPalette[colorbarSteps - 1 - i])
422
+ .attr('stroke', 'none');
423
+
424
+ // Colorbar border with rounded corners (top and bottom)
425
+ gColorbarRects.append('rect')
426
+ .attr('x', colorbarX)
427
+ .attr('y', colorbarY)
428
+ .attr('width', colorbarWidth)
429
+ .attr('height', colorbarHeight)
430
+ .attr('rx', colorbarRadius)
431
+ .attr('ry', colorbarRadius)
432
+ .attr('fill', 'none')
433
+ .attr('stroke', 'var(--border-color)')
434
+ .attr('stroke-width', 1);
435
+
436
+ // Colorbar labels (outside clipPath so they're visible)
437
+ const colorbarTicks = [-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1];
438
+ gColorbarLabels.selectAll('text.colorbar-tick')
439
+ .data(colorbarTicks)
440
+ .join('text')
441
+ .attr('class', 'colorbar-label')
442
+ .attr('text-anchor', 'start')
443
+ .attr('x', colorbarX + colorbarWidth + 6)
444
+ .attr('y', d => colorbarY + (1 - d) / 2 * colorbarHeight)
445
+ .attr('dominant-baseline', 'middle')
446
+ .text(d => d.toFixed(2));
447
+ }
448
+
449
+ // Initial render + resize handling
450
+ const rerender = () => render();
451
+ if (window.ResizeObserver) {
452
+ const ro = new ResizeObserver(() => rerender());
453
+ ro.observe(container);
454
+ } else {
455
+ window.addEventListener('resize', rerender);
456
+ }
457
+ };
458
+
459
+ if (document.readyState === 'loading') {
460
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
461
+ } else {
462
+ ensureD3(bootstrap);
463
+ }
464
+ })();
465
+ </script>
466
+
app/src/content/embeds/{d3-evaluation1-naive.html → d3-evaluation-configurable.html} RENAMED
@@ -1,4 +1,4 @@
1
- <div class="d3-eval-grid d3-eval-grid-1"></div>
2
  <style>
3
  .d3-eval-grid {
4
  padding: 2px;
@@ -91,11 +91,18 @@
91
  }, { once: true });
92
  };
93
 
 
 
 
 
 
 
 
94
  const bootstrap = () => {
95
  const scriptEl = document.currentScript;
96
  let container = scriptEl ? scriptEl.previousElementSibling : null;
97
- if (!(container && container.classList && container.classList.contains('d3-eval-grid-1'))) {
98
- const candidates = Array.from(document.querySelectorAll('.d3-eval-grid-1'))
99
  .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
100
  container = candidates[candidates.length - 1] || null;
101
  }
@@ -105,8 +112,32 @@
105
  container.dataset.mounted = 'true';
106
  }
107
 
108
- // Find data attribute
109
  let mountEl = container;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
111
  mountEl = mountEl.parentElement;
112
  }
@@ -118,15 +149,6 @@
118
  }
119
  } catch(_) {}
120
 
121
- // Check for experiments filter attribute
122
- let experimentsFilter = null;
123
- try {
124
- const expAttr = container.getAttribute('data-experiments');
125
- if (expAttr) {
126
- experimentsFilter = JSON.parse(expAttr);
127
- }
128
- } catch(_) {}
129
-
130
  const DEFAULT_JSON = '/data/evaluation_summary.json';
131
  const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
132
 
@@ -151,9 +173,8 @@
151
 
152
  fetchFirstAvailable(JSON_PATHS)
153
  .then(rawData => {
154
- // Chart 1: Only Prompt and Basic steering (but reserve space for all)
155
  const allExperiments = ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty', '2D optimized', '8D optimized'];
156
- const visibleExperiments = ['Prompt', 'Basic steering'];
157
 
158
  // Metrics in 2x4 grid layout (8 metrics)
159
  const metrics = [
@@ -174,14 +195,177 @@
174
  data[d.metric][d.experiment] = { mean: d.mean, std: d.std };
175
  });
176
 
177
- // Color palette - consistent across all charts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  const allColors = {
179
- 'Prompt': '#4c4c4c',
180
- 'Basic steering': '#b2b2b2',
181
- 'Clamping': '#b2b2cc',
182
- 'Clamping + Penalty': '#b2b2e6',
183
- '2D optimized': '#b2ffb2',
184
- '8D optimized': '#ffb2ff'
185
  };
186
 
187
  const gridContainer = document.createElement('div');
@@ -412,3 +596,4 @@
412
  }
413
  })();
414
  </script>
 
 
1
+ <div class="d3-eval-grid d3-eval-grid-configurable"></div>
2
  <style>
3
  .d3-eval-grid {
4
  padding: 2px;
 
91
  }, { once: true });
92
  };
93
 
94
+ // Define experiment states
95
+ const EXPERIMENT_STATES = {
96
+ 'naive': ['Prompt', 'Basic steering'],
97
+ 'clamp': ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty'],
98
+ 'multi': ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty', '2D optimized', '8D optimized']
99
+ };
100
+
101
  const bootstrap = () => {
102
  const scriptEl = document.currentScript;
103
  let container = scriptEl ? scriptEl.previousElementSibling : null;
104
+ if (!(container && container.classList && container.classList.contains('d3-eval-grid-configurable'))) {
105
+ const candidates = Array.from(document.querySelectorAll('.d3-eval-grid-configurable'))
106
  .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
107
  container = candidates[candidates.length - 1] || null;
108
  }
 
112
  container.dataset.mounted = 'true';
113
  }
114
 
115
+ // Find config attribute
116
  let mountEl = container;
117
+ let configValue = null;
118
+ while (mountEl && !mountEl.getAttribute?.('data-config')) {
119
+ mountEl = mountEl.parentElement;
120
+ }
121
+ try {
122
+ const configAttr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-config') : null;
123
+ if (configAttr && configAttr.trim()) {
124
+ // Try to parse as JSON first, otherwise treat as string
125
+ try {
126
+ configValue = JSON.parse(configAttr);
127
+ } catch(_) {
128
+ configValue = configAttr.trim();
129
+ }
130
+ }
131
+ } catch(_) {}
132
+
133
+ // Determine visible experiments based on config
134
+ // Default to 'naive' if no config provided
135
+ const stateName = typeof configValue === 'string' ? configValue.toLowerCase() :
136
+ (configValue && configValue.state) ? configValue.state.toLowerCase() : 'naive';
137
+ const visibleExperiments = EXPERIMENT_STATES[stateName] || EXPERIMENT_STATES['naive'];
138
+
139
+ // Find data attribute
140
+ mountEl = container;
141
  while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
142
  mountEl = mountEl.parentElement;
143
  }
 
149
  }
150
  } catch(_) {}
151
 
 
 
 
 
 
 
 
 
 
152
  const DEFAULT_JSON = '/data/evaluation_summary.json';
153
  const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
154
 
 
173
 
174
  fetchFirstAvailable(JSON_PATHS)
175
  .then(rawData => {
176
+ // All experiments for consistent positioning
177
  const allExperiments = ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty', '2D optimized', '8D optimized'];
 
178
 
179
  // Metrics in 2x4 grid layout (8 metrics)
180
  const metrics = [
 
195
  data[d.metric][d.experiment] = { mean: d.mean, std: d.std };
196
  });
197
 
198
+ // Color palette - use categorical colors with similar hues for related experiments
199
+ const getCategoricalColors = (count) => {
200
+ try {
201
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
202
+ return window.ColorPalettes.getColors('categorical', count);
203
+ }
204
+ } catch (_) {}
205
+ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
206
+ const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab'];
207
+ const pool = [primary, ...tableau];
208
+ const arr = []; for (let i = 0; i < count; i++) { arr.push(pool[i % pool.length]); }
209
+ return arr;
210
+ };
211
+
212
+ // Get base colors for groups - start with primary color for Basic steering
213
+ const baseColors = getCategoricalColors(3);
214
+
215
+ // Create variations using sequential palette for similar hues - more harmonious
216
+ const getSequentialVariations = (baseColor, count) => {
217
+ try {
218
+ // Try to use ColorPalettes sequential generator if available
219
+ if (window.ColorPalettes && typeof window.ColorPalettes.getPrimaryOKLCH === 'function') {
220
+ // Parse base color to extract its hue
221
+ const parseColor = (color) => {
222
+ const el = document.createElement('span');
223
+ el.style.color = color;
224
+ document.body.appendChild(el);
225
+ const rgb = getComputedStyle(el).color.match(/\d+/g);
226
+ document.body.removeChild(el);
227
+ if (!rgb || rgb.length < 3) return null;
228
+ return { r: rgb[0]/255, g: rgb[1]/255, b: rgb[2]/255 };
229
+ };
230
+
231
+ const rgb = parseColor(baseColor);
232
+ if (!rgb) return Array(count).fill(baseColor);
233
+
234
+ // Convert RGB to HSL to get hue
235
+ const max = Math.max(rgb.r, rgb.g, rgb.b);
236
+ const min = Math.min(rgb.r, rgb.g, rgb.b);
237
+ const delta = max - min;
238
+ let h = 0;
239
+ if (delta !== 0) {
240
+ if (max === rgb.r) h = ((rgb.g - rgb.b) / delta) % 6;
241
+ else if (max === rgb.g) h = (rgb.b - rgb.r) / delta + 2;
242
+ else h = (rgb.r - rgb.g) / delta + 4;
243
+ }
244
+ h = h * 60;
245
+ if (h < 0) h += 360;
246
+
247
+ // Get primary OKLCH to use as base for sequential generation
248
+ const primaryOKLCH = window.ColorPalettes.getPrimaryOKLCH();
249
+ if (primaryOKLCH) {
250
+ // Create a temporary OKLCH color with the base color's hue
251
+ // Use the primary's L and C as reference, but use the base color's hue
252
+ const baseL = 0.65; // Medium lightness
253
+ const baseC = 0.2; // Medium chroma
254
+
255
+ // Generate sequential palette with the base color's hue
256
+ // This creates harmonious variations
257
+ const tempOKLCH = { L: baseL, C: baseC, h: h };
258
+
259
+ // Use ColorPalettes sequential generator if we can create a custom one
260
+ // Otherwise, create subtle variations manually
261
+ const variations = [];
262
+ for (let i = 0; i < count; i++) {
263
+ const t = count === 1 ? 0.5 : i / (count - 1);
264
+ // More subtle variation: smaller range for harmony
265
+ const LVar = baseL + (t - 0.5) * 0.12; // Range: 0.59 to 0.71 (more subtle)
266
+ const CVar = baseC * (0.95 + t * 0.1); // Slight saturation variation
267
+
268
+ // Use ColorPalettes oklchToHexSafe if available
269
+ if (window.ColorPalettes && window.ColorPalettes.getColors) {
270
+ // Try to get sequential colors and adjust hue
271
+ // For now, use manual HSL conversion with better parameters
272
+ const light = Math.max(0.5, Math.min(0.75, LVar));
273
+ const sat = Math.max(0.35, Math.min(0.55, CVar * 2.5));
274
+ const hue = h / 360;
275
+ const c = sat * (1 - Math.abs(2 * light - 1));
276
+ const x = c * (1 - Math.abs((hue * 6) % 2 - 1));
277
+ const m = light - c / 2;
278
+ let r, g, b;
279
+ if (hue < 1/6) { r = c; g = x; b = 0; }
280
+ else if (hue < 2/6) { r = x; g = c; b = 0; }
281
+ else if (hue < 3/6) { r = 0; g = c; b = x; }
282
+ else if (hue < 4/6) { r = 0; g = x; b = c; }
283
+ else if (hue < 5/6) { r = x; g = 0; b = c; }
284
+ else { r = c; g = 0; b = x; }
285
+ const toHex = (n) => Math.round(Math.max(0, Math.min(255, (n + m) * 255))).toString(16).padStart(2, '0').toUpperCase();
286
+ variations.push(`#${toHex(r)}${toHex(g)}${toHex(b)}`);
287
+ } else {
288
+ variations.push(baseColor);
289
+ }
290
+ }
291
+ return variations;
292
+ }
293
+ }
294
+ } catch (_) {}
295
+
296
+ // Fallback: create subtle variations manually
297
+ try {
298
+ const parseColor = (color) => {
299
+ const el = document.createElement('span');
300
+ el.style.color = color;
301
+ document.body.appendChild(el);
302
+ const rgb = getComputedStyle(el).color.match(/\d+/g);
303
+ document.body.removeChild(el);
304
+ if (!rgb || rgb.length < 3) return null;
305
+ return { r: rgb[0]/255, g: rgb[1]/255, b: rgb[2]/255 };
306
+ };
307
+
308
+ const rgb = parseColor(baseColor);
309
+ if (!rgb) return Array(count).fill(baseColor);
310
+
311
+ // Convert RGB to HSL
312
+ const max = Math.max(rgb.r, rgb.g, rgb.b);
313
+ const min = Math.min(rgb.r, rgb.g, rgb.b);
314
+ const delta = max - min;
315
+ let h = 0;
316
+ if (delta !== 0) {
317
+ if (max === rgb.r) h = ((rgb.g - rgb.b) / delta) % 6;
318
+ else if (max === rgb.g) h = (rgb.b - rgb.r) / delta + 2;
319
+ else h = (rgb.r - rgb.g) / delta + 4;
320
+ }
321
+ h = h * 60;
322
+ if (h < 0) h += 360;
323
+
324
+ // More harmonious variations: smaller, subtler changes
325
+ const variations = [];
326
+ for (let i = 0; i < count; i++) {
327
+ const t = count === 1 ? 0.5 : i / (count - 1);
328
+ // Subtle lightness variation: smaller range for harmony
329
+ const light = 0.6 + (t - 0.5) * 0.15; // Range: 0.525 to 0.675 (more subtle)
330
+ const sat = 0.45 + t * 0.15; // Range: 0.45 to 0.60 (more controlled)
331
+
332
+ // Convert HSL to RGB
333
+ const hue = h / 360;
334
+ const c = sat * (1 - Math.abs(2 * light - 1));
335
+ const x = c * (1 - Math.abs((hue * 6) % 2 - 1));
336
+ const m = light - c / 2;
337
+ let r, g, b;
338
+ if (hue < 1/6) { r = c; g = x; b = 0; }
339
+ else if (hue < 2/6) { r = x; g = c; b = 0; }
340
+ else if (hue < 3/6) { r = 0; g = c; b = x; }
341
+ else if (hue < 4/6) { r = 0; g = x; b = c; }
342
+ else if (hue < 5/6) { r = x; g = 0; b = c; }
343
+ else { r = c; g = 0; b = x; }
344
+ const toHex = (n) => Math.round(Math.max(0, Math.min(255, (n + m) * 255))).toString(16).padStart(2, '0').toUpperCase();
345
+ variations.push(`#${toHex(r)}${toHex(g)}${toHex(b)}`);
346
+ }
347
+ return variations;
348
+ } catch (_) {
349
+ return Array(count).fill(baseColor);
350
+ }
351
+ };
352
+
353
+ // Create color groups
354
+ // baseColors[0] is primary color (first in categorical palette)
355
+ // baseColors[1] is second color
356
+ // baseColors[2] is third color
357
+ const clampBase = baseColors[1] || '#4e79a7'; // Second color for clamping group
358
+ const optimizedBase = baseColors[2] || '#59a14f'; // Third color for optimized group
359
+ const clampVariations = getSequentialVariations(clampBase, 2);
360
+ const optimizedVariations = getSequentialVariations(optimizedBase, 2);
361
+
362
  const allColors = {
363
+ 'Prompt': '#4c4c4c', // Keep gray for baseline/reference
364
+ 'Basic steering': baseColors[0] || '#E889AB', // Primary color (first in palette)
365
+ 'Clamping': clampVariations[0] || '#4e79a7',
366
+ 'Clamping + Penalty': clampVariations[1] || '#5a8ab8',
367
+ '2D optimized': optimizedVariations[0] || '#59a14f',
368
+ '8D optimized': optimizedVariations[1] || '#6bb26b'
369
  };
370
 
371
  const gridContainer = document.createElement('div');
 
596
  }
597
  })();
598
  </script>
599
+
app/src/content/embeds/d3-evaluation-grid.html DELETED
@@ -1,467 +0,0 @@
1
- <div class="d3-eval-grid"></div>
2
- <style>
3
- .d3-eval-grid {
4
- padding: 8px;
5
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
6
- }
7
-
8
- .d3-eval-grid .chart-card {
9
- background: var(--surface-bg);
10
- border: 1px solid var(--border-color);
11
- border-radius: 10px;
12
- padding: 16px;
13
- }
14
-
15
- .d3-eval-grid .grid-container {
16
- display: grid;
17
- grid-template-columns: repeat(2, 1fr);
18
- gap: 24px;
19
- margin-bottom: 16px;
20
- }
21
-
22
- @media (max-width: 768px) {
23
- .d3-eval-grid .grid-container {
24
- grid-template-columns: 1fr;
25
- }
26
- }
27
-
28
- .d3-eval-grid .subplot {
29
- background: var(--surface-bg);
30
- border: 1px solid var(--border-color);
31
- border-radius: 8px;
32
- padding: 12px;
33
- }
34
-
35
- .d3-eval-grid .subplot-title {
36
- font-size: 13px;
37
- font-weight: 600;
38
- color: var(--text-color);
39
- margin-bottom: 8px;
40
- text-align: center;
41
- }
42
-
43
- .d3-eval-grid .legend {
44
- display: flex;
45
- flex-wrap: wrap;
46
- gap: 8px 16px;
47
- padding-top: 12px;
48
- border-top: 1px solid var(--border-color);
49
- font-size: 12px;
50
- justify-content: center;
51
- }
52
-
53
- .d3-eval-grid .legend-item {
54
- display: flex;
55
- align-items: center;
56
- gap: 6px;
57
- cursor: pointer;
58
- transition: opacity 0.2s;
59
- }
60
-
61
- .d3-eval-grid .legend-item.dimmed {
62
- opacity: 0.3;
63
- }
64
-
65
- .d3-eval-grid .legend-swatch {
66
- width: 14px;
67
- height: 14px;
68
- border-radius: 3px;
69
- border: 1px solid var(--border-color);
70
- }
71
-
72
- .d3-eval-grid .axes path,
73
- .d3-eval-grid .axes line {
74
- stroke: var(--axis-color);
75
- }
76
-
77
- .d3-eval-grid .axes text {
78
- fill: var(--tick-color);
79
- font-size: 10px;
80
- }
81
-
82
- .d3-eval-grid .grid line {
83
- stroke: var(--grid-color);
84
- stroke-dasharray: 2,2;
85
- opacity: 0.5;
86
- }
87
-
88
- .d3-eval-grid .axis-label {
89
- fill: var(--text-color);
90
- font-size: 11px;
91
- font-weight: 600;
92
- }
93
-
94
- .d3-eval-grid .d3-tooltip {
95
- position: absolute;
96
- pointer-events: none;
97
- padding: 8px 10px;
98
- background: var(--surface-bg);
99
- border: 1px solid var(--border-color);
100
- border-radius: 8px;
101
- font-size: 11px;
102
- line-height: 1.5;
103
- box-shadow: 0 4px 24px rgba(0,0,0,.18);
104
- opacity: 0;
105
- transition: opacity 0.2s;
106
- z-index: 1000;
107
- }
108
-
109
- .d3-eval-grid .bar {
110
- transition: opacity 0.2s;
111
- }
112
-
113
- .d3-eval-grid .bar.dimmed {
114
- opacity: 0.2;
115
- }
116
- </style>
117
- <script>
118
- (() => {
119
- const ensureD3 = (cb) => {
120
- if (window.d3 && typeof window.d3.select === 'function') return cb();
121
- let s = document.getElementById('d3-cdn-script');
122
- if (!s) {
123
- s = document.createElement('script');
124
- s.id = 'd3-cdn-script';
125
- s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
126
- document.head.appendChild(s);
127
- }
128
- s.addEventListener('load', () => {
129
- if (window.d3 && typeof window.d3.select === 'function') cb();
130
- }, { once: true });
131
- };
132
-
133
- const bootstrap = () => {
134
- const scriptEl = document.currentScript;
135
- let container = scriptEl ? scriptEl.previousElementSibling : null;
136
- if (!(container && container.classList && container.classList.contains('d3-eval-grid'))) {
137
- const candidates = Array.from(document.querySelectorAll('.d3-eval-grid'))
138
- .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
139
- container = candidates[candidates.length - 1] || null;
140
- }
141
- if (!container) return;
142
- if (container.dataset) {
143
- if (container.dataset.mounted === 'true') return;
144
- container.dataset.mounted = 'true';
145
- }
146
-
147
- // Find data attribute
148
- let mountEl = container;
149
- while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
150
- mountEl = mountEl.parentElement;
151
- }
152
- let providedData = null;
153
- try {
154
- const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
155
- if (attr && attr.trim()) {
156
- providedData = attr.trim().startsWith('[') ? JSON.parse(attr) : attr.trim();
157
- }
158
- } catch(_) {}
159
-
160
- // Check for experiments filter attribute
161
- let experimentsFilter = null;
162
- try {
163
- const expAttr = container.getAttribute('data-experiments');
164
- if (expAttr) {
165
- experimentsFilter = JSON.parse(expAttr);
166
- }
167
- } catch(_) {}
168
-
169
- const DEFAULT_JSON = '/data/evaluation_summary.json';
170
- const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
171
-
172
- const JSON_PATHS = typeof providedData === 'string'
173
- ? [ensureDataPrefix(providedData)]
174
- : [
175
- DEFAULT_JSON,
176
- './assets/data/evaluation_summary.json',
177
- '../assets/data/evaluation_summary.json',
178
- '../../assets/data/evaluation_summary.json'
179
- ];
180
-
181
- const fetchFirstAvailable = async (paths) => {
182
- for (const p of paths) {
183
- try {
184
- const r = await fetch(p, { cache: 'no-cache' });
185
- if (r.ok) return await r.json();
186
- } catch(_){}
187
- }
188
- throw new Error('JSON not found');
189
- };
190
-
191
- fetchFirstAvailable(JSON_PATHS)
192
- .then(rawData => {
193
- // All experiments in order
194
- const allExperiments = ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty', '2D optimized', '8D optimized'];
195
-
196
- // Use filtered experiments if provided, otherwise use all
197
- const experiments = experimentsFilter || allExperiments;
198
-
199
- // Metrics in 2x3 grid layout
200
- const metrics = [
201
- { key: 'llm_score_concept', label: 'LLM Concept Score', format: d3.format('.2f') },
202
- { key: 'llm_score_instruction', label: 'LLM Instruction Score', format: d3.format('.2f') },
203
- { key: 'llm_score_fluency', label: 'LLM Fluency Score', format: d3.format('.2f') },
204
- { key: 'rep3', label: '3-gram Repetition Fraction', format: d3.format('.2f') },
205
- { key: 'mean_llm_score', label: 'Mean LLM Score', format: d3.format('.2f') },
206
- { key: 'harmonic_llm_score', label: 'Harmonic Mean LLM Score', format: d3.format('.2f') }
207
- ];
208
-
209
- // Restructure data
210
- const data = {};
211
- rawData.forEach(d => {
212
- if (!data[d.metric]) data[d.metric] = {};
213
- data[d.metric][d.experiment] = { mean: d.mean, std: d.std };
214
- });
215
-
216
- // Color palette - consistent across all charts
217
- const allColors = {
218
- 'Prompt': '#4c4c4c',
219
- 'Basic steering': '#b2b2b2',
220
- 'Clamping': '#b2b2cc',
221
- 'Clamping + Penalty': '#b2b2e6',
222
- '2D optimized': '#b2ffb2',
223
- '8D optimized': '#ffb2ff'
224
- };
225
-
226
- const card = document.createElement('div');
227
- card.className = 'chart-card';
228
- container.appendChild(card);
229
-
230
- const gridContainer = document.createElement('div');
231
- gridContainer.className = 'grid-container';
232
- card.appendChild(gridContainer);
233
-
234
- // Tooltip
235
- const tooltip = d3.select(card).append('div')
236
- .attr('class', 'd3-tooltip')
237
- .style('transform', 'translate(-9999px, -9999px)');
238
-
239
- let hoveredExperiment = null;
240
-
241
- // Create each subplot
242
- metrics.forEach((metric, idx) => {
243
- const subplot = document.createElement('div');
244
- subplot.className = 'subplot';
245
- subplot.dataset.metric = metric.key;
246
- gridContainer.appendChild(subplot);
247
-
248
- const title = document.createElement('div');
249
- title.className = 'subplot-title';
250
- title.textContent = metric.label;
251
- subplot.appendChild(title);
252
-
253
- const svg = d3.select(subplot).append('svg')
254
- .attr('width', '100%')
255
- .style('display', 'block');
256
-
257
- const g = svg.append('g');
258
- const gGrid = g.append('g').attr('class', 'grid');
259
- const gBars = g.append('g').attr('class', 'bars');
260
- const gErrorBars = g.append('g').attr('class', 'error-bars');
261
- const gAxes = g.append('g').attr('class', 'axes');
262
-
263
- subplot._render = () => {
264
- const width = subplot.clientWidth || 300;
265
- const height = Math.max(200, Math.round(width * 0.6));
266
- const margin = { top: 10, right: 10, bottom: 60, left: 50 };
267
- const innerWidth = width - margin.left - margin.right;
268
- const innerHeight = height - margin.top - margin.bottom;
269
-
270
- svg.attr('height', height);
271
- g.attr('transform', `translate(${margin.left},${margin.top})`);
272
-
273
- // Scales
274
- const x = d3.scaleBand()
275
- .domain(experiments)
276
- .range([0, innerWidth])
277
- .padding(0.2);
278
-
279
- // Find y domain for this metric
280
- const values = experiments.map(exp => data[metric.key]?.[exp]?.mean).filter(v => v !== undefined);
281
- const stds = experiments.map(exp => data[metric.key]?.[exp]?.std).filter(v => v !== undefined);
282
- const maxVal = d3.max(values.map((v, i) => v + stds[i]));
283
- const minVal = d3.min(values.map((v, i) => Math.max(0, v - stds[i])));
284
-
285
- const y = d3.scaleLinear()
286
- .domain([Math.max(0, minVal * 0.95), maxVal * 1.05])
287
- .range([innerHeight, 0])
288
- .nice();
289
-
290
- // Grid
291
- gGrid.selectAll('*').remove();
292
- gGrid.selectAll('line')
293
- .data(y.ticks(4))
294
- .join('line')
295
- .attr('x1', 0)
296
- .attr('x2', innerWidth)
297
- .attr('y1', d => y(d))
298
- .attr('y2', d => y(d));
299
-
300
- // Axes
301
- gAxes.selectAll('*').remove();
302
-
303
- const xAxis = gAxes.append('g')
304
- .attr('transform', `translate(0,${innerHeight})`)
305
- .call(d3.axisBottom(x).tickSize(3));
306
-
307
- xAxis.selectAll('text')
308
- .attr('transform', 'rotate(-45)')
309
- .style('text-anchor', 'end')
310
- .attr('dx', '-0.5em')
311
- .attr('dy', '0.15em');
312
-
313
- gAxes.append('g')
314
- .call(d3.axisLeft(y).ticks(4).tickFormat(metric.format).tickSize(3));
315
-
316
- // Draw bars
317
- const bars = [];
318
- experiments.forEach(exp => {
319
- const d = data[metric.key]?.[exp];
320
- if (d) {
321
- bars.push({
322
- experiment: exp,
323
- mean: d.mean,
324
- std: d.std,
325
- color: allColors[exp],
326
- x: x(exp),
327
- y: y(d.mean),
328
- width: x.bandwidth(),
329
- height: innerHeight - y(d.mean)
330
- });
331
- }
332
- });
333
-
334
- gBars.selectAll('rect')
335
- .data(bars)
336
- .join('rect')
337
- .attr('class', 'bar')
338
- .attr('x', d => d.x)
339
- .attr('y', d => d.y)
340
- .attr('width', d => d.width)
341
- .attr('height', d => d.height)
342
- .attr('fill', d => d.color)
343
- .attr('rx', 2)
344
- .classed('dimmed', d => hoveredExperiment && d.experiment !== hoveredExperiment)
345
- .on('mouseenter', (event, d) => {
346
- hoveredExperiment = d.experiment;
347
- updateAll();
348
- tooltip
349
- .style('opacity', 1)
350
- .html(`
351
- <div><strong>${d.experiment}</strong></div>
352
- <div style="margin-top: 4px;">${metric.label}</div>
353
- <div style="margin-top: 4px;"><strong>Mean:</strong> ${metric.format(d.mean)}</div>
354
- <div><strong>Std:</strong> ${metric.format(d.std)}</div>
355
- `);
356
- })
357
- .on('mousemove', (event) => {
358
- const [mx, my] = d3.pointer(event, card);
359
- tooltip.style('transform', `translate(${mx + 10}px, ${my + 10}px)`);
360
- })
361
- .on('mouseleave', () => {
362
- hoveredExperiment = null;
363
- updateAll();
364
- tooltip.style('opacity', 0).style('transform', 'translate(-9999px, -9999px)');
365
- });
366
-
367
- // Error bars
368
- gErrorBars.selectAll('line')
369
- .data(bars)
370
- .join('line')
371
- .attr('x1', d => d.x + d.width / 2)
372
- .attr('x2', d => d.x + d.width / 2)
373
- .attr('y1', d => y(d.mean + d.std))
374
- .attr('y2', d => y(Math.max(0, d.mean - d.std)))
375
- .attr('stroke', '#666')
376
- .attr('stroke-width', 1.5)
377
- .attr('opacity', 0.6);
378
-
379
- // Error bar caps
380
- gErrorBars.selectAll('.cap-top')
381
- .data(bars)
382
- .join('line')
383
- .attr('class', 'cap-top')
384
- .attr('x1', d => d.x + d.width / 2 - 3)
385
- .attr('x2', d => d.x + d.width / 2 + 3)
386
- .attr('y1', d => y(d.mean + d.std))
387
- .attr('y2', d => y(d.mean + d.std))
388
- .attr('stroke', '#666')
389
- .attr('stroke-width', 1.5)
390
- .attr('opacity', 0.6);
391
-
392
- gErrorBars.selectAll('.cap-bottom')
393
- .data(bars)
394
- .join('line')
395
- .attr('class', 'cap-bottom')
396
- .attr('x1', d => d.x + d.width / 2 - 3)
397
- .attr('x2', d => d.x + d.width / 2 + 3)
398
- .attr('y1', d => y(Math.max(0, d.mean - d.std)))
399
- .attr('y2', d => y(Math.max(0, d.mean - d.std)))
400
- .attr('stroke', '#666')
401
- .attr('stroke-width', 1.5)
402
- .attr('opacity', 0.6);
403
- };
404
- });
405
-
406
- // Legend
407
- const legend = document.createElement('div');
408
- legend.className = 'legend';
409
- experiments.forEach(exp => {
410
- const item = document.createElement('div');
411
- item.className = 'legend-item';
412
- item.dataset.experiment = exp;
413
- item.innerHTML = `
414
- <div class="legend-swatch" style="background: ${allColors[exp]}"></div>
415
- <span>${exp}</span>
416
- `;
417
- legend.appendChild(item);
418
- });
419
- card.appendChild(legend);
420
-
421
- // Legend interaction
422
- legend.querySelectorAll('.legend-item').forEach(item => {
423
- item.addEventListener('mouseenter', () => {
424
- hoveredExperiment = item.dataset.experiment;
425
- updateAll();
426
- });
427
- item.addEventListener('mouseleave', () => {
428
- hoveredExperiment = null;
429
- updateAll();
430
- });
431
- });
432
-
433
- const updateAll = () => {
434
- gridContainer.querySelectorAll('.subplot').forEach(subplot => {
435
- if (subplot._render) subplot._render();
436
- });
437
-
438
- legend.querySelectorAll('.legend-item').forEach(item => {
439
- if (hoveredExperiment && item.dataset.experiment !== hoveredExperiment) {
440
- item.classList.add('dimmed');
441
- } else {
442
- item.classList.remove('dimmed');
443
- }
444
- });
445
- };
446
-
447
- updateAll();
448
-
449
- if (window.ResizeObserver) {
450
- const ro = new ResizeObserver(() => updateAll());
451
- ro.observe(container);
452
- } else {
453
- window.addEventListener('resize', updateAll);
454
- }
455
- })
456
- .catch(err => {
457
- container.innerHTML = `<div style="color: red; padding: 20px;">Error: ${err.message}</div>`;
458
- });
459
- };
460
-
461
- if (document.readyState === 'loading') {
462
- document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
463
- } else {
464
- ensureD3(bootstrap);
465
- }
466
- })();
467
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/content/embeds/d3-evaluation2-clamp.html DELETED
@@ -1,414 +0,0 @@
1
- <div class="d3-eval-grid d3-eval-grid-2"></div>
2
- <style>
3
- .d3-eval-grid {
4
- padding: 2px;
5
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
6
- }
7
-
8
- .d3-eval-grid .grid-container {
9
- display: grid;
10
- grid-template-columns: repeat(2, 1fr);
11
- gap: 8px;
12
- }
13
-
14
- @media (max-width: 768px) {
15
- .d3-eval-grid .grid-container {
16
- grid-template-columns: 1fr;
17
- }
18
- }
19
-
20
- .d3-eval-grid .subplot {
21
- padding: 4px;
22
- }
23
-
24
- .d3-eval-grid .subplot-title {
25
- font-size: 12px;
26
- font-weight: 600;
27
- color: var(--text-color);
28
- margin-bottom: 4px;
29
- text-align: center;
30
- }
31
-
32
-
33
- .d3-eval-grid .axes path,
34
- .d3-eval-grid .axes line {
35
- stroke: var(--axis-color);
36
- }
37
-
38
- .d3-eval-grid .axes text {
39
- fill: var(--tick-color);
40
- font-size: 9px;
41
- }
42
-
43
- .d3-eval-grid .grid line {
44
- stroke: var(--grid-color);
45
- stroke-dasharray: 2,2;
46
- opacity: 0.5;
47
- }
48
-
49
- .d3-eval-grid .axis-label {
50
- fill: var(--text-color);
51
- font-size: 11px;
52
- font-weight: 600;
53
- }
54
-
55
- .d3-eval-grid .d3-tooltip {
56
- position: absolute;
57
- pointer-events: none;
58
- padding: 8px 10px;
59
- background: var(--surface-bg);
60
- border: 1px solid var(--border-color);
61
- border-radius: 8px;
62
- font-size: 11px;
63
- line-height: 1.5;
64
- box-shadow: 0 4px 24px rgba(0,0,0,.18);
65
- opacity: 0;
66
- transition: opacity 0.2s;
67
- z-index: 1000;
68
- }
69
-
70
- .d3-eval-grid .bar {
71
- transition: opacity 0.2s;
72
- }
73
-
74
- .d3-eval-grid .bar.dimmed {
75
- opacity: 0.2;
76
- }
77
- </style>
78
- <script>
79
- (() => {
80
- const ensureD3 = (cb) => {
81
- if (window.d3 && typeof window.d3.select === 'function') return cb();
82
- let s = document.getElementById('d3-cdn-script');
83
- if (!s) {
84
- s = document.createElement('script');
85
- s.id = 'd3-cdn-script';
86
- s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
87
- document.head.appendChild(s);
88
- }
89
- s.addEventListener('load', () => {
90
- if (window.d3 && typeof window.d3.select === 'function') cb();
91
- }, { once: true });
92
- };
93
-
94
- const bootstrap = () => {
95
- const scriptEl = document.currentScript;
96
- let container = scriptEl ? scriptEl.previousElementSibling : null;
97
- if (!(container && container.classList && container.classList.contains('d3-eval-grid-2'))) {
98
- const candidates = Array.from(document.querySelectorAll('.d3-eval-grid-2'))
99
- .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
100
- container = candidates[candidates.length - 1] || null;
101
- }
102
- if (!container) return;
103
- if (container.dataset) {
104
- if (container.dataset.mounted === 'true') return;
105
- container.dataset.mounted = 'true';
106
- }
107
-
108
- // Find data attribute
109
- let mountEl = container;
110
- while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
111
- mountEl = mountEl.parentElement;
112
- }
113
- let providedData = null;
114
- try {
115
- const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
116
- if (attr && attr.trim()) {
117
- providedData = attr.trim().startsWith('[') ? JSON.parse(attr) : attr.trim();
118
- }
119
- } catch(_) {}
120
-
121
- // Check for experiments filter attribute
122
- let experimentsFilter = null;
123
- try {
124
- const expAttr = container.getAttribute('data-experiments');
125
- if (expAttr) {
126
- experimentsFilter = JSON.parse(expAttr);
127
- }
128
- } catch(_) {}
129
-
130
- const DEFAULT_JSON = '/data/evaluation_summary.json';
131
- const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
132
-
133
- const JSON_PATHS = typeof providedData === 'string'
134
- ? [ensureDataPrefix(providedData)]
135
- : [
136
- DEFAULT_JSON,
137
- './assets/data/evaluation_summary.json',
138
- '../assets/data/evaluation_summary.json',
139
- '../../assets/data/evaluation_summary.json'
140
- ];
141
-
142
- const fetchFirstAvailable = async (paths) => {
143
- for (const p of paths) {
144
- try {
145
- const r = await fetch(p, { cache: 'no-cache' });
146
- if (r.ok) return await r.json();
147
- } catch(_){}
148
- }
149
- throw new Error('JSON not found');
150
- };
151
-
152
- fetchFirstAvailable(JSON_PATHS)
153
- .then(rawData => {
154
- // Chart 2: Add clamping experiments (but reserve space for all)
155
- const allExperiments = ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty', '2D optimized', '8D optimized'];
156
- const visibleExperiments = ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty'];
157
-
158
- // Metrics in 2x4 grid layout (8 metrics)
159
- const metrics = [
160
- { key: 'llm_score_concept', label: 'LLM Concept Score', format: d3.format('.2f') },
161
- { key: 'eiffel', label: 'Explicit Concept Presence', format: d3.format('.2f') },
162
- { key: 'llm_score_instruction', label: 'LLM Instruction Score', format: d3.format('.2f') },
163
- { key: 'minus_log_prob', label: 'Surprise in Original Model', format: d3.format('.2f') },
164
- { key: 'llm_score_fluency', label: 'LLM Fluency Score', format: d3.format('.2f') },
165
- { key: 'rep3', label: '3-gram Repetition Fraction', format: d3.format('.2f') },
166
- { key: 'mean_llm_score', label: 'Mean LLM Score', format: d3.format('.2f') },
167
- { key: 'harmonic_llm_score', label: 'Harmonic Mean LLM Score', format: d3.format('.2f') }
168
- ];
169
-
170
- // Restructure data
171
- const data = {};
172
- rawData.forEach(d => {
173
- if (!data[d.metric]) data[d.metric] = {};
174
- data[d.metric][d.experiment] = { mean: d.mean, std: d.std };
175
- });
176
-
177
- // Color palette - consistent across all charts
178
- const allColors = {
179
- 'Prompt': '#4c4c4c',
180
- 'Basic steering': '#b2b2b2',
181
- 'Clamping': '#b2b2cc',
182
- 'Clamping + Penalty': '#b2b2e6',
183
- '2D optimized': '#b2ffb2',
184
- '8D optimized': '#ffb2ff'
185
- };
186
-
187
- const gridContainer = document.createElement('div');
188
- gridContainer.className = 'grid-container';
189
- container.appendChild(gridContainer);
190
-
191
- // Tooltip
192
- const tooltip = d3.select(container).append('div')
193
- .attr('class', 'd3-tooltip')
194
- .style('transform', 'translate(-9999px, -9999px)');
195
-
196
- let hoveredExperiment = null;
197
-
198
- // Create each subplot
199
- metrics.forEach((metric, idx) => {
200
- const subplot = document.createElement('div');
201
- subplot.className = 'subplot';
202
- subplot.dataset.metric = metric.key;
203
- gridContainer.appendChild(subplot);
204
-
205
- const title = document.createElement('div');
206
- title.className = 'subplot-title';
207
- title.textContent = metric.label;
208
- subplot.appendChild(title);
209
-
210
- const svg = d3.select(subplot).append('svg')
211
- .attr('width', '100%')
212
- .style('display', 'block');
213
-
214
- const g = svg.append('g');
215
- const gGrid = g.append('g').attr('class', 'grid');
216
- const gBars = g.append('g').attr('class', 'bars');
217
- const gErrorBars = g.append('g').attr('class', 'error-bars');
218
- const gAxes = g.append('g').attr('class', 'axes');
219
- const gLabels = g.append('g').attr('class', 'value-labels');
220
-
221
- subplot._render = () => {
222
- const width = subplot.clientWidth || 300;
223
- const height = Math.max(200, Math.round(width * 0.6));
224
- const margin = { top: 10, right: 20, bottom: 70, left: 42 };
225
- const innerWidth = width - margin.left - margin.right;
226
- const innerHeight = height - margin.top - margin.bottom;
227
-
228
- svg.attr('height', height);
229
- g.attr('transform', `translate(${margin.left},${margin.top})`);
230
-
231
- // Scales - use all experiments for consistent positioning
232
- const x = d3.scaleBand()
233
- .domain(allExperiments)
234
- .range([0, innerWidth])
235
- .padding(0.2);
236
-
237
- // Fixed y-axis ranges based on metric type
238
- const yDomains = {
239
- 'llm_score_concept': [0, 2],
240
- 'llm_score_instruction': [0, 2],
241
- 'llm_score_fluency': [0, 2],
242
- 'mean_llm_score': [0, 2],
243
- 'harmonic_llm_score': [0, 2],
244
- 'eiffel': [0, 1],
245
- 'minus_log_prob': [0, 2],
246
- 'rep3': [0, 0.5]
247
- };
248
-
249
- const y = d3.scaleLinear()
250
- .domain(yDomains[metric.key] || [0, 1])
251
- .range([innerHeight, 0]);
252
-
253
- // Grid
254
- gGrid.selectAll('*').remove();
255
- gGrid.selectAll('line')
256
- .data(y.ticks(4))
257
- .join('line')
258
- .attr('x1', 0)
259
- .attr('x2', innerWidth)
260
- .attr('y1', d => y(d))
261
- .attr('y2', d => y(d));
262
-
263
- // Axes
264
- gAxes.selectAll('*').remove();
265
-
266
- const xAxis = gAxes.append('g')
267
- .attr('transform', `translate(0,${innerHeight})`)
268
- .call(d3.axisBottom(x).tickSize(3));
269
-
270
- // Only show labels for visible experiments
271
- xAxis.selectAll('text')
272
- .attr('transform', 'rotate(-45)')
273
- .style('text-anchor', 'end')
274
- .attr('dx', '-0.5em')
275
- .attr('dy', '0.15em')
276
- .style('opacity', function() {
277
- const text = d3.select(this).text();
278
- return visibleExperiments.includes(text) ? 1 : 0;
279
- });
280
-
281
- gAxes.append('g')
282
- .call(d3.axisLeft(y).ticks(4).tickFormat(metric.format).tickSize(3));
283
-
284
- // Draw bars (only for visible experiments)
285
- const bars = [];
286
- visibleExperiments.forEach(exp => {
287
- const d = data[metric.key]?.[exp];
288
- if (d) {
289
- bars.push({
290
- experiment: exp,
291
- mean: d.mean,
292
- std: d.std,
293
- color: allColors[exp],
294
- x: x(exp),
295
- y: y(d.mean),
296
- width: x.bandwidth(),
297
- height: innerHeight - y(d.mean)
298
- });
299
- }
300
- });
301
-
302
- gBars.selectAll('rect')
303
- .data(bars)
304
- .join('rect')
305
- .attr('class', 'bar')
306
- .attr('x', d => d.x)
307
- .attr('y', d => d.y)
308
- .attr('width', d => d.width)
309
- .attr('height', d => d.height)
310
- .attr('fill', d => d.color)
311
- .attr('rx', 2)
312
- .classed('dimmed', d => hoveredExperiment && d.experiment !== hoveredExperiment)
313
- .on('mouseenter', (event, d) => {
314
- hoveredExperiment = d.experiment;
315
-
316
- // Show value label on bar
317
- gLabels.selectAll('text').remove();
318
- gLabels.append('text')
319
- .attr('x', d.x + d.width / 2)
320
- .attr('y', d.y - 5)
321
- .attr('text-anchor', 'middle')
322
- .attr('fill', 'var(--text-color)')
323
- .attr('font-size', '11px')
324
- .attr('font-weight', '600')
325
- .text(metric.format(d.mean));
326
-
327
- updateAll();
328
- tooltip
329
- .style('opacity', 1)
330
- .html(`
331
- <div><strong>${d.experiment}</strong></div>
332
- <div style="margin-top: 4px;">${metric.label}</div>
333
- <div style="margin-top: 4px;"><strong>Mean:</strong> ${metric.format(d.mean)}</div>
334
- <div><strong>Std:</strong> ${metric.format(d.std)}</div>
335
- `);
336
- })
337
- .on('mousemove', (event) => {
338
- const [mx, my] = d3.pointer(event, container);
339
- tooltip.style('transform', `translate(${mx + 10}px, ${my + 10}px)`);
340
- })
341
- .on('mouseleave', () => {
342
- hoveredExperiment = null;
343
- gLabels.selectAll('text').remove();
344
- updateAll();
345
- tooltip.style('opacity', 0).style('transform', 'translate(-9999px, -9999px)');
346
- });
347
-
348
- // Error bars
349
- gErrorBars.selectAll('line')
350
- .data(bars)
351
- .join('line')
352
- .attr('x1', d => d.x + d.width / 2)
353
- .attr('x2', d => d.x + d.width / 2)
354
- .attr('y1', d => y(d.mean + d.std))
355
- .attr('y2', d => y(Math.max(0, d.mean - d.std)))
356
- .attr('stroke', '#666')
357
- .attr('stroke-width', 1.5)
358
- .attr('opacity', 0.6);
359
-
360
- // Error bar caps
361
- gErrorBars.selectAll('.cap-top')
362
- .data(bars)
363
- .join('line')
364
- .attr('class', 'cap-top')
365
- .attr('x1', d => d.x + d.width / 2 - 3)
366
- .attr('x2', d => d.x + d.width / 2 + 3)
367
- .attr('y1', d => y(d.mean + d.std))
368
- .attr('y2', d => y(d.mean + d.std))
369
- .attr('stroke', '#666')
370
- .attr('stroke-width', 1.5)
371
- .attr('opacity', 0.6);
372
-
373
- gErrorBars.selectAll('.cap-bottom')
374
- .data(bars)
375
- .join('line')
376
- .attr('class', 'cap-bottom')
377
- .attr('x1', d => d.x + d.width / 2 - 3)
378
- .attr('x2', d => d.x + d.width / 2 + 3)
379
- .attr('y1', d => y(Math.max(0, d.mean - d.std)))
380
- .attr('y2', d => y(Math.max(0, d.mean - d.std)))
381
- .attr('stroke', '#666')
382
- .attr('stroke-width', 1.5)
383
- .attr('opacity', 0.6);
384
- };
385
- });
386
-
387
- const updateAll = () => {
388
- gridContainer.querySelectorAll('.subplot').forEach(subplot => {
389
- if (subplot._render) subplot._render();
390
- });
391
-
392
- };
393
-
394
- updateAll();
395
-
396
- if (window.ResizeObserver) {
397
- const ro = new ResizeObserver(() => updateAll());
398
- ro.observe(container);
399
- } else {
400
- window.addEventListener('resize', updateAll);
401
- }
402
- })
403
- .catch(err => {
404
- container.innerHTML = `<div style="color: red; padding: 20px;">Error: ${err.message}</div>`;
405
- });
406
- };
407
-
408
- if (document.readyState === 'loading') {
409
- document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
410
- } else {
411
- ensureD3(bootstrap);
412
- }
413
- })();
414
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/content/embeds/d3-evaluation3-multi.html DELETED
@@ -1,414 +0,0 @@
1
- <div class="d3-eval-grid d3-eval-grid-3"></div>
2
- <style>
3
- .d3-eval-grid {
4
- padding: 2px;
5
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
6
- }
7
-
8
- .d3-eval-grid .grid-container {
9
- display: grid;
10
- grid-template-columns: repeat(2, 1fr);
11
- gap: 8px;
12
- }
13
-
14
- @media (max-width: 768px) {
15
- .d3-eval-grid .grid-container {
16
- grid-template-columns: 1fr;
17
- }
18
- }
19
-
20
- .d3-eval-grid .subplot {
21
- padding: 4px;
22
- }
23
-
24
- .d3-eval-grid .subplot-title {
25
- font-size: 12px;
26
- font-weight: 600;
27
- color: var(--text-color);
28
- margin-bottom: 4px;
29
- text-align: center;
30
- }
31
-
32
-
33
- .d3-eval-grid .axes path,
34
- .d3-eval-grid .axes line {
35
- stroke: var(--axis-color);
36
- }
37
-
38
- .d3-eval-grid .axes text {
39
- fill: var(--tick-color);
40
- font-size: 9px;
41
- }
42
-
43
- .d3-eval-grid .grid line {
44
- stroke: var(--grid-color);
45
- stroke-dasharray: 2,2;
46
- opacity: 0.5;
47
- }
48
-
49
- .d3-eval-grid .axis-label {
50
- fill: var(--text-color);
51
- font-size: 11px;
52
- font-weight: 600;
53
- }
54
-
55
- .d3-eval-grid .d3-tooltip {
56
- position: absolute;
57
- pointer-events: none;
58
- padding: 8px 10px;
59
- background: var(--surface-bg);
60
- border: 1px solid var(--border-color);
61
- border-radius: 8px;
62
- font-size: 11px;
63
- line-height: 1.5;
64
- box-shadow: 0 4px 24px rgba(0,0,0,.18);
65
- opacity: 0;
66
- transition: opacity 0.2s;
67
- z-index: 1000;
68
- }
69
-
70
- .d3-eval-grid .bar {
71
- transition: opacity 0.2s;
72
- }
73
-
74
- .d3-eval-grid .bar.dimmed {
75
- opacity: 0.2;
76
- }
77
- </style>
78
- <script>
79
- (() => {
80
- const ensureD3 = (cb) => {
81
- if (window.d3 && typeof window.d3.select === 'function') return cb();
82
- let s = document.getElementById('d3-cdn-script');
83
- if (!s) {
84
- s = document.createElement('script');
85
- s.id = 'd3-cdn-script';
86
- s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
87
- document.head.appendChild(s);
88
- }
89
- s.addEventListener('load', () => {
90
- if (window.d3 && typeof window.d3.select === 'function') cb();
91
- }, { once: true });
92
- };
93
-
94
- const bootstrap = () => {
95
- const scriptEl = document.currentScript;
96
- let container = scriptEl ? scriptEl.previousElementSibling : null;
97
- if (!(container && container.classList && container.classList.contains('d3-eval-grid-3'))) {
98
- const candidates = Array.from(document.querySelectorAll('.d3-eval-grid-3'))
99
- .filter((el) => !(el.dataset && el.dataset.mounted === 'true'));
100
- container = candidates[candidates.length - 1] || null;
101
- }
102
- if (!container) return;
103
- if (container.dataset) {
104
- if (container.dataset.mounted === 'true') return;
105
- container.dataset.mounted = 'true';
106
- }
107
-
108
- // Find data attribute
109
- let mountEl = container;
110
- while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
111
- mountEl = mountEl.parentElement;
112
- }
113
- let providedData = null;
114
- try {
115
- const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
116
- if (attr && attr.trim()) {
117
- providedData = attr.trim().startsWith('[') ? JSON.parse(attr) : attr.trim();
118
- }
119
- } catch(_) {}
120
-
121
- // Check for experiments filter attribute
122
- let experimentsFilter = null;
123
- try {
124
- const expAttr = container.getAttribute('data-experiments');
125
- if (expAttr) {
126
- experimentsFilter = JSON.parse(expAttr);
127
- }
128
- } catch(_) {}
129
-
130
- const DEFAULT_JSON = '/data/evaluation_summary.json';
131
- const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
132
-
133
- const JSON_PATHS = typeof providedData === 'string'
134
- ? [ensureDataPrefix(providedData)]
135
- : [
136
- DEFAULT_JSON,
137
- './assets/data/evaluation_summary.json',
138
- '../assets/data/evaluation_summary.json',
139
- '../../assets/data/evaluation_summary.json'
140
- ];
141
-
142
- const fetchFirstAvailable = async (paths) => {
143
- for (const p of paths) {
144
- try {
145
- const r = await fetch(p, { cache: 'no-cache' });
146
- if (r.ok) return await r.json();
147
- } catch(_){}
148
- }
149
- throw new Error('JSON not found');
150
- };
151
-
152
- fetchFirstAvailable(JSON_PATHS)
153
- .then(rawData => {
154
- // Chart 3: All experiments including multi-layer optimization
155
- const allExperiments = ['Prompt', 'Basic steering', 'Clamping', 'Clamping + Penalty', '2D optimized', '8D optimized'];
156
- const visibleExperiments = allExperiments;
157
-
158
- // Metrics in 2x4 grid layout (8 metrics)
159
- const metrics = [
160
- { key: 'llm_score_concept', label: 'LLM Concept Score', format: d3.format('.2f') },
161
- { key: 'eiffel', label: 'Explicit Concept Presence', format: d3.format('.2f') },
162
- { key: 'llm_score_instruction', label: 'LLM Instruction Score', format: d3.format('.2f') },
163
- { key: 'minus_log_prob', label: 'Surprise in Original Model', format: d3.format('.2f') },
164
- { key: 'llm_score_fluency', label: 'LLM Fluency Score', format: d3.format('.2f') },
165
- { key: 'rep3', label: '3-gram Repetition Fraction', format: d3.format('.2f') },
166
- { key: 'mean_llm_score', label: 'Mean LLM Score', format: d3.format('.2f') },
167
- { key: 'harmonic_llm_score', label: 'Harmonic Mean LLM Score', format: d3.format('.2f') }
168
- ];
169
-
170
- // Restructure data
171
- const data = {};
172
- rawData.forEach(d => {
173
- if (!data[d.metric]) data[d.metric] = {};
174
- data[d.metric][d.experiment] = { mean: d.mean, std: d.std };
175
- });
176
-
177
- // Color palette - consistent across all charts
178
- const allColors = {
179
- 'Prompt': '#4c4c4c',
180
- 'Basic steering': '#b2b2b2',
181
- 'Clamping': '#b2b2cc',
182
- 'Clamping + Penalty': '#b2b2e6',
183
- '2D optimized': '#b2ffb2',
184
- '8D optimized': '#ffb2ff'
185
- };
186
-
187
- const gridContainer = document.createElement('div');
188
- gridContainer.className = 'grid-container';
189
- container.appendChild(gridContainer);
190
-
191
- // Tooltip
192
- const tooltip = d3.select(container).append('div')
193
- .attr('class', 'd3-tooltip')
194
- .style('transform', 'translate(-9999px, -9999px)');
195
-
196
- let hoveredExperiment = null;
197
-
198
- // Create each subplot
199
- metrics.forEach((metric, idx) => {
200
- const subplot = document.createElement('div');
201
- subplot.className = 'subplot';
202
- subplot.dataset.metric = metric.key;
203
- gridContainer.appendChild(subplot);
204
-
205
- const title = document.createElement('div');
206
- title.className = 'subplot-title';
207
- title.textContent = metric.label;
208
- subplot.appendChild(title);
209
-
210
- const svg = d3.select(subplot).append('svg')
211
- .attr('width', '100%')
212
- .style('display', 'block');
213
-
214
- const g = svg.append('g');
215
- const gGrid = g.append('g').attr('class', 'grid');
216
- const gBars = g.append('g').attr('class', 'bars');
217
- const gErrorBars = g.append('g').attr('class', 'error-bars');
218
- const gAxes = g.append('g').attr('class', 'axes');
219
- const gLabels = g.append('g').attr('class', 'value-labels');
220
-
221
- subplot._render = () => {
222
- const width = subplot.clientWidth || 300;
223
- const height = Math.max(200, Math.round(width * 0.6));
224
- const margin = { top: 10, right: 20, bottom: 70, left: 42 };
225
- const innerWidth = width - margin.left - margin.right;
226
- const innerHeight = height - margin.top - margin.bottom;
227
-
228
- svg.attr('height', height);
229
- g.attr('transform', `translate(${margin.left},${margin.top})`);
230
-
231
- // Scales - use all experiments for consistent positioning
232
- const x = d3.scaleBand()
233
- .domain(allExperiments)
234
- .range([0, innerWidth])
235
- .padding(0.2);
236
-
237
- // Fixed y-axis ranges based on metric type
238
- const yDomains = {
239
- 'llm_score_concept': [0, 2],
240
- 'llm_score_instruction': [0, 2],
241
- 'llm_score_fluency': [0, 2],
242
- 'mean_llm_score': [0, 2],
243
- 'harmonic_llm_score': [0, 2],
244
- 'eiffel': [0, 1],
245
- 'minus_log_prob': [0, 2],
246
- 'rep3': [0, 0.5]
247
- };
248
-
249
- const y = d3.scaleLinear()
250
- .domain(yDomains[metric.key] || [0, 1])
251
- .range([innerHeight, 0]);
252
-
253
- // Grid
254
- gGrid.selectAll('*').remove();
255
- gGrid.selectAll('line')
256
- .data(y.ticks(4))
257
- .join('line')
258
- .attr('x1', 0)
259
- .attr('x2', innerWidth)
260
- .attr('y1', d => y(d))
261
- .attr('y2', d => y(d));
262
-
263
- // Axes
264
- gAxes.selectAll('*').remove();
265
-
266
- const xAxis = gAxes.append('g')
267
- .attr('transform', `translate(0,${innerHeight})`)
268
- .call(d3.axisBottom(x).tickSize(3));
269
-
270
- // Only show labels for visible experiments
271
- xAxis.selectAll('text')
272
- .attr('transform', 'rotate(-45)')
273
- .style('text-anchor', 'end')
274
- .attr('dx', '-0.5em')
275
- .attr('dy', '0.15em')
276
- .style('opacity', function() {
277
- const text = d3.select(this).text();
278
- return visibleExperiments.includes(text) ? 1 : 0;
279
- });
280
-
281
- gAxes.append('g')
282
- .call(d3.axisLeft(y).ticks(4).tickFormat(metric.format).tickSize(3));
283
-
284
- // Draw bars (only for visible experiments)
285
- const bars = [];
286
- visibleExperiments.forEach(exp => {
287
- const d = data[metric.key]?.[exp];
288
- if (d) {
289
- bars.push({
290
- experiment: exp,
291
- mean: d.mean,
292
- std: d.std,
293
- color: allColors[exp],
294
- x: x(exp),
295
- y: y(d.mean),
296
- width: x.bandwidth(),
297
- height: innerHeight - y(d.mean)
298
- });
299
- }
300
- });
301
-
302
- gBars.selectAll('rect')
303
- .data(bars)
304
- .join('rect')
305
- .attr('class', 'bar')
306
- .attr('x', d => d.x)
307
- .attr('y', d => d.y)
308
- .attr('width', d => d.width)
309
- .attr('height', d => d.height)
310
- .attr('fill', d => d.color)
311
- .attr('rx', 2)
312
- .classed('dimmed', d => hoveredExperiment && d.experiment !== hoveredExperiment)
313
- .on('mouseenter', (event, d) => {
314
- hoveredExperiment = d.experiment;
315
-
316
- // Show value label on bar
317
- gLabels.selectAll('text').remove();
318
- gLabels.append('text')
319
- .attr('x', d.x + d.width / 2)
320
- .attr('y', d.y - 5)
321
- .attr('text-anchor', 'middle')
322
- .attr('fill', 'var(--text-color)')
323
- .attr('font-size', '11px')
324
- .attr('font-weight', '600')
325
- .text(metric.format(d.mean));
326
-
327
- updateAll();
328
- tooltip
329
- .style('opacity', 1)
330
- .html(`
331
- <div><strong>${d.experiment}</strong></div>
332
- <div style="margin-top: 4px;">${metric.label}</div>
333
- <div style="margin-top: 4px;"><strong>Mean:</strong> ${metric.format(d.mean)}</div>
334
- <div><strong>Std:</strong> ${metric.format(d.std)}</div>
335
- `);
336
- })
337
- .on('mousemove', (event) => {
338
- const [mx, my] = d3.pointer(event, container);
339
- tooltip.style('transform', `translate(${mx + 10}px, ${my + 10}px)`);
340
- })
341
- .on('mouseleave', () => {
342
- hoveredExperiment = null;
343
- gLabels.selectAll('text').remove();
344
- updateAll();
345
- tooltip.style('opacity', 0).style('transform', 'translate(-9999px, -9999px)');
346
- });
347
-
348
- // Error bars
349
- gErrorBars.selectAll('line')
350
- .data(bars)
351
- .join('line')
352
- .attr('x1', d => d.x + d.width / 2)
353
- .attr('x2', d => d.x + d.width / 2)
354
- .attr('y1', d => y(d.mean + d.std))
355
- .attr('y2', d => y(Math.max(0, d.mean - d.std)))
356
- .attr('stroke', '#666')
357
- .attr('stroke-width', 1.5)
358
- .attr('opacity', 0.6);
359
-
360
- // Error bar caps
361
- gErrorBars.selectAll('.cap-top')
362
- .data(bars)
363
- .join('line')
364
- .attr('class', 'cap-top')
365
- .attr('x1', d => d.x + d.width / 2 - 3)
366
- .attr('x2', d => d.x + d.width / 2 + 3)
367
- .attr('y1', d => y(d.mean + d.std))
368
- .attr('y2', d => y(d.mean + d.std))
369
- .attr('stroke', '#666')
370
- .attr('stroke-width', 1.5)
371
- .attr('opacity', 0.6);
372
-
373
- gErrorBars.selectAll('.cap-bottom')
374
- .data(bars)
375
- .join('line')
376
- .attr('class', 'cap-bottom')
377
- .attr('x1', d => d.x + d.width / 2 - 3)
378
- .attr('x2', d => d.x + d.width / 2 + 3)
379
- .attr('y1', d => y(Math.max(0, d.mean - d.std)))
380
- .attr('y2', d => y(Math.max(0, d.mean - d.std)))
381
- .attr('stroke', '#666')
382
- .attr('stroke-width', 1.5)
383
- .attr('opacity', 0.6);
384
- };
385
- });
386
-
387
- const updateAll = () => {
388
- gridContainer.querySelectorAll('.subplot').forEach(subplot => {
389
- if (subplot._render) subplot._render();
390
- });
391
-
392
- };
393
-
394
- updateAll();
395
-
396
- if (window.ResizeObserver) {
397
- const ro = new ResizeObserver(() => updateAll());
398
- ro.observe(container);
399
- } else {
400
- window.addEventListener('resize', updateAll);
401
- }
402
- })
403
- .catch(err => {
404
- container.innerHTML = `<div style="color: red; padding: 20px;">Error: ${err.message}</div>`;
405
- });
406
- };
407
-
408
- if (document.readyState === 'loading') {
409
- document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
410
- } else {
411
- ensureD3(bootstrap);
412
- }
413
- })();
414
- </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/src/content/embeds/d3-first-experiments.html CHANGED
@@ -7,6 +7,7 @@
7
 
8
  .d3-first-experiments .slider-container {
9
  margin-bottom: 12px;
 
10
  }
11
 
12
  .d3-first-experiments .slider-label {
@@ -34,6 +35,10 @@
34
  var(--primary-color) 100%);
35
  outline: none;
36
  -webkit-appearance: none;
 
 
 
 
37
  }
38
 
39
  .d3-first-experiments input[type="range"]::-webkit-slider-thumb {
@@ -61,7 +66,7 @@
61
  .d3-first-experiments .columns-container {
62
  display: grid;
63
  grid-template-columns: 1fr 1fr;
64
- gap: 8px;
65
  }
66
 
67
  @media (max-width: 768px) {
@@ -114,11 +119,11 @@
114
 
115
  .d3-first-experiments .highlight {
116
  font-weight: 700;
117
- color: #ec4899;
118
  }
119
 
120
  .d3-first-experiments .note {
121
- margin-top: 8px;
122
  font-size: 11px;
123
  color: var(--muted-color);
124
  font-style: italic;
@@ -134,6 +139,28 @@
134
  font-size: 12px;
135
  white-space: pre-wrap;
136
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  </style>
138
  <script>
139
  (() => {
@@ -294,6 +321,11 @@
294
  const minIntensity = intensities[0];
295
  const maxIntensity = intensities[intensities.length - 1];
296
 
 
 
 
 
 
297
  // Create UI
298
  container.innerHTML = `
299
  <div class="slider-container">
@@ -307,6 +339,10 @@
307
  step="0.5"
308
  value="${minIntensity}"
309
  class="steering-slider">
 
 
 
 
310
  </div>
311
  <div class="columns-container">
312
  <div class="column">
 
7
 
8
  .d3-first-experiments .slider-container {
9
  margin-bottom: 12px;
10
+ position: relative;
11
  }
12
 
13
  .d3-first-experiments .slider-label {
 
35
  var(--primary-color) 100%);
36
  outline: none;
37
  -webkit-appearance: none;
38
+ position: relative;
39
+ z-index: 1;
40
+ margin-top: 20px;
41
+ margin-bottom: 20px;
42
  }
43
 
44
  .d3-first-experiments input[type="range"]::-webkit-slider-thumb {
 
66
  .d3-first-experiments .columns-container {
67
  display: grid;
68
  grid-template-columns: 1fr 1fr;
69
+ gap: 20px;
70
  }
71
 
72
  @media (max-width: 768px) {
 
119
 
120
  .d3-first-experiments .highlight {
121
  font-weight: 700;
122
+ color: var(--primary-color);
123
  }
124
 
125
  .d3-first-experiments .note {
126
+ margin-top: 20px;
127
  font-size: 11px;
128
  color: var(--muted-color);
129
  font-style: italic;
 
139
  font-size: 12px;
140
  white-space: pre-wrap;
141
  }
142
+
143
+ .d3-first-experiments .slider-marker {
144
+ position: absolute;
145
+ width: 1px;
146
+ background: rgba(0, 0, 0, 0.25);
147
+ pointer-events: none;
148
+ z-index: 0;
149
+ }
150
+
151
+ .d3-first-experiments .slider-marker-top {
152
+ top: 40px;
153
+ height: 7px;
154
+ }
155
+
156
+ .d3-first-experiments .slider-marker-bottom {
157
+ top: 70px;
158
+ height: 7px;
159
+ }
160
+
161
+ [data-theme="dark"] .d3-first-experiments .slider-marker {
162
+ background: rgba(255, 255, 255, 0.25);
163
+ }
164
  </style>
165
  <script>
166
  (() => {
 
321
  const minIntensity = intensities[0];
322
  const maxIntensity = intensities[intensities.length - 1];
323
 
324
+ // Calculate marker position for value 6
325
+ const markerValue = 6;
326
+ const markerPosition = ((markerValue - minIntensity) / (maxIntensity - minIntensity)) * 100;
327
+ const showMarker = markerValue >= minIntensity && markerValue <= maxIntensity;
328
+
329
  // Create UI
330
  container.innerHTML = `
331
  <div class="slider-container">
 
339
  step="0.5"
340
  value="${minIntensity}"
341
  class="steering-slider">
342
+ ${showMarker ? `
343
+ <div class="slider-marker slider-marker-top" style="left: ${markerPosition}%;"></div>
344
+ <div class="slider-marker slider-marker-bottom" style="left: ${markerPosition}%;"></div>
345
+ ` : ''}
346
  </div>
347
  <div class="columns-container">
348
  <div class="column">
app/src/content/embeds/d3-harmonic-mean.html ADDED
@@ -0,0 +1,842 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Harmonic Mean Charts - Two Side-by-Side Charts
3
+
4
+ Two line charts showing arithmetic and harmonic mean of LLM scores as a function of steering coefficient.
5
+
6
+ Configuration via data-config attribute:
7
+ {
8
+ "dataUrl": "./assets/data/sweep_1d_metrics.csv",
9
+ "xColumn": "alpha"
10
+ }
11
+
12
+ CSV format (with mean/std):
13
+ alpha, arithmetic_mean_mean, arithmetic_mean_std, harmonic_mean_mean, harmonic_mean_std
14
+
15
+ Example usage in MDX:
16
+ <HtmlEmbed
17
+ src="embeds/d3-harmonic-mean.html"
18
+ data="sweep_1d_metrics.csv"
19
+ />
20
+ -->
21
+ <div class="d3-harmonic-mean"></div>
22
+ <style>
23
+ .d3-harmonic-mean {
24
+ position: relative;
25
+ container-type: inline-size;
26
+ }
27
+
28
+ /* Grid - 2 columns side by side */
29
+ .d3-harmonic-mean__grid {
30
+ display: grid;
31
+ grid-template-columns: repeat(2, minmax(0, 1fr));
32
+ gap: 16px;
33
+ }
34
+
35
+ /* Container queries - basées sur la largeur du container parent */
36
+ @container (max-width: 600px) {
37
+ .d3-harmonic-mean__grid {
38
+ grid-template-columns: 1fr;
39
+ }
40
+ }
41
+
42
+ .chart-cell {
43
+ display: flex;
44
+ flex-direction: column;
45
+ position: relative;
46
+ padding: 12px;
47
+ box-shadow: inset 0 0 0 1px var(--border-color);
48
+ border-radius: 8px;
49
+ background: var(--page-bg);
50
+ }
51
+
52
+ .chart-cell__title {
53
+ font-size: 13px;
54
+ font-weight: 700;
55
+ color: var(--text-color);
56
+ margin-bottom: 8px;
57
+ padding-bottom: 8px;
58
+ }
59
+
60
+ .chart-cell__body {
61
+ position: relative;
62
+ width: 100%;
63
+ overflow: hidden;
64
+ }
65
+
66
+ .chart-cell__body svg {
67
+ max-width: 100%;
68
+ height: auto;
69
+ display: block;
70
+ }
71
+
72
+ .d3-harmonic-mean__legend {
73
+ display: flex;
74
+ gap: 16px;
75
+ margin-top: 16px;
76
+ font-size: 11px;
77
+ color: var(--text-color);
78
+ align-items: center;
79
+ justify-content: center;
80
+ }
81
+
82
+ .d3-harmonic-mean__legend-item {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 6px;
86
+ }
87
+
88
+ .d3-harmonic-mean__legend-line {
89
+ width: 20px;
90
+ height: 2px;
91
+ border-radius: 1px;
92
+ }
93
+
94
+ .d3-harmonic-mean__legend-band {
95
+ width: 20px;
96
+ height: 12px;
97
+ border-radius: 2px;
98
+ }
99
+
100
+ /* Reset button */
101
+ .chart-cell .reset-button {
102
+ position: absolute;
103
+ top: 12px;
104
+ right: 12px;
105
+ z-index: 10;
106
+ display: none;
107
+ opacity: 0;
108
+ transition: opacity 0.2s ease;
109
+ font-size: 11px;
110
+ padding: 3px 6px;
111
+ border-radius: 4px;
112
+ background: var(--surface-bg);
113
+ color: var(--text-color);
114
+ border: 1px solid var(--border-color);
115
+ cursor: pointer;
116
+ }
117
+
118
+ /* Axes */
119
+ .d3-harmonic-mean .axes path {
120
+ display: none;
121
+ }
122
+
123
+ .d3-harmonic-mean .axes line {
124
+ stroke: var(--axis-color);
125
+ }
126
+
127
+ .d3-harmonic-mean .axes text {
128
+ fill: var(--tick-color);
129
+ font-size: 10px;
130
+ }
131
+
132
+ .d3-harmonic-mean .axis-label {
133
+ fill: var(--text-color);
134
+ font-size: 10px;
135
+ font-weight: 300;
136
+ opacity: 0.7;
137
+ stroke: var(--page-bg, white);
138
+ stroke-width: 3px;
139
+ paint-order: stroke fill;
140
+ }
141
+
142
+ .d3-harmonic-mean .grid line {
143
+ stroke: var(--grid-color);
144
+ }
145
+
146
+ /* Lines */
147
+ .d3-harmonic-mean path.main-line {
148
+ fill: none;
149
+ stroke-width: 2;
150
+ transition: opacity 0.2s ease;
151
+ }
152
+
153
+ /* Uncertainty band */
154
+ .d3-harmonic-mean path.uncertainty-band {
155
+ fill: var(--primary-color, #E889AB);
156
+ fill-opacity: 0.2;
157
+ stroke: none;
158
+ }
159
+
160
+ /* Tooltip */
161
+ .d3-harmonic-mean .d3-tooltip {
162
+ z-index: 20;
163
+ backdrop-filter: saturate(1.12) blur(8px);
164
+ }
165
+
166
+ .d3-harmonic-mean .d3-tooltip__inner {
167
+ display: flex;
168
+ flex-direction: column;
169
+ gap: 6px;
170
+ min-width: 200px;
171
+ }
172
+
173
+ .d3-harmonic-mean .d3-tooltip__inner>div:first-child {
174
+ font-weight: 800;
175
+ letter-spacing: 0.1px;
176
+ margin-bottom: 0;
177
+ }
178
+
179
+ .d3-harmonic-mean .d3-tooltip__inner>div:nth-child(2) {
180
+ font-size: 11px;
181
+ color: var(--muted-color);
182
+ display: block;
183
+ margin-top: -4px;
184
+ margin-bottom: 2px;
185
+ letter-spacing: 0.1px;
186
+ }
187
+ </style>
188
+ <script>
189
+ (() => {
190
+ const ensureD3 = (cb) => {
191
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
192
+ let s = document.getElementById('d3-cdn-script');
193
+ 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); }
194
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
195
+ s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
196
+ };
197
+
198
+ const bootstrap = () => {
199
+ const scriptEl = document.currentScript;
200
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
201
+ if (!(container && container.classList && container.classList.contains('d3-harmonic-mean'))) {
202
+ const cs = Array.from(document.querySelectorAll('.d3-harmonic-mean')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
203
+ container = cs[cs.length - 1] || null;
204
+ }
205
+ if (!container) return;
206
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
207
+
208
+ const d3 = window.d3;
209
+
210
+ // Read config from HtmlEmbed props
211
+ function readEmbedConfig() {
212
+ let mountEl = container;
213
+ while (mountEl && !mountEl.getAttribute?.('data-config')) {
214
+ mountEl = mountEl.parentElement;
215
+ }
216
+
217
+ let providedConfig = null;
218
+ try {
219
+ const cfg = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-config') : null;
220
+ if (cfg && cfg.trim()) {
221
+ providedConfig = cfg.trim().startsWith('{') ? JSON.parse(cfg) : cfg;
222
+ }
223
+ } catch (e) {
224
+ // Failed to parse data-config
225
+ }
226
+ return providedConfig || {};
227
+ }
228
+
229
+ const embedConfig = readEmbedConfig();
230
+
231
+ // Also check for data-datafiles attribute (used by HtmlEmbed component)
232
+ let providedData = null;
233
+ try {
234
+ let mountEl = container;
235
+ while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
236
+ mountEl = mountEl.parentElement;
237
+ }
238
+ const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
239
+ if (attr && attr.trim()) {
240
+ providedData = attr.trim();
241
+ }
242
+ } catch (e) {}
243
+
244
+ // Metrics configuration - exactly matching the image
245
+ const METRICS = [
246
+ { key: 'arithmetic_mean', label: 'Arithmetic mean of LLM scores', yAxisLabel: 'Score' },
247
+ { key: 'harmonic_mean', label: 'Harmonic mean of LLM scores', yAxisLabel: 'Score' }
248
+ ];
249
+
250
+ // Determine data URL - try config first, then data attribute, then default
251
+ const dataUrlFromConfig = embedConfig.dataUrl;
252
+ const dataUrlFromAttr = providedData;
253
+ const DEFAULT_CSV = '/data/stats_L15F21576.csv';
254
+ const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
255
+
256
+ const CSV_PATHS = dataUrlFromConfig
257
+ ? [dataUrlFromConfig]
258
+ : (dataUrlFromAttr
259
+ ? [ensureDataPrefix(dataUrlFromAttr)]
260
+ : [
261
+ DEFAULT_CSV,
262
+ './assets/data/stats_L15F21576.csv',
263
+ '../assets/data/stats_L15F21576.csv',
264
+ '../../assets/data/stats_L15F21576.csv',
265
+ './assets/data/sweep_1d_metrics.csv',
266
+ '../assets/data/sweep_1d_metrics.csv'
267
+ ]);
268
+
269
+ // Get categorical colors for lines
270
+ const getLineColor = () => {
271
+ try {
272
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
273
+ const colors = window.ColorPalettes.getColors('categorical', 1);
274
+ if (colors && colors.length > 0) return colors[0];
275
+ }
276
+ } catch (_) {}
277
+ return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
278
+ };
279
+
280
+ // Configuration
281
+ const CONFIG = {
282
+ csvPaths: CSV_PATHS,
283
+ xColumn: embedConfig.xColumn || 'alpha',
284
+ metrics: METRICS,
285
+ chartHeight: 240,
286
+ margin: { top: 20, right: 20, bottom: 40, left: 50 },
287
+ zoomExtent: [1.0, 8],
288
+ xAxisLabel: embedConfig.xAxisLabel || 'Steering coefficient α',
289
+ lineColor: embedConfig.lineColor || getLineColor()
290
+ };
291
+
292
+ // Create grid
293
+ const grid = document.createElement('div');
294
+ grid.className = 'd3-harmonic-mean__grid';
295
+ container.appendChild(grid);
296
+
297
+ // Create legend container
298
+ const legend = document.createElement('div');
299
+ legend.className = 'd3-harmonic-mean__legend';
300
+ container.appendChild(legend);
301
+
302
+ // Create chart cells
303
+ CONFIG.metrics.forEach((metricConfig, idx) => {
304
+ const cell = document.createElement('div');
305
+ cell.className = 'chart-cell';
306
+ cell.style.zIndex = CONFIG.metrics.length - idx;
307
+ cell.innerHTML = `
308
+ <div class="chart-cell__title">${metricConfig.label}</div>
309
+ <button class="reset-button">Reset</button>
310
+ <div class="chart-cell__body"></div>
311
+ `;
312
+ grid.appendChild(cell);
313
+ });
314
+
315
+ // Data
316
+ let allData = [];
317
+
318
+ // Function to determine smart format based on data values
319
+ function createSmartFormatter(values) {
320
+ if (!values || values.length === 0) return (v) => v;
321
+
322
+ const min = d3.min(values);
323
+ const max = d3.max(values);
324
+ const range = max - min;
325
+
326
+ // Check if all values are effectively integers (within 0.001 tolerance)
327
+ const allIntegers = values.every(v => Math.abs(v - Math.round(v)) < 0.001);
328
+
329
+ // Large numbers (billions): format as "X.XXB"
330
+ if (max >= 1e9) {
331
+ return (v) => {
332
+ const billions = v / 1e9;
333
+ return allIntegers && billions === Math.round(billions)
334
+ ? d3.format('d')(Math.round(billions)) + 'B'
335
+ : d3.format('.2f')(billions) + 'B';
336
+ };
337
+ }
338
+
339
+ // Millions: format as "X.XXM" or "XM"
340
+ if (max >= 1e6) {
341
+ return (v) => {
342
+ const millions = v / 1e6;
343
+ return allIntegers && millions === Math.round(millions)
344
+ ? d3.format('d')(Math.round(millions)) + 'M'
345
+ : d3.format('.2f')(millions) + 'M';
346
+ };
347
+ }
348
+
349
+ // Thousands: format as "X.Xk" or "Xk"
350
+ if (max >= 1000 && range >= 100) {
351
+ return (v) => {
352
+ const thousands = v / 1000;
353
+ return allIntegers && thousands === Math.round(thousands)
354
+ ? d3.format('d')(Math.round(thousands)) + 'k'
355
+ : d3.format('.1f')(thousands) + 'k';
356
+ };
357
+ }
358
+
359
+ // Regular numbers
360
+ if (allIntegers) {
361
+ return (v) => d3.format('d')(Math.round(v));
362
+ }
363
+
364
+ // Small decimals: use appropriate precision
365
+ if (range < 1) {
366
+ return (v) => d3.format('.3f')(v);
367
+ } else if (range < 10) {
368
+ return (v) => d3.format('.2f')(v);
369
+ } else {
370
+ return (v) => d3.format('.1f')(v);
371
+ }
372
+ }
373
+
374
+ // Init each chart
375
+ function initChart(cellElement, metricConfig) {
376
+ const bodyEl = cellElement.querySelector('.chart-cell__body');
377
+ const resetBtn = cellElement.querySelector('.reset-button');
378
+
379
+ const metricKey = metricConfig.key;
380
+ let hasMoved = false;
381
+
382
+ // Tooltip
383
+ let tip = cellElement.querySelector('.d3-tooltip');
384
+ let tipInner;
385
+ if (!tip) {
386
+ tip = document.createElement('div');
387
+ tip.className = 'd3-tooltip';
388
+ Object.assign(tip.style, {
389
+ position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none',
390
+ padding: '10px 12px', borderRadius: '12px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)',
391
+ 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', zIndex: '20'
392
+ });
393
+ tipInner = document.createElement('div');
394
+ tipInner.className = 'd3-tooltip__inner';
395
+ tip.appendChild(tipInner);
396
+ cellElement.appendChild(tip);
397
+ } else {
398
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
399
+ }
400
+
401
+ // Create SVG
402
+ const svg = d3.select(bodyEl).append('svg').attr('width', '100%').style('display', 'block');
403
+
404
+ // Clip path
405
+ const clipId = 'clip-' + Math.random().toString(36).slice(2);
406
+ const clipPath = svg.append('defs').append('clipPath').attr('id', clipId);
407
+ const clipRect = clipPath.append('rect');
408
+
409
+ // Groups
410
+ const g = svg.append('g');
411
+ const gGrid = g.append('g').attr('class', 'grid');
412
+ const gAxes = g.append('g').attr('class', 'axes');
413
+ const gPlot = g.append('g').attr('class', 'plot').attr('clip-path', `url(#${clipId})`);
414
+ const gHover = g.append('g').attr('class', 'hover-layer');
415
+ const overlay = g.append('rect').attr('class', 'overlay').attr('fill', 'none').attr('pointer-events', 'all').style('cursor', 'grab')
416
+ .on('mousedown', function () {
417
+ d3.select(this).style('cursor', 'grabbing');
418
+ tip.style.opacity = '0';
419
+ if (hoverLine) hoverLine.style('display', 'none');
420
+ })
421
+ .on('mouseup', function () { d3.select(this).style('cursor', 'grab'); });
422
+
423
+ // Scales
424
+ const xScale = d3.scaleLinear();
425
+ const yScale = d3.scaleLinear();
426
+
427
+ // Hover state
428
+ let hoverLine = null;
429
+ let dataPoints = [];
430
+ let hideTipTimer = null;
431
+ let hasMeanStd = false;
432
+
433
+ // Formatters (will be set in render())
434
+ let formatX = (v) => v;
435
+ let formatY = (v) => v;
436
+
437
+ // Zoom
438
+ const zoom = d3.zoom().scaleExtent(CONFIG.zoomExtent).on('zoom', zoomed);
439
+ overlay.call(zoom);
440
+
441
+ function zoomed(event) {
442
+ const transform = event.transform;
443
+ hasMoved = transform.k !== 1 || transform.x !== 0 || transform.y !== 0;
444
+ updateResetButton();
445
+
446
+ const newXScale = transform.rescaleX(xScale);
447
+ const newYScale = transform.rescaleY(yScale);
448
+
449
+ const innerWidth = xScale.range()[1];
450
+
451
+ // Update grid
452
+ const gridTicks = newYScale.ticks(5);
453
+ gGrid.selectAll('line').data(gridTicks).join('line')
454
+ .attr('x1', 0).attr('x2', innerWidth)
455
+ .attr('y1', d => newYScale(d)).attr('y2', d => newYScale(d))
456
+ .attr('stroke', 'var(--grid-color)');
457
+
458
+ // Update uncertainty band (if mean/std available)
459
+ if (hasMeanStd && dataPoints[0] && dataPoints[0].yUpper != null) {
460
+ const area = d3.area()
461
+ .x(d => newXScale(d.x))
462
+ .y0(d => newYScale(d.yLower))
463
+ .y1(d => newYScale(d.yUpper))
464
+ .curve(d3.curveMonotoneX);
465
+
466
+ gPlot.selectAll('path.uncertainty-band')
467
+ .attr('d', area(dataPoints));
468
+ }
469
+
470
+ // Update line
471
+ const line = d3.line()
472
+ .x(d => newXScale(d.x))
473
+ .y(d => newYScale(d.y))
474
+ .curve(d3.curveMonotoneX);
475
+
476
+ gPlot.selectAll('path.main-line')
477
+ .attr('d', line(dataPoints));
478
+
479
+ // Update axes
480
+ gAxes.select('.x-axis').call(d3.axisBottom(newXScale).ticks(5).tickSizeOuter(0).tickFormat(formatX));
481
+ gAxes.select('.y-axis').call(d3.axisLeft(newYScale).ticks(5).tickSizeOuter(0).tickFormat(formatY));
482
+ }
483
+
484
+ function updateResetButton() {
485
+ if (hasMoved) {
486
+ resetBtn.style.display = 'block';
487
+ requestAnimationFrame(() => { resetBtn.style.opacity = '1'; });
488
+ } else {
489
+ resetBtn.style.opacity = '0';
490
+ setTimeout(() => { if (!hasMoved) resetBtn.style.display = 'none'; }, 200);
491
+ }
492
+ }
493
+
494
+ function render() {
495
+ const rect = bodyEl.getBoundingClientRect();
496
+ const width = Math.max(1, Math.round(rect.width || 400));
497
+ const height = CONFIG.chartHeight;
498
+ svg.attr('width', width).attr('height', height);
499
+
500
+ const margin = CONFIG.margin;
501
+ const innerWidth = width - margin.left - margin.right;
502
+ const innerHeight = height - margin.top - margin.bottom;
503
+
504
+ g.attr('transform', `translate(${margin.left},${margin.top})`);
505
+
506
+ // Filter and prepare data for this metric
507
+ // Support both mean/std columns and direct value columns
508
+ const meanKey = `${metricKey}_mean`;
509
+ const stdKey = `${metricKey}_std`;
510
+ hasMeanStd = allData.some(d => d[meanKey] != null && d[stdKey] != null);
511
+
512
+ if (hasMeanStd) {
513
+ // Data has mean and std columns
514
+ dataPoints = allData
515
+ .filter(d => d[CONFIG.xColumn] != null && !isNaN(d[CONFIG.xColumn]) &&
516
+ d[meanKey] != null && !isNaN(d[meanKey]) &&
517
+ d[stdKey] != null && !isNaN(d[stdKey]))
518
+ .map(d => ({
519
+ x: +d[CONFIG.xColumn],
520
+ y: +d[meanKey],
521
+ yUpper: +d[meanKey] + +d[stdKey],
522
+ yLower: +d[meanKey] - +d[stdKey]
523
+ }))
524
+ .sort((a, b) => a.x - b.x);
525
+ } else {
526
+ // Data has direct value columns (fallback)
527
+ dataPoints = allData
528
+ .filter(d => d[CONFIG.xColumn] != null && !isNaN(d[CONFIG.xColumn]) && d[metricKey] != null && !isNaN(d[metricKey]))
529
+ .map(d => ({ x: +d[CONFIG.xColumn], y: +d[metricKey] }))
530
+ .sort((a, b) => a.x - b.x);
531
+ }
532
+
533
+ if (!dataPoints.length) {
534
+ return;
535
+ }
536
+
537
+ // Auto-compute domains from data
538
+ const xExtent = d3.extent(dataPoints, d => d.x);
539
+ const yExtent = hasMeanStd
540
+ ? d3.extent([...dataPoints.map(d => d.yUpper), ...dataPoints.map(d => d.yLower)])
541
+ : d3.extent(dataPoints, d => d.y);
542
+
543
+ // Ensure Y axis never goes below 0
544
+ const yDomain = [Math.max(0, yExtent[0]), yExtent[1]];
545
+
546
+ xScale.domain(xExtent).range([0, innerWidth]);
547
+ yScale.domain(yDomain).range([innerHeight, 0]);
548
+
549
+ // Create smart formatters based on actual data
550
+ const xValues = dataPoints.map(d => d.x);
551
+ const yValues = dataPoints.map(d => d.y);
552
+ formatX = createSmartFormatter(xValues);
553
+ formatY = createSmartFormatter(yValues);
554
+
555
+ // Update clip
556
+ clipRect.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
557
+
558
+ // Update overlay
559
+ overlay.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
560
+
561
+ // Update zoom extent
562
+ zoom.extent([[0, 0], [innerWidth, innerHeight]])
563
+ .translateExtent([[0, 0], [innerWidth, innerHeight]]);
564
+
565
+ // Grid
566
+ gGrid.selectAll('line').data(yScale.ticks(5)).join('line')
567
+ .attr('x1', 0).attr('x2', innerWidth)
568
+ .attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
569
+ .attr('stroke', 'var(--grid-color)');
570
+
571
+ // Axes
572
+ gAxes.selectAll('*').remove();
573
+ gAxes.append('g').attr('class', 'x-axis').attr('transform', `translate(0,${innerHeight})`)
574
+ .call(d3.axisBottom(xScale).ticks(5).tickSizeOuter(0).tickFormat(formatX));
575
+ gAxes.append('g').attr('class', 'y-axis')
576
+ .call(d3.axisLeft(yScale).ticks(5).tickSizeOuter(0).tickFormat(formatY));
577
+ gAxes.selectAll('.domain, .tick line').attr('stroke', 'var(--axis-color)');
578
+ gAxes.selectAll('text').attr('fill', 'var(--tick-color)');
579
+
580
+ // Axis labels
581
+ gAxes.append('text')
582
+ .attr('class', 'axis-label')
583
+ .attr('x', innerWidth / 2)
584
+ .attr('y', innerHeight + 32)
585
+ .attr('text-anchor', 'middle')
586
+ .text(CONFIG.xAxisLabel);
587
+
588
+ gAxes.append('text')
589
+ .attr('class', 'axis-label')
590
+ .attr('transform', 'rotate(-90)')
591
+ .attr('x', -innerHeight / 2)
592
+ .attr('y', -38)
593
+ .attr('text-anchor', 'middle')
594
+ .text(metricConfig.yAxisLabel || 'Value');
595
+
596
+ // Uncertainty band (if mean/std available)
597
+ if (hasMeanStd) {
598
+ const area = d3.area()
599
+ .x(d => xScale(d.x))
600
+ .y0(d => yScale(d.yLower))
601
+ .y1(d => yScale(d.yUpper))
602
+ .curve(d3.curveMonotoneX);
603
+
604
+ gPlot.selectAll('path.uncertainty-band').data([dataPoints]).join('path')
605
+ .attr('class', 'uncertainty-band')
606
+ .attr('d', area);
607
+ }
608
+
609
+ // Main line
610
+ const mainLine = d3.line()
611
+ .x(d => xScale(d.x))
612
+ .y(d => yScale(d.y))
613
+ .curve(d3.curveMonotoneX);
614
+
615
+ gPlot.selectAll('path.main-line').data([dataPoints]).join('path')
616
+ .attr('class', 'main-line')
617
+ .attr('fill', 'none')
618
+ .attr('stroke', CONFIG.lineColor)
619
+ .attr('stroke-width', 2)
620
+ .attr('opacity', 0.85)
621
+ .attr('d', mainLine);
622
+
623
+ // Hover
624
+ setupHover(innerWidth, innerHeight);
625
+ }
626
+
627
+ function setupHover(innerWidth, innerHeight) {
628
+ gHover.selectAll('*').remove();
629
+
630
+ hoverLine = gHover.append('line')
631
+ .style('stroke', 'var(--text-color)')
632
+ .attr('stroke-opacity', 0.25)
633
+ .attr('stroke-width', 1)
634
+ .attr('y1', 0)
635
+ .attr('y2', innerHeight)
636
+ .style('display', 'none')
637
+ .attr('pointer-events', 'none');
638
+
639
+ overlay.on('mousemove', function (ev) {
640
+ if (ev.buttons === 0) onHoverMove(ev);
641
+ }).on('mouseleave', onHoverLeave);
642
+ }
643
+
644
+ function onHoverMove(ev) {
645
+ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
646
+
647
+ const [mx, my] = d3.pointer(ev, overlay.node());
648
+ const targetX = xScale.invert(mx);
649
+
650
+ // Find nearest data point
651
+ let nearest = dataPoints[0];
652
+ let minDist = Math.abs(dataPoints[0].x - targetX);
653
+ for (let i = 1; i < dataPoints.length; i++) {
654
+ const dist = Math.abs(dataPoints[i].x - targetX);
655
+ if (dist < minDist) {
656
+ minDist = dist;
657
+ nearest = dataPoints[i];
658
+ }
659
+ }
660
+
661
+ const xpx = xScale(nearest.x);
662
+ hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
663
+
664
+ let html = `<div><strong>${metricConfig.label}</strong></div>`;
665
+ html += `<div>α = ${formatX(nearest.x)}</div>`;
666
+ if (hasMeanStd && nearest.yUpper != null && nearest.yLower != null) {
667
+ html += `<div>Mean: ${formatY(nearest.y)}</div>`;
668
+ html += `<div>± 1 std dev: [${formatY(nearest.yLower)}, ${formatY(nearest.yUpper)}]</div>`;
669
+ } else {
670
+ html += `<div>${metricConfig.yAxisLabel || 'Value'}: ${formatY(nearest.y)}</div>`;
671
+ }
672
+
673
+ tipInner.innerHTML = html;
674
+ const offsetX = 12, offsetY = 12;
675
+ tip.style.opacity = '1';
676
+ tip.style.transform = `translate(${Math.round(mx + offsetX + CONFIG.margin.left)}px, ${Math.round(my + offsetY + CONFIG.margin.top)}px)`;
677
+ }
678
+
679
+ function onHoverLeave() {
680
+ hideTipTimer = setTimeout(() => {
681
+ tip.style.opacity = '0';
682
+ tip.style.transform = 'translate(-9999px, -9999px)';
683
+ if (hoverLine) hoverLine.style('display', 'none');
684
+ }, 100);
685
+ }
686
+
687
+ // Reset button
688
+ resetBtn.addEventListener('click', () => {
689
+ overlay.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
690
+ });
691
+
692
+ return { render };
693
+ }
694
+
695
+ // Transform long format CSV to wide format
696
+ function transformLongToWide(longData) {
697
+ // Mapping from CSV quantity names to embed metric keys
698
+ const quantityMap = {
699
+ 'arithmetic_mean': 'arithmetic_mean',
700
+ 'harmonic_mean': 'harmonic_mean'
701
+ };
702
+
703
+ // Group by steering_intensity
704
+ const grouped = {};
705
+ longData.forEach(row => {
706
+ const intensity = parseFloat(row.steering_intensity);
707
+ if (isNaN(intensity)) return;
708
+
709
+ if (!grouped[intensity]) {
710
+ grouped[intensity] = { alpha: intensity, steering_intensity: intensity };
711
+ }
712
+
713
+ const quantity = row.quantity;
714
+ const statType = row.stat_type;
715
+ const value = parseFloat(row.value);
716
+
717
+ if (isNaN(value)) return;
718
+
719
+ // Map quantity name to metric key
720
+ const metricKey = quantityMap[quantity] || quantity;
721
+
722
+ // Store mean and std
723
+ if (statType === 'mean') {
724
+ grouped[intensity][`${metricKey}_mean`] = value;
725
+ } else if (statType === 'std') {
726
+ grouped[intensity][`${metricKey}_std`] = value;
727
+ }
728
+ });
729
+
730
+ return Object.values(grouped);
731
+ }
732
+
733
+ // Load data
734
+ async function load() {
735
+ try {
736
+ const fetchFirstAvailable = async (paths) => {
737
+ for (const p of paths) {
738
+ try {
739
+ const r = await fetch(p, { cache: 'no-cache' });
740
+ if (r.ok) return await r.text();
741
+ } catch(_){}
742
+ }
743
+ throw new Error('CSV not found at any of the paths: ' + paths.join(', '));
744
+ };
745
+
746
+ const csvText = await fetchFirstAvailable(CONFIG.csvPaths);
747
+ const rawData = d3.csvParse(csvText);
748
+
749
+ // Check if data is in long format (has quantity, stat_type, value columns)
750
+ const isLongFormat = rawData.length > 0 &&
751
+ rawData[0].hasOwnProperty('quantity') &&
752
+ rawData[0].hasOwnProperty('stat_type') &&
753
+ rawData[0].hasOwnProperty('value');
754
+
755
+ if (isLongFormat) {
756
+ allData = transformLongToWide(rawData);
757
+ // Update xColumn to use steering_intensity if available
758
+ if (allData.length > 0 && allData[0].steering_intensity != null) {
759
+ CONFIG.xColumn = 'steering_intensity';
760
+ }
761
+ } else {
762
+ allData = rawData;
763
+ }
764
+
765
+ // Init all charts
766
+ const cells = Array.from(grid.querySelectorAll('.chart-cell'));
767
+ const chartInstances = cells.map((cell, idx) => initChart(cell, CONFIG.metrics[idx]));
768
+
769
+ // Render all
770
+ chartInstances.forEach(chart => chart.render());
771
+
772
+ // Update legend (once for the whole group)
773
+ const hasMeanStd = allData.some(d => {
774
+ return CONFIG.metrics.some(m => {
775
+ const meanKey = `${m.key}_mean`;
776
+ const stdKey = `${m.key}_std`;
777
+ return d[meanKey] != null && d[stdKey] != null;
778
+ });
779
+ });
780
+
781
+ if (hasMeanStd) {
782
+ legend.innerHTML = `
783
+ <div class="d3-harmonic-mean__legend-item">
784
+ <div class="d3-harmonic-mean__legend-line" style="background: ${CONFIG.lineColor};"></div>
785
+ <span>Mean</span>
786
+ </div>
787
+ <div class="d3-harmonic-mean__legend-item">
788
+ <div class="d3-harmonic-mean__legend-band" style="background: ${CONFIG.lineColor}; opacity: 0.2;"></div>
789
+ <span>± 1 std dev</span>
790
+ </div>
791
+ `;
792
+ } else {
793
+ legend.innerHTML = `
794
+ <div class="d3-harmonic-mean__legend-item">
795
+ <div class="d3-harmonic-mean__legend-line" style="background: ${CONFIG.lineColor};"></div>
796
+ <span>Mean</span>
797
+ </div>
798
+ `;
799
+ }
800
+
801
+ // Responsive - observe container for resize
802
+ let resizeTimer;
803
+ const handleResize = () => {
804
+ clearTimeout(resizeTimer);
805
+ resizeTimer = setTimeout(() => {
806
+ chartInstances.forEach(chart => chart.render());
807
+ }, 100);
808
+ };
809
+
810
+ const ro = window.ResizeObserver ? new ResizeObserver(handleResize) : null;
811
+ if (ro) {
812
+ ro.observe(container);
813
+ }
814
+
815
+ // Also observe window resize as fallback
816
+ window.addEventListener('resize', handleResize);
817
+
818
+ // Force a re-render after a short delay to ensure proper sizing
819
+ setTimeout(() => {
820
+ chartInstances.forEach(chart => chart.render());
821
+ }, 100);
822
+
823
+ } catch (e) {
824
+ const pre = document.createElement('pre');
825
+ pre.textContent = 'Error loading data: ' + (e && e.message ? e.message : e);
826
+ pre.style.color = 'var(--danger, #b00020)';
827
+ pre.style.fontSize = '12px';
828
+ container.appendChild(pre);
829
+ }
830
+ }
831
+
832
+ load();
833
+ };
834
+
835
+ if (document.readyState === 'loading') {
836
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
837
+ } else {
838
+ ensureD3(bootstrap);
839
+ }
840
+ })();
841
+ </script>
842
+
app/src/content/embeds/d3-six-line-chart.html ADDED
@@ -0,0 +1,874 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Multi-Line Charts Grid
3
+
4
+ A configurable grid of line charts with zoom/pan, smoothing, and hover tooltips.
5
+
6
+ Configuration via data-config attribute:
7
+ {
8
+ "dataUrl": "./assets/data/your_data.csv",
9
+ "charts": [
10
+ { "title": "Chart 1", "metric": "metric1" },
11
+ { "title": "Chart 2", "metric": "metric2" },
12
+ ...
13
+ ],
14
+ "smoothingWindow": 15,
15
+ "smoothingCurve": "monotoneX",
16
+ "gridColumns": 3 // Optional: number of columns (default: 3)
17
+ }
18
+
19
+ CSV format: run_name, step, metric1, metric2, ...
20
+
21
+ Example usage in MDX:
22
+ <HtmlEmbed
23
+ src="embeds/d3-six-line-charts.html"
24
+ config={{
25
+ dataUrl: "./assets/data/attention_evals.csv",
26
+ charts: [
27
+ { title: "HellaSwag", metric: "hellaswag" },
28
+ { title: "MMLU", metric: "mmlu" },
29
+ { title: "ARC", metric: "arc" },
30
+ { title: "PIQA", metric: "piqa" },
31
+ { title: "OpenBookQA", metric: "openbookqa" },
32
+ { title: "WinoGrande", metric: "winogrande" }
33
+ ],
34
+ smoothingWindow: 15
35
+ }}
36
+ />
37
+ -->
38
+ <div class="d3-multi-charts"></div>
39
+ <style>
40
+ .d3-multi-charts {
41
+ position: relative;
42
+ container-type: inline-size;
43
+ }
44
+
45
+ /* Legend header */
46
+ .d3-multi-charts__header {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: center;
50
+ margin-bottom: 16px;
51
+ }
52
+
53
+ .d3-multi-charts__header .legend-bottom {
54
+ display: flex;
55
+ flex-direction: column;
56
+ align-items: center;
57
+ gap: 6px;
58
+ font-size: 12px;
59
+ color: var(--text-color);
60
+ max-width: 80%;
61
+ }
62
+
63
+ .d3-multi-charts__header .legend-bottom .legend-title {
64
+ font-size: 12px;
65
+ font-weight: 700;
66
+ color: var(--text-color);
67
+ }
68
+
69
+ .d3-multi-charts__header .legend-bottom .items {
70
+ display: flex;
71
+ flex-wrap: wrap;
72
+ gap: 8px 14px;
73
+ justify-content: center;
74
+ }
75
+
76
+ .d3-multi-charts__header .legend-bottom .item {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: 6px;
80
+ white-space: nowrap;
81
+ cursor: pointer;
82
+ }
83
+
84
+ .d3-multi-charts__header .legend-bottom .swatch {
85
+ width: 14px;
86
+ height: 14px;
87
+ border-radius: 3px;
88
+ border: 1px solid var(--border-color);
89
+ display: inline-block;
90
+ }
91
+
92
+ /* Grid */
93
+ .d3-multi-charts__grid {
94
+ display: grid;
95
+ grid-template-columns: repeat(3, minmax(0, 1fr));
96
+ gap: 12px;
97
+ }
98
+
99
+ /* Container queries - basées sur la largeur du container parent, pas de la viewport */
100
+ @container (max-width: 900px) {
101
+ .d3-multi-charts__grid {
102
+ grid-template-columns: repeat(2, minmax(0, 1fr));
103
+ }
104
+ }
105
+
106
+ @container (max-width: 600px) {
107
+ .d3-multi-charts__grid {
108
+ grid-template-columns: 1fr;
109
+ }
110
+ }
111
+
112
+ .chart-cell {
113
+ display: flex;
114
+ flex-direction: column;
115
+ position: relative;
116
+ padding: 12px;
117
+ box-shadow: inset 0 0 0 1px var(--border-color);
118
+ border-radius: 8px;
119
+ }
120
+
121
+ .chart-cell__title {
122
+ font-size: 13px;
123
+ font-weight: 700;
124
+ color: var(--text-color);
125
+ margin-bottom: 8px;
126
+ padding-bottom: 8px;
127
+ }
128
+
129
+ .chart-cell__body {
130
+ position: relative;
131
+ width: 100%;
132
+ overflow: hidden;
133
+ }
134
+
135
+ .chart-cell__body svg {
136
+ max-width: 100%;
137
+ height: auto;
138
+ display: block;
139
+ }
140
+
141
+ /* Reset button */
142
+ .chart-cell .reset-button {
143
+ position: absolute;
144
+ top: 12px;
145
+ right: 12px;
146
+ z-index: 10;
147
+ display: none;
148
+ opacity: 0;
149
+ transition: opacity 0.2s ease;
150
+ font-size: 11px;
151
+ padding: 3px 6px;
152
+ border-radius: 4px;
153
+ background: var(--surface-bg);
154
+ color: var(--text-color);
155
+ border: 1px solid var(--border-color);
156
+ cursor: pointer;
157
+ }
158
+
159
+ /* Axes */
160
+ .d3-multi-charts .axes path {
161
+ display: none;
162
+ }
163
+
164
+ .d3-multi-charts .axes line {
165
+ stroke: var(--axis-color);
166
+ }
167
+
168
+ .d3-multi-charts .axes text {
169
+ fill: var(--tick-color);
170
+ font-size: 10px;
171
+ }
172
+
173
+ .d3-multi-charts .axis-label {
174
+ fill: var(--text-color);
175
+ font-size: 10px;
176
+ font-weight: 300;
177
+ opacity: 0.7;
178
+ stroke: var(--page-bg, white);
179
+ stroke-width: 3px;
180
+ paint-order: stroke fill;
181
+ }
182
+
183
+ .d3-multi-charts .grid line {
184
+ stroke: var(--grid-color);
185
+ }
186
+
187
+ /* Lines */
188
+ .d3-multi-charts path.main-line {
189
+ transition: opacity 0.2s ease;
190
+ }
191
+
192
+ .d3-multi-charts path.ghost-line {
193
+ transition: opacity 0.6s ease;
194
+ }
195
+
196
+ /* Ghosting on hover */
197
+ .d3-multi-charts.hovering path.main-line.ghost {
198
+ opacity: .25;
199
+ }
200
+
201
+ .d3-multi-charts.hovering path.ghost-line.ghost {
202
+ opacity: .05;
203
+ }
204
+
205
+ .d3-multi-charts.hovering .legend-bottom .item.ghost {
206
+ opacity: .35;
207
+ }
208
+
209
+ /* Tooltip */
210
+ .d3-multi-charts .d3-tooltip {
211
+ z-index: 20;
212
+ backdrop-filter: saturate(1.12) blur(8px);
213
+ }
214
+
215
+ .d3-multi-charts .d3-tooltip__inner {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 6px;
219
+ min-width: 200px;
220
+ }
221
+
222
+ .d3-multi-charts .d3-tooltip__inner>div:first-child {
223
+ font-weight: 800;
224
+ letter-spacing: 0.1px;
225
+ margin-bottom: 0;
226
+ }
227
+
228
+ .d3-multi-charts .d3-tooltip__inner>div:nth-child(2) {
229
+ font-size: 11px;
230
+ color: var(--muted-color);
231
+ display: block;
232
+ margin-top: -4px;
233
+ margin-bottom: 2px;
234
+ letter-spacing: 0.1px;
235
+ }
236
+
237
+ .d3-multi-charts .d3-tooltip__inner>div:nth-child(n+3) {
238
+ padding-top: 6px;
239
+ border-top: 1px solid var(--border-color);
240
+ }
241
+
242
+ .d3-multi-charts .d3-tooltip__color-dot {
243
+ display: inline-block;
244
+ width: 12px;
245
+ height: 12px;
246
+ border-radius: 3px;
247
+ border: 1px solid var(--border-color);
248
+ }
249
+
250
+ /* Trackio footer removed */
251
+ </style>
252
+ <script>
253
+ (() => {
254
+ const ensureD3 = (cb) => {
255
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
256
+ let s = document.getElementById('d3-cdn-script');
257
+ 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); }
258
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
259
+ s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
260
+ };
261
+
262
+ const bootstrap = () => {
263
+ const scriptEl = document.currentScript;
264
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
265
+ if (!(container && container.classList && container.classList.contains('d3-multi-charts'))) {
266
+ const cs = Array.from(document.querySelectorAll('.d3-multi-charts')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
267
+ container = cs[cs.length - 1] || null;
268
+ }
269
+ if (!container) return;
270
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
271
+
272
+ const d3 = window.d3;
273
+
274
+ // Read config from HtmlEmbed props
275
+ function readEmbedConfig() {
276
+ let mountEl = container;
277
+ while (mountEl && !mountEl.getAttribute?.('data-config')) {
278
+ mountEl = mountEl.parentElement;
279
+ }
280
+
281
+ let providedConfig = null;
282
+ try {
283
+ const cfg = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-config') : null;
284
+ if (cfg && cfg.trim()) {
285
+ providedConfig = cfg.trim().startsWith('{') ? JSON.parse(cfg) : cfg;
286
+ }
287
+ } catch (e) {
288
+ // Failed to parse data-config
289
+ }
290
+ return providedConfig || {};
291
+ }
292
+
293
+ const embedConfig = readEmbedConfig();
294
+
295
+ // Configuration
296
+ const CONFIG = {
297
+ dataUrl: embedConfig.dataUrl || './assets/data/attention_evals.csv',
298
+ charts: embedConfig.charts || [],
299
+ smoothing: embedConfig.smoothing !== undefined ? embedConfig.smoothing : false,
300
+ smoothingWindow: embedConfig.smoothingWindow || 15,
301
+ smoothingCurve: embedConfig.smoothingCurve || 'monotoneX',
302
+ gridColumns: embedConfig.gridColumns || 3,
303
+ chartHeight: 240,
304
+ margin: { top: 20, right: 20, bottom: 40, left: 50 },
305
+ zoomExtent: [1.0, 8],
306
+ xAxisLabel: embedConfig.xAxisLabel || 'Consumed tokens',
307
+ yAxisLabel: embedConfig.yAxisLabel || 'Value',
308
+ xColumn: embedConfig.xColumn || 'tokens',
309
+ runColumn: embedConfig.runColumn || 'run_name'
310
+ };
311
+
312
+ if (!CONFIG.charts.length) {
313
+ container.innerHTML = '<p style="color: var(--danger); font-size: 12px;">Error: No charts configured</p>';
314
+ return;
315
+ }
316
+
317
+ // Create legend header
318
+ const header = document.createElement('div');
319
+ header.className = 'd3-multi-charts__header';
320
+ header.innerHTML = `
321
+ <div class="legend-bottom">
322
+ <div class="legend-title">Legend</div>
323
+ <div class="items"></div>
324
+ </div>
325
+ `;
326
+ container.appendChild(header);
327
+
328
+ // Create grid
329
+ const grid = document.createElement('div');
330
+ grid.className = 'd3-multi-charts__grid';
331
+ container.appendChild(grid);
332
+
333
+ // Trackio footer removed
334
+
335
+ // Create chart cells
336
+ CONFIG.charts.forEach((chartConfig, idx) => {
337
+ const cell = document.createElement('div');
338
+ cell.className = 'chart-cell';
339
+ cell.style.zIndex = CONFIG.charts.length - idx; // Stacking order
340
+ cell.innerHTML = `
341
+ <div class="chart-cell__title">${chartConfig.title}</div>
342
+ <button class="reset-button">Reset</button>
343
+ <div class="chart-cell__body"></div>
344
+ `;
345
+ grid.appendChild(cell);
346
+ });
347
+
348
+ // Data
349
+ let allData = [];
350
+ let runList = [];
351
+ let runColorMap = {};
352
+
353
+ // Smoothing
354
+ const getCurve = (smooth) => {
355
+ if (!smooth) return d3.curveLinear;
356
+ switch (CONFIG.smoothingCurve) {
357
+ case 'catmullRom': return d3.curveCatmullRom.alpha(0.5);
358
+ case 'monotoneX': return d3.curveMonotoneX;
359
+ case 'basis': return d3.curveBasis;
360
+ default: return d3.curveLinear;
361
+ }
362
+ };
363
+
364
+ function movingAverage(values, windowSize) {
365
+ if (!Array.isArray(values) || values.length === 0 || windowSize <= 1) return values;
366
+ const half = Math.floor(windowSize / 2);
367
+ const out = new Array(values.length);
368
+ for (let i = 0; i < values.length; i++) {
369
+ let sum = 0; let count = 0;
370
+ const start = Math.max(0, i - half);
371
+ const end = Math.min(values.length - 1, i + half);
372
+ for (let j = start; j <= end; j++) { if (!Number.isNaN(values[j].value)) { sum += values[j].value; count++; } }
373
+ const avg = count ? (sum / count) : values[i].value;
374
+ out[i] = { step: values[i].step, value: avg };
375
+ }
376
+ return out;
377
+ }
378
+
379
+ function applySmoothing(values, smooth) {
380
+ if (!smooth) return values;
381
+ return movingAverage(values, CONFIG.smoothingWindow);
382
+ }
383
+
384
+ // Function to determine smart format based on data values
385
+ function createSmartFormatter(values) {
386
+ if (!values || values.length === 0) return (v) => v;
387
+
388
+ const min = d3.min(values);
389
+ const max = d3.max(values);
390
+ const range = max - min;
391
+
392
+ // Check if all values are effectively integers (within 0.001 tolerance)
393
+ const allIntegers = values.every(v => Math.abs(v - Math.round(v)) < 0.001);
394
+
395
+ // Large numbers (billions): format as "X.XXB"
396
+ if (max >= 1e9) {
397
+ return (v) => {
398
+ const billions = v / 1e9;
399
+ return allIntegers && billions === Math.round(billions)
400
+ ? d3.format('d')(Math.round(billions)) + 'B'
401
+ : d3.format('.2f')(billions) + 'B';
402
+ };
403
+ }
404
+
405
+ // Millions: format as "X.XXM" or "XM"
406
+ if (max >= 1e6) {
407
+ return (v) => {
408
+ const millions = v / 1e6;
409
+ return allIntegers && millions === Math.round(millions)
410
+ ? d3.format('d')(Math.round(millions)) + 'M'
411
+ : d3.format('.2f')(millions) + 'M';
412
+ };
413
+ }
414
+
415
+ // Thousands: format as "X.Xk" or "Xk"
416
+ if (max >= 1000 && range >= 100) {
417
+ return (v) => {
418
+ const thousands = v / 1000;
419
+ return allIntegers && thousands === Math.round(thousands)
420
+ ? d3.format('d')(Math.round(thousands)) + 'k'
421
+ : d3.format('.1f')(thousands) + 'k';
422
+ };
423
+ }
424
+
425
+ // Regular numbers
426
+ if (allIntegers) {
427
+ return (v) => d3.format('d')(Math.round(v));
428
+ }
429
+
430
+ // Small decimals: use appropriate precision
431
+ if (range < 1) {
432
+ return (v) => d3.format('.3f')(v);
433
+ } else if (range < 10) {
434
+ return (v) => d3.format('.2f')(v);
435
+ } else {
436
+ return (v) => d3.format('.1f')(v);
437
+ }
438
+ }
439
+
440
+ // Colors
441
+ const getRunColors = (n) => {
442
+ try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch (_) { }
443
+ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
444
+ return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', '#9B59B6', '#16A085', ...(d3.schemeTableau10 || [])].slice(0, n);
445
+ };
446
+
447
+ // Init each chart
448
+ function initChart(cellElement, chartConfig) {
449
+ const bodyEl = cellElement.querySelector('.chart-cell__body');
450
+ const resetBtn = cellElement.querySelector('.reset-button');
451
+
452
+ const metric = chartConfig.metric;
453
+ let smoothEnabled = CONFIG.smoothing;
454
+ let hasMoved = false;
455
+
456
+ // Tooltip
457
+ let tip = cellElement.querySelector('.d3-tooltip');
458
+ let tipInner;
459
+ if (!tip) {
460
+ tip = document.createElement('div');
461
+ tip.className = 'd3-tooltip';
462
+ Object.assign(tip.style, {
463
+ position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none',
464
+ padding: '10px 12px', borderRadius: '12px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)',
465
+ 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', zIndex: '20'
466
+ });
467
+ tipInner = document.createElement('div');
468
+ tipInner.className = 'd3-tooltip__inner';
469
+ tip.appendChild(tipInner);
470
+ cellElement.appendChild(tip);
471
+ } else {
472
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
473
+ }
474
+
475
+ // Create SVG
476
+ const svg = d3.select(bodyEl).append('svg').attr('width', '100%').style('display', 'block');
477
+
478
+ // Clip path
479
+ const clipId = 'clip-' + Math.random().toString(36).slice(2);
480
+ const clipPath = svg.append('defs').append('clipPath').attr('id', clipId);
481
+ const clipRect = clipPath.append('rect');
482
+
483
+ // Groups
484
+ const g = svg.append('g');
485
+ const gGrid = g.append('g').attr('class', 'grid');
486
+ const gAxes = g.append('g').attr('class', 'axes');
487
+ const gPlot = g.append('g').attr('class', 'plot').attr('clip-path', `url(#${clipId})`);
488
+ const gHover = g.append('g').attr('class', 'hover-layer');
489
+ const overlay = g.append('rect').attr('class', 'overlay').attr('fill', 'none').attr('pointer-events', 'all').style('cursor', 'grab')
490
+ .on('mousedown', function () {
491
+ d3.select(this).style('cursor', 'grabbing');
492
+ tip.style.opacity = '0';
493
+ if (hoverLine) hoverLine.style('display', 'none');
494
+ })
495
+ .on('mouseup', function () { d3.select(this).style('cursor', 'grab'); });
496
+
497
+ // Scales
498
+ const xScale = d3.scaleLinear();
499
+ const yScale = d3.scaleLinear();
500
+
501
+ // Hover state
502
+ let hoverLine = null;
503
+ let steps = [];
504
+ let hideTipTimer = null;
505
+
506
+ // Formatters (will be set in render())
507
+ let formatStep = (v) => v;
508
+ let formatValue = (v) => v;
509
+
510
+ // Zoom
511
+ const zoom = d3.zoom().scaleExtent(CONFIG.zoomExtent).on('zoom', zoomed);
512
+ overlay.call(zoom);
513
+
514
+ function zoomed(event) {
515
+ const transform = event.transform;
516
+ hasMoved = transform.k !== 1 || transform.x !== 0 || transform.y !== 0;
517
+ updateResetButton();
518
+
519
+ const newXScale = transform.rescaleX(xScale);
520
+ const newYScale = transform.rescaleY(yScale);
521
+
522
+ const innerWidth = xScale.range()[1];
523
+
524
+ // Update grid
525
+ const gridTicks = newYScale.ticks(5);
526
+ gGrid.selectAll('line').data(gridTicks).join('line')
527
+ .attr('x1', 0).attr('x2', innerWidth)
528
+ .attr('y1', d => newYScale(d)).attr('y2', d => newYScale(d))
529
+ .attr('stroke', 'var(--grid-color)');
530
+
531
+ // Update lines
532
+ const line = d3.line()
533
+ .x(d => newXScale(d.step))
534
+ .y(d => newYScale(d.value))
535
+ .curve(getCurve(smoothEnabled));
536
+
537
+ gPlot.selectAll('path.ghost-line')
538
+ .attr('d', d => {
539
+ const rawLine = d3.line().x(d => newXScale(d.step)).y(d => newYScale(d.value)).curve(d3.curveLinear);
540
+ return rawLine(d.values);
541
+ });
542
+
543
+ gPlot.selectAll('path.main-line')
544
+ .attr('d', d => line(applySmoothing(d.values, smoothEnabled)));
545
+
546
+ // Update axes
547
+ gAxes.select('.x-axis').call(d3.axisBottom(newXScale).ticks(5).tickSizeOuter(0).tickFormat(formatStep));
548
+ gAxes.select('.y-axis').call(d3.axisLeft(newYScale).ticks(5).tickSizeOuter(0).tickFormat(formatValue));
549
+ }
550
+
551
+ function updateResetButton() {
552
+ if (hasMoved) {
553
+ resetBtn.style.display = 'block';
554
+ requestAnimationFrame(() => { resetBtn.style.opacity = '1'; });
555
+ } else {
556
+ resetBtn.style.opacity = '0';
557
+ setTimeout(() => { if (!hasMoved) resetBtn.style.display = 'none'; }, 200);
558
+ }
559
+ }
560
+
561
+ function render() {
562
+ const rect = bodyEl.getBoundingClientRect();
563
+ const width = Math.max(1, Math.round(rect.width || 400));
564
+ const height = CONFIG.chartHeight;
565
+ svg.attr('width', width).attr('height', height);
566
+
567
+ const margin = CONFIG.margin;
568
+ const innerWidth = width - margin.left - margin.right;
569
+ const innerHeight = height - margin.top - margin.bottom;
570
+
571
+ g.attr('transform', `translate(${margin.left},${margin.top})`);
572
+
573
+ // Filter data for this metric
574
+ const metricData = allData.filter(d => d[metric] != null && !isNaN(d[metric]));
575
+
576
+ if (!metricData.length) {
577
+ return;
578
+ }
579
+
580
+ // Auto-compute domains from data
581
+ const stepExtent = d3.extent(metricData, d => d.step);
582
+ const valueExtent = d3.extent(metricData, d => d[metric]);
583
+
584
+ xScale.domain(stepExtent).range([0, innerWidth]);
585
+ yScale.domain(valueExtent).range([innerHeight, 0]);
586
+
587
+ // Create smart formatters based on actual data
588
+ const stepValues = metricData.map(d => d.step);
589
+ const metricValues = metricData.map(d => d[metric]);
590
+ formatStep = createSmartFormatter(stepValues);
591
+ formatValue = createSmartFormatter(metricValues);
592
+
593
+ // Update clip
594
+ clipRect.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
595
+
596
+ // Update overlay
597
+ overlay.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
598
+
599
+ // Update zoom extent
600
+ zoom.extent([[0, 0], [innerWidth, innerHeight]])
601
+ .translateExtent([[0, 0], [innerWidth, innerHeight]]);
602
+
603
+ // Grid
604
+ gGrid.selectAll('line').data(yScale.ticks(5)).join('line')
605
+ .attr('x1', 0).attr('x2', innerWidth)
606
+ .attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
607
+ .attr('stroke', 'var(--grid-color)');
608
+
609
+ // Axes
610
+ gAxes.selectAll('*').remove();
611
+ gAxes.append('g').attr('class', 'x-axis').attr('transform', `translate(0,${innerHeight})`)
612
+ .call(d3.axisBottom(xScale).ticks(5).tickSizeOuter(0).tickFormat(formatStep));
613
+ gAxes.append('g').attr('class', 'y-axis')
614
+ .call(d3.axisLeft(yScale).ticks(5).tickSizeOuter(0).tickFormat(formatValue));
615
+ gAxes.selectAll('.domain, .tick line').attr('stroke', 'var(--axis-color)');
616
+ gAxes.selectAll('text').attr('fill', 'var(--tick-color)');
617
+
618
+ // Axis labels
619
+ gAxes.append('text')
620
+ .attr('class', 'axis-label')
621
+ .attr('x', innerWidth / 2)
622
+ .attr('y', innerHeight + 32)
623
+ .attr('text-anchor', 'middle')
624
+ .text(CONFIG.xAxisLabel);
625
+
626
+ gAxes.append('text')
627
+ .attr('class', 'axis-label')
628
+ .attr('transform', 'rotate(-90)')
629
+ .attr('x', -innerHeight / 2)
630
+ .attr('y', -38)
631
+ .attr('text-anchor', 'middle')
632
+ .text(CONFIG.yAxisLabel);
633
+
634
+ // Group data by run
635
+ const dataByRun = {};
636
+ runList.forEach(run => { dataByRun[run] = []; });
637
+ metricData.forEach(d => {
638
+ if (dataByRun[d.run]) dataByRun[d.run].push({ step: d.step, value: d[metric] });
639
+ });
640
+ runList.forEach(run => { dataByRun[run].sort((a, b) => a.step - b.step); });
641
+
642
+ const series = runList.map(run => ({ run, color: runColorMap[run], values: dataByRun[run] })).filter(s => s.values.length > 0);
643
+
644
+ // Ghost lines
645
+ const ghostLine = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value)).curve(d3.curveLinear);
646
+ gPlot.selectAll('path.ghost-line').data(series, d => d.run).join('path')
647
+ .attr('class', 'ghost-line')
648
+ .attr('fill', 'none')
649
+ .attr('stroke', d => d.color)
650
+ .attr('stroke-width', 1.5)
651
+ .attr('opacity', smoothEnabled ? 0.15 : 0)
652
+ .attr('pointer-events', 'none')
653
+ .attr('d', d => ghostLine(d.values));
654
+
655
+ // Main lines
656
+ const mainLine = d3.line().x(d => xScale(d.step)).y(d => yScale(d.value)).curve(getCurve(smoothEnabled));
657
+ gPlot.selectAll('path.main-line').data(series, d => d.run).join('path')
658
+ .attr('class', 'main-line')
659
+ .attr('fill', 'none')
660
+ .attr('stroke', d => d.color)
661
+ .attr('stroke-width', 2)
662
+ .attr('opacity', 0.85)
663
+ .attr('d', d => mainLine(applySmoothing(d.values, smoothEnabled)));
664
+
665
+ // Hover
666
+ setupHover(series, innerWidth, innerHeight);
667
+ }
668
+
669
+ function setupHover(series, innerWidth, innerHeight) {
670
+ gHover.selectAll('*').remove();
671
+
672
+ hoverLine = gHover.append('line')
673
+ .style('stroke', 'var(--text-color)')
674
+ .attr('stroke-opacity', 0.25)
675
+ .attr('stroke-width', 1)
676
+ .attr('y1', 0)
677
+ .attr('y2', innerHeight)
678
+ .style('display', 'none')
679
+ .attr('pointer-events', 'none');
680
+
681
+ const stepSet = new Set();
682
+ series.forEach(s => s.values.forEach(v => stepSet.add(v.step)));
683
+ steps = Array.from(stepSet).sort((a, b) => a - b);
684
+
685
+ overlay.on('mousemove', function (ev) {
686
+ if (ev.buttons === 0) onHoverMove(ev, series);
687
+ }).on('mouseleave', onHoverLeave);
688
+ }
689
+
690
+ function onHoverMove(ev, series) {
691
+ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
692
+
693
+ const [mx, my] = d3.pointer(ev, overlay.node());
694
+ const targetStep = xScale.invert(mx);
695
+ const nearest = steps.reduce((best, t) => Math.abs(t - targetStep) < Math.abs(best - targetStep) ? t : best, steps[0]);
696
+
697
+ const xpx = xScale(nearest);
698
+ hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
699
+
700
+ let html = `<div><strong>${chartConfig.title}</strong></div>`;
701
+ html += `<div>${formatStep(nearest)}</div>`;
702
+
703
+ const entries = series.map(s => {
704
+ const values = s.values;
705
+ let before = null, after = null;
706
+ for (let i = 0; i < values.length; i++) {
707
+ if (values[i].step <= nearest) before = values[i];
708
+ if (values[i].step >= nearest && !after) { after = values[i]; break; }
709
+ }
710
+
711
+ let interpolatedValue = null;
712
+ if (before && after && before.step !== after.step) {
713
+ const t = (nearest - before.step) / (after.step - before.step);
714
+ interpolatedValue = before.value + t * (after.value - before.value);
715
+ } else if (before && before.step === nearest) {
716
+ interpolatedValue = before.value;
717
+ } else if (after && after.step === nearest) {
718
+ interpolatedValue = after.value;
719
+ } else if (before) {
720
+ interpolatedValue = before.value;
721
+ } else if (after) {
722
+ interpolatedValue = after.value;
723
+ }
724
+
725
+ return { run: s.run, color: s.color, value: interpolatedValue };
726
+ }).filter(e => e.value != null);
727
+
728
+ entries.sort((a, b) => b.value - a.value);
729
+
730
+ entries.forEach(e => {
731
+ html += `<div style="display:flex;align-items:center;gap:8px;"><span class="d3-tooltip__color-dot" style="background:${e.color}"></span><span>${e.run}</span><span style="margin-left:auto;font-weight:normal;">${e.value.toFixed(4)}</span></div>`;
732
+ });
733
+
734
+ tipInner.innerHTML = html;
735
+ const offsetX = 12, offsetY = 12;
736
+ tip.style.opacity = '1';
737
+ tip.style.transform = `translate(${Math.round(mx + offsetX + CONFIG.margin.left)}px, ${Math.round(my + offsetY + CONFIG.margin.top)}px)`;
738
+ }
739
+
740
+ function onHoverLeave() {
741
+ hideTipTimer = setTimeout(() => {
742
+ tip.style.opacity = '0';
743
+ tip.style.transform = 'translate(-9999px, -9999px)';
744
+ if (hoverLine) hoverLine.style('display', 'none');
745
+ }, 100);
746
+ }
747
+
748
+ // Reset button
749
+ resetBtn.addEventListener('click', () => {
750
+ overlay.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
751
+ });
752
+
753
+ return { render };
754
+ }
755
+
756
+ // Load data
757
+ async function load() {
758
+ try {
759
+ const response = await fetch(CONFIG.dataUrl, { cache: 'no-cache' });
760
+ if (!response.ok) throw new Error(`Failed to load data: ${response.status} ${response.statusText}`);
761
+
762
+ const csvText = await response.text();
763
+
764
+ // Parse CSV (long format: run_name, metric, tokens, value)
765
+ const rawRows = d3.csvParse(csvText, d => ({
766
+ run: (d[CONFIG.runColumn] || '').trim(),
767
+ metric: (d.metric || '').trim(),
768
+ tokens: +d[CONFIG.xColumn],
769
+ value: +d.value
770
+ }));
771
+
772
+ // Pivot data: group by run + tokens, create columns for each metric
773
+ const pivotMap = new Map();
774
+ rawRows.forEach(row => {
775
+ if (isNaN(row.tokens) || isNaN(row.value)) return;
776
+
777
+ const key = `${row.run}|${row.tokens}`;
778
+ if (!pivotMap.has(key)) {
779
+ pivotMap.set(key, { run: row.run, step: row.tokens });
780
+ }
781
+ const pivotRow = pivotMap.get(key);
782
+ pivotRow[row.metric] = row.value;
783
+ });
784
+
785
+ allData = Array.from(pivotMap.values());
786
+
787
+ runList = Array.from(new Set(allData.map(d => d.run))).sort();
788
+
789
+ const colors = getRunColors(runList.length);
790
+ runList.forEach((run, i) => { runColorMap[run] = colors[i % colors.length]; });
791
+
792
+ // Build legend
793
+ const legendItemsHost = header.querySelector('.legend-bottom .items');
794
+ if (legendItemsHost) {
795
+ legendItemsHost.innerHTML = runList.map(run => {
796
+ const color = runColorMap[run];
797
+ return `<span class="item" data-run="${run}"><span class="swatch" style="background:${color}"></span><span>${run}</span></span>`;
798
+ }).join('');
799
+
800
+ // Add hover interactions
801
+ legendItemsHost.querySelectorAll('.item').forEach(el => {
802
+ el.addEventListener('mouseenter', () => {
803
+ const run = el.getAttribute('data-run');
804
+ container.classList.add('hovering');
805
+ grid.querySelectorAll('path.main-line').forEach(path => {
806
+ const pathRun = d3.select(path).datum()?.run;
807
+ path.classList.toggle('ghost', pathRun !== run);
808
+ });
809
+ grid.querySelectorAll('path.ghost-line').forEach(path => {
810
+ const pathRun = d3.select(path).datum()?.run;
811
+ path.classList.toggle('ghost', pathRun !== run);
812
+ });
813
+ legendItemsHost.querySelectorAll('.item').forEach(it => {
814
+ it.classList.toggle('ghost', it.getAttribute('data-run') !== run);
815
+ });
816
+ });
817
+
818
+ el.addEventListener('mouseleave', () => {
819
+ container.classList.remove('hovering');
820
+ grid.querySelectorAll('path.main-line').forEach(path => path.classList.remove('ghost'));
821
+ grid.querySelectorAll('path.ghost-line').forEach(path => path.classList.remove('ghost'));
822
+ legendItemsHost.querySelectorAll('.item').forEach(it => it.classList.remove('ghost'));
823
+ });
824
+ });
825
+ }
826
+
827
+ // Init all charts
828
+ const cells = Array.from(grid.querySelectorAll('.chart-cell'));
829
+ const chartInstances = cells.map((cell, idx) => initChart(cell, CONFIG.charts[idx]));
830
+
831
+ // Render all
832
+ chartInstances.forEach(chart => chart.render());
833
+
834
+ // Responsive - observe container for resize
835
+ let resizeTimer;
836
+ const handleResize = () => {
837
+ clearTimeout(resizeTimer);
838
+ resizeTimer = setTimeout(() => {
839
+ chartInstances.forEach(chart => chart.render());
840
+ }, 100);
841
+ };
842
+
843
+ const ro = window.ResizeObserver ? new ResizeObserver(handleResize) : null;
844
+ if (ro) {
845
+ ro.observe(container);
846
+ }
847
+
848
+ // Also observe window resize as fallback
849
+ window.addEventListener('resize', handleResize);
850
+
851
+ // Force a re-render after a short delay to ensure proper sizing
852
+ setTimeout(() => {
853
+ chartInstances.forEach(chart => chart.render());
854
+ }, 100);
855
+
856
+ } catch (e) {
857
+ const pre = document.createElement('pre');
858
+ pre.textContent = 'Error loading data: ' + (e && e.message ? e.message : e);
859
+ pre.style.color = 'var(--danger, #b00020)';
860
+ pre.style.fontSize = '12px';
861
+ container.appendChild(pre);
862
+ }
863
+ }
864
+
865
+ load();
866
+ };
867
+
868
+ if (document.readyState === 'loading') {
869
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
870
+ } else {
871
+ ensureD3(bootstrap);
872
+ }
873
+ })();
874
+ </script>
app/src/content/embeds/d3-sweep-1d-metrics.html ADDED
@@ -0,0 +1,862 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ Sweep 1D Metrics - Six Metrics Grid
3
+
4
+ A grid of 6 line charts showing metrics as a function of steering coefficient alpha.
5
+
6
+ Configuration via data-config attribute:
7
+ {
8
+ "dataUrl": "./assets/data/sweep_1d_metrics.csv",
9
+ "xColumn": "alpha",
10
+ "metrics": [
11
+ { "key": "concept_inclusion", "label": "LLM concept score", "yAxisLabel": "Score" },
12
+ { "key": "eiffel", "label": "Explicit concept inclusion", "yAxisLabel": "Fraction" },
13
+ { "key": "instruction_following", "label": "LLM instruction score", "yAxisLabel": "Score" },
14
+ { "key": "surprise", "label": "Surprise in reference model", "yAxisLabel": "Value" },
15
+ { "key": "fluency", "label": "LLM fluency score", "yAxisLabel": "Score" },
16
+ { "key": "repetition", "label": "3-gram repetition", "yAxisLabel": "Fraction" }
17
+ ]
18
+ }
19
+
20
+ CSV format (with mean/std):
21
+ alpha, concept_inclusion_mean, concept_inclusion_std, instruction_following_mean, instruction_following_std, ...
22
+ CSV format (simple, fallback):
23
+ alpha, concept_inclusion, instruction_following, fluency, surprise, repetition, eiffel
24
+
25
+ Example usage in MDX:
26
+ <HtmlEmbed
27
+ src="embeds/d3-sweep-1d-metrics.html"
28
+ config={{
29
+ dataUrl: "./assets/data/sweep_1d_metrics.csv"
30
+ }}
31
+ />
32
+ -->
33
+ <div class="d3-sweep-1d"></div>
34
+ <style>
35
+ .d3-sweep-1d {
36
+ position: relative;
37
+ container-type: inline-size;
38
+ }
39
+
40
+ /* Grid - 2 columns x 3 rows */
41
+ .d3-sweep-1d__grid {
42
+ display: grid;
43
+ grid-template-columns: repeat(2, minmax(0, 1fr));
44
+ gap: 16px;
45
+ }
46
+
47
+ /* Container queries - basées sur la largeur du container parent */
48
+ @container (max-width: 600px) {
49
+ .d3-sweep-1d__grid {
50
+ grid-template-columns: 1fr;
51
+ }
52
+ }
53
+
54
+ .chart-cell {
55
+ display: flex;
56
+ flex-direction: column;
57
+ position: relative;
58
+ padding: 12px;
59
+ box-shadow: inset 0 0 0 1px var(--border-color);
60
+ border-radius: 8px;
61
+ background: var(--page-bg);
62
+ }
63
+
64
+ .chart-cell__title {
65
+ font-size: 13px;
66
+ font-weight: 700;
67
+ color: var(--text-color);
68
+ margin-bottom: 8px;
69
+ padding-bottom: 8px;
70
+ }
71
+
72
+ .chart-cell__body {
73
+ position: relative;
74
+ width: 100%;
75
+ overflow: hidden;
76
+ }
77
+
78
+ .chart-cell__body svg {
79
+ max-width: 100%;
80
+ height: auto;
81
+ display: block;
82
+ }
83
+
84
+ .d3-sweep-1d__legend {
85
+ display: flex;
86
+ gap: 16px;
87
+ margin-top: 16px;
88
+ font-size: 11px;
89
+ color: var(--text-color);
90
+ align-items: center;
91
+ justify-content: center;
92
+ }
93
+
94
+ .d3-sweep-1d__legend-item {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ }
99
+
100
+ .d3-sweep-1d__legend-line {
101
+ width: 20px;
102
+ height: 2px;
103
+ border-radius: 1px;
104
+ }
105
+
106
+ .d3-sweep-1d__legend-band {
107
+ width: 20px;
108
+ height: 12px;
109
+ border-radius: 2px;
110
+ }
111
+
112
+ /* Reset button */
113
+ .chart-cell .reset-button {
114
+ position: absolute;
115
+ top: 12px;
116
+ right: 12px;
117
+ z-index: 10;
118
+ display: none;
119
+ opacity: 0;
120
+ transition: opacity 0.2s ease;
121
+ font-size: 11px;
122
+ padding: 3px 6px;
123
+ border-radius: 4px;
124
+ background: var(--surface-bg);
125
+ color: var(--text-color);
126
+ border: 1px solid var(--border-color);
127
+ cursor: pointer;
128
+ }
129
+
130
+ /* Axes */
131
+ .d3-sweep-1d .axes path {
132
+ display: none;
133
+ }
134
+
135
+ .d3-sweep-1d .axes line {
136
+ stroke: var(--axis-color);
137
+ }
138
+
139
+ .d3-sweep-1d .axes text {
140
+ fill: var(--tick-color);
141
+ font-size: 10px;
142
+ }
143
+
144
+ .d3-sweep-1d .axis-label {
145
+ fill: var(--text-color);
146
+ font-size: 10px;
147
+ font-weight: 300;
148
+ opacity: 0.7;
149
+ stroke: var(--page-bg, white);
150
+ stroke-width: 3px;
151
+ paint-order: stroke fill;
152
+ }
153
+
154
+ .d3-sweep-1d .grid line {
155
+ stroke: var(--grid-color);
156
+ }
157
+
158
+ /* Lines */
159
+ .d3-sweep-1d path.main-line {
160
+ fill: none;
161
+ stroke-width: 2;
162
+ transition: opacity 0.2s ease;
163
+ }
164
+
165
+ /* Uncertainty band */
166
+ .d3-sweep-1d path.uncertainty-band {
167
+ fill: var(--primary-color, #E889AB);
168
+ fill-opacity: 0.2;
169
+ stroke: none;
170
+ }
171
+
172
+ /* Tooltip */
173
+ .d3-sweep-1d .d3-tooltip {
174
+ z-index: 20;
175
+ backdrop-filter: saturate(1.12) blur(8px);
176
+ }
177
+
178
+ .d3-sweep-1d .d3-tooltip__inner {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 6px;
182
+ min-width: 200px;
183
+ }
184
+
185
+ .d3-sweep-1d .d3-tooltip__inner>div:first-child {
186
+ font-weight: 800;
187
+ letter-spacing: 0.1px;
188
+ margin-bottom: 0;
189
+ }
190
+
191
+ .d3-sweep-1d .d3-tooltip__inner>div:nth-child(2) {
192
+ font-size: 11px;
193
+ color: var(--muted-color);
194
+ display: block;
195
+ margin-top: -4px;
196
+ margin-bottom: 2px;
197
+ letter-spacing: 0.1px;
198
+ }
199
+ </style>
200
+ <script>
201
+ (() => {
202
+ const ensureD3 = (cb) => {
203
+ if (window.d3 && typeof window.d3.select === 'function') return cb();
204
+ let s = document.getElementById('d3-cdn-script');
205
+ 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); }
206
+ const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
207
+ s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
208
+ };
209
+
210
+ const bootstrap = () => {
211
+ const scriptEl = document.currentScript;
212
+ let container = scriptEl ? scriptEl.previousElementSibling : null;
213
+ if (!(container && container.classList && container.classList.contains('d3-sweep-1d'))) {
214
+ const cs = Array.from(document.querySelectorAll('.d3-sweep-1d')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
215
+ container = cs[cs.length - 1] || null;
216
+ }
217
+ if (!container) return;
218
+ if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
219
+
220
+ const d3 = window.d3;
221
+
222
+ // Read config from HtmlEmbed props
223
+ function readEmbedConfig() {
224
+ let mountEl = container;
225
+ while (mountEl && !mountEl.getAttribute?.('data-config')) {
226
+ mountEl = mountEl.parentElement;
227
+ }
228
+
229
+ let providedConfig = null;
230
+ try {
231
+ const cfg = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-config') : null;
232
+ if (cfg && cfg.trim()) {
233
+ providedConfig = cfg.trim().startsWith('{') ? JSON.parse(cfg) : cfg;
234
+ }
235
+ } catch (e) {
236
+ // Failed to parse data-config
237
+ }
238
+ return providedConfig || {};
239
+ }
240
+
241
+ const embedConfig = readEmbedConfig();
242
+
243
+ // Also check for data-datafiles attribute (used by HtmlEmbed component)
244
+ let providedData = null;
245
+ try {
246
+ let mountEl = container;
247
+ while (mountEl && !mountEl.getAttribute?.('data-datafiles')) {
248
+ mountEl = mountEl.parentElement;
249
+ }
250
+ const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
251
+ if (attr && attr.trim()) {
252
+ providedData = attr.trim();
253
+ }
254
+ } catch (e) {}
255
+
256
+ // Default metrics configuration - order matches the image exactly
257
+ const DEFAULT_METRICS = [
258
+ { key: 'concept_inclusion', label: 'LLM concept score', yAxisLabel: 'Score' },
259
+ { key: 'eiffel', label: 'Explicit concept inclusion', yAxisLabel: 'Fraction' },
260
+ { key: 'instruction_following', label: 'LLM instruction score', yAxisLabel: 'Score' },
261
+ { key: 'surprise', label: 'Surprise in reference model', yAxisLabel: 'Value' },
262
+ { key: 'fluency', label: 'LLM fluency score', yAxisLabel: 'Score' },
263
+ { key: 'repetition', label: '3-gram repetition', yAxisLabel: 'Fraction' }
264
+ ];
265
+
266
+ // Determine data URL - try config first, then data attribute, then default
267
+ const dataUrlFromConfig = embedConfig.dataUrl;
268
+ const dataUrlFromAttr = providedData;
269
+ const DEFAULT_CSV = '/data/stats_L15F21576.csv';
270
+ const ensureDataPrefix = (p) => (typeof p === 'string' && p && !p.includes('/')) ? `/data/${p}` : p;
271
+
272
+ const CSV_PATHS = dataUrlFromConfig
273
+ ? [dataUrlFromConfig]
274
+ : (dataUrlFromAttr
275
+ ? [ensureDataPrefix(dataUrlFromAttr)]
276
+ : [
277
+ DEFAULT_CSV,
278
+ './assets/data/stats_L15F21576.csv',
279
+ '../assets/data/stats_L15F21576.csv',
280
+ '../../assets/data/stats_L15F21576.csv',
281
+ './assets/data/sweep_1d_metrics.csv',
282
+ '../assets/data/sweep_1d_metrics.csv'
283
+ ]);
284
+
285
+ // Get categorical colors for lines
286
+ const getLineColor = () => {
287
+ try {
288
+ if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
289
+ const colors = window.ColorPalettes.getColors('categorical', 1);
290
+ if (colors && colors.length > 0) return colors[0];
291
+ }
292
+ } catch (_) {}
293
+ return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#E889AB';
294
+ };
295
+
296
+ // Configuration
297
+ const CONFIG = {
298
+ csvPaths: CSV_PATHS,
299
+ xColumn: embedConfig.xColumn || 'alpha',
300
+ metrics: embedConfig.metrics || DEFAULT_METRICS,
301
+ chartHeight: 240,
302
+ margin: { top: 20, right: 20, bottom: 40, left: 50 },
303
+ zoomExtent: [1.0, 8],
304
+ xAxisLabel: embedConfig.xAxisLabel || 'Steering coefficient α',
305
+ lineColor: embedConfig.lineColor || getLineColor()
306
+ };
307
+
308
+ // Create grid
309
+ const grid = document.createElement('div');
310
+ grid.className = 'd3-sweep-1d__grid';
311
+ container.appendChild(grid);
312
+
313
+ // Create legend container
314
+ const legend = document.createElement('div');
315
+ legend.className = 'd3-sweep-1d__legend';
316
+ container.appendChild(legend);
317
+
318
+ // Create chart cells
319
+ CONFIG.metrics.forEach((metricConfig, idx) => {
320
+ const cell = document.createElement('div');
321
+ cell.className = 'chart-cell';
322
+ cell.style.zIndex = CONFIG.metrics.length - idx;
323
+ cell.innerHTML = `
324
+ <div class="chart-cell__title">${metricConfig.label}</div>
325
+ <button class="reset-button">Reset</button>
326
+ <div class="chart-cell__body"></div>
327
+ `;
328
+ grid.appendChild(cell);
329
+ });
330
+
331
+ // Data
332
+ let allData = [];
333
+
334
+ // Function to determine smart format based on data values
335
+ function createSmartFormatter(values) {
336
+ if (!values || values.length === 0) return (v) => v;
337
+
338
+ const min = d3.min(values);
339
+ const max = d3.max(values);
340
+ const range = max - min;
341
+
342
+ // Check if all values are effectively integers (within 0.001 tolerance)
343
+ const allIntegers = values.every(v => Math.abs(v - Math.round(v)) < 0.001);
344
+
345
+ // Large numbers (billions): format as "X.XXB"
346
+ if (max >= 1e9) {
347
+ return (v) => {
348
+ const billions = v / 1e9;
349
+ return allIntegers && billions === Math.round(billions)
350
+ ? d3.format('d')(Math.round(billions)) + 'B'
351
+ : d3.format('.2f')(billions) + 'B';
352
+ };
353
+ }
354
+
355
+ // Millions: format as "X.XXM" or "XM"
356
+ if (max >= 1e6) {
357
+ return (v) => {
358
+ const millions = v / 1e6;
359
+ return allIntegers && millions === Math.round(millions)
360
+ ? d3.format('d')(Math.round(millions)) + 'M'
361
+ : d3.format('.2f')(millions) + 'M';
362
+ };
363
+ }
364
+
365
+ // Thousands: format as "X.Xk" or "Xk"
366
+ if (max >= 1000 && range >= 100) {
367
+ return (v) => {
368
+ const thousands = v / 1000;
369
+ return allIntegers && thousands === Math.round(thousands)
370
+ ? d3.format('d')(Math.round(thousands)) + 'k'
371
+ : d3.format('.1f')(thousands) + 'k';
372
+ };
373
+ }
374
+
375
+ // Regular numbers
376
+ if (allIntegers) {
377
+ return (v) => d3.format('d')(Math.round(v));
378
+ }
379
+
380
+ // Small decimals: use appropriate precision
381
+ if (range < 1) {
382
+ return (v) => d3.format('.3f')(v);
383
+ } else if (range < 10) {
384
+ return (v) => d3.format('.2f')(v);
385
+ } else {
386
+ return (v) => d3.format('.1f')(v);
387
+ }
388
+ }
389
+
390
+ // Init each chart
391
+ function initChart(cellElement, metricConfig) {
392
+ const bodyEl = cellElement.querySelector('.chart-cell__body');
393
+ const resetBtn = cellElement.querySelector('.reset-button');
394
+
395
+ const metricKey = metricConfig.key;
396
+ let hasMoved = false;
397
+
398
+ // Tooltip
399
+ let tip = cellElement.querySelector('.d3-tooltip');
400
+ let tipInner;
401
+ if (!tip) {
402
+ tip = document.createElement('div');
403
+ tip.className = 'd3-tooltip';
404
+ Object.assign(tip.style, {
405
+ position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none',
406
+ padding: '10px 12px', borderRadius: '12px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)',
407
+ 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', zIndex: '20'
408
+ });
409
+ tipInner = document.createElement('div');
410
+ tipInner.className = 'd3-tooltip__inner';
411
+ tip.appendChild(tipInner);
412
+ cellElement.appendChild(tip);
413
+ } else {
414
+ tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
415
+ }
416
+
417
+ // Create SVG
418
+ const svg = d3.select(bodyEl).append('svg').attr('width', '100%').style('display', 'block');
419
+
420
+ // Clip path
421
+ const clipId = 'clip-' + Math.random().toString(36).slice(2);
422
+ const clipPath = svg.append('defs').append('clipPath').attr('id', clipId);
423
+ const clipRect = clipPath.append('rect');
424
+
425
+ // Groups
426
+ const g = svg.append('g');
427
+ const gGrid = g.append('g').attr('class', 'grid');
428
+ const gAxes = g.append('g').attr('class', 'axes');
429
+ const gPlot = g.append('g').attr('class', 'plot').attr('clip-path', `url(#${clipId})`);
430
+ const gHover = g.append('g').attr('class', 'hover-layer');
431
+ const overlay = g.append('rect').attr('class', 'overlay').attr('fill', 'none').attr('pointer-events', 'all').style('cursor', 'grab')
432
+ .on('mousedown', function () {
433
+ d3.select(this).style('cursor', 'grabbing');
434
+ tip.style.opacity = '0';
435
+ if (hoverLine) hoverLine.style('display', 'none');
436
+ })
437
+ .on('mouseup', function () { d3.select(this).style('cursor', 'grab'); });
438
+
439
+ // Scales
440
+ const xScale = d3.scaleLinear();
441
+ const yScale = d3.scaleLinear();
442
+
443
+ // Hover state
444
+ let hoverLine = null;
445
+ let dataPoints = [];
446
+ let hideTipTimer = null;
447
+ let hasMeanStd = false;
448
+
449
+ // Formatters (will be set in render())
450
+ let formatX = (v) => v;
451
+ let formatY = (v) => v;
452
+
453
+ // Zoom
454
+ const zoom = d3.zoom().scaleExtent(CONFIG.zoomExtent).on('zoom', zoomed);
455
+ overlay.call(zoom);
456
+
457
+ function zoomed(event) {
458
+ const transform = event.transform;
459
+ hasMoved = transform.k !== 1 || transform.x !== 0 || transform.y !== 0;
460
+ updateResetButton();
461
+
462
+ const newXScale = transform.rescaleX(xScale);
463
+ const newYScale = transform.rescaleY(yScale);
464
+
465
+ const innerWidth = xScale.range()[1];
466
+
467
+ // Update grid
468
+ const gridTicks = newYScale.ticks(5);
469
+ gGrid.selectAll('line').data(gridTicks).join('line')
470
+ .attr('x1', 0).attr('x2', innerWidth)
471
+ .attr('y1', d => newYScale(d)).attr('y2', d => newYScale(d))
472
+ .attr('stroke', 'var(--grid-color)');
473
+
474
+ // Update uncertainty band (if mean/std available)
475
+ if (hasMeanStd && dataPoints[0] && dataPoints[0].yUpper != null) {
476
+ const area = d3.area()
477
+ .x(d => newXScale(d.x))
478
+ .y0(d => newYScale(d.yLower))
479
+ .y1(d => newYScale(d.yUpper))
480
+ .curve(d3.curveMonotoneX);
481
+
482
+ gPlot.selectAll('path.uncertainty-band')
483
+ .attr('d', area(dataPoints));
484
+ }
485
+
486
+ // Update line
487
+ const line = d3.line()
488
+ .x(d => newXScale(d.x))
489
+ .y(d => newYScale(d.y))
490
+ .curve(d3.curveMonotoneX);
491
+
492
+ gPlot.selectAll('path.main-line')
493
+ .attr('d', line(dataPoints));
494
+
495
+ // Update axes
496
+ gAxes.select('.x-axis').call(d3.axisBottom(newXScale).ticks(5).tickSizeOuter(0).tickFormat(formatX));
497
+ gAxes.select('.y-axis').call(d3.axisLeft(newYScale).ticks(5).tickSizeOuter(0).tickFormat(formatY));
498
+ }
499
+
500
+ function updateResetButton() {
501
+ if (hasMoved) {
502
+ resetBtn.style.display = 'block';
503
+ requestAnimationFrame(() => { resetBtn.style.opacity = '1'; });
504
+ } else {
505
+ resetBtn.style.opacity = '0';
506
+ setTimeout(() => { if (!hasMoved) resetBtn.style.display = 'none'; }, 200);
507
+ }
508
+ }
509
+
510
+ function render() {
511
+ const rect = bodyEl.getBoundingClientRect();
512
+ const width = Math.max(1, Math.round(rect.width || 400));
513
+ const height = CONFIG.chartHeight;
514
+ svg.attr('width', width).attr('height', height);
515
+
516
+ const margin = CONFIG.margin;
517
+ const innerWidth = width - margin.left - margin.right;
518
+ const innerHeight = height - margin.top - margin.bottom;
519
+
520
+ g.attr('transform', `translate(${margin.left},${margin.top})`);
521
+
522
+ // Filter and prepare data for this metric
523
+ // Support both mean/std columns and direct value columns
524
+ const meanKey = `${metricKey}_mean`;
525
+ const stdKey = `${metricKey}_std`;
526
+ hasMeanStd = allData.some(d => d[meanKey] != null && d[stdKey] != null);
527
+
528
+ if (hasMeanStd) {
529
+ // Data has mean and std columns
530
+ dataPoints = allData
531
+ .filter(d => d[CONFIG.xColumn] != null && !isNaN(d[CONFIG.xColumn]) &&
532
+ d[meanKey] != null && !isNaN(d[meanKey]) &&
533
+ d[stdKey] != null && !isNaN(d[stdKey]))
534
+ .map(d => ({
535
+ x: +d[CONFIG.xColumn],
536
+ y: +d[meanKey],
537
+ yUpper: +d[meanKey] + +d[stdKey],
538
+ yLower: +d[meanKey] - +d[stdKey]
539
+ }))
540
+ .sort((a, b) => a.x - b.x);
541
+ } else {
542
+ // Data has direct value columns (fallback)
543
+ dataPoints = allData
544
+ .filter(d => d[CONFIG.xColumn] != null && !isNaN(d[CONFIG.xColumn]) && d[metricKey] != null && !isNaN(d[metricKey]))
545
+ .map(d => ({ x: +d[CONFIG.xColumn], y: +d[metricKey] }))
546
+ .sort((a, b) => a.x - b.x);
547
+ }
548
+
549
+ if (!dataPoints.length) {
550
+ return;
551
+ }
552
+
553
+ // Auto-compute domains from data
554
+ const xExtent = d3.extent(dataPoints, d => d.x);
555
+ const yExtent = hasMeanStd
556
+ ? d3.extent([...dataPoints.map(d => d.yUpper), ...dataPoints.map(d => d.yLower)])
557
+ : d3.extent(dataPoints, d => d.y);
558
+
559
+ // Ensure Y axis never goes below 0
560
+ const yDomain = [Math.max(0, yExtent[0]), yExtent[1]];
561
+
562
+ xScale.domain(xExtent).range([0, innerWidth]);
563
+ yScale.domain(yDomain).range([innerHeight, 0]);
564
+
565
+ // Create smart formatters based on actual data
566
+ const xValues = dataPoints.map(d => d.x);
567
+ const yValues = dataPoints.map(d => d.y);
568
+ formatX = createSmartFormatter(xValues);
569
+ formatY = createSmartFormatter(yValues);
570
+
571
+ // Update clip
572
+ clipRect.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
573
+
574
+ // Update overlay
575
+ overlay.attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
576
+
577
+ // Update zoom extent
578
+ zoom.extent([[0, 0], [innerWidth, innerHeight]])
579
+ .translateExtent([[0, 0], [innerWidth, innerHeight]]);
580
+
581
+ // Grid
582
+ gGrid.selectAll('line').data(yScale.ticks(5)).join('line')
583
+ .attr('x1', 0).attr('x2', innerWidth)
584
+ .attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
585
+ .attr('stroke', 'var(--grid-color)');
586
+
587
+ // Axes
588
+ gAxes.selectAll('*').remove();
589
+ gAxes.append('g').attr('class', 'x-axis').attr('transform', `translate(0,${innerHeight})`)
590
+ .call(d3.axisBottom(xScale).ticks(5).tickSizeOuter(0).tickFormat(formatX));
591
+ gAxes.append('g').attr('class', 'y-axis')
592
+ .call(d3.axisLeft(yScale).ticks(5).tickSizeOuter(0).tickFormat(formatY));
593
+ gAxes.selectAll('.domain, .tick line').attr('stroke', 'var(--axis-color)');
594
+ gAxes.selectAll('text').attr('fill', 'var(--tick-color)');
595
+
596
+ // Axis labels
597
+ gAxes.append('text')
598
+ .attr('class', 'axis-label')
599
+ .attr('x', innerWidth / 2)
600
+ .attr('y', innerHeight + 32)
601
+ .attr('text-anchor', 'middle')
602
+ .text(CONFIG.xAxisLabel);
603
+
604
+ gAxes.append('text')
605
+ .attr('class', 'axis-label')
606
+ .attr('transform', 'rotate(-90)')
607
+ .attr('x', -innerHeight / 2)
608
+ .attr('y', -38)
609
+ .attr('text-anchor', 'middle')
610
+ .text(metricConfig.yAxisLabel || 'Value');
611
+
612
+ // Uncertainty band (if mean/std available)
613
+ if (hasMeanStd) {
614
+ const area = d3.area()
615
+ .x(d => xScale(d.x))
616
+ .y0(d => yScale(d.yLower))
617
+ .y1(d => yScale(d.yUpper))
618
+ .curve(d3.curveMonotoneX);
619
+
620
+ gPlot.selectAll('path.uncertainty-band').data([dataPoints]).join('path')
621
+ .attr('class', 'uncertainty-band')
622
+ .attr('d', area);
623
+ }
624
+
625
+ // Main line
626
+ const mainLine = d3.line()
627
+ .x(d => xScale(d.x))
628
+ .y(d => yScale(d.y))
629
+ .curve(d3.curveMonotoneX);
630
+
631
+ gPlot.selectAll('path.main-line').data([dataPoints]).join('path')
632
+ .attr('class', 'main-line')
633
+ .attr('fill', 'none')
634
+ .attr('stroke', CONFIG.lineColor)
635
+ .attr('stroke-width', 2)
636
+ .attr('opacity', 0.85)
637
+ .attr('d', mainLine);
638
+
639
+ // Hover
640
+ setupHover(innerWidth, innerHeight);
641
+ }
642
+
643
+ function setupHover(innerWidth, innerHeight) {
644
+ gHover.selectAll('*').remove();
645
+
646
+ hoverLine = gHover.append('line')
647
+ .style('stroke', 'var(--text-color)')
648
+ .attr('stroke-opacity', 0.25)
649
+ .attr('stroke-width', 1)
650
+ .attr('y1', 0)
651
+ .attr('y2', innerHeight)
652
+ .style('display', 'none')
653
+ .attr('pointer-events', 'none');
654
+
655
+ overlay.on('mousemove', function (ev) {
656
+ if (ev.buttons === 0) onHoverMove(ev);
657
+ }).on('mouseleave', onHoverLeave);
658
+ }
659
+
660
+ function onHoverMove(ev) {
661
+ if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; }
662
+
663
+ const [mx, my] = d3.pointer(ev, overlay.node());
664
+ const targetX = xScale.invert(mx);
665
+
666
+ // Find nearest data point
667
+ let nearest = dataPoints[0];
668
+ let minDist = Math.abs(dataPoints[0].x - targetX);
669
+ for (let i = 1; i < dataPoints.length; i++) {
670
+ const dist = Math.abs(dataPoints[i].x - targetX);
671
+ if (dist < minDist) {
672
+ minDist = dist;
673
+ nearest = dataPoints[i];
674
+ }
675
+ }
676
+
677
+ const xpx = xScale(nearest.x);
678
+ hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
679
+
680
+ let html = `<div><strong>${metricConfig.label}</strong></div>`;
681
+ html += `<div>α = ${formatX(nearest.x)}</div>`;
682
+ if (hasMeanStd && nearest.yUpper != null && nearest.yLower != null) {
683
+ html += `<div>Mean: ${formatY(nearest.y)}</div>`;
684
+ html += `<div>± 1 std dev: [${formatY(nearest.yLower)}, ${formatY(nearest.yUpper)}]</div>`;
685
+ } else {
686
+ html += `<div>${metricConfig.yAxisLabel || 'Value'}: ${formatY(nearest.y)}</div>`;
687
+ }
688
+
689
+ tipInner.innerHTML = html;
690
+ const offsetX = 12, offsetY = 12;
691
+ tip.style.opacity = '1';
692
+ tip.style.transform = `translate(${Math.round(mx + offsetX + CONFIG.margin.left)}px, ${Math.round(my + offsetY + CONFIG.margin.top)}px)`;
693
+ }
694
+
695
+ function onHoverLeave() {
696
+ hideTipTimer = setTimeout(() => {
697
+ tip.style.opacity = '0';
698
+ tip.style.transform = 'translate(-9999px, -9999px)';
699
+ if (hoverLine) hoverLine.style('display', 'none');
700
+ }, 100);
701
+ }
702
+
703
+ // Reset button
704
+ resetBtn.addEventListener('click', () => {
705
+ overlay.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
706
+ });
707
+
708
+ return { render };
709
+ }
710
+
711
+ // Transform long format CSV to wide format
712
+ function transformLongToWide(longData) {
713
+ // Mapping from CSV quantity names to embed metric keys
714
+ const quantityMap = {
715
+ 'llm_score_concept': 'concept_inclusion',
716
+ 'eiffel': 'eiffel',
717
+ 'llm_score_instruction': 'instruction_following',
718
+ 'surprise': 'surprise',
719
+ 'llm_score_fluency': 'fluency',
720
+ 'rep3': 'repetition'
721
+ };
722
+
723
+ // Group by steering_intensity
724
+ const grouped = {};
725
+ longData.forEach(row => {
726
+ const intensity = parseFloat(row.steering_intensity);
727
+ if (isNaN(intensity)) return;
728
+
729
+ if (!grouped[intensity]) {
730
+ grouped[intensity] = { alpha: intensity, steering_intensity: intensity };
731
+ }
732
+
733
+ const quantity = row.quantity;
734
+ const statType = row.stat_type;
735
+ const value = parseFloat(row.value);
736
+
737
+ if (isNaN(value)) return;
738
+
739
+ // Map quantity name to metric key
740
+ const metricKey = quantityMap[quantity] || quantity;
741
+
742
+ // Store mean and std
743
+ if (statType === 'mean') {
744
+ grouped[intensity][`${metricKey}_mean`] = value;
745
+ } else if (statType === 'std') {
746
+ grouped[intensity][`${metricKey}_std`] = value;
747
+ }
748
+ });
749
+
750
+ return Object.values(grouped);
751
+ }
752
+
753
+ // Load data
754
+ async function load() {
755
+ try {
756
+ const fetchFirstAvailable = async (paths) => {
757
+ for (const p of paths) {
758
+ try {
759
+ const r = await fetch(p, { cache: 'no-cache' });
760
+ if (r.ok) return await r.text();
761
+ } catch(_){}
762
+ }
763
+ throw new Error('CSV not found at any of the paths: ' + paths.join(', '));
764
+ };
765
+
766
+ const csvText = await fetchFirstAvailable(CONFIG.csvPaths);
767
+ const rawData = d3.csvParse(csvText);
768
+
769
+ // Check if data is in long format (has quantity, stat_type, value columns)
770
+ const isLongFormat = rawData.length > 0 &&
771
+ rawData[0].hasOwnProperty('quantity') &&
772
+ rawData[0].hasOwnProperty('stat_type') &&
773
+ rawData[0].hasOwnProperty('value');
774
+
775
+ if (isLongFormat) {
776
+ allData = transformLongToWide(rawData);
777
+ // Update xColumn to use steering_intensity if available
778
+ if (allData.length > 0 && allData[0].steering_intensity != null) {
779
+ CONFIG.xColumn = 'steering_intensity';
780
+ }
781
+ } else {
782
+ allData = rawData;
783
+ }
784
+
785
+ // Init all charts
786
+ const cells = Array.from(grid.querySelectorAll('.chart-cell'));
787
+ const chartInstances = cells.map((cell, idx) => initChart(cell, CONFIG.metrics[idx]));
788
+
789
+ // Render all
790
+ chartInstances.forEach(chart => chart.render());
791
+
792
+ // Update legend (once for the whole group)
793
+ const hasMeanStd = allData.some(d => {
794
+ return CONFIG.metrics.some(m => {
795
+ const meanKey = `${m.key}_mean`;
796
+ const stdKey = `${m.key}_std`;
797
+ return d[meanKey] != null && d[stdKey] != null;
798
+ });
799
+ });
800
+
801
+ if (hasMeanStd) {
802
+ legend.innerHTML = `
803
+ <div class="d3-sweep-1d__legend-item">
804
+ <div class="d3-sweep-1d__legend-line" style="background: ${CONFIG.lineColor};"></div>
805
+ <span>Mean</span>
806
+ </div>
807
+ <div class="d3-sweep-1d__legend-item">
808
+ <div class="d3-sweep-1d__legend-band" style="background: ${CONFIG.lineColor}; opacity: 0.2;"></div>
809
+ <span>± 1 std dev</span>
810
+ </div>
811
+ `;
812
+ } else {
813
+ legend.innerHTML = `
814
+ <div class="d3-sweep-1d__legend-item">
815
+ <div class="d3-sweep-1d__legend-line" style="background: ${CONFIG.lineColor};"></div>
816
+ <span>Mean</span>
817
+ </div>
818
+ `;
819
+ }
820
+
821
+ // Responsive - observe container for resize
822
+ let resizeTimer;
823
+ const handleResize = () => {
824
+ clearTimeout(resizeTimer);
825
+ resizeTimer = setTimeout(() => {
826
+ chartInstances.forEach(chart => chart.render());
827
+ }, 100);
828
+ };
829
+
830
+ const ro = window.ResizeObserver ? new ResizeObserver(handleResize) : null;
831
+ if (ro) {
832
+ ro.observe(container);
833
+ }
834
+
835
+ // Also observe window resize as fallback
836
+ window.addEventListener('resize', handleResize);
837
+
838
+ // Force a re-render after a short delay to ensure proper sizing
839
+ setTimeout(() => {
840
+ chartInstances.forEach(chart => chart.render());
841
+ }, 100);
842
+
843
+ } catch (e) {
844
+ const pre = document.createElement('pre');
845
+ pre.textContent = 'Error loading data: ' + (e && e.message ? e.message : e);
846
+ pre.style.color = 'var(--danger, #b00020)';
847
+ pre.style.fontSize = '12px';
848
+ container.appendChild(pre);
849
+ }
850
+ }
851
+
852
+ load();
853
+ };
854
+
855
+ if (document.readyState === 'loading') {
856
+ document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
857
+ } else {
858
+ ensureD3(bootstrap);
859
+ }
860
+ })();
861
+ </script>
862
+
app/src/styles/_variables.css CHANGED
@@ -11,7 +11,7 @@
11
  --default-font-family: Source Sans Pro, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
12
 
13
  /* Brand (OKLCH base + derived states) */
14
- --primary-base: oklch(0.75 0.12 337);
15
  --primary-color: var(--primary-base);
16
  --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
17
  --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);
 
11
  --default-font-family: Source Sans Pro, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
12
 
13
  /* Brand (OKLCH base + derived states) */
14
+ --primary-base: oklch(0.75 0.12 47);
15
  --primary-color: var(--primary-base);
16
  --primary-color-hover: oklch(from var(--primary-color) calc(l - 0.05) c h);
17
  --primary-color-active: oklch(from var(--primary-color) calc(l - 0.10) c h);