Spaces:
Running
Running
thibaud frere
commited on
Commit
Β·
1ee6ce7
1
Parent(s):
91b0400
cleanup
Browse filesThis view is limited to 50 files because it contains too many changes. Β
See raw diff
- .gitignore +2 -1
- .temp-template-sync +0 -1
- app/.astro/astro/content.d.ts +284 -0
- app/package-lock.json +0 -0
- app/package.json +0 -0
- app/public/scripts/color-palettes.js +82 -44
- app/scripts/latex-to-mdx/README.md +4 -4
- app/scripts/latex-to-mdx/input/sections/05_foundation_models.tex.temp +0 -224
- app/scripts/latex-to-mdx/input/sections/test.md +0 -488
- app/scripts/latex-to-mdx/mdx-converter.mjs +25 -25
- app/scripts/latex-to-mdx/post-processor.mjs +1 -1
- app/scripts/sync-template.mjs +67 -14
- app/src/components/ColorPicker.astro +0 -118
- app/src/components/{ResponsiveImage.astro β Figure.astro} +9 -20
- app/src/components/{MultiImage.astro β MultiFigure.astro} +10 -10
- app/src/components/Palettes.astro +0 -170
- app/src/components/Sidenote.astro +8 -8
- app/src/components/demo/ColorPicker.astro +633 -0
- app/src/components/demo/Palettes.astro +596 -0
- app/src/components/trackio/Trackio.svelte +500 -259
- app/src/components/{TrackioWrapper.astro β trackio/TrackioWrapper.astro} +219 -147
- app/src/components/trackio/core/adaptive-sampler.js +9 -9
- app/src/content/assets/data/data.json +3 -0
- .devcontainer/devcontainer.json β app/src/content/assets/data/font-sprite-mapping.json +2 -2
- app/src/content/assets/data/font-sprite.svg +0 -0
- app/src/content/assets/data/font_manifest.json +3 -0
- app/src/content/assets/data/typography_data.json +3 -0
- app/src/content/assets/sprites/font-sprite.svg +0 -0
- app/src/content/chapters/demo/best-pratices.mdx +2 -2
- app/src/content/chapters/demo/components.mdx +9 -9
- app/src/content/chapters/demo/debug-components.mdx +1 -1
- app/src/content/chapters/demo/introduction.mdx +3 -2
- app/src/content/chapters/demo/markdown.mdx +2 -2
- app/src/content/chapters/demo/vibe-coding-charts.mdx +74 -25
- app/src/content/chapters/demo/writing-your-content.mdx +5 -4
- app/src/content/chapters/your-first-chapter.mdx +2 -0
- app/src/content/embeds/arxiv/arxiv.html +566 -0
- app/src/content/embeds/arxiv/fetch_arxiv_api.py +270 -0
- app/src/content/embeds/arxiv/generate_umap.py +329 -0
- app/src/content/embeds/banner.html +235 -244
- app/src/content/embeds/d3-bar.html +224 -82
- app/src/content/embeds/d3-equation-editor.html +677 -0
- app/src/content/embeds/d3-line-quad.html +272 -134
- app/src/content/embeds/d3-matrix.html +56 -47
- app/src/content/embeds/d3-neural-network.html +427 -329
- app/src/content/embeds/d3-pie-quad.html +8 -8
- app/src/content/embeds/d3-scatter.html +1 -1
- app/src/content/embeds/d3-umap-typography.html +804 -0
- app/src/content/embeds/demo/color-picker.html +0 -226
- app/src/content/embeds/demo/palettes.html +0 -219
.gitignore
CHANGED
|
@@ -19,7 +19,8 @@ node_modules/
|
|
| 19 |
*.env
|
| 20 |
*.cache
|
| 21 |
|
| 22 |
-
app/scripts/latex-
|
|
|
|
| 23 |
|
| 24 |
# PDF export
|
| 25 |
app/public/*.pdf
|
|
|
|
| 19 |
*.env
|
| 20 |
*.cache
|
| 21 |
|
| 22 |
+
app/scripts/latex-to-mdx/output/
|
| 23 |
+
app/src/content/embeds/typography/generated
|
| 24 |
|
| 25 |
# PDF export
|
| 26 |
app/public/*.pdf
|
.temp-template-sync
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
Subproject commit ead50ba9028719475151d26696248ef6fe996b70
|
|
|
|
|
|
app/.astro/astro/content.d.ts
CHANGED
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare module 'astro:content' {
|
| 2 |
+
interface Render {
|
| 3 |
+
'.mdx': Promise<{
|
| 4 |
+
Content: import('astro').MarkdownInstance<{}>['Content'];
|
| 5 |
+
headings: import('astro').MarkdownHeading[];
|
| 6 |
+
remarkPluginFrontmatter: Record<string, any>;
|
| 7 |
+
components: import('astro').MDXInstance<{}>['components'];
|
| 8 |
+
}>;
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
declare module 'astro:content' {
|
| 13 |
+
interface RenderResult {
|
| 14 |
+
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
| 15 |
+
headings: import('astro').MarkdownHeading[];
|
| 16 |
+
remarkPluginFrontmatter: Record<string, any>;
|
| 17 |
+
}
|
| 18 |
+
interface Render {
|
| 19 |
+
'.md': Promise<RenderResult>;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export interface RenderedContent {
|
| 23 |
+
html: string;
|
| 24 |
+
metadata?: {
|
| 25 |
+
imagePaths: Array<string>;
|
| 26 |
+
[key: string]: unknown;
|
| 27 |
+
};
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
declare module 'astro:content' {
|
| 32 |
+
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
| 33 |
+
|
| 34 |
+
export type CollectionKey = keyof AnyEntryMap;
|
| 35 |
+
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
|
| 36 |
+
|
| 37 |
+
export type ContentCollectionKey = keyof ContentEntryMap;
|
| 38 |
+
export type DataCollectionKey = keyof DataEntryMap;
|
| 39 |
+
|
| 40 |
+
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
| 41 |
+
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
|
| 42 |
+
ContentEntryMap[C]
|
| 43 |
+
>['slug'];
|
| 44 |
+
|
| 45 |
+
/** @deprecated Use `getEntry` instead. */
|
| 46 |
+
export function getEntryBySlug<
|
| 47 |
+
C extends keyof ContentEntryMap,
|
| 48 |
+
E extends ValidContentEntrySlug<C> | (string & {}),
|
| 49 |
+
>(
|
| 50 |
+
collection: C,
|
| 51 |
+
// Note that this has to accept a regular string too, for SSR
|
| 52 |
+
entrySlug: E,
|
| 53 |
+
): E extends ValidContentEntrySlug<C>
|
| 54 |
+
? Promise<CollectionEntry<C>>
|
| 55 |
+
: Promise<CollectionEntry<C> | undefined>;
|
| 56 |
+
|
| 57 |
+
/** @deprecated Use `getEntry` instead. */
|
| 58 |
+
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
|
| 59 |
+
collection: C,
|
| 60 |
+
entryId: E,
|
| 61 |
+
): Promise<CollectionEntry<C>>;
|
| 62 |
+
|
| 63 |
+
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
|
| 64 |
+
collection: C,
|
| 65 |
+
filter?: (entry: CollectionEntry<C>) => entry is E,
|
| 66 |
+
): Promise<E[]>;
|
| 67 |
+
export function getCollection<C extends keyof AnyEntryMap>(
|
| 68 |
+
collection: C,
|
| 69 |
+
filter?: (entry: CollectionEntry<C>) => unknown,
|
| 70 |
+
): Promise<CollectionEntry<C>[]>;
|
| 71 |
+
|
| 72 |
+
export function getEntry<
|
| 73 |
+
C extends keyof ContentEntryMap,
|
| 74 |
+
E extends ValidContentEntrySlug<C> | (string & {}),
|
| 75 |
+
>(entry: {
|
| 76 |
+
collection: C;
|
| 77 |
+
slug: E;
|
| 78 |
+
}): E extends ValidContentEntrySlug<C>
|
| 79 |
+
? Promise<CollectionEntry<C>>
|
| 80 |
+
: Promise<CollectionEntry<C> | undefined>;
|
| 81 |
+
export function getEntry<
|
| 82 |
+
C extends keyof DataEntryMap,
|
| 83 |
+
E extends keyof DataEntryMap[C] | (string & {}),
|
| 84 |
+
>(entry: {
|
| 85 |
+
collection: C;
|
| 86 |
+
id: E;
|
| 87 |
+
}): E extends keyof DataEntryMap[C]
|
| 88 |
+
? Promise<DataEntryMap[C][E]>
|
| 89 |
+
: Promise<CollectionEntry<C> | undefined>;
|
| 90 |
+
export function getEntry<
|
| 91 |
+
C extends keyof ContentEntryMap,
|
| 92 |
+
E extends ValidContentEntrySlug<C> | (string & {}),
|
| 93 |
+
>(
|
| 94 |
+
collection: C,
|
| 95 |
+
slug: E,
|
| 96 |
+
): E extends ValidContentEntrySlug<C>
|
| 97 |
+
? Promise<CollectionEntry<C>>
|
| 98 |
+
: Promise<CollectionEntry<C> | undefined>;
|
| 99 |
+
export function getEntry<
|
| 100 |
+
C extends keyof DataEntryMap,
|
| 101 |
+
E extends keyof DataEntryMap[C] | (string & {}),
|
| 102 |
+
>(
|
| 103 |
+
collection: C,
|
| 104 |
+
id: E,
|
| 105 |
+
): E extends keyof DataEntryMap[C]
|
| 106 |
+
? Promise<DataEntryMap[C][E]>
|
| 107 |
+
: Promise<CollectionEntry<C> | undefined>;
|
| 108 |
+
|
| 109 |
+
/** Resolve an array of entry references from the same collection */
|
| 110 |
+
export function getEntries<C extends keyof ContentEntryMap>(
|
| 111 |
+
entries: {
|
| 112 |
+
collection: C;
|
| 113 |
+
slug: ValidContentEntrySlug<C>;
|
| 114 |
+
}[],
|
| 115 |
+
): Promise<CollectionEntry<C>[]>;
|
| 116 |
+
export function getEntries<C extends keyof DataEntryMap>(
|
| 117 |
+
entries: {
|
| 118 |
+
collection: C;
|
| 119 |
+
id: keyof DataEntryMap[C];
|
| 120 |
+
}[],
|
| 121 |
+
): Promise<CollectionEntry<C>[]>;
|
| 122 |
+
|
| 123 |
+
export function render<C extends keyof AnyEntryMap>(
|
| 124 |
+
entry: AnyEntryMap[C][string],
|
| 125 |
+
): Promise<RenderResult>;
|
| 126 |
+
|
| 127 |
+
export function reference<C extends keyof AnyEntryMap>(
|
| 128 |
+
collection: C,
|
| 129 |
+
): import('astro/zod').ZodEffects<
|
| 130 |
+
import('astro/zod').ZodString,
|
| 131 |
+
C extends keyof ContentEntryMap
|
| 132 |
+
? {
|
| 133 |
+
collection: C;
|
| 134 |
+
slug: ValidContentEntrySlug<C>;
|
| 135 |
+
}
|
| 136 |
+
: {
|
| 137 |
+
collection: C;
|
| 138 |
+
id: keyof DataEntryMap[C];
|
| 139 |
+
}
|
| 140 |
+
>;
|
| 141 |
+
// Allow generic `string` to avoid excessive type errors in the config
|
| 142 |
+
// if `dev` is not running to update as you edit.
|
| 143 |
+
// Invalid collection names will be caught at build time.
|
| 144 |
+
export function reference<C extends string>(
|
| 145 |
+
collection: C,
|
| 146 |
+
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
|
| 147 |
+
|
| 148 |
+
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
| 149 |
+
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
|
| 150 |
+
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
| 151 |
+
>;
|
| 152 |
+
|
| 153 |
+
type ContentEntryMap = {
|
| 154 |
+
"chapters": {
|
| 155 |
+
"demo/best-pratices.mdx": {
|
| 156 |
+
id: "demo/best-pratices.mdx";
|
| 157 |
+
slug: "demo/best-pratices";
|
| 158 |
+
body: string;
|
| 159 |
+
collection: "chapters";
|
| 160 |
+
data: any
|
| 161 |
+
} & { render(): Render[".mdx"] };
|
| 162 |
+
"demo/components.mdx": {
|
| 163 |
+
id: "demo/components.mdx";
|
| 164 |
+
slug: "demo/components";
|
| 165 |
+
body: string;
|
| 166 |
+
collection: "chapters";
|
| 167 |
+
data: any
|
| 168 |
+
} & { render(): Render[".mdx"] };
|
| 169 |
+
"demo/debug-components.mdx": {
|
| 170 |
+
id: "demo/debug-components.mdx";
|
| 171 |
+
slug: "demo/debug-components";
|
| 172 |
+
body: string;
|
| 173 |
+
collection: "chapters";
|
| 174 |
+
data: any
|
| 175 |
+
} & { render(): Render[".mdx"] };
|
| 176 |
+
"demo/getting-started.mdx": {
|
| 177 |
+
id: "demo/getting-started.mdx";
|
| 178 |
+
slug: "demo/getting-started";
|
| 179 |
+
body: string;
|
| 180 |
+
collection: "chapters";
|
| 181 |
+
data: any
|
| 182 |
+
} & { render(): Render[".mdx"] };
|
| 183 |
+
"demo/greetings.mdx": {
|
| 184 |
+
id: "demo/greetings.mdx";
|
| 185 |
+
slug: "demo/greetings";
|
| 186 |
+
body: string;
|
| 187 |
+
collection: "chapters";
|
| 188 |
+
data: any
|
| 189 |
+
} & { render(): Render[".mdx"] };
|
| 190 |
+
"demo/introduction.mdx": {
|
| 191 |
+
id: "demo/introduction.mdx";
|
| 192 |
+
slug: "demo/introduction";
|
| 193 |
+
body: string;
|
| 194 |
+
collection: "chapters";
|
| 195 |
+
data: any
|
| 196 |
+
} & { render(): Render[".mdx"] };
|
| 197 |
+
"demo/latex-convertion.mdx": {
|
| 198 |
+
id: "demo/latex-convertion.mdx";
|
| 199 |
+
slug: "demo/latex-convertion";
|
| 200 |
+
body: string;
|
| 201 |
+
collection: "chapters";
|
| 202 |
+
data: any
|
| 203 |
+
} & { render(): Render[".mdx"] };
|
| 204 |
+
"demo/markdown.mdx": {
|
| 205 |
+
id: "demo/markdown.mdx";
|
| 206 |
+
slug: "demo/markdown";
|
| 207 |
+
body: string;
|
| 208 |
+
collection: "chapters";
|
| 209 |
+
data: any
|
| 210 |
+
} & { render(): Render[".mdx"] };
|
| 211 |
+
"demo/vibe-coding-charts.mdx": {
|
| 212 |
+
id: "demo/vibe-coding-charts.mdx";
|
| 213 |
+
slug: "demo/vibe-coding-charts";
|
| 214 |
+
body: string;
|
| 215 |
+
collection: "chapters";
|
| 216 |
+
data: any
|
| 217 |
+
} & { render(): Render[".mdx"] };
|
| 218 |
+
"demo/writing-your-content.mdx": {
|
| 219 |
+
id: "demo/writing-your-content.mdx";
|
| 220 |
+
slug: "demo/writing-your-content";
|
| 221 |
+
body: string;
|
| 222 |
+
collection: "chapters";
|
| 223 |
+
data: any
|
| 224 |
+
} & { render(): Render[".mdx"] };
|
| 225 |
+
"your-first-chapter.mdx": {
|
| 226 |
+
id: "your-first-chapter.mdx";
|
| 227 |
+
slug: "your-first-chapter";
|
| 228 |
+
body: string;
|
| 229 |
+
collection: "chapters";
|
| 230 |
+
data: any
|
| 231 |
+
} & { render(): Render[".mdx"] };
|
| 232 |
+
};
|
| 233 |
+
"embeds": {
|
| 234 |
+
"vibe-code-d3-embeds-directives.md": {
|
| 235 |
+
id: "vibe-code-d3-embeds-directives.md";
|
| 236 |
+
slug: "vibe-code-d3-embeds-directives";
|
| 237 |
+
body: string;
|
| 238 |
+
collection: "embeds";
|
| 239 |
+
data: any
|
| 240 |
+
} & { render(): Render[".md"] };
|
| 241 |
+
};
|
| 242 |
+
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
type DataEntryMap = {
|
| 246 |
+
"assets": {
|
| 247 |
+
"data/data": {
|
| 248 |
+
id: "data/data";
|
| 249 |
+
collection: "assets";
|
| 250 |
+
data: any
|
| 251 |
+
};
|
| 252 |
+
"data/font-sprite-mapping": {
|
| 253 |
+
id: "data/font-sprite-mapping";
|
| 254 |
+
collection: "assets";
|
| 255 |
+
data: any
|
| 256 |
+
};
|
| 257 |
+
"data/font_manifest": {
|
| 258 |
+
id: "data/font_manifest";
|
| 259 |
+
collection: "assets";
|
| 260 |
+
data: any
|
| 261 |
+
};
|
| 262 |
+
"data/llm_benchmarks": {
|
| 263 |
+
id: "data/llm_benchmarks";
|
| 264 |
+
collection: "assets";
|
| 265 |
+
data: any
|
| 266 |
+
};
|
| 267 |
+
"data/mnist-variant-model": {
|
| 268 |
+
id: "data/mnist-variant-model";
|
| 269 |
+
collection: "assets";
|
| 270 |
+
data: any
|
| 271 |
+
};
|
| 272 |
+
"data/typography_data": {
|
| 273 |
+
id: "data/typography_data";
|
| 274 |
+
collection: "assets";
|
| 275 |
+
data: any
|
| 276 |
+
};
|
| 277 |
+
};
|
| 278 |
+
|
| 279 |
+
};
|
| 280 |
+
|
| 281 |
+
type AnyEntryMap = ContentEntryMap & DataEntryMap;
|
| 282 |
+
|
| 283 |
+
export type ContentConfig = never;
|
| 284 |
+
}
|
app/package-lock.json
CHANGED
|
Binary files a/app/package-lock.json and b/app/package-lock.json differ
|
|
|
app/package.json
CHANGED
|
Binary files a/app/package.json and b/app/package.json differ
|
|
|
app/public/scripts/color-palettes.js
CHANGED
|
@@ -46,63 +46,95 @@
|
|
| 46 |
return { r, g, b: b3 };
|
| 47 |
};
|
| 48 |
const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
|
| 49 |
-
const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a*a + b*b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
|
| 50 |
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
| 51 |
const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
|
| 52 |
-
const toHex = ({ r, g, b }) => {
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
|
|
|
|
| 57 |
const css = getCssVar('--primary-color');
|
| 58 |
-
if (!css) return
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
const rgb = parseCssColorToRgb(css);
|
| 61 |
-
if (rgb)
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
};
|
| 64 |
// No count management via CSS anymore; counts are passed directly to the API
|
| 65 |
|
| 66 |
const generators = {
|
| 67 |
-
categorical: (
|
| 68 |
-
const
|
| 69 |
-
const { r, g, b } = parseHex(baseHex);
|
| 70 |
-
const { L, a, b: bb } = rgbToOklab(r,g,b);
|
| 71 |
-
const { C, h } = oklabToOklch(L,a,bb);
|
| 72 |
const L0 = Math.min(0.85, Math.max(0.4, L));
|
| 73 |
const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
|
| 74 |
const total = Math.max(1, Math.min(12, count || 8));
|
| 75 |
const hueStep = 360 / total;
|
| 76 |
const results = [];
|
| 77 |
-
for (let i=0;i<total;i++) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
return results;
|
| 79 |
},
|
| 80 |
-
sequential: (
|
| 81 |
-
const
|
| 82 |
-
const { r, g, b } = parseHex(baseHex);
|
| 83 |
-
const { L, a, b: bb } = rgbToOklab(r,g,b);
|
| 84 |
-
const { C, h } = oklabToOklch(L,a,bb);
|
| 85 |
const total = Math.max(1, Math.min(12, count || 8));
|
| 86 |
const startL = Math.max(0.25, L - 0.18);
|
| 87 |
const endL = Math.min(0.92, L + 0.18);
|
| 88 |
const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
|
| 89 |
const out = [];
|
| 90 |
-
for (let i=0;i<total;i++) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
return out;
|
| 92 |
},
|
| 93 |
-
diverging: (
|
| 94 |
-
const
|
| 95 |
-
const { r, g, b } = parseHex(baseHex);
|
| 96 |
-
const baseLab = rgbToOklab(r,g,b);
|
| 97 |
-
const baseLch = oklabToOklch(baseLab.L, baseLab.a, baseLab.b);
|
| 98 |
const total = Math.max(1, Math.min(12, count || 8));
|
| 99 |
|
| 100 |
// Left endpoint: EXACT primary color (no darkening)
|
| 101 |
-
const leftLab =
|
| 102 |
// Right endpoint: complement with same L and similar C (clamped safe)
|
| 103 |
-
const compH = (
|
| 104 |
-
const cSafe = Math.min(0.35, Math.max(0.08,
|
| 105 |
-
const rightLab = oklchToOklab(
|
| 106 |
const whiteLab = { L: 0.98, a: 0, b: 0 }; // center nearβwhite
|
| 107 |
|
| 108 |
const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
|
|
@@ -152,18 +184,22 @@
|
|
| 152 |
let lastSignature = '';
|
| 153 |
|
| 154 |
const updatePalettes = () => {
|
| 155 |
-
const
|
| 156 |
-
const
|
|
|
|
| 157 |
if (signature === lastSignature) return;
|
| 158 |
lastSignature = signature;
|
| 159 |
-
try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary } })); } catch {}
|
| 160 |
};
|
| 161 |
|
| 162 |
const bootstrap = () => {
|
|
|
|
| 163 |
updatePalettes();
|
|
|
|
|
|
|
| 164 |
const mo = new MutationObserver(() => updatePalettes());
|
| 165 |
mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
|
| 166 |
-
|
| 167 |
// Utility: choose high-contrast (or softened) text style against an arbitrary background color
|
| 168 |
const pickTextStyleForBackground = (bgCss, opts = {}) => {
|
| 169 |
const cssRoot = document.documentElement;
|
|
@@ -175,13 +211,13 @@
|
|
| 175 |
if (!rgb) return null;
|
| 176 |
return rgb; // already 0..1
|
| 177 |
};
|
| 178 |
-
const mixRgb01 = (a, b, t) => ({ r: a.r*(1-t)+b.r*t, g: a.g*(1-t)+b.g*t, b: a.b*(1-t)+b.b*t });
|
| 179 |
const relLum = (rgb) => {
|
| 180 |
const f = (u) => srgbToLinear(u);
|
| 181 |
-
return 0.2126*f(rgb.r) + 0.7152*f(rgb.g) + 0.0722*f(rgb.b);
|
| 182 |
};
|
| 183 |
const contrast = (fg, bg) => {
|
| 184 |
-
const L1 = relLum(fg), L2 = relLum(bg); const a = Math.max(L1,L2), b = Math.min(L1,L2);
|
| 185 |
return (a + 0.05) / (b + 0.05);
|
| 186 |
};
|
| 187 |
try {
|
|
@@ -193,7 +229,7 @@
|
|
| 193 |
.filter(x => !!x.rgb);
|
| 194 |
// Pick the max contrast
|
| 195 |
let best = candidates[0]; let bestCR = contrast(best.rgb, bg);
|
| 196 |
-
for (let i=1;i<candidates.length;i++){
|
| 197 |
const cr = contrast(candidates[i].rgb, bg);
|
| 198 |
if (cr > bestCR) { best = candidates[i]; bestCR = cr; }
|
| 199 |
}
|
|
@@ -206,7 +242,7 @@
|
|
| 206 |
finalRgb = mixRgb01(best.rgb, mutedRgb, blend);
|
| 207 |
}
|
| 208 |
const haloStrength = Math.min(1, Math.max(0, Number(opts.haloStrength == null ? 0.5 : opts.haloStrength)));
|
| 209 |
-
const stroke = (best.css === '#000' || best.css.toLowerCase() === 'black') ? `rgba(255,255,255,${0.30 + 0.40*haloStrength})` : `rgba(0,0,0,${0.30 + 0.30*haloStrength})`;
|
| 210 |
return { fill: toHex(finalRgb), stroke, strokeWidth: (opts.haloWidth == null ? 1 : Number(opts.haloWidth)) };
|
| 211 |
} catch {
|
| 212 |
return { fill: getCssVar('--text-color') || '#000', stroke: 'var(--transparent-page-contrast)', strokeWidth: 1 };
|
|
@@ -214,14 +250,16 @@
|
|
| 214 |
};
|
| 215 |
window.ColorPalettes = {
|
| 216 |
refresh: updatePalettes,
|
| 217 |
-
notify: () => { try { const
|
| 218 |
getPrimary: () => getPrimaryHex(),
|
|
|
|
| 219 |
getColors: (key, count = 6) => {
|
| 220 |
-
const
|
|
|
|
| 221 |
const total = Math.max(1, Math.min(12, Number(count) || 6));
|
| 222 |
-
if (key === 'categorical') return generators.categorical(
|
| 223 |
-
if (key === 'sequential') return generators.sequential(
|
| 224 |
-
if (key === 'diverging') return generators.diverging(
|
| 225 |
return [];
|
| 226 |
},
|
| 227 |
getTextStyleForBackground: (bgCss, opts) => pickTextStyleForBackground(bgCss, opts || {}),
|
|
|
|
| 46 |
return { r, g, b: b3 };
|
| 47 |
};
|
| 48 |
const oklchToOklab = (L, C, hDeg) => { const h = (hDeg * Math.PI) / 180; return { L, a: C * Math.cos(h), b: C * Math.sin(h) }; };
|
| 49 |
+
const oklabToOklch = (L, a, b) => { const C = Math.sqrt(a * a + b * b); let h = Math.atan2(b, a) * 180 / Math.PI; if (h < 0) h += 360; return { L, C, h }; };
|
| 50 |
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
| 51 |
const isInGamut = ({ r, g, b }) => r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
|
| 52 |
+
const toHex = ({ r, g, b }) => {
|
| 53 |
+
const R = Math.round(clamp01(r) * 255), G = Math.round(clamp01(g) * 255), B = Math.round(clamp01(b) * 255);
|
| 54 |
+
const h = (n) => n.toString(16).padStart(2, '0');
|
| 55 |
+
return `#${h(R)}${h(G)}${h(B)}`.toUpperCase();
|
| 56 |
+
};
|
| 57 |
+
const oklchToHexSafe = (L, C, h) => { let c = C; for (let i = 0; i < 12; i++) { const { a, b } = oklchToOklab(L, c, h); const rgb = oklabToRgb(L, a, b); if (isInGamut(rgb)) return toHex(rgb); c = Math.max(0, c - 0.02); } return toHex(oklabToRgb(L, 0, 0)); };
|
| 58 |
+
const parseCssColorToRgb = (css) => { try { const el = document.createElement('span'); el.style.color = css; document.body.appendChild(el); const cs = getComputedStyle(el).color; document.body.removeChild(el); const m = cs.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i); if (!m) return null; return { r: Number(m[1]) / 255, g: Number(m[2]) / 255, b: Number(m[3]) / 255 }; } catch { return null; } };
|
| 59 |
|
| 60 |
+
// Get primary color in OKLCH format to preserve precision
|
| 61 |
+
const getPrimaryOKLCH = () => {
|
| 62 |
const css = getCssVar('--primary-color');
|
| 63 |
+
if (!css) return null;
|
| 64 |
+
|
| 65 |
+
// For OKLCH colors, return the exact values without conversion
|
| 66 |
+
if (css.includes('oklch')) {
|
| 67 |
+
const oklchMatch = css.match(/oklch\(([^)]+)\)/);
|
| 68 |
+
if (oklchMatch) {
|
| 69 |
+
const values = oklchMatch[1].split(/\s+/).map(v => parseFloat(v.trim()));
|
| 70 |
+
if (values.length >= 3) {
|
| 71 |
+
const [L, C, h] = values;
|
| 72 |
+
return { L, C, h };
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// For non-OKLCH colors, convert to OKLCH for consistency
|
| 78 |
const rgb = parseCssColorToRgb(css);
|
| 79 |
+
if (rgb) {
|
| 80 |
+
const { L, a, b } = rgbToOklab(rgb.r, rgb.g, rgb.b);
|
| 81 |
+
const { C, h } = oklabToOklch(L, a, b);
|
| 82 |
+
return { L, C, h };
|
| 83 |
+
}
|
| 84 |
+
return null;
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// Keep getPrimaryHex for backward compatibility, but now it converts from OKLCH
|
| 88 |
+
const getPrimaryHex = () => {
|
| 89 |
+
const oklch = getPrimaryOKLCH();
|
| 90 |
+
if (!oklch) return null;
|
| 91 |
+
|
| 92 |
+
const { a, b } = oklchToOklab(oklch.L, oklch.C, oklch.h);
|
| 93 |
+
const rgb = oklabToRgb(oklch.L, a, b);
|
| 94 |
+
return toHex(rgb);
|
| 95 |
};
|
| 96 |
// No count management via CSS anymore; counts are passed directly to the API
|
| 97 |
|
| 98 |
const generators = {
|
| 99 |
+
categorical: (baseOKLCH, count) => {
|
| 100 |
+
const { L, C, h } = baseOKLCH;
|
|
|
|
|
|
|
|
|
|
| 101 |
const L0 = Math.min(0.85, Math.max(0.4, L));
|
| 102 |
const C0 = Math.min(0.35, Math.max(0.1, C || 0.2));
|
| 103 |
const total = Math.max(1, Math.min(12, count || 8));
|
| 104 |
const hueStep = 360 / total;
|
| 105 |
const results = [];
|
| 106 |
+
for (let i = 0; i < total; i++) {
|
| 107 |
+
const hDeg = (h + i * hueStep) % 360;
|
| 108 |
+
const lVar = ((i % 3) - 1) * 0.04;
|
| 109 |
+
results.push(oklchToHexSafe(Math.max(0.4, Math.min(0.85, L0 + lVar)), C0, hDeg));
|
| 110 |
+
}
|
| 111 |
return results;
|
| 112 |
},
|
| 113 |
+
sequential: (baseOKLCH, count) => {
|
| 114 |
+
const { L, C, h } = baseOKLCH;
|
|
|
|
|
|
|
|
|
|
| 115 |
const total = Math.max(1, Math.min(12, count || 8));
|
| 116 |
const startL = Math.max(0.25, L - 0.18);
|
| 117 |
const endL = Math.min(0.92, L + 0.18);
|
| 118 |
const cBase = Math.min(0.33, Math.max(0.08, C * 0.9 + 0.06));
|
| 119 |
const out = [];
|
| 120 |
+
for (let i = 0; i < total; i++) {
|
| 121 |
+
const t = total === 1 ? 0 : i / (total - 1);
|
| 122 |
+
const lNow = startL * (1 - t) + endL * t;
|
| 123 |
+
const cNow = cBase * (0.85 + 0.15 * (1 - Math.abs(0.5 - t) * 2));
|
| 124 |
+
out.push(oklchToHexSafe(lNow, cNow, h));
|
| 125 |
+
}
|
| 126 |
return out;
|
| 127 |
},
|
| 128 |
+
diverging: (baseOKLCH, count) => {
|
| 129 |
+
const { L, C, h } = baseOKLCH;
|
|
|
|
|
|
|
|
|
|
| 130 |
const total = Math.max(1, Math.min(12, count || 8));
|
| 131 |
|
| 132 |
// Left endpoint: EXACT primary color (no darkening)
|
| 133 |
+
const leftLab = oklchToOklab(L, C, h);
|
| 134 |
// Right endpoint: complement with same L and similar C (clamped safe)
|
| 135 |
+
const compH = (h + 180) % 360;
|
| 136 |
+
const cSafe = Math.min(0.35, Math.max(0.08, C));
|
| 137 |
+
const rightLab = oklchToOklab(L, cSafe, compH);
|
| 138 |
const whiteLab = { L: 0.98, a: 0, b: 0 }; // center nearβwhite
|
| 139 |
|
| 140 |
const hexFromOKLab = (L, a, b) => toHex(oklabToRgb(L, a, b));
|
|
|
|
| 184 |
let lastSignature = '';
|
| 185 |
|
| 186 |
const updatePalettes = () => {
|
| 187 |
+
const primaryOKLCH = getPrimaryOKLCH();
|
| 188 |
+
const primaryHex = getPrimaryHex();
|
| 189 |
+
const signature = `${primaryOKLCH?.L},${primaryOKLCH?.C},${primaryOKLCH?.h}`;
|
| 190 |
if (signature === lastSignature) return;
|
| 191 |
lastSignature = signature;
|
| 192 |
+
try { document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary: primaryHex, primaryOKLCH } })); } catch { }
|
| 193 |
};
|
| 194 |
|
| 195 |
const bootstrap = () => {
|
| 196 |
+
// Initial setup - only run once on page load
|
| 197 |
updatePalettes();
|
| 198 |
+
|
| 199 |
+
// Observer will handle all subsequent changes
|
| 200 |
const mo = new MutationObserver(() => updatePalettes());
|
| 201 |
mo.observe(MODE.cssRoot, { attributes: true, attributeFilter: ['style', 'data-theme'] });
|
| 202 |
+
|
| 203 |
// Utility: choose high-contrast (or softened) text style against an arbitrary background color
|
| 204 |
const pickTextStyleForBackground = (bgCss, opts = {}) => {
|
| 205 |
const cssRoot = document.documentElement;
|
|
|
|
| 211 |
if (!rgb) return null;
|
| 212 |
return rgb; // already 0..1
|
| 213 |
};
|
| 214 |
+
const mixRgb01 = (a, b, t) => ({ r: a.r * (1 - t) + b.r * t, g: a.g * (1 - t) + b.g * t, b: a.b * (1 - t) + b.b * t });
|
| 215 |
const relLum = (rgb) => {
|
| 216 |
const f = (u) => srgbToLinear(u);
|
| 217 |
+
return 0.2126 * f(rgb.r) + 0.7152 * f(rgb.g) + 0.0722 * f(rgb.b);
|
| 218 |
};
|
| 219 |
const contrast = (fg, bg) => {
|
| 220 |
+
const L1 = relLum(fg), L2 = relLum(bg); const a = Math.max(L1, L2), b = Math.min(L1, L2);
|
| 221 |
return (a + 0.05) / (b + 0.05);
|
| 222 |
};
|
| 223 |
try {
|
|
|
|
| 229 |
.filter(x => !!x.rgb);
|
| 230 |
// Pick the max contrast
|
| 231 |
let best = candidates[0]; let bestCR = contrast(best.rgb, bg);
|
| 232 |
+
for (let i = 1; i < candidates.length; i++) {
|
| 233 |
const cr = contrast(candidates[i].rgb, bg);
|
| 234 |
if (cr > bestCR) { best = candidates[i]; bestCR = cr; }
|
| 235 |
}
|
|
|
|
| 242 |
finalRgb = mixRgb01(best.rgb, mutedRgb, blend);
|
| 243 |
}
|
| 244 |
const haloStrength = Math.min(1, Math.max(0, Number(opts.haloStrength == null ? 0.5 : opts.haloStrength)));
|
| 245 |
+
const stroke = (best.css === '#000' || best.css.toLowerCase() === 'black') ? `rgba(255,255,255,${0.30 + 0.40 * haloStrength})` : `rgba(0,0,0,${0.30 + 0.30 * haloStrength})`;
|
| 246 |
return { fill: toHex(finalRgb), stroke, strokeWidth: (opts.haloWidth == null ? 1 : Number(opts.haloWidth)) };
|
| 247 |
} catch {
|
| 248 |
return { fill: getCssVar('--text-color') || '#000', stroke: 'var(--transparent-page-contrast)', strokeWidth: 1 };
|
|
|
|
| 250 |
};
|
| 251 |
window.ColorPalettes = {
|
| 252 |
refresh: updatePalettes,
|
| 253 |
+
notify: () => { try { const primaryOKLCH = getPrimaryOKLCH(); const primaryHex = getPrimaryHex(); document.dispatchEvent(new CustomEvent('palettes:updated', { detail: { primary: primaryHex, primaryOKLCH } })); } catch { } },
|
| 254 |
getPrimary: () => getPrimaryHex(),
|
| 255 |
+
getPrimaryOKLCH: () => getPrimaryOKLCH(),
|
| 256 |
getColors: (key, count = 6) => {
|
| 257 |
+
const primaryOKLCH = getPrimaryOKLCH();
|
| 258 |
+
if (!primaryOKLCH) return [];
|
| 259 |
const total = Math.max(1, Math.min(12, Number(count) || 6));
|
| 260 |
+
if (key === 'categorical') return generators.categorical(primaryOKLCH, total);
|
| 261 |
+
if (key === 'sequential') return generators.sequential(primaryOKLCH, total);
|
| 262 |
+
if (key === 'diverging') return generators.diverging(primaryOKLCH, total);
|
| 263 |
return [];
|
| 264 |
},
|
| 265 |
getTextStyleForBackground: (bgCss, opts) => pickTextStyleForBackground(bgCss, opts || {}),
|
app/scripts/latex-to-mdx/README.md
CHANGED
|
@@ -49,7 +49,7 @@ latex-to-mdx/
|
|
| 49 |
### π¨ **Automatic Styling**
|
| 50 |
- **Highlights**: `\highlight{text}` β `<span class="highlight">text</span>`
|
| 51 |
- **Auto cleanup**: Removal of numbering `(1)`, `(2)`, etc.
|
| 52 |
-
- **Astro components**: Images β `
|
| 53 |
|
| 54 |
### π§ **Robust Pipeline**
|
| 55 |
- **LaTeX preprocessor**: Reference cleanup before Pandoc
|
|
@@ -83,7 +83,7 @@ title: "Your Article Title"
|
|
| 83 |
description: "Generated from LaTeX"
|
| 84 |
---
|
| 85 |
|
| 86 |
-
import
|
| 87 |
import figure1 from '../assets/image/figure1.png';
|
| 88 |
|
| 89 |
## Section with invisible anchor
|
|
@@ -96,7 +96,7 @@ Reference to an interactive [equation](#equation-name).
|
|
| 96 |
Equation with KaTeX ID:
|
| 97 |
$$\htmlId{equation-name}{E = mc^2}$$
|
| 98 |
|
| 99 |
-
<
|
| 100 |
```
|
| 101 |
|
| 102 |
## βοΈ Required Astro Configuration
|
|
@@ -141,7 +141,7 @@ export default defineConfig({
|
|
| 141 |
- Code snippet injection
|
| 142 |
|
| 143 |
4. **MDX Conversion** (`mdx-converter.mjs`)
|
| 144 |
-
- Images transformation β `
|
| 145 |
- HTML span escaping correction
|
| 146 |
- Automatic imports generation
|
| 147 |
- MDX frontmatter
|
|
|
|
| 49 |
### π¨ **Automatic Styling**
|
| 50 |
- **Highlights**: `\highlight{text}` β `<span class="highlight">text</span>`
|
| 51 |
- **Auto cleanup**: Removal of numbering `(1)`, `(2)`, etc.
|
| 52 |
+
- **Astro components**: Images β `Figure` with automatic imports
|
| 53 |
|
| 54 |
### π§ **Robust Pipeline**
|
| 55 |
- **LaTeX preprocessor**: Reference cleanup before Pandoc
|
|
|
|
| 83 |
description: "Generated from LaTeX"
|
| 84 |
---
|
| 85 |
|
| 86 |
+
import Figure from '../components/Figure.astro';
|
| 87 |
import figure1 from '../assets/image/figure1.png';
|
| 88 |
|
| 89 |
## Section with invisible anchor
|
|
|
|
| 96 |
Equation with KaTeX ID:
|
| 97 |
$$\htmlId{equation-name}{E = mc^2}$$
|
| 98 |
|
| 99 |
+
<Figure src={figure1} alt="Description" />
|
| 100 |
```
|
| 101 |
|
| 102 |
## βοΈ Required Astro Configuration
|
|
|
|
| 141 |
- Code snippet injection
|
| 142 |
|
| 143 |
4. **MDX Conversion** (`mdx-converter.mjs`)
|
| 144 |
+
- Images transformation β `Figure`
|
| 145 |
- HTML span escaping correction
|
| 146 |
- Automatic imports generation
|
| 147 |
- MDX frontmatter
|
app/scripts/latex-to-mdx/input/sections/05_foundation_models.tex.temp
DELETED
|
@@ -1,224 +0,0 @@
|
|
| 1 |
-
\section{Generalist Robot Policies}
|
| 2 |
-
\label{sec:learning-foundation}
|
| 3 |
-
|
| 4 |
-
\epigraph{\textit{Specialization is for insects}}{Robert A. Heinlein}
|
| 5 |
-
|
| 6 |
-
> **TL;DR**
|
| 7 |
-
> Openly available large scale datasets and the development of stable, expressive and efficient architecture fostered research on the development of generalist robot policies that can operate across embodiment and tasks.
|
| 8 |
-
|
| 9 |
-
The advent of large models trained on internet-scale datasets has drastically influenced fields like Computer Vision (CV) and Natural Language Processing (NLP), shifting the paradigm towards combining (1) an initial, task-agnostic large-scale pre-training stage and a (2) task-specific, adjustment phase.
|
| 10 |
-
The pre-training/adaptation paradigm has now largely replaced more classic approaches consisting of task-specific data collection, curation and model training in many subdomains within CV and NLP, motivated by the main drawback of limited scalability for \emph{task-specific approaches}, traditionally labor intensive.
|
| 11 |
-
Factors including (1) the advancements in generalist models learned with self-supervision for perception [@oquabDINOv2LearningRobust2024] or semantic understanding [@devlinBERTPretrainingDeep2019] and (2) the popularization collective efforts to aggregate large-scale openly available datasets [@collaborationOpenXEmbodimentRobotic2025,khazatskyDROIDLargeScaleInTheWild2025] are increasingly pushing the field of robot learning towards the pre-train-and-adapt paradigm.
|
| 12 |
-
This shift taps into the long-standing challenge of developing generalist robot policies, and holds the premise to surpass traditionally siloed approaches to robotics problems and develop a \emph{foundation robotics model}.
|
| 13 |
-
While Section~\ref{sec:learning-bc-single} introduced methods for learning \emph{single-task policies} such as ACT or Diffusion Policy, in this section we present advancements in developing \emph{generalist, multi-task, policies}, capable of performing a wide range of tasks across different environments and embodiments, and guided by unstructured instructions given via natural language.
|
| 14 |
-
|
| 15 |
-
 {#fig-fig:ch5-ml-vs-robotics-foundation}
|
| 16 |
-
|
| 17 |
-
*Fields within ML such as Computer Vision and NLP converged on the development of foundation models, trained on a variety of large scale models and capable to perform multiple downstream tasks (top). Conversely, robotics suffered from limited standardization in terms of the architectures used, and siloed, task specific datasets, incurring in a high degree of fragmentation which traditionally hindered the development of generalist models for robotics in favour of task-specific models (bottom).*
|
| 18 |
-
|
| 19 |
-
\subsection{Preliminaries: Models and Data}
|
| 20 |
-
The remarkable success of foundation models in NLP and CV is predicated on two core principles: architectural innovation and joint data-compute scaling.
|
| 21 |
-
The transformer architecture proved instrumental in capturing long-range dependencies in sequential data such as text, and its stability and expressivity made it the \emph{de facto} standard for modern large-scale models trained on internet-scale amounts of data.
|
| 22 |
-
In stark contrast with popular NLP [@raffelExploringLimitsTransfer2023] and CV [@ImageNet_VSS09] general-purpose datasets, the field of robotics has historically developed around task-specific datasets which hinders scalability across problems, resulting in a concrete data deficit for general-purpose robot learning.
|
| 23 |
-
Unlike the wealth of relatively readily available text and images on the internet, robotics data is intrinsically embodied---datasets collected for a manipulation robot typically differ entirely from locomotion datasets.
|
| 24 |
-
Further, datasets consisting of expert demonstrations are (1) intrinsically expensive to collect (2) and notoriously heterogeneous---different human experts may perform the same task optimally yet in very different ways.
|
| 25 |
-
In particular, since each expert trajectory is tied to a specific robot platform and the operating conditions of its environment and task, data heterogeneity has long posed a \emph{methodological} challenge for scaling robotics datasets via aggregation.
|
| 26 |
-
Beyond this, heterogeneity also raises \emph{conceptual} issues: naively mixing data across embodiments can induce negative transfer, as control strategies developed in isolation for different robot systems in different environments may even conflict when combined.
|
| 27 |
-
Thus, the high degree of fragmentation of robotics datasets and tasks has traditionally led to the development of \emph{specialist} policies, trained on small, task-specific datasets, and which excel at their designated task but fail to generalize to new situations (Figure~\ref{fig:ch5-ml-vs-robotics-foundation}).
|
| 28 |
-
|
| 29 |
-
![Early efforts in the development of generalist models for robotics include BC-Zero [@jangBCZZeroShotTask2022], RT-1 [@brohanRT1RoboticsTransformer2023], and RT-2 [@brohanRT2VisionLanguageActionModels2023]: large scale models trained on thousands of demonstrations. The open release of the Open-X [@collaborationOpenXEmbodimentRobotic2025] and DROID datasets [@khazatskyDROIDLargeScaleInTheWild2025] fostered the development of open source models: OpenVLA [@kimOpenVLAOpenSourceVisionLanguageAction2024], \pi_0 [@black$p_0$VisionLanguageActionFlow2024] and SmolVLA [@shukorSmolVLAVisionLanguageActionModel2025].](assets/image/ch5/ch5-generalist-policies-timeline.png) {#fig-fig:ch5-generalist-policies-timeline}
|
| 30 |
-
|
| 31 |
-
*Early efforts in the development of generalist models for robotics include BC-Zero [@jangBCZZeroShotTask2022], RT-1 [@brohanRT1RoboticsTransformer2023], and RT-2 [@brohanRT2VisionLanguageActionModels2023]: large scale models trained on thousands of demonstrations. The open release of the Open-X [@collaborationOpenXEmbodimentRobotic2025] and DROID datasets [@khazatskyDROIDLargeScaleInTheWild2025] fostered the development of open source models: OpenVLA [@kimOpenVLAOpenSourceVisionLanguageAction2024], \pi_0 [@black$p_0$VisionLanguageActionFlow2024] and SmolVLA [@shukorSmolVLAVisionLanguageActionModel2025].*
|
| 32 |
-
|
| 33 |
-
Motivated by the pursuit of generalist robot policies, the research community started investigating what and how to integrate from other domains within ML.
|
| 34 |
-
Figure~\ref{fig:ch5-generalist-policies-timeline} shows a timeline of some of the most popular contributions attempting at developing generalist policies.
|
| 35 |
-
Starting from BC-Zero, a latent variable model trained on 25K+ demonstrations, the field has now evolved into \( \pi_0 \), a transformer-based model trained on 10M+ demonstrations and exhibiting strong few-shot capabilities across tasks and embodiments.
|
| 36 |
-
For starters, Robotics Transformer 1 (RT-1) [@brohanRT1RoboticsTransformer2023] represented a significant step in the direction of developing a generalist robot policies over prior work including (1) BC-Zero [@jangBCZZeroShotTask2022] and (2) Gato [@reedGeneralistAgent2022], in that~@brohanRT1RoboticsTransformer2023 uses a much larger and diverse set of training tasks compared to both BC-Zero and Gato.
|
| 37 |
-
In particular, RT-1 uses a transformer architecture, and is trained on as many as 130k human-recorded trajectories collected over 13 robots in the span on 17 months.
|
| 38 |
-
RT-1 learns to process a history of camera images and a natural language instruction, and feeds the resulting sequence of high-dimensional tokens to a transformer, trained using a \emph{classification loss on a discretized actions space} consisting of 6 256 bins, each for each joint of a 6-dof robotic arm.
|
| 39 |
-
|
| 40 |
-
Perhaps motivated by the contemporary successes of the transformer architecture in both CV and NLP, the same group of authors investigated using a discrete output space to model---inherently continuous---quantities such as actions, leveraging a (1) more powerful architecture and (2) scaling up the dataset used \citep[RT-2]{brohanRT2VisionLanguageActionModels2023}.
|
| 41 |
-
In RT-2,~@brohanRT2VisionLanguageActionModels2023 propose inheriting internet-scale semantic knowledge from large-scale multi-modal datasets to learn a single, \emph{unified model} for robotics control.
|
| 42 |
-
Such a model, termed \emph{Vision-Language-Action} (VLA) in the original RT-2 paper, effectively casts robot control as a language modeling problem, and in particular as a Visual Question-Answering (VQ\&A) task, whereby the output token space used to represent \emph{string} tokens is shared with the \emph{8-bits tokens} used to represent the 256 actuation levels of a 6-dof robot joint.
|
| 43 |
-
In their work,~@brohanRT2VisionLanguageActionModels2023 propose co-fine-tuning then-leading large-scale VLMs such as PaLIX [@chenPaLIXScalingMultilingual2023] or PaLM-E [@driessPaLMEEmbodiedMultimodal2023] on a mix of web and robotics data, thus complementing VQ\&A training with robotics-specific signal, learning to directly output robot actions in a shared token space for visual and language inputs.
|
| 44 |
-
Using large models trained on internet-scale data as backbones for VLAs allows models to tap into the rich semantic knowledge embedded in the VLM's parameters, interpret new commands as well as recognize unseen objects by connecting them to concepts acquired while pre-training.
|
| 45 |
-
For instance,~@brohanRT2VisionLanguageActionModels2023 show that while RT-2 has never been explicitly trained to repurpose tools for a hammering task, it can still combine its semantic understanding of images, so that when asked which object between (1) a piece of paper, (2) a pair of headphones or (3) a rock may be used instead of a hammer, it answers correctly, (3).
|
| 46 |
-
|
| 47 |
-
Traditionally, research involved not only training the model but also collecting the underlying data, a costly and time-consuming processβfor instance, @jangBCZZeroShotTask2022 gathered 25K+ trajectories before training, while RT-1 required 130K+.
|
| 48 |
-
In turn, the data used in robot learning research efforts have traditionally proved rather fragmented, tailored to the specific task considered by the specific group of researchers who collected it, ultimately hindering integration.
|
| 49 |
-
The Open X-Embodiment project [@collaborationOpenXEmbodimentRobotic2025] was a landmark effort to address the data fragmentation problem, curating the aggregation of 60 \emph{existing} robotics datasets from 22 different robot embodiments and 21 institutions, resulting in a total 1.4M of cross-embodiments, cross-tasks, openly-available trajectories.
|
| 50 |
-
Besides the contribution of an aggregate, large scale dataset,~@collaborationOpenXEmbodimentRobotic2025 also demonstrated significant positive transfer \emph{across tasks and embodiments}, showing that a single model trained on multi-embodiment data can outperform specialist models trained on their respective single-embodiment datasets.
|
| 51 |
-
The Distributed Robot Interaction Dataset (DROID) [@khazatskyDROIDLargeScaleInTheWild2025] represents another significant step towards addressing the problem of scarse and disaggregated data in robot learning, providing a unique dataset consisting of 75K+ human demonstrations collected in realistic (\emph{in-the-wild}) manipulation settings, providing another cornerstone for building general-purpose robot policies.
|
| 52 |
-
Recently, foundational datasets curated through large, centralized efforts, are increasingly complemented by decentralized, community-driven collection of robotics data.
|
| 53 |
-
Software libraries as **LeRobot**~have been instrumental in enabling decentralized collection of large amounts of data, providing the infrastructure for researchers and practitioners to easily contribute trajectories from range of embodiments, democratizing data access via distributed collection.
|
| 54 |
-
|
| 55 |
-
The success of large, proprietary models like RT-1 and RT-2, highlighted a growing accessibility gap in robotics research, as training and deploying large-scale models requires computational resources simply unattainable for most research institutions.
|
| 56 |
-
The OpenVLA project [@kimOpenVLAOpenSourceVisionLanguageAction2024] emerged in direct contrast of closed-source counterparts, as a community-driven effort to create powerful, openly available VLAs.
|
| 57 |
-
In particular,~@kimOpenVLAOpenSourceVisionLanguageAction2024 trained OpenVLA by exclusively leveraging openly available data (970K+ from the Open-X dataset), and share training recipes alongside the model weights.
|
| 58 |
-
Architecturally, OpenVLA integrates a pre-trained vision encoder to project visual tokens into the embedding space of Llama2-7B [@touvronLlama2Open2023] language model backbone.
|
| 59 |
-
The language model backbone is then used to predict \emph{discrete action tokens} over 256 activation levels.
|
| 60 |
-
|
| 61 |
-
 {#fig-fig:ch5-trends}
|
| 62 |
-
|
| 63 |
-
*Robot learning is undergoing a paradigmatic shift: centralized data collections (A, left) are increasingly larger, often comprising Ms of demonstrations, and (A, right) decentralized approaches to data collection are also rising as an alternative for large scale data collection. (B) Generalist models are also becoming increasingly smaller and easier to run on limited hardware.*
|
| 64 |
-
|
| 65 |
-
Figure~\ref{fig:ch5-trends} illustrates graphically the two most relevant trends in modern robot learning.
|
| 66 |
-
As datasets collected via centralized, cross-institutions cooperation of increasing size are made available for the research community, decentralized datasets collected by individual researchers and practitioners have also gained traction recently, closing the gap with academic benchmarks thanks to community-contributed datasets.
|
| 67 |
-
Further, models used across tasks and embodiments are also becoming much more compute-efficient, and as a result the models' size has been consistently reducing over time, with consequent gains for autonomous robots in real-world, resource-constrained environments.
|
| 68 |
-
|
| 69 |
-
\subsection{Modern VLAs}
|
| 70 |
-
Modern recipes to train large scale VLAs extend early efforts to learn foundation models from large amounts of data via BC, introducing significant advancements concerning both architectural and procedural aspects.
|
| 71 |
-
From an architectural perspective, modern VLAs such as \pi_0 [@black$p_0$VisionLanguageActionFlow2024] leverage a \emph{unified transformer model} for efficiency of computation, while maintaining specialized sub-components within the model for visual perception and action prediction, enabling cross-task performance via language conditioning.
|
| 72 |
-
Crucially, modern VLAs including~@black$p_0$VisionLanguageActionFlow2024[\pi_0] and~@shukorSmolVLAVisionLanguageActionModel2025[SmolVLA] adopt \emph{unified} transformer models employing disjoint set of weights (\emph{experts}) for compute-efficient visual-semantic understanding and robotic control.
|
| 73 |
-
Procedurally, modern VLAs complement advanced Vision-Language Model (VLM) backbones with action-specific modules (1) adopting mid-sized \emph{action experts} to model continuous actions distributions \( p (a_{t:t+H_a} \vert o_t) \)---avoiding discrete action tokens entirely---and (2) relying on~\emph{action chunking} \citep[Section~\ref{sec:learning-bc-single}]{zhaoLearningFineGrainedBimanual2023} as a strategy to reduce error compounding when predicting multiple actions learning from inherently non-i.i.d. data, such as demonstration data.
|
| 74 |
-
|
| 75 |
-
These architectural and procedural innovations present three benefits.
|
| 76 |
-
First, developing architectures that exploit internet-scale pre-trained backbones allows to fully capitalizes on the vast world knowledge and skills state-of-the-art VLMs exhibit, preventig models from needing to learn visual, linguistic and semantic concepts from scratch.
|
| 77 |
-
Second, using generative models for continuous action distributions allows to learn rich, multimodal data distributions, a much more likely scenario in the big-data regime typically tackled while developing generalist policies.
|
| 78 |
-
Further, introducing two separate components for perception and action planning could enable using Mixture of Experts (MoE) architectures [@fedusReviewSparseExpert2022], more efficient to run and thus resulting in faster inference---a key features for models deployed in real-world scenarios.
|
| 79 |
-
This new paradigm has been at the core of some of the most capable generalist policies developed to date, capable to few-shot adapt to novel tasks and to perform highly dexterous manipulation tasks, ranging from end-to-end folding laundry, to bussing tables.
|
| 80 |
-
|
| 81 |
-
\subsubsection{VLMs for VLAs}
|
| 82 |
-
VLMs are designed to process both visual and textual modalities---most commonly by taking both images and text as input and generating text conditioned on the visual context.
|
| 83 |
-
Recent advances in VLMs have been driven by the success of LLMs, with many approaches building upon pretrained LLMs and adopting similar training paradigms to the ones used in language modeling.
|
| 84 |
-
Typically, VLMs [@alayracFlamingoVisualLanguage2022,laurenconWhatMattersWhen2024,linVILAPretrainingVisual2024] are constructed by integrating a pretrained vision encoder [@radfordLearningTransferableVisual2021,zhaiSigmoidLossLanguage2023,finiMultimodalAutoregressivePretraining2024] with a pretrained LLM [@grattafioriLlama3Herd2024,jiangMistral7B2023].
|
| 85 |
-
Training then proceeds in multiple multimodal stages, beginning with a large-scale pretraining on datasets containing image-text pairs [@LAION-COCO,kakaobrain2022coyo700m] and interleaved vision-language corpora [@OBELICS,MMC4], all followed by a supervised fine-tuning stage on instruction-tuning datasets [@LLaVA-1.5,tong2024cambrian,laurenconWhatMattersWhen2024].
|
| 86 |
-
The inherent multimodal nature of VLMs enables them to jointly reason over vision and language.
|
| 87 |
-
Pre-training on vast internet-scale datasets allows these models to associate visual patterns with textual descriptions, thereby acquiring a rich semantic understanding of the world---knowledge about objects, their properties, and relationships---without explicit supervision for each concept.
|
| 88 |
-
In turn, integrating a VLM as a perception backbone for a VLA allows the complete model to inherit rich world knowledge, sidestepping the need to learn visual and semantic representations from scratch.
|
| 89 |
-
In principle, this allows the robot to ground high-level natural language instructions in its visual context, and possibly recognize unseen objects by connecting them to pre-trained concepts absorbed during pre-training, improving on the possibility to generalize to novel scenarios.
|
| 90 |
-
|
| 91 |
-
Recently, compute efficiency has also become a central focus in VLM research.
|
| 92 |
-
Several works aim to reduce training costs by using smaller, more diverse datasets [@LLaVA-1.5,InstructBLIP,bai2025qwen25vl,zhu2024minigpt,tong2024cambrian], training smaller-scale models [@marafiotiSmolVLMRedefiningSmall2025, moondream,minicmpv2024], or by adapting pretrained unimodal models by tuning only a small subset of parameters [@shukor2023epalm,vallaeys2024improveddepalm,MAPL,FROMAGe,tsimpoukelli2021multimodalfrozen,BLIP-2].
|
| 93 |
-
While the majority of VLM research focuses on image and text modalities, recent work has demonstrated that similar techniques can be extended to integrate additional modalities, such as video and audio [@wang2025internvideo2,liu2024kangaroo,zhang2025videollama,kong2024audioflam]---a particularly promising direction of research for robotics applications, where multiple sensor modalities can be integrated effectively.
|
| 94 |
-
This trend towards efficiency is paramount for robotics applications, where policies must operate under the stringent constraints of real-world deployment.
|
| 95 |
-
Indeed, robots often possess limited on-board computational resources and must react in real-time to dynamic environments.
|
| 96 |
-
Smaller and faster VLMs have thus become quintessential for developing responsive autonomous systems, enabling high-frequency control loops by reducing the latency between perception and action.
|
| 97 |
-
|
| 98 |
-
\subsection{\( \pi_0 \)}
|
| 99 |
-
|
| 100 |
-
\pi_0 [@black$p_0$VisionLanguageActionFlow2024] introduce a VLA consisting of a MoE architecture consisting of (1) a pre-trained VLM backbone (Gemma 2.6B [@teamGemma2Improving2024]) and (2) a dedicated action expert used to generate continuous actions via flow matching.
|
| 101 |
-
Images and language are embedded with a late-fusion VLM (PaliGemma), while proprioceptive state and actions chunks are routed to a smaller action expert, initialized from scratch.
|
| 102 |
-
The two separate experts communicate via self-attention layers, but maintain disjoint weights to obtain query, key and values matrices at each layer, maintaining specialization while efficiently allocating computation.
|
| 103 |
-
|
| 104 |
-
![The \pi_0 architecture, as in [@black$p_0$\beginalign]
|
| 105 |
-
VisionLanguageActionFlow2024. Vision and language tokens are routed to a VLM backbone which is prevented from attending robot proprioperceptive states and action tokens, which are instead routed to a smaller subset of weights within the architecture. The architecture is trained with Flow Matching on 10M+ trajectories from a mixture of closed and openly available datasets.](assets/image/ch5/ch5-pi0.png) {#fig-fig:ch5-pi0}
|
| 106 |
-
|
| 107 |
-
*The \pi_0 architecture, as in [@black$p_0$\beginalign]
|
| 108 |
-
VisionLanguageActionFlow2024. Vision and language tokens are routed to a VLM backbone which is prevented from attending robot proprioperceptive states and action tokens, which are instead routed to a smaller subset of weights within the architecture. The architecture is trained with Flow Matching on 10M+ trajectories from a mixture of closed and openly available datasets.*
|
| 109 |
-
|
| 110 |
-
Concretely, \( \pi_0 \) is a unified transformer with two disjoint sets of weights \( \phi, \theta\).
|
| 111 |
-
A larger VLM backbone \( p_\phi \) initialized from Gemma 2.6B processes multiple image frames obtained from multiple cameras points \( [\{ I_t \}_{t=1}^n] \), as well as a language instruction \([\ell_t]\) used to describe the task considered.
|
| 112 |
-
Concurrently, a 300M-parameter \emph{action expert} based on a similar transformer architecture is used processes the robot proprioperceptive state \(q_t\) and an action chunk \(a_{t:t+H_a}\) (Figure~\ref{fig:ch5-pi0}).
|
| 113 |
-
The different expert networks operate separately in processing the respective inputs and turning them into query, key and value matrices, and only share information between each other via self-attention layers.
|
| 114 |
-
The outputs from the VLM backbone are disregarded, while the vector field regressed by the action expert is used to iteratively refine the action process.
|
| 115 |
-
In particular, \pi_0 uses a \emph{blockwise causal attention mask} over tokens belonging to three separate blocks: (1) image and language tokens \(\mathcal T_i \) obtained from \([\{ I_t \}_{t=1}^n, \ell_t]\), (2) proprioperceptive tokens \(\mathcal T_q \) obtained from \(q_t\), and (3) the action tokens \( \mathcal T_a \) for items in the chunk \(a^{\tau}_{t:t+H_a}\) at time \( \tau \) in the flow-matching process.
|
| 116 |
-
Notably, \emph{within} each block the attention operations are bidirectional, while across blocks, future blocks are masked out.
|
| 117 |
-
Formally, this corresponds to using the attention mask
|
| 118 |
-
\begin{equation*}
|
| 119 |
-
\mathbf{A} =
|
| 120 |
-
\bordermatrix{
|
| 121 |
-
& \mathcal{T}_i & \mathcal{T}_q & \mathcal{T}_a \cr
|
| 122 |
-
\mathcal{T}_i & \mathbf{1} & \mathbf{0} & \mathbf{0} \cr
|
| 123 |
-
\mathcal{T}_q & \mathbf{1} & \mathbf{1} & \mathbf{0} \cr
|
| 124 |
-
\mathcal{T}_a & \mathbf{1} & \mathbf{1} & \mathbf{1} \cr
|
| 125 |
-
},
|
| 126 |
-
\quad \mathbf{1}: \text{Bidirectional Attention}, \ \mathbf{0}: \text{Masked Attention}
|
| 127 |
-
\end{equation*}
|
| 128 |
-
Note how \emph{intra}-block directional attention allows tokens to communicate freely, while \emph{inter}-block communication is mediated by the attention mask \(\mathbf{A} \).
|
| 129 |
-
\emph{Blockwise causal masking} effectively prevents the pre-trained perception-language tokens from attending to robotics-tokens, likely out of distribution for VLM backbones traditionally trained on large corpora of internet, non-robotics, data.
|
| 130 |
-
Crucially, because communication is obstructed between image-language tokens, proprioperceptive and action tokens, one can cache keys and values across denoising steps at runtime time, incuring in a reduced computational footprint and faster inference.
|
| 131 |
-
|
| 132 |
-
In \pi_0, both the VLM backbone and action expert are update using a \emph{flow matching} loss, and in particular are updated minimizing:
|
| 133 |
-
|
| 134 |
-
$$
|
| 135 |
-
|
| 136 |
-
\mathcal{L}(\phi, \theta) &= \mathbb{E}_{\tau, \epsilon, o_t, a_{t:t+H_a}}\Big[
|
| 137 |
-
\big\Vert
|
| 138 |
-
v_\theta(\underbrace{\tau a_{t:t+H_a} + (1-\tau) \epsilon}_{\tilde a_{t:t+H_a}},\, o_t,\, \tau)
|
| 139 |
-
- (\epsilon - a_{t:t+H_a})
|
| 140 |
-
\big\Vert^2
|
| 141 |
-
\Big], \label{eq:pi0-loss}
|
| 142 |
-
|
| 143 |
-
&\tau \sim \mathrm{Beta}_{[0,s]}(1.5,1), \quad
|
| 144 |
-
\epsilon \sim \mathcal{N}(\mathbf{0}, \mathbf{I}), \quad
|
| 145 |
-
o_t, a_{t:t+H_a} \sim \mathcal D \notag
|
| 146 |
-
|
| 147 |
-
$$
|
| 148 |
-
|
| 149 |
-
Where the experts parametrized by the separate weights \( \phi, \theta \) interact with each other via self-attention layers only, so that the action expert \( v_\theta \) internal computations also depend on the VLM backbone's parameters \( \phi \).
|
| 150 |
-
Importantly,~@black
|
| 151 |
-
\end{align$p_0$VisionLanguageActionFlow2024} minimize~\ref{eq:pi0-loss} over both the multimodal backbone and action expert parameters, thus updating the internal representations of the VLM using BC-specific gradients.
|
| 152 |
-
In contrast,~@driessKnowledgeInsulatingVisionLanguageAction2025 later show that failing to insulate the VLM knowledge from the flow matching gradients actually harms performance.
|
| 153 |
-
Inference is performed iteratively refining action chunks while numerically forward-integrating the vector field predicted by the action expert,
|
| 154 |
-
\begin{equation}
|
| 155 |
-
a_{t:t+H_a}^{\tau + \delta} = a_{t:t+H_a}^{\tau } + \delta v_\theta(a_{t:t+H_a}^{\tau }, o_t)
|
| 156 |
-
\end{equation}
|
| 157 |
-
|
| 158 |
-
Flow matching \citep[Section\ref{sec:ch4-flow-matching}]{lipmanFlowMatchingGenerative2023} can be seen as a continuous time, detetrministic generalization of Diffusion and has proven effective in modeling highly complex multi-modal distributions, including those over images and video.
|
| 159 |
-
In turn, its application to large-scale data collections of multiple human behaviors across tasks and embodiments appears rather consequential, particularly considering how it can enable faster inference via a reduced number of denoising steps---as few as 10, in \pi_0.
|
| 160 |
-
In particular, the action expert is model as a conditional flow matching model.
|
| 161 |
-
Each action token embeds a noisy action \(a_i^{\tau} \in a^\tau_{t:t+H_a}\), alongside a sinusoidal encoding of the \emph{flow process} timestep \(\tau\).
|
| 162 |
-
The action expert then leverages full bidirectional attention across the \(H_a\) action tokens provided, as well as attends to previous proprioperceptive and image-language tokens as well.
|
| 163 |
-
Interestingly, differently from a standard flow matching pipeline~@lipmanFlowMatchingGenerative2023, \(\tau\) is \emph{not} sampled from a uniform distribution \(\tau \sim \mathcal U([0,1]) \), but rather obtained from \(\tau \sim \textrm{Beta}(1.5,1) \) defined on the \( [0,s], s<1 \) support (Figure~\ref{fig:ch5-pi0-sampling-timesteps}).
|
| 164 |
-
|
| 165 |
-
 {#fig-fig:ch5-pi0-sampling-timesteps}
|
| 166 |
-
|
| 167 |
-
Using such Beta distribution emphasizes higher noise levels during training, a choice~@black$p_0$VisionLanguageActionFlow2024 argue allows \pi_0 to focus on learning the mean of the data distribution \( \mathbb E[a_{t:t+H_a} \vert o_t] \) during training, in keeping with~@esserScalingRectifiedFlow2024.
|
| 168 |
-
To further optimize performance and reduce inference time,~@black$p_0$VisionLanguageActionFlow2024 propose reducing the support of the timestep distribution to \([0,s], \ s < 1 \), as for any forward-integration step size \( \delta = 1-s \) timesteps above \(s \) are never sampled at inference time.
|
| 169 |
-
|
| 170 |
-
Besides adopting a MoE architecture with a VLM backbone initialized from a pre-trained model and trained jointly with an action expert via flow matching, \pi_0 also relies on a unique pre-training corpus mixes open data of 10M+ trajectories, which~@black$p_0$VisionLanguageActionFlow2024 claim to be the largest dataset used in building a foundational model in robotics to date.
|
| 171 |
-
The dataset used to train \pi_0---referred to as \( \pi \) dataset---comprises a private, undisclosed portion obtained via teleoperation aggregated to openly available datasets including Open-X and DROID, with \(\approx 9.1\
|
| 172 |
-
Open datasets such as DROID and Open-X are complemeneted with expert trajectories with of dexterous demonstrations tasks spanning 7 robot configurations and 68 different tasks.
|
| 173 |
-
~@black$p_0$VisionLanguageActionFlow2024 show that pre-training on the \( \pi \) dataset yields a broadly capable base model, which can be adapted via post-training on narrower high-quality task data, inducing fluent multi-stage behavior while retaining robustness.
|
| 174 |
-
In particular,~@black$p_0$VisionLanguageActionFlow2024 report that, across a variety of benchmarks, \pi_0 pretrained on the \( \pi \) dataset and post-trained on extra high-quality data demonstrations \emph{consistently outperform} \pi_0 trained from scratch (i.e., without pretraining on the \( \pi \) dataset), further scoring the relevance of pretraining.
|
| 175 |
-
~@black$p_0$VisionLanguageActionFlow2024 offer an intuition behind this finding: high-quality demonstrations of a given task typically do not contain mistakes, and how human demonstrator may recover from them.
|
| 176 |
-
In turn, robot trained on high-quality data exclusively with BC may be incapable to recover from failure.
|
| 177 |
-
Conversely, large scale collections of human demonstrations are typically much more diverse (if anything, for their sheer scale), and therefore typically contain rich and diverse information, which may prove suboptimal for any given task when considered in isolation but that proves invaluable in coupling with a small, narrower set of demonstrations.
|
| 178 |
-
|
| 179 |
-
Lastly,~@black$p_0$VisionLanguageActionFlow2024 present cross-embodiment experiments where they demonstrate \pi_0's ability to control both mobile and static manipulator robots with varying arm embodiments.
|
| 180 |
-
The emergence of cross-embodiment capabilities is largely to be attributed to the presence of large scale cross-embodiment data in the data mixture, handled by \pi_0 defaulting to the maximal configuration size across the \( \pi \) dataset, and zero-padding robots with fewer dof.
|
| 181 |
-
In that \pi_0 constantly processes 18 DoFs robots (two 6-DoF arms, two grippers, base, vertical torso), regardless of the kind of robot, and robots with fewer dofs are zero-padded.
|
| 182 |
-
\pi_0 also relies on three camera views, and uses masked image slots for training and deployment scenarios with fewer cameras.
|
| 183 |
-
|
| 184 |
-
\subsubsection{Code Example: Using \pi_0}
|
| 185 |
-
\todo{add code example}
|
| 186 |
-
|
| 187 |
-
\subsection{SmolVLA}
|
| 188 |
-
VLAs remain in an early stage of development and are not yet as mature or widely adopted as LLMs and VLMs.
|
| 189 |
-
Further, much of the impactful VLA progress remains proprietary, with many models sharing only weights while withholding full training details and essential methodological components.
|
| 190 |
-
SmolVLA [@shukorSmolVLAVisionLanguageActionModel2025] is an entirely open-source research effort, aiming to democratize the developments of robotics foundation models by open sourcing model, training recipes and data used.
|
| 191 |
-
|
| 192 |
-
![The SmolVLA architecture, as in [@shukorSmolVLAVisionLanguageActionModel2025]. SmolVLA is a compact MoE model trained with flow matching to denoise action chunks. Vision and language tokens are fed to a VLM backbone, and share information with the proprioperceptive and action tokens via the attention mechanism. The attention expert interleaves SA and CA layers for further conditioning on the visual features from the VLM backbone. SmolVLA skips computations and reduces the visual tokens, resulting in 6x less memory usage than \pi_0.](assets/image/ch5/ch5-smolvla.png) {#fig-fig:ch5-smolvla}
|
| 193 |
-
|
| 194 |
-
*The SmolVLA architecture, as in [@shukorSmolVLAVisionLanguageActionModel2025]. SmolVLA is a compact MoE model trained with flow matching to denoise action chunks. Vision and language tokens are fed to a VLM backbone, and share information with the proprioperceptive and action tokens via the attention mechanism. The attention expert interleaves SA and CA layers for further conditioning on the visual features from the VLM backbone. SmolVLA skips computations and reduces the visual tokens, resulting in 6x less memory usage than \pi_0.*
|
| 195 |
-
|
| 196 |
-
While encouraging efforts like \pi_0 [@black$p_0$VisionLanguageActionFlow2024] demonstrate the feasibility of open VLA systems, they remain (1) large and compute-intensive and (2) dependent on closed datasets collected via centralized efforts on costly robotic platforms, ultimately hindering accessibility.
|
| 197 |
-
SmolVLA mitigates both these accessibility issues by (1) prioritizing a compact, compute-efficient VLA design and (2) targeting community-contributed datasets on accessible robotic platforms such as the SO-100 and SO-101 arms.
|
| 198 |
-
Similarly to \pi_0, SmolVLA (Figure~\ref{fig:ch5-smolvla}) employs a MoE architecture combining a pretrained VLM backbone with a dedicated action expert, and trains with flow matching.
|
| 199 |
-
To ensure efficiency and accessibility, SmolVLA adopts SmolVLM-2 [@marafiotiSmolVLMRedefiningSmall2025] as its VLM backbone, considering SmolVLM-2's reduced size and capability to process multiple image inputs alongside text items.
|
| 200 |
-
SmolVLM-2 uses SigLIP [@zhaiSigmoidLossLanguage2023] as vision encoder, producing visual features for a SmolLM2 language decoder [@allalSmolLM2WhenSmol2025].
|
| 201 |
-
Further, SmolVLA adopts a smaller action expert consisting of \(\sim\)100M parameters and an interleaved stack of self and cross-attention layers.
|
| 202 |
-
To improve efficiency, the action expert adopts a reduced embedding dimension compared to the VLM backbone, resulting in \( d_{v_\theta} = 0.75 d_{\text{VLM}} \).
|
| 203 |
-
[@shukorSmolVLAVisionLanguageActionModel2025]'s design choices thus result in a much smaller size model compared to \pi_0, consisting of around 450M parameters versus \pi_0's 3.3B parameters.
|
| 204 |
-
|
| 205 |
-
Effectively, SmolVLA consumes multi-view RGB images, a natural-language instruction, and a projected sensorimotor state token as inputs, together with the noised \emph{action chunk} \( \tilde{a_{t:t+H_a}} \) the action expert \( v_\theta \) is trained to denoise.
|
| 206 |
-
In particular, robot proprioperceptive states are projected into a shared token space with the VLM to match \( d_{\text{VLM}} \), and successively projected into the expert's token space.
|
| 207 |
-
Similarily to \pi_0, SmolVLA adopts separate experts communicating exclusively through self-attention layers, which do not employ the same blockwise causal masking in favour of a simple causal masking, resulting in a lower triangular attention mask.
|
| 208 |
-
|
| 209 |
-
In contrast with \pi_0, the action expert interleaves \emph{cross-attention} (CA) and \emph{self-attention} (SA) layers, a choice shown to yield higher success and smoother action chunks in practice.
|
| 210 |
-
While in the expert SA layers, tokens are used to obtain queries, keys and values, CA layers use action tokens only as queries, and instead project visual, language and proprioperceptive tokens in a shared action space to obtain keys and values.
|
| 211 |
-
Notably, keys and values can be cached as well, resulting in performance gains at inference time.
|
| 212 |
-
|
| 213 |
-
SmolVLA trims both token and layer compute.
|
| 214 |
-
First, it \emph{reduces visual tokens} via pixel shuffle to a fixed budget of 64 tokens per frame, foregoing tiling used during VLM pretraining for runtime efficiency.
|
| 215 |
-
Second, it \emph{skips upper VLM layers}: the action expert consumes features from the first \(N\) decoder layers, with \(N=L/2\) providing a good speed-performance trade-off and effectively halving downstream compute for the larger part of SmolVLA.
|
| 216 |
-
Beyond model compactness, SmolVLA also contributes an inference stack that decouples action prediction from execution for responsiveness on modest hardware (Section~\ref{sec:ch4-async-inference}).
|
| 217 |
-
|
| 218 |
-
Departing from reliance on proprietary datasets, SmolVLA pretrains exclusively on 450+ \emph{community datasets}, totaling 20K+ trajectories.
|
| 219 |
-
Because instructions in community contributed dataset can be noisy or missing, the authors re-annotate tasks with a small off-the-shelf VLM using frames sampled from the dataset, and standardize camera viewpoints by mapping sources to a consistent top/wrist/side ordering.
|
| 220 |
-
At inference, similarily to \pi_0, SmolVLA integrates flow over 10 steps, resulting in fast inference.
|
| 221 |
-
SmolVLA proves effective across a range of both real-world and simulated environments, rivaling \pi_0 while being close to 40\
|
| 222 |
-
|
| 223 |
-
\subsubsection{Code Example: Using SmolVLA}
|
| 224 |
-
\todo{add code example}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/scripts/latex-to-mdx/input/sections/test.md
DELETED
|
@@ -1,488 +0,0 @@
|
|
| 1 |
-
# Classical Robotics {#sec:classical}
|
| 2 |
-
|
| 3 |
-
::: epigraph
|
| 4 |
-
*Know your enemy* \[\...\]
|
| 5 |
-
|
| 6 |
-
Sun Tzu
|
| 7 |
-
:::
|
| 8 |
-
|
| 9 |
-
::: tldr
|
| 10 |
-
Learning-based approaches to robotics are motivated by the need to (1)
|
| 11 |
-
generalize across tasks and embodiments (2) reduce dependency on human
|
| 12 |
-
expertise (3) leverage historical trends on the production of data---all
|
| 13 |
-
traditionally overlooked by dynamics-based techniques.
|
| 14 |
-
:::
|
| 15 |
-
|
| 16 |
-
## Explicit and Implicit Models
|
| 17 |
-
|
| 18 |
-
{#fig:generating-motion-atlas
|
| 23 |
-
width="50%"}
|
| 24 |
-
|
| 25 |
-
Robotics is concerned with producing artificial motion in the physical
|
| 26 |
-
world in useful, reliable and safe fashion. Thus, robotics is an
|
| 27 |
-
inherently multi-disciplinar domain: producing autonomous motion in the
|
| 28 |
-
physical world requires, to the very least, interfacing different
|
| 29 |
-
software (motion planners) and hardware (motion executioners)
|
| 30 |
-
components. Further, knowledge of mechanical, electrical, and software
|
| 31 |
-
engineering, as well as rigid-body mechanics and control theory have
|
| 32 |
-
therefore proven quintessential in robotics since the field first
|
| 33 |
-
developed in the 1950s. More recently, Machine Learning (ML) has also
|
| 34 |
-
proved effective in robotics, complementing these more traditional
|
| 35 |
-
disciplinesΒ [@connellRobotLearning1993]. As a direct consequence of its
|
| 36 |
-
multi-disciplinar nature, robotics has developed as a rather wide array
|
| 37 |
-
of methods, all concerned with the main purpose of .
|
| 38 |
-
|
| 39 |
-
Methods to produce robotics motion range from traditional *explicit*
|
| 40 |
-
models---[^1] methods, leveraging precise descriptions of the mechanics
|
| 41 |
-
of robots' rigid bodies and their interactions with eventual obstacles
|
| 42 |
-
in the environment---to *implicit* models--- methods, treating
|
| 43 |
-
artificial motion as a statistical pattern to learn given multiple
|
| 44 |
-
sensorimotor
|
| 45 |
-
readingsΒ [@agrawalComputationalSensorimotorLearning; @bekrisStateRobotMotion2024].
|
| 46 |
-
A variety of methods have been developed between these two extrema. For
|
| 47 |
-
instance, Β @hansenTemporalDifferenceLearning2022 show how learning-based
|
| 48 |
-
systems can benefit from information on the physics of problems,
|
| 49 |
-
complementing a traditional learning method such as Temporal Difference
|
| 50 |
-
(TD)-learningΒ @suttonReinforcementLearningIntroduction2018 with
|
| 51 |
-
Model-Predictive Control (MPC). Conversely, as explicit models may be
|
| 52 |
-
relying on assumptions proving overly simplistic---or even
|
| 53 |
-
unrealistic---in practice, learning can prove effective to improve
|
| 54 |
-
modeling of complex phenomena or complement
|
| 55 |
-
perceptionΒ [@mccormacSemanticFusionDense3D2016]. Such examples aim at
|
| 56 |
-
demonstrating the richness of approaches to robotics, and
|
| 57 |
-
FigureΒ [1](#fig:generating-motion-atlas){reference-type="ref"
|
| 58 |
-
reference="fig:generating-motion-atlas"} graphically illustrates some of
|
| 59 |
-
the most relevant techniques. Such a list is clearly far from being
|
| 60 |
-
exhaustive, and we refer toΒ @bekrisStateRobotMotion2024 for a more
|
| 61 |
-
comprehensive overview of both general and application-specific methods
|
| 62 |
-
for motion generation. In this section, we wish to introduce the
|
| 63 |
-
inherent benefits of ---the core focus on this tutorial.
|
| 64 |
-
|
| 65 |
-
## Different Types of Motion
|
| 66 |
-
|
| 67 |
-
{#fig:robotics-platforms-atlas
|
| 73 |
-
width="70%"}
|
| 74 |
-
|
| 75 |
-
In the vast majority of instances, robotics deals with producing motion
|
| 76 |
-
via actuating joints connecting nearly entirely-rigid links. A key
|
| 77 |
-
distinction between focus areas in robotics is based on whether the
|
| 78 |
-
generated motion modifies (1) the absolute state of the environment (via
|
| 79 |
-
dexterity), (2) the relative state of the robot with respect to its
|
| 80 |
-
environment (exercising mobility skills), or (3) a combination of the
|
| 81 |
-
two (FigureΒ [2](#fig:robotics-platforms-atlas){reference-type="ref"
|
| 82 |
-
reference="fig:robotics-platforms-atlas"}).
|
| 83 |
-
|
| 84 |
-
Effects such as (1) are typically achieved *through* the robot, i.e.
|
| 85 |
-
generating motion to perform an action inducing a desirable
|
| 86 |
-
modification, effectively *manipulating* the environment (manipulation).
|
| 87 |
-
Motions like (2) may result in changes in the robot's physical location
|
| 88 |
-
within its environment. Generally, modifications to a robot's location
|
| 89 |
-
within its environment may be considered instances of the general
|
| 90 |
-
*locomotion* problem, further specified as *wheeled* or *legged*
|
| 91 |
-
locomotion based on whenever a robot makes use of wheels or leg(s) to
|
| 92 |
-
move in the environment. Lastly, an increased level of dynamism in the
|
| 93 |
-
robot-environment interactions can be obtained combining (1) and (2),
|
| 94 |
-
thus designing systems capable to interact with *and* move within their
|
| 95 |
-
environment. This category is problems is typically termed *mobile
|
| 96 |
-
manipulation*, and is characterized by a typically much larger set of
|
| 97 |
-
control variables compared to either locomotion or manipulation alone.
|
| 98 |
-
|
| 99 |
-
The traditional body of work developed since the very inception of
|
| 100 |
-
robotics is increasingly complemented by learning-based approaches. ML
|
| 101 |
-
has indeed proven particularly transformative across the entire robotics
|
| 102 |
-
stack, first empowering planning-based techniques with improved state
|
| 103 |
-
estimation used for traditional
|
| 104 |
-
planningΒ [@tangPerceptionNavigationAutonomous2023] and then end-to-end
|
| 105 |
-
replacing controllers, effectively yielding perception-to-action
|
| 106 |
-
methodsΒ [@koberReinforcementLearningRobotics]. Work in producing robots
|
| 107 |
-
capable of navigating a diverse set of terrains demonstrated the premise
|
| 108 |
-
of both dynamics and learning-based approaches for
|
| 109 |
-
locomotionΒ [@griffinWalkingStabilizationUsing2017; @jiDribbleBotDynamicLegged2023; @leeLearningQuadrupedalLocomotion2020; @margolisRapidLocomotionReinforcement2022],
|
| 110 |
-
and recent works on whole-body control indicated the premise of
|
| 111 |
-
learning-based approaches to generate rich motion on complex robots,
|
| 112 |
-
including
|
| 113 |
-
humanoidsΒ [@zhangWoCoCoLearningWholeBody2024; @bjorckGR00TN1Open2025].
|
| 114 |
-
Manipulation has also been widely studied, particularly considering its
|
| 115 |
-
relevance for many impactful use-cases ranging from high-risk
|
| 116 |
-
applications for
|
| 117 |
-
humansΒ [@fujitaDevelopmentRobotsNuclear2020; @alizadehComprehensiveSurveySpace2024]
|
| 118 |
-
to manufacturingΒ [@sannemanStateIndustrialRobotics2020]. While explicit
|
| 119 |
-
models have proven fundamental in achieving important milestones towards
|
| 120 |
-
the development of modern robotics, recent works leveraging implicit
|
| 121 |
-
models proved particularly promising in surpassing scalability and
|
| 122 |
-
applicability challenges via
|
| 123 |
-
learningΒ [@koberReinforcementLearningRobotics].
|
| 124 |
-
|
| 125 |
-
## Example: Planar Manipulation
|
| 126 |
-
|
| 127 |
-
Robot manipulators typically consist of a series of links and joints,
|
| 128 |
-
articulated in a chain finally connected to an *end-effector*. Actuated
|
| 129 |
-
joints are considered responsible for generating motion of the links,
|
| 130 |
-
while the end effector is instead used to perform specific actions at
|
| 131 |
-
the target location (e.g., grasping/releasing objects via
|
| 132 |
-
closing/opening a gripper end-effector, using a specialized tool like a
|
| 133 |
-
screwdriver, etc.).
|
| 134 |
-
|
| 135 |
-
Recently, the development of low-cost manipulators like the
|
| 136 |
-
ALOHAΒ [@zhaoLearningFineGrainedBimanual2023]
|
| 137 |
-
ALOHA-2Β [@aldacoALOHA2Enhanced] and
|
| 138 |
-
SO-100/SO-101Β [@knightStandardOpenSO100] platforms significantly lowered
|
| 139 |
-
the barrier to entry to robotics, considering the increased
|
| 140 |
-
accessibility of these robots compared to more traditional platforms
|
| 141 |
-
like the Franka Emika Panda arm
|
| 142 |
-
(FigureΒ [3](#fig:robotic-platforms-costs){reference-type="ref"
|
| 143 |
-
reference="fig:robotic-platforms-costs"}).
|
| 144 |
-
|
| 145 |
-
{#fig:robotic-platforms-costs
|
| 152 |
-
width="40%"}
|
| 153 |
-
|
| 154 |
-
Deriving an intuition as per why learning-based approaches are gaining
|
| 155 |
-
popularity in the robotics community requires briefly analyzing
|
| 156 |
-
traditional approaches for manipulation, leveraging tools like forward
|
| 157 |
-
and inverse kinematics (FK, IK) and control theory. Providing a detailed
|
| 158 |
-
overview of these methods falls (well) out of the scope of this
|
| 159 |
-
tutorial, and we refer the reader to works
|
| 160 |
-
includingΒ @sicilianoSpringerHandbookRobotics2016
|
| 161 |
-
[@lynchModernRoboticsMechanics2017; @tedrakeRoboticManipulationPerception; @tedrakeUnderactuatedRoboticsAlgorithms]
|
| 162 |
-
for a much more comprehensive description of these techniques. Here, we
|
| 163 |
-
mostly wish to highlight the benefits of ML over these traditional
|
| 164 |
-
techniques
|
| 165 |
-
|
| 166 |
-
{#fig:make-so100-planar-manipulator
|
| 172 |
-
width="70%"}
|
| 173 |
-
|
| 174 |
-
Consider the (simple) case where a SO-100 is restrained from actuating
|
| 175 |
-
(1) the shoulder pane and (2) the wrist flex and roll motors. This
|
| 176 |
-
effectively reduces the degrees of freedom of the SO-100 from the
|
| 177 |
-
original 5+1 (5 joints + 1 gripper) to 2+1 (shoulder lift, elbow flex +
|
| 178 |
-
gripper). As the end-effector does not impact motion in this model, the
|
| 179 |
-
SO-100 is effectively reduced to the planar manipulator robot presented
|
| 180 |
-
in FigureΒ [4](#fig:make-so100-planar-manipulator){reference-type="ref"
|
| 181 |
-
reference="fig:make-so100-planar-manipulator"}, where spheres represent
|
| 182 |
-
actuators, and solid lines indicate length-$l$ links from the base of
|
| 183 |
-
the SO-100 to the end-effector (*ee*).
|
| 184 |
-
|
| 185 |
-
Further, let us make the simplifying assumption that actuators can
|
| 186 |
-
produce rotations up to $2 \pi$ radians. In practice, this is seldom the
|
| 187 |
-
case due to movement obstructions caused by the robot body itself (for
|
| 188 |
-
instance, the shoulder lift cannot produce counter-clockwise movement
|
| 189 |
-
due to the presence of the robot's base used to secure the SO-100 to its
|
| 190 |
-
support and host the robot bus), but we will introduce movement
|
| 191 |
-
obstruction at a later stage.
|
| 192 |
-
|
| 193 |
-
All these simplifying assumptions leave us with the planar manipulator
|
| 194 |
-
of FigureΒ [5](#fig:planar-manipulation-simple){reference-type="ref"
|
| 195 |
-
reference="fig:planar-manipulation-simple"}, free of moving its
|
| 196 |
-
end-effector by controlling the angles $\theta_1$ and $\theta_2$,
|
| 197 |
-
jointly referred to as the robot's *configuration*, and indicated with
|
| 198 |
-
$q = [\theta_1, \theta_2 ] \in [-\pi, +\pi]^2$. The axis attached to the
|
| 199 |
-
joints indicate the associated reference frame, whereas circular arrows
|
| 200 |
-
indicate the maximal feasible rotation allowed at each joint. In this
|
| 201 |
-
tutorial, we do not cover topics related to spatial algebra, and we
|
| 202 |
-
instead refer the reader to @lynchModernRoboticsMechanics2017
|
| 203 |
-
[ChapterΒ 2] and @tedrakeRoboticManipulationPerception [ChapterΒ 3] for
|
| 204 |
-
excellent explanations of the mechanics and theoretical foundations of
|
| 205 |
-
producing motion on rigid bodies.
|
| 206 |
-
|
| 207 |
-
<figure id="fig:planar-manipulator-floor-shelf">
|
| 208 |
-
<figure id="fig:planar-manipulation-simple">
|
| 209 |
-
<img src="figures/ch2/ch2-planar-manipulator-free.png"
|
| 210 |
-
style="height:3.2cm" />
|
| 211 |
-
<figcaption>Free to move</figcaption>
|
| 212 |
-
</figure>
|
| 213 |
-
<figure id="fig:planar-manipulator-floor">
|
| 214 |
-
<img src="figures/ch2/ch2-planar-manipulator-floor.png"
|
| 215 |
-
style="height:3.2cm" />
|
| 216 |
-
<figcaption>Constrained by the surface</figcaption>
|
| 217 |
-
</figure>
|
| 218 |
-
<figure id="fig:planar-manipulator-floor-shelf">
|
| 219 |
-
<img src="figures/ch2/ch2-planar-manipulator-floor-shelf.png"
|
| 220 |
-
style="height:3.2cm" />
|
| 221 |
-
<figcaption>Constrained by surface and (fixed) obstacle</figcaption>
|
| 222 |
-
</figure>
|
| 223 |
-
<figcaption>Planar, 2-dof schematic representation of the SO-100
|
| 224 |
-
manipulator under diverse deployment settings. From left to right:
|
| 225 |
-
completely free of moving; constrained by the presence of the surface;
|
| 226 |
-
constrained by the surface and presence of obstacles. Circular arrows
|
| 227 |
-
around each joint indicate the maximal rotation feasible at that
|
| 228 |
-
joint.</figcaption>
|
| 229 |
-
</figure>
|
| 230 |
-
|
| 231 |
-
Considering the (toy) example presented in
|
| 232 |
-
FigureΒ [5](#fig:planar-manipulation-simple){reference-type="ref"
|
| 233 |
-
reference="fig:planar-manipulation-simple"}, then we can analytically
|
| 234 |
-
write the end-effector's position $p \in \mathbb R^2$ as a function of
|
| 235 |
-
the robot's configuration,
|
| 236 |
-
$p = p(q), p: \mathcal Q \mapsto \mathbb R^2$. In particular, we have:
|
| 237 |
-
$$\begin{equation*}
|
| 238 |
-
p(q) =
|
| 239 |
-
\begin{pmatrix}
|
| 240 |
-
p_x(\theta_1, \theta_2) \\
|
| 241 |
-
p_y(\theta_1, \theta_2)
|
| 242 |
-
\end{pmatrix}
|
| 243 |
-
=
|
| 244 |
-
\begin{pmatrix}
|
| 245 |
-
l \cos(\theta_1) + l \cos(\theta_1 + \theta_2) \\
|
| 246 |
-
l \sin(\theta_1) + l \sin(\theta_1 + \theta_2)
|
| 247 |
-
\end{pmatrix}
|
| 248 |
-
\in S^{n=2}_{l_1+l_2} = \{ p(q) \in \mathbb R^2: \Vert p(q) \Vert_2^2 \leq (2l)^2, \ \forall q \in \mathcal Q \}
|
| 249 |
-
\end{equation*}$$
|
| 250 |
-
|
| 251 |
-
Deriving the end-effector's *pose*---position *and* orientation---in
|
| 252 |
-
some $m$-dimensional space
|
| 253 |
-
$\vec{p} \in \mathcal{P} \subset \mathbb{R}^{m}$ starting from the
|
| 254 |
-
configuration $\q \in \mathcal Q \subset \mathbb R^n$ of a $n$-joints
|
| 255 |
-
robot is referred to as *forward kinematics* (FK), whereas identifying
|
| 256 |
-
the configuration corresponding to any given target pose is termed
|
| 257 |
-
*inverse kinematics* (IK). In that, FK is used to map a robot
|
| 258 |
-
configuration into the corresponding end-effector pose, whereas IK is
|
| 259 |
-
used to reconstruct the configuration(s) given an end-effector pose.
|
| 260 |
-
|
| 261 |
-
In the simplified case here considered (for which $\vec{p} \equiv p$, as
|
| 262 |
-
the orientation of the end-effector is disregarded for simplicity), one
|
| 263 |
-
can solve the problem of controlling the end-effector's location to
|
| 264 |
-
reach a goal position $p^*$ by solving analytically for
|
| 265 |
-
$q: p(q) = f_{\FK}(q) = p^*$. However, in the general case, one might
|
| 266 |
-
not be able to solve this problem analytically, and can typically resort
|
| 267 |
-
to iterative optimization methods comparing candidate solutions using a
|
| 268 |
-
loss function (in the simplest case, $\Vert p(q) - p^* \Vert_2^2$ is a
|
| 269 |
-
natural candidate), yielding:
|
| 270 |
-
|
| 271 |
-
$$\begin{align}
|
| 272 |
-
\min_{q \in \mathcal Q} \Vert p(q) - p^* \Vert_2^2 \, .
|
| 273 |
-
\label{eq:ik_problem}
|
| 274 |
-
\end{align}$$
|
| 275 |
-
|
| 276 |
-
Exact analytical solutions to IK are even less appealing when one
|
| 277 |
-
considers the presence of obstacles in the robot's workspace, resulting
|
| 278 |
-
in constraints on the possible values of
|
| 279 |
-
$q \in \mathcal Q \subseteq [-\pi, +\pi]^n \subset \mathbb R^n$ in the
|
| 280 |
-
general case of $n$-links robots.
|
| 281 |
-
|
| 282 |
-
For instance, the robot in
|
| 283 |
-
FigureΒ [6](#fig:planar-manipulator-floor){reference-type="ref"
|
| 284 |
-
reference="fig:planar-manipulator-floor"} is (very naturally) obstacled
|
| 285 |
-
by the presence of the surface upon which it rests: $\theta_1$ can now
|
| 286 |
-
exclusively vary within $[0, \pi]$, while possible variations in
|
| 287 |
-
$\theta_2$ depend on $\theta_1$ (when $\theta_1 \to 0$ or
|
| 288 |
-
$\theta_1 \to \pi$, further downwards movements are restricted). Even
|
| 289 |
-
for a simplified kinematic model, developing techniques to
|
| 290 |
-
solveΒ eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
|
| 291 |
-
reference="eq:ik_problem"} is in general non-trivial in the presence of
|
| 292 |
-
constraints, particularly considering that the feasible set of solutions
|
| 293 |
-
$\mathcal Q$ may change across problems.
|
| 294 |
-
FigureΒ [8](#fig:planar-manipulator-floor-shelf){reference-type="ref"
|
| 295 |
-
reference="fig:planar-manipulator-floor-shelf"} provides an example of
|
| 296 |
-
how the environment influences the feasible set considered, with a new
|
| 297 |
-
set of constraints deriving from the position of a new obstacle.
|
| 298 |
-
|
| 299 |
-
However, IK---solving
|
| 300 |
-
eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
|
| 301 |
-
reference="eq:ik_problem"} for a feasible $q$---only proves useful in
|
| 302 |
-
determining information regarding the robot's configuration in the goal
|
| 303 |
-
pose, and crucially does not provide information on the *trajectory* to
|
| 304 |
-
follow over time to reach a target pose. Expert-defined trajectories
|
| 305 |
-
obviate to this problem providing a length-$K$ succession of goal poses
|
| 306 |
-
$\tau_K = [p^*_0, p^*_1, \dots p^*_K]$ for tracking. In practice,
|
| 307 |
-
trajectories can also be obtained automatically through *motion
|
| 308 |
-
planning* algorithms, thus avoiding expensive trajectory definition from
|
| 309 |
-
human experts. However, tracking $\tau_K$ via IK can prove prohibitively
|
| 310 |
-
expensive, as tracking would require $K$ resolutions of
|
| 311 |
-
eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
|
| 312 |
-
reference="eq:ik_problem"} (one for each target pose). *Differential*
|
| 313 |
-
inverse kinematics (diff-IK) complements IK via closed-form solution of
|
| 314 |
-
a variant of
|
| 315 |
-
eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
|
| 316 |
-
reference="eq:ik_problem"}. Let $J(q)$ denote the Jacobian matrix of
|
| 317 |
-
(partial) derivatives of the FK-function
|
| 318 |
-
$f_\FK: \mathcal Q \mapsto \mathcal P$, such that
|
| 319 |
-
$J(q) = \frac{\partial f_{FK}(q)}{\partial q }$. Then, one can apply the
|
| 320 |
-
chain rule to any $p(q) = f_{\FK}(q)$, deriving $\dot p = J(q) \dot q$,
|
| 321 |
-
and thus finally relating variations in the robot configurations to
|
| 322 |
-
variations in pose, thereby providing a platform for control.
|
| 323 |
-
|
| 324 |
-
Given a desired end-effector trajectory $\targetvel(t)$ (1) indicating
|
| 325 |
-
anchor regions in space and (2) how much time to spend in each region,
|
| 326 |
-
diff-IK finds $\dot q(t)$ solving for joints' *velocities* instead of
|
| 327 |
-
*configurations*, $$\begin{align}
|
| 328 |
-
\dot q(t) = \arg\min_\nu \; \lVert J(q(t)) \nu - \targetvel (t) \rVert_2^2
|
| 329 |
-
\label{eq:reg_ik_velocity}
|
| 330 |
-
\end{align}$$
|
| 331 |
-
|
| 332 |
-
UnlikeΒ eq.Β [\[eq:ik_problem\]](#eq:ik_problem){reference-type="ref"
|
| 333 |
-
reference="eq:ik_problem"}, solving for $\dot q$ is much less dependent
|
| 334 |
-
on the environment (typically, variations in velocity are constrained by
|
| 335 |
-
physical limits on the actuators). Conveniently,
|
| 336 |
-
eq.Β [\[eq:reg_ik_velocity\]](#eq:reg_ik_velocity){reference-type="ref"
|
| 337 |
-
reference="eq:reg_ik_velocity"} also often admits the closed-form
|
| 338 |
-
solution $\dot q = J(q)^+ \targetvel$, where $J^+(q)$ denotes the
|
| 339 |
-
Moore-Penrose pseudo-inverse of $J(q)$. Finally, discrete-time joint
|
| 340 |
-
configurations $q$ can be reconstructed from joint velocities $\dot q$
|
| 341 |
-
using forward-integration on the continuous-time joint velocity ,
|
| 342 |
-
$q_{t+1} = q_t + \Delta t\,\dot q_t$ for a given $\Delta t$, resulting
|
| 343 |
-
in tracking via diff-IK.
|
| 344 |
-
|
| 345 |
-
Following trajectories with diff-IK is a valid option in well-controlled
|
| 346 |
-
and static environments (e.g., industrial manipulators in controlled
|
| 347 |
-
manufacturing settings), and relies on the ability to define a set of
|
| 348 |
-
target velocities to track
|
| 349 |
-
$[\targetvel_0, \targetvel_1, \dots, \targetvel_k ]$---an error-prone
|
| 350 |
-
task largely requiring human expertise. Furthermore, diff-IK relies on
|
| 351 |
-
the ability to (1) access $J(q) \, \forall q \in \mathcal Q$ and (2)
|
| 352 |
-
compute its pseudo-inverse at every iteration of a given control
|
| 353 |
-
cycle---a challenging assumption in highly dynamical settings, or for
|
| 354 |
-
complex kinematic chains.
|
| 355 |
-
|
| 356 |
-
### Adding Feedback Loops
|
| 357 |
-
|
| 358 |
-
While very effective when a goal trajectory has been well specified, the
|
| 359 |
-
performance of diff-IK can degrade significantly in the presence of
|
| 360 |
-
modeling/tracking errors, or in the presence of non-modeled dynamics in
|
| 361 |
-
the environment.
|
| 362 |
-
|
| 363 |
-
::: wrapfigure
|
| 364 |
-
r0.3
|
| 365 |
-
{width="\\linewidth"}
|
| 366 |
-
:::
|
| 367 |
-
|
| 368 |
-
One such case is presented in
|
| 369 |
-
FigureΒ [\[fig:planar-manipulator-box-velocity\]](#fig:planar-manipulator-box-velocity){reference-type="ref"
|
| 370 |
-
reference="fig:planar-manipulator-box-velocity"}, where another rigid
|
| 371 |
-
body other than the manipulator is moving in the environment along the
|
| 372 |
-
horizontal axis, with velocity $\dot x_B$. Accounting analytically for
|
| 373 |
-
the presence of this disturbance---for instance, to prevent the midpoint
|
| 374 |
-
of the link from ever colliding with the object---requires access to
|
| 375 |
-
$\dot x_B$ at least, to derive the equation characterizing the motion of
|
| 376 |
-
the environment.
|
| 377 |
-
|
| 378 |
-
Less predictable disturbances however (e.g.,
|
| 379 |
-
$\dot x_B \leftarrow \dot x_B + \eps, \eps \sim N(0,1)$) may prove
|
| 380 |
-
challenging to model analytically, and one could attain the same result
|
| 381 |
-
of preventing link-object collision by adding a condition on the
|
| 382 |
-
distance between the midpoint of $l$ and $x_B$, enforced through a
|
| 383 |
-
feedback loop on the position of the robot and object at each control
|
| 384 |
-
cycle.
|
| 385 |
-
|
| 386 |
-
To mitigate the effect of modeling errors, sensing noise and other
|
| 387 |
-
disturbances, classical pipelines indeed do augment diff-IK with
|
| 388 |
-
feedback control looping back quantities of interest. In practice,
|
| 389 |
-
following a trajectory with a closed feedback loop might consist in
|
| 390 |
-
backwarding the error between the target and measured pose,
|
| 391 |
-
$\Delta p = \targetpos - p(q)$, hereby modifying the control applied to
|
| 392 |
-
$\dot q = J(q)^+ (\targetvel + k_p \Delta p )$, with $k_p$ defined as
|
| 393 |
-
the (proportional) gain.
|
| 394 |
-
|
| 395 |
-
More advanced techniques for control consisting in feedback
|
| 396 |
-
linearization, PID control, Linear Quatratic Regulator (LQR) or
|
| 397 |
-
Model-Predictive Control (MPC) can be employed to stabilize tracking and
|
| 398 |
-
reject moderate perturbations, and we refer to
|
| 399 |
-
@sicilianoSpringerHandbookRobotics2016 [ChapterΒ 8] for in-detail
|
| 400 |
-
explanation of these concepts, or [@tedrakeRoboticManipulationPerception
|
| 401 |
-
ChapterΒ 8] for a simple, intuitive example in the case of a point-mass
|
| 402 |
-
system. Nonetheless, feedback control presents its challenges as well:
|
| 403 |
-
tuning gains remains laborious and system-specific. Further,
|
| 404 |
-
manipulation tasks present intermittent contacts inducing hybrid
|
| 405 |
-
dynamics (mode switches) and discontinuities in the Jacobian,
|
| 406 |
-
challenging the stability guarantees of the controller and thus often
|
| 407 |
-
necessitating rather conservative gains and substantial hand-tuning.
|
| 408 |
-
|
| 409 |
-
We point the interested reader toΒ @sicilianoSpringerHandbookRobotics2016
|
| 410 |
-
[ChapterΒ 2,7,8], @lynchModernRoboticsMechanics2017 [ChapterΒ 6,11],
|
| 411 |
-
andΒ @tedrakeRoboticManipulationPerception [ChapterΒ 3,8] for extended
|
| 412 |
-
coverage of FK, IK, diff-IK and control for (diff-)IK.
|
| 413 |
-
|
| 414 |
-
## Limitations of Dynamics-based Robotics
|
| 415 |
-
|
| 416 |
-
Despite the last 60+ years of robotics research, autonomous robots are
|
| 417 |
-
still largely incapable of performing tasks at human-level performance
|
| 418 |
-
in the physical world generalizing across (1) robot embodiments
|
| 419 |
-
(different manipulators, different locomotion platforms, etc.) and (2)
|
| 420 |
-
tasks (tying shoe-laces, manipulating a diverse set of objects). While
|
| 421 |
-
essential in the early development of robotics, the aforementioned
|
| 422 |
-
methods require significant human expertise to be used in practice, and
|
| 423 |
-
are typically specific to a particular applicative problem.
|
| 424 |
-
|
| 425 |
-
{#fig:classical-limitations
|
| 434 |
-
width="90%"}
|
| 435 |
-
|
| 436 |
-
Dynamics-based robotics pipelines have historically been now within most
|
| 437 |
-
architectures for specific purposes. That is, sensing, state estimation,
|
| 438 |
-
mapping, planning, (diff-)IK, and low-level control have been
|
| 439 |
-
traditionally developed as distinct modules with fixed interfaces.
|
| 440 |
-
Pipelining these specific modules proved error-prone, and brittleness
|
| 441 |
-
emerges---alongside compounding errors---whenever changes incur (e.g.,
|
| 442 |
-
changes in lighting for sensing, occlusion/failure of sensors, control
|
| 443 |
-
failures). Adapting such a stack to new tasks or robotic platforms often
|
| 444 |
-
entails re-specifying objectives, constraints, and heuristics at
|
| 445 |
-
multiple stages, incurring significant engineering overhead.
|
| 446 |
-
|
| 447 |
-
Moreover, classical planners operate on compact, assumed-sufficient
|
| 448 |
-
state representations; extending them to reason directly over raw,
|
| 449 |
-
heterogeneous and noisy data streams is non-trivial. This results in a ,
|
| 450 |
-
as incorporating high-dimensional perceptual inputs (RGB, depth,
|
| 451 |
-
tactile, audio) traditionally required extensive engineering efforts to
|
| 452 |
-
extract meaningful features for control. Also, the large number of
|
| 453 |
-
tasks, coupled with the adoption of *per-task* planners, goal
|
| 454 |
-
parameterizations, and safety constraints, results in an explosion in
|
| 455 |
-
design and validation options, with little opportunity to reuse
|
| 456 |
-
solutions across tasks.
|
| 457 |
-
|
| 458 |
-
Setting aside integration and scalability challenges: developing
|
| 459 |
-
accurate modeling of contact, friction, and compliance for complicated
|
| 460 |
-
systems remains difficult. Rigid-body approximations are often
|
| 461 |
-
insufficient in the presence of deformable objects, and of the methods
|
| 462 |
-
developed. In the case of complex, time-dependent and/or non-linear
|
| 463 |
-
dynamics, even moderate mismatches in parameters, unmodeled evolutions,
|
| 464 |
-
or grasp-induced couplings can qualitatively affect the observed
|
| 465 |
-
dynamics.
|
| 466 |
-
|
| 467 |
-
Lastly, dynamics-based methods (naturally) overlook the rather recent .
|
| 468 |
-
The curation of academic datasets by large centralized groups of human
|
| 469 |
-
experts in
|
| 470 |
-
roboticsΒ [@collaborationOpenXEmbodimentRobotic2025; @khazatskyDROIDLargeScaleInTheWild2025]
|
| 471 |
-
is now increasingly complemented by a by individuals with varied
|
| 472 |
-
expertise. If not tangentially, dynamics-based approaches are not posed
|
| 473 |
-
to maximally benefit from this trend, which holds the premise of
|
| 474 |
-
allowing generalization in the space of tasks and embodiments, like data
|
| 475 |
-
was the cornerstone for advancements in
|
| 476 |
-
visionΒ [@alayracFlamingoVisualLanguage2022] and natural-language
|
| 477 |
-
understandingΒ [@brownLanguageModelsAre2020].
|
| 478 |
-
|
| 479 |
-
Taken together, these limitations
|
| 480 |
-
(FigureΒ [9](#fig:classical-limitations){reference-type="ref"
|
| 481 |
-
reference="fig:classical-limitations"}) motivate the exploration of
|
| 482 |
-
learning-based approaches that can (1) integrate perception and control
|
| 483 |
-
more tightly, (2) adapt across tasks and embodiments with reduced expert
|
| 484 |
-
modeling interventions and (3) scale gracefully in performance as more
|
| 485 |
-
robotics data becomes available.
|
| 486 |
-
|
| 487 |
-
[^1]: In here, we refer to both *kinematics* and *dynamics*-based
|
| 488 |
-
control.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/scripts/latex-to-mdx/mdx-converter.mjs
CHANGED
|
@@ -124,12 +124,12 @@ function addComponentImports(content) {
|
|
| 124 |
|
| 125 |
|
| 126 |
/**
|
| 127 |
-
* Convert grouped figures (subfigures) to
|
| 128 |
* @param {string} content - MDX content
|
| 129 |
-
* @returns {string} - Content with
|
| 130 |
*/
|
| 131 |
-
function
|
| 132 |
-
console.log(' πΌοΈβ¨ Converting subfigures to
|
| 133 |
|
| 134 |
let convertedCount = 0;
|
| 135 |
|
|
@@ -187,8 +187,8 @@ function convertSubfiguresToMultiImage(content) {
|
|
| 187 |
.replace(/'/g, "\\'")
|
| 188 |
.trim();
|
| 189 |
|
| 190 |
-
// Mark
|
| 191 |
-
usedComponents.add('
|
| 192 |
|
| 193 |
// Determine layout based on number of images
|
| 194 |
let layout = 'auto';
|
|
@@ -196,12 +196,12 @@ function convertSubfiguresToMultiImage(content) {
|
|
| 196 |
else if (images.length === 3) layout = '3-column';
|
| 197 |
else if (images.length === 4) layout = '4-column';
|
| 198 |
|
| 199 |
-
// Generate
|
| 200 |
const imagesJson = images.map(img =>
|
| 201 |
` {\n src: ${img.src},\n alt: "${img.alt}",\n caption: "${img.caption}",\n id: "${img.id}"\n }`
|
| 202 |
).join(',\n');
|
| 203 |
|
| 204 |
-
return `<
|
| 205 |
images={[
|
| 206 |
${imagesJson}
|
| 207 |
]}
|
|
@@ -213,7 +213,7 @@ ${imagesJson}
|
|
| 213 |
});
|
| 214 |
|
| 215 |
if (convertedCount > 0) {
|
| 216 |
-
console.log(` β
Converted ${convertedCount} subfigure group(s) to
|
| 217 |
} else {
|
| 218 |
console.log(' βΉοΈ No subfigure groups found');
|
| 219 |
}
|
|
@@ -222,23 +222,23 @@ ${imagesJson}
|
|
| 222 |
}
|
| 223 |
|
| 224 |
/**
|
| 225 |
-
* Transform images to
|
| 226 |
* @param {string} content - MDX content
|
| 227 |
-
* @returns {string} - Content with
|
| 228 |
*/
|
| 229 |
/**
|
| 230 |
-
* Create
|
| 231 |
* @param {string} src - Clean image source
|
| 232 |
* @param {string} alt - Alt text
|
| 233 |
* @param {string} id - Element ID
|
| 234 |
* @param {string} caption - Figure caption
|
| 235 |
* @param {string} width - Optional width
|
| 236 |
-
* @returns {string} -
|
| 237 |
*/
|
| 238 |
-
function
|
| 239 |
const varName = generateImageVarName(src);
|
| 240 |
imageImports.set(src, varName);
|
| 241 |
-
usedComponents.add('
|
| 242 |
|
| 243 |
const props = [];
|
| 244 |
props.push(`src={${varName}}`);
|
|
@@ -249,11 +249,11 @@ function createResponsiveImageComponent(src, alt = '', id = '', caption = '', wi
|
|
| 249 |
if (alt) props.push(`alt="${alt}"`);
|
| 250 |
if (caption) props.push(`caption={'${caption}'}`);
|
| 251 |
|
| 252 |
-
return `<
|
| 253 |
}
|
| 254 |
|
| 255 |
function transformImages(content) {
|
| 256 |
-
console.log(' πΌοΈ Transforming images to
|
| 257 |
|
| 258 |
let hasImages = false;
|
| 259 |
|
|
@@ -297,7 +297,7 @@ function transformImages(content) {
|
|
| 297 |
const altText = cleanAltText(cleanCap);
|
| 298 |
hasImages = true;
|
| 299 |
|
| 300 |
-
return
|
| 301 |
}
|
| 302 |
);
|
| 303 |
|
|
@@ -309,7 +309,7 @@ function transformImages(content) {
|
|
| 309 |
const cleanAlt = cleanAltText(alt || 'Figure');
|
| 310 |
hasImages = true;
|
| 311 |
|
| 312 |
-
return
|
| 313 |
}
|
| 314 |
);
|
| 315 |
|
|
@@ -320,7 +320,7 @@ function transformImages(content) {
|
|
| 320 |
const cleanSrc = cleanSrcPath(src);
|
| 321 |
hasImages = true;
|
| 322 |
|
| 323 |
-
return
|
| 324 |
}
|
| 325 |
);
|
| 326 |
|
|
@@ -333,7 +333,7 @@ function transformImages(content) {
|
|
| 333 |
const altText = cleanAltText(cleanCap);
|
| 334 |
hasImages = true;
|
| 335 |
|
| 336 |
-
return
|
| 337 |
}
|
| 338 |
);
|
| 339 |
|
|
@@ -346,7 +346,7 @@ function transformImages(content) {
|
|
| 346 |
const altText = cleanAltText(cleanCap);
|
| 347 |
hasImages = true;
|
| 348 |
|
| 349 |
-
return
|
| 350 |
}
|
| 351 |
);
|
| 352 |
|
|
@@ -364,12 +364,12 @@ function transformImages(content) {
|
|
| 364 |
if (idMatch) id = idMatch[1];
|
| 365 |
}
|
| 366 |
|
| 367 |
-
return
|
| 368 |
}
|
| 369 |
);
|
| 370 |
|
| 371 |
if (hasImages) {
|
| 372 |
-
console.log(' β
|
| 373 |
}
|
| 374 |
|
| 375 |
return content;
|
|
@@ -822,7 +822,7 @@ function processMdxContent(content, latexContent = '') {
|
|
| 822 |
processedContent = formatDisplayMathBlocks(processedContent);
|
| 823 |
processedContent = removeHtmlComments(processedContent);
|
| 824 |
processedContent = cleanMdxSyntax(processedContent);
|
| 825 |
-
processedContent =
|
| 826 |
processedContent = transformImages(processedContent);
|
| 827 |
processedContent = transformStyledSpans(processedContent);
|
| 828 |
processedContent = transformReferenceLinks(processedContent);
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
/**
|
| 127 |
+
* Convert grouped figures (subfigures) to MultiFigure components
|
| 128 |
* @param {string} content - MDX content
|
| 129 |
+
* @returns {string} - Content with MultiFigure components for grouped figures
|
| 130 |
*/
|
| 131 |
+
function convertSubfiguresToMultiFigure(content) {
|
| 132 |
+
console.log(' πΌοΈβ¨ Converting subfigures to MultiFigure components...');
|
| 133 |
|
| 134 |
let convertedCount = 0;
|
| 135 |
|
|
|
|
| 187 |
.replace(/'/g, "\\'")
|
| 188 |
.trim();
|
| 189 |
|
| 190 |
+
// Mark MultiFigure component as used
|
| 191 |
+
usedComponents.add('MultiFigure');
|
| 192 |
|
| 193 |
// Determine layout based on number of images
|
| 194 |
let layout = 'auto';
|
|
|
|
| 196 |
else if (images.length === 3) layout = '3-column';
|
| 197 |
else if (images.length === 4) layout = '4-column';
|
| 198 |
|
| 199 |
+
// Generate MultiFigure component
|
| 200 |
const imagesJson = images.map(img =>
|
| 201 |
` {\n src: ${img.src},\n alt: "${img.alt}",\n caption: "${img.caption}",\n id: "${img.id}"\n }`
|
| 202 |
).join(',\n');
|
| 203 |
|
| 204 |
+
return `<MultiFigure
|
| 205 |
images={[
|
| 206 |
${imagesJson}
|
| 207 |
]}
|
|
|
|
| 213 |
});
|
| 214 |
|
| 215 |
if (convertedCount > 0) {
|
| 216 |
+
console.log(` β
Converted ${convertedCount} subfigure group(s) to MultiFigure component(s)`);
|
| 217 |
} else {
|
| 218 |
console.log(' βΉοΈ No subfigure groups found');
|
| 219 |
}
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
/**
|
| 225 |
+
* Transform images to Figure components
|
| 226 |
* @param {string} content - MDX content
|
| 227 |
+
* @returns {string} - Content with Figure components
|
| 228 |
*/
|
| 229 |
/**
|
| 230 |
+
* Create Figure component with import
|
| 231 |
* @param {string} src - Clean image source
|
| 232 |
* @param {string} alt - Alt text
|
| 233 |
* @param {string} id - Element ID
|
| 234 |
* @param {string} caption - Figure caption
|
| 235 |
* @param {string} width - Optional width
|
| 236 |
+
* @returns {string} - Figure component markup
|
| 237 |
*/
|
| 238 |
+
function createFigureComponent(src, alt = '', id = '', caption = '', width = '') {
|
| 239 |
const varName = generateImageVarName(src);
|
| 240 |
imageImports.set(src, varName);
|
| 241 |
+
usedComponents.add('Figure');
|
| 242 |
|
| 243 |
const props = [];
|
| 244 |
props.push(`src={${varName}}`);
|
|
|
|
| 249 |
if (alt) props.push(`alt="${alt}"`);
|
| 250 |
if (caption) props.push(`caption={'${caption}'}`);
|
| 251 |
|
| 252 |
+
return `<Figure\n ${props.join('\n ')}\n/>`;
|
| 253 |
}
|
| 254 |
|
| 255 |
function transformImages(content) {
|
| 256 |
+
console.log(' πΌοΈ Transforming images to Figure components with imports...');
|
| 257 |
|
| 258 |
let hasImages = false;
|
| 259 |
|
|
|
|
| 297 |
const altText = cleanAltText(cleanCap);
|
| 298 |
hasImages = true;
|
| 299 |
|
| 300 |
+
return createFigureComponent(cleanSrc, altText, id, cleanCap);
|
| 301 |
}
|
| 302 |
);
|
| 303 |
|
|
|
|
| 309 |
const cleanAlt = cleanAltText(alt || 'Figure');
|
| 310 |
hasImages = true;
|
| 311 |
|
| 312 |
+
return createFigureComponent(cleanSrc, cleanAlt);
|
| 313 |
}
|
| 314 |
);
|
| 315 |
|
|
|
|
| 320 |
const cleanSrc = cleanSrcPath(src);
|
| 321 |
hasImages = true;
|
| 322 |
|
| 323 |
+
return createFigureComponent(cleanSrc, 'Figure');
|
| 324 |
}
|
| 325 |
);
|
| 326 |
|
|
|
|
| 333 |
const altText = cleanAltText(cleanCap);
|
| 334 |
hasImages = true;
|
| 335 |
|
| 336 |
+
return createFigureComponent(cleanSrc, altText, id, cleanCap);
|
| 337 |
}
|
| 338 |
);
|
| 339 |
|
|
|
|
| 346 |
const altText = cleanAltText(cleanCap);
|
| 347 |
hasImages = true;
|
| 348 |
|
| 349 |
+
return createFigureComponent(cleanSrc, altText, id, cleanCap);
|
| 350 |
}
|
| 351 |
);
|
| 352 |
|
|
|
|
| 364 |
if (idMatch) id = idMatch[1];
|
| 365 |
}
|
| 366 |
|
| 367 |
+
return createFigureComponent(cleanSrc, cleanAlt, id);
|
| 368 |
}
|
| 369 |
);
|
| 370 |
|
| 371 |
if (hasImages) {
|
| 372 |
+
console.log(' β
Figure components with imports will be created');
|
| 373 |
}
|
| 374 |
|
| 375 |
return content;
|
|
|
|
| 822 |
processedContent = formatDisplayMathBlocks(processedContent);
|
| 823 |
processedContent = removeHtmlComments(processedContent);
|
| 824 |
processedContent = cleanMdxSyntax(processedContent);
|
| 825 |
+
processedContent = convertSubfiguresToMultiFigure(processedContent);
|
| 826 |
processedContent = transformImages(processedContent);
|
| 827 |
processedContent = transformStyledSpans(processedContent);
|
| 828 |
processedContent = transformReferenceLinks(processedContent);
|
app/scripts/latex-to-mdx/post-processor.mjs
CHANGED
|
@@ -263,7 +263,7 @@ function fixAllAttributes(content) {
|
|
| 263 |
return `data-reference="${before}-${after}"`;
|
| 264 |
});
|
| 265 |
|
| 266 |
-
// Fix id attributes containing colons (like in
|
| 267 |
content = content.replace(/id="([^"]*):([^"]*)"/g, (match, before, after) => {
|
| 268 |
fixedCount++;
|
| 269 |
return `id="${before}-${after}"`;
|
|
|
|
| 263 |
return `data-reference="${before}-${after}"`;
|
| 264 |
});
|
| 265 |
|
| 266 |
+
// Fix id attributes containing colons (like in Figure components)
|
| 267 |
content = content.replace(/id="([^"]*):([^"]*)"/g, (match, before, after) => {
|
| 268 |
fixedCount++;
|
| 269 |
return `id="${before}-${after}"`;
|
app/scripts/sync-template.mjs
CHANGED
|
@@ -28,7 +28,7 @@ const PRESERVE_PATHS = [
|
|
| 28 |
// Project-specific content
|
| 29 |
'app/src/content',
|
| 30 |
|
| 31 |
-
// Public data (symlink to our data)
|
| 32 |
'app/public/data',
|
| 33 |
|
| 34 |
// Local configuration
|
|
@@ -75,7 +75,7 @@ console.log('');
|
|
| 75 |
async function executeCommand(command, options = {}) {
|
| 76 |
try {
|
| 77 |
if (isDryRun && !options.allowInDryRun) {
|
| 78 |
-
console.log(`[DRY-RUN]
|
| 79 |
return '';
|
| 80 |
}
|
| 81 |
console.log(`$ ${command}`);
|
|
@@ -141,7 +141,20 @@ async function syncFile(sourcePath, targetPath) {
|
|
| 141 |
}
|
| 142 |
}
|
| 143 |
|
| 144 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
if (await pathExists(targetPath)) {
|
| 146 |
try {
|
| 147 |
const stats = await fs.lstat(targetPath);
|
|
@@ -161,11 +174,11 @@ async function syncFile(sourcePath, targetPath) {
|
|
| 161 |
// Assurer que le rΓ©pertoire parent existe
|
| 162 |
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
| 163 |
|
| 164 |
-
//
|
| 165 |
try {
|
| 166 |
const sourceStats = await fs.lstat(sourcePath);
|
| 167 |
if (sourceStats.isSymbolicLink()) {
|
| 168 |
-
console.log(`π SYMLINK (ignored): ${relativeTarget}`);
|
| 169 |
return;
|
| 170 |
}
|
| 171 |
} catch (error) {
|
|
@@ -173,7 +186,7 @@ async function syncFile(sourcePath, targetPath) {
|
|
| 173 |
return;
|
| 174 |
}
|
| 175 |
|
| 176 |
-
//
|
| 177 |
if (await pathExists(targetPath)) {
|
| 178 |
await fs.rm(targetPath, { recursive: true, force: true });
|
| 179 |
}
|
|
@@ -212,19 +225,55 @@ async function cloneOrUpdateTemplate() {
|
|
| 212 |
|
| 213 |
// Nettoyer le dossier temporaire s'il existe
|
| 214 |
if (await pathExists(TEMP_DIR)) {
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
} else {
|
| 218 |
console.log(`[DRY-RUN] Suppression: ${TEMP_DIR}`);
|
| 219 |
}
|
| 220 |
}
|
| 221 |
|
| 222 |
-
//
|
| 223 |
await executeCommand(`git clone ${TEMPLATE_REPO} "${TEMP_DIR}"`, { allowInDryRun: true });
|
| 224 |
|
| 225 |
return TEMP_DIR;
|
| 226 |
}
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
async function showSummary(templateDir) {
|
| 229 |
console.log('\nπ SYNCHRONIZATION SUMMARY');
|
| 230 |
console.log('================================');
|
|
@@ -265,19 +314,23 @@ async function cleanup() {
|
|
| 265 |
|
| 266 |
async function main() {
|
| 267 |
try {
|
| 268 |
-
//
|
| 269 |
const packageJsonPath = path.join(APP_ROOT, 'package.json');
|
| 270 |
if (!(await pathExists(packageJsonPath))) {
|
| 271 |
-
throw new Error(`Package.json
|
| 272 |
}
|
| 273 |
|
| 274 |
-
//
|
| 275 |
const templateDir = await cloneOrUpdateTemplate();
|
| 276 |
|
| 277 |
// Synchroniser
|
| 278 |
console.log('\nπ Synchronisation en cours...');
|
| 279 |
await syncDirectory(templateDir, PROJECT_ROOT);
|
| 280 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
// Afficher le rΓ©sumΓ©
|
| 282 |
await showSummary(templateDir);
|
| 283 |
|
|
@@ -292,7 +345,7 @@ async function main() {
|
|
| 292 |
}
|
| 293 |
}
|
| 294 |
|
| 295 |
-
//
|
| 296 |
process.on('SIGINT', async () => {
|
| 297 |
console.log('\n\nβ οΈ Interruption detected, cleaning up...');
|
| 298 |
await cleanup();
|
|
|
|
| 28 |
// Project-specific content
|
| 29 |
'app/src/content',
|
| 30 |
|
| 31 |
+
// Public data (symlink to our data) - CRITICAL: preserve this symlink
|
| 32 |
'app/public/data',
|
| 33 |
|
| 34 |
// Local configuration
|
|
|
|
| 75 |
async function executeCommand(command, options = {}) {
|
| 76 |
try {
|
| 77 |
if (isDryRun && !options.allowInDryRun) {
|
| 78 |
+
console.log(`[DRY-RUN] Command: ${command}`);
|
| 79 |
return '';
|
| 80 |
}
|
| 81 |
console.log(`$ ${command}`);
|
|
|
|
| 141 |
}
|
| 142 |
}
|
| 143 |
|
| 144 |
+
// Check if target file is a symbolic link to preserve
|
| 145 |
+
if (await pathExists(targetPath)) {
|
| 146 |
+
try {
|
| 147 |
+
const targetStats = await fs.lstat(targetPath);
|
| 148 |
+
if (targetStats.isSymbolicLink()) {
|
| 149 |
+
console.log(`π SYMLINK TARGET (preserved): ${relativeTarget}`);
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
} catch (error) {
|
| 153 |
+
console.warn(`β οΈ Impossible de vΓ©rifier ${targetPath}: ${error.message}`);
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// Create backup if file already exists (and is not a symbolic link)
|
| 158 |
if (await pathExists(targetPath)) {
|
| 159 |
try {
|
| 160 |
const stats = await fs.lstat(targetPath);
|
|
|
|
| 174 |
// Assurer que le rΓ©pertoire parent existe
|
| 175 |
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
| 176 |
|
| 177 |
+
// Check if source is a symbolic link
|
| 178 |
try {
|
| 179 |
const sourceStats = await fs.lstat(sourcePath);
|
| 180 |
if (sourceStats.isSymbolicLink()) {
|
| 181 |
+
console.log(`π SYMLINK SOURCE (ignored): ${relativeTarget}`);
|
| 182 |
return;
|
| 183 |
}
|
| 184 |
} catch (error) {
|
|
|
|
| 186 |
return;
|
| 187 |
}
|
| 188 |
|
| 189 |
+
// Remove target file if it exists (to handle symbolic links)
|
| 190 |
if (await pathExists(targetPath)) {
|
| 191 |
await fs.rm(targetPath, { recursive: true, force: true });
|
| 192 |
}
|
|
|
|
| 225 |
|
| 226 |
// Nettoyer le dossier temporaire s'il existe
|
| 227 |
if (await pathExists(TEMP_DIR)) {
|
| 228 |
+
await fs.rm(TEMP_DIR, { recursive: true, force: true });
|
| 229 |
+
if (isDryRun) {
|
|
|
|
| 230 |
console.log(`[DRY-RUN] Suppression: ${TEMP_DIR}`);
|
| 231 |
}
|
| 232 |
}
|
| 233 |
|
| 234 |
+
// Clone template repo (even in dry-run to be able to compare)
|
| 235 |
await executeCommand(`git clone ${TEMPLATE_REPO} "${TEMP_DIR}"`, { allowInDryRun: true });
|
| 236 |
|
| 237 |
return TEMP_DIR;
|
| 238 |
}
|
| 239 |
|
| 240 |
+
async function ensureDataSymlink() {
|
| 241 |
+
const dataSymlinkPath = path.join(APP_ROOT, 'public', 'data');
|
| 242 |
+
const dataSourcePath = path.join(APP_ROOT, 'src', 'content', 'assets', 'data');
|
| 243 |
+
|
| 244 |
+
// Check if symlink exists and is correct
|
| 245 |
+
if (await pathExists(dataSymlinkPath)) {
|
| 246 |
+
try {
|
| 247 |
+
const stats = await fs.lstat(dataSymlinkPath);
|
| 248 |
+
if (stats.isSymbolicLink()) {
|
| 249 |
+
const target = await fs.readlink(dataSymlinkPath);
|
| 250 |
+
const expectedTarget = path.relative(path.dirname(dataSymlinkPath), dataSourcePath);
|
| 251 |
+
if (target === expectedTarget) {
|
| 252 |
+
console.log('π Data symlink is correct');
|
| 253 |
+
return;
|
| 254 |
+
} else {
|
| 255 |
+
console.log(`β οΈ Data symlink points to wrong target: ${target} (expected: ${expectedTarget})`);
|
| 256 |
+
}
|
| 257 |
+
} else {
|
| 258 |
+
console.log('β οΈ app/public/data exists but is not a symlink');
|
| 259 |
+
}
|
| 260 |
+
} catch (error) {
|
| 261 |
+
console.log(`β οΈ Error checking symlink: ${error.message}`);
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Recreate symlink
|
| 266 |
+
if (!isDryRun) {
|
| 267 |
+
if (await pathExists(dataSymlinkPath)) {
|
| 268 |
+
await fs.rm(dataSymlinkPath, { recursive: true, force: true });
|
| 269 |
+
}
|
| 270 |
+
await fs.symlink(path.relative(path.dirname(dataSymlinkPath), dataSourcePath), dataSymlinkPath);
|
| 271 |
+
console.log('β
Data symlink recreated');
|
| 272 |
+
} else {
|
| 273 |
+
console.log('[DRY-RUN] Would recreate data symlink');
|
| 274 |
+
}
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
async function showSummary(templateDir) {
|
| 278 |
console.log('\nπ SYNCHRONIZATION SUMMARY');
|
| 279 |
console.log('================================');
|
|
|
|
| 314 |
|
| 315 |
async function main() {
|
| 316 |
try {
|
| 317 |
+
// Verify we're in the correct directory
|
| 318 |
const packageJsonPath = path.join(APP_ROOT, 'package.json');
|
| 319 |
if (!(await pathExists(packageJsonPath))) {
|
| 320 |
+
throw new Error(`Package.json not found in ${APP_ROOT}. Are you in the correct directory?`);
|
| 321 |
}
|
| 322 |
|
| 323 |
+
// Clone the template
|
| 324 |
const templateDir = await cloneOrUpdateTemplate();
|
| 325 |
|
| 326 |
// Synchroniser
|
| 327 |
console.log('\nπ Synchronisation en cours...');
|
| 328 |
await syncDirectory(templateDir, PROJECT_ROOT);
|
| 329 |
|
| 330 |
+
// S'assurer que le lien symbolique des donnΓ©es est correct
|
| 331 |
+
console.log('\nπ VΓ©rification du lien symbolique des donnΓ©es...');
|
| 332 |
+
await ensureDataSymlink();
|
| 333 |
+
|
| 334 |
// Afficher le rΓ©sumΓ©
|
| 335 |
await showSummary(templateDir);
|
| 336 |
|
|
|
|
| 345 |
}
|
| 346 |
}
|
| 347 |
|
| 348 |
+
// Signal handling to clean up on interruption
|
| 349 |
process.on('SIGINT', async () => {
|
| 350 |
console.log('\n\nβ οΈ Interruption detected, cleaning up...');
|
| 351 |
await cleanup();
|
app/src/components/ColorPicker.astro
DELETED
|
@@ -1,118 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
---
|
| 3 |
-
<div class="color-picker" style="width:100%; margin: 10px 0;">
|
| 4 |
-
<style>
|
| 5 |
-
.color-picker .picker__stack { display:flex; flex-direction:column; gap:12px; }
|
| 6 |
-
.color-picker .current-card { display:grid; grid-template-columns: 30% 70%; align-items: center; gap:14px; padding:14px 32px 14px 16px; border:1px solid var(--border-color); background: var(--surface-bg); border-radius: 12px; }
|
| 7 |
-
.color-picker .current-left { display:flex; flex-direction: column; gap:8px; min-width: 0; }
|
| 8 |
-
.color-picker .current-right { display:flex; flex-direction: column; gap:8px; padding-left: 14px; border-left: 1px solid var(--border-color); }
|
| 9 |
-
.color-picker .current-main { display:flex; align-items:center; gap:12px; min-width: 0; }
|
| 10 |
-
.color-picker .current-swatch { width: 64px; height: 64px; border-radius: 8px; border: 1px solid var(--border-color); }
|
| 11 |
-
.color-picker .current-text { display:flex; flex-direction: column; line-height: 1.2; min-width: 0; }
|
| 12 |
-
.color-picker .current-name { font-size: 14px; font-weight: 800; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
|
| 13 |
-
.color-picker .current-hex, .color-picker .current-extra { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
|
| 14 |
-
.color-picker .picker__label { font-weight:700; font-size: 12px; color: var(--muted-color); text-transform: uppercase; letter-spacing: .02em; }
|
| 15 |
-
.color-picker .hue-slider { position:relative; height:16px; border-radius:10px; border:1px solid var(--border-color); background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); cursor: ew-resize; touch-action: none; flex: 1 1 auto; min-width: 200px; }
|
| 16 |
-
.color-picker .hue-knob { position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2; box-shadow: 0 0 0 1px rgba(0,0,0,.05); }
|
| 17 |
-
.color-picker .hue-slider:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
| 18 |
-
.color-picker .hue-value { font-variant-numeric: tabular-nums; color: var(--muted-color); font-size: 12px; }
|
| 19 |
-
@media (max-width: 720px) { .color-picker .current-card { grid-template-columns: 1fr; } .color-picker .current-right { padding-left: 0; border-left: none; } }
|
| 20 |
-
</style>
|
| 21 |
-
<div class="picker__stack">
|
| 22 |
-
<div class="current-card">
|
| 23 |
-
<div class="current-left">
|
| 24 |
-
<div class="current-main">
|
| 25 |
-
<div class="current-swatch" aria-label="Current color" title="Current color"></div>
|
| 26 |
-
<div class="current-text">
|
| 27 |
-
<div class="current-name">β</div>
|
| 28 |
-
<div class="current-hex">β</div>
|
| 29 |
-
<div class="current-extra current-lch">β</div>
|
| 30 |
-
<div class="current-extra current-rgb">β</div>
|
| 31 |
-
</div>
|
| 32 |
-
</div>
|
| 33 |
-
</div>
|
| 34 |
-
<div class="current-right">
|
| 35 |
-
<div class="picker__label">Hue</div>
|
| 36 |
-
<div class="hue-slider" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="337" tabindex="0">
|
| 37 |
-
<div class="hue-knob"></div>
|
| 38 |
-
</div>
|
| 39 |
-
<div class="hue-value">337Β°</div>
|
| 40 |
-
</div>
|
| 41 |
-
</div>
|
| 42 |
-
</div>
|
| 43 |
-
</div>
|
| 44 |
-
<script>
|
| 45 |
-
(() => {
|
| 46 |
-
const COLOR_NAMES = [{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Big bang Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Pink","hex":"#ff0099"},{"name":"Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
|
| 47 |
-
if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
|
| 48 |
-
|
| 49 |
-
if (!window.__colorPickerBus) {
|
| 50 |
-
window.__colorPickerBus = (() => {
|
| 51 |
-
let hue = 337; let adjusting=false; const listeners = new Set();
|
| 52 |
-
return { get: () => ({ hue, adjusting }), publish: (sourceId, nextHue, isAdj) => { hue=((nextHue%360)+360)%360; adjusting=!!isAdj; listeners.forEach(fn => { try { fn({ sourceId, hue, adjusting }); } catch{} }); }, subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); } };
|
| 53 |
-
})();
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
const bootstrap = () => {
|
| 57 |
-
const root = document.querySelector('.color-picker'); if (!root || root.dataset.mounted) return; root.dataset.mounted='true';
|
| 58 |
-
const slider = root.querySelector('.hue-slider'); const knob = root.querySelector('.hue-knob'); const hueValue = root.querySelector('.hue-value'); const currentSwatch = root.querySelector('.current-swatch'); const currentName = root.querySelector('.current-name'); const currentHex = root.querySelector('.current-hex'); const currentLch = root.querySelector('.current-lch'); const currentRgb = root.querySelector('.current-rgb');
|
| 59 |
-
const bus = window.__colorPickerBus; const instanceId = Math.random().toString(36).slice(2);
|
| 60 |
-
const getKnobRadius = () => { try { const w = knob ? knob.getBoundingClientRect().width : 0; return w ? w/2 : 8; } catch { return 8; } };
|
| 61 |
-
const hexToHsl = (H) => {
|
| 62 |
-
const s = H.replace('#','');
|
| 63 |
-
const v = s.length===3 ? s.split('').map(ch=>ch+ch).join('') : s;
|
| 64 |
-
const bigint = parseInt(v, 16);
|
| 65 |
-
let r = (bigint >> 16) & 255, g = (bigint >> 8) & 255, b = bigint & 255;
|
| 66 |
-
r /= 255; g /= 255; b /= 255;
|
| 67 |
-
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
| 68 |
-
let h = 0, s2 = 0, l = (max + min) / 2;
|
| 69 |
-
if (max !== min) {
|
| 70 |
-
const d = max - min;
|
| 71 |
-
s2 = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
| 72 |
-
switch (max) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; default: h = (r - g) / d + 4; }
|
| 73 |
-
h /= 6;
|
| 74 |
-
}
|
| 75 |
-
return { h: Math.round(h*360), s: Math.round(s2*100), l: Math.round(l*100) };
|
| 76 |
-
};
|
| 77 |
-
const hslToHex = (h, s, l) => {
|
| 78 |
-
s /= 100; l /= 100;
|
| 79 |
-
const k = n => (n + h/30) % 12;
|
| 80 |
-
const a = s * Math.min(l, 1 - l);
|
| 81 |
-
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
| 82 |
-
const toHex = x => Math.round(255 * x).toString(16).padStart(2, '0');
|
| 83 |
-
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`.toUpperCase();
|
| 84 |
-
};
|
| 85 |
-
// Precompute hues for the provided color-name list
|
| 86 |
-
const NAME_HUES = COLOR_NAMES.map((c) => {
|
| 87 |
-
const hh = hexToHsl(c.hex).h || 0;
|
| 88 |
-
return { name: c.name, hue: hh };
|
| 89 |
-
});
|
| 90 |
-
// Pick closest name by circular hue distance; fallback to coarse labels
|
| 91 |
-
const getName = (hex) => {
|
| 92 |
-
const h = hexToHsl(hex).h || 0;
|
| 93 |
-
let bestName = 'β';
|
| 94 |
-
let best = 361;
|
| 95 |
-
for (let i = 0; i < NAME_HUES.length; i++) {
|
| 96 |
-
const hh = NAME_HUES[i].hue;
|
| 97 |
-
const d = Math.abs(hh - h);
|
| 98 |
-
const dist = Math.min(d, 360 - d);
|
| 99 |
-
if (dist < best) { best = dist; bestName = NAME_HUES[i].name; }
|
| 100 |
-
}
|
| 101 |
-
if (bestName !== 'β') return bestName;
|
| 102 |
-
const labels=['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta'];
|
| 103 |
-
const idx=Math.round(((h%360)/360)*(labels.length-1));
|
| 104 |
-
return labels[idx];
|
| 105 |
-
};
|
| 106 |
-
const updateUI = (h, adjusting) => { const rect = slider.getBoundingClientRect(); const r=Math.min(getKnobRadius(), Math.max(0, rect.width/2 - 1)); const t=Math.max(0, Math.min(1, (h/360))); const leftPx = r + t * Math.max(0, (rect.width - 2*r)); if (knob) knob.style.left = (leftPx/rect.width*100) + '%'; if (hueValue) hueValue.textContent=`${Math.round(h)}Β°`; if (slider) slider.setAttribute('aria-valuenow', String(Math.round(h))); const L=62, S=72; const baseHex=hslToHex(h,S,L); if (currentSwatch) currentSwatch.style.background=baseHex; if (currentName) currentName.textContent=getName(baseHex); if (currentHex) currentHex.textContent=baseHex; if (currentLch) currentLch.textContent = `HSL ${L}, ${S}, ${Math.round(h)}Β°`; if (currentRgb){ const hex=baseHex.replace('#',''); const R=parseInt(hex.slice(0,2),16), G=parseInt(hex.slice(2,4),16), B=parseInt(hex.slice(4,6),16); currentRgb.textContent=`RGB ${R}, ${G}, ${B}`; } const hoverHex=hslToHex(h, Math.max(0,S-10), Math.max(0, L-8)); const rootEl=document.documentElement; rootEl.style.setProperty('--primary-color', baseHex); rootEl.style.setProperty('--primary-color-hover', hoverHex); };
|
| 107 |
-
const getHueFromEvent = (ev) => { const rect=slider.getBoundingClientRect(); const clientX=ev.touches ? ev.touches[0].clientX : ev.clientX; const x = clientX - rect.left; const r=Math.min(getKnobRadius(), Math.max(0, rect.width/2 - 1)); const effX=Math.max(r, Math.min(rect.width - r, x)); const denom=Math.max(1, rect.width - 2*r); const t=(effX - r) / denom; return t*360; };
|
| 108 |
-
const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => { if (sourceId === instanceId) return; updateUI(hue, adjusting); });
|
| 109 |
-
try { let initH=337; if (window.ColorPalettes && typeof window.ColorPalettes.getPrimary==='function'){ const hex=window.ColorPalettes.getPrimary(); initH = hexToHsl(hex).h || initH; } else { const cssPrimary=getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); if (cssPrimary) { initH = hexToHsl(cssPrimary).h || initH; } } const { hue: sharedHue } = bus.get(); updateUI(initH ?? sharedHue, false); bus.publish(instanceId, initH ?? sharedHue, false); } catch { const { hue: sharedHue } = bus.get(); updateUI(sharedHue, false); }
|
| 110 |
-
const onDown = (ev) => { ev.preventDefault(); const h=getHueFromEvent(ev); updateUI(h, true); bus.publish(instanceId, h, true); const move=(e)=>{ e.preventDefault && e.preventDefault(); const hh=getHueFromEvent(e); updateUI(hh, true); bus.publish(instanceId, hh, true); }; const up=()=>{ bus.publish(instanceId, getHueFromEvent(ev), false); window.removeEventListener('mousemove', move); window.removeEventListener('touchmove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up); }; window.addEventListener('mousemove', move, { passive:false }); window.addEventListener('touchmove', move, { passive:false }); window.addEventListener('mouseup', up, { once:true }); window.addEventListener('touchend', up, { once:true }); };
|
| 111 |
-
if (slider){ slider.addEventListener('mousedown', onDown); slider.addEventListener('touchstart', onDown, { passive:false }); slider.addEventListener('keydown', (e)=>{ const step=e.shiftKey?10:2; if (e.key==='ArrowLeft'){ e.preventDefault(); const { hue } = bus.get(); const h=hue-step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); } if (e.key==='ArrowRight'){ e.preventDefault(); const { hue } = bus.get(); const h=hue+step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); } }); }
|
| 112 |
-
const ro=new MutationObserver(()=>{ if (!document.body.contains(root)){ unsubscribe && unsubscribe(); ro.disconnect(); } }); ro.observe(document.body, { childList:true, subtree:true });
|
| 113 |
-
};
|
| 114 |
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once:true }); else bootstrap();
|
| 115 |
-
})();
|
| 116 |
-
</script>
|
| 117 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/src/components/{ResponsiveImage.astro β Figure.astro}
RENAMED
|
@@ -175,13 +175,10 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 175 |
|
| 176 |
<script is:inline>
|
| 177 |
(() => {
|
| 178 |
-
console.log("ResponsiveImage script: Starting execution");
|
| 179 |
const scriptEl = document.currentScript;
|
| 180 |
-
console.log("ResponsiveImage script: scriptEl =", scriptEl);
|
| 181 |
const root = scriptEl ? scriptEl.previousElementSibling : null;
|
| 182 |
-
console.log("ResponsiveImage script: root =", root);
|
| 183 |
if (!root) {
|
| 184 |
-
console.log("
|
| 185 |
return;
|
| 186 |
}
|
| 187 |
const img =
|
|
@@ -190,9 +187,8 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 190 |
: root.querySelector
|
| 191 |
? root.querySelector("img")
|
| 192 |
: null;
|
| 193 |
-
console.log("ResponsiveImage script: img =", img);
|
| 194 |
if (!img) {
|
| 195 |
-
console.log("
|
| 196 |
return;
|
| 197 |
}
|
| 198 |
|
|
@@ -209,13 +205,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 209 |
};
|
| 210 |
|
| 211 |
const initZoomIfNeeded = () => {
|
| 212 |
-
console.log("ResponsiveImage: checking zoom for", img);
|
| 213 |
-
console.log(
|
| 214 |
-
"ResponsiveImage: data-zoomable =",
|
| 215 |
-
img.getAttribute("data-zoomable"),
|
| 216 |
-
);
|
| 217 |
if (img.getAttribute("data-zoomable") !== "1") return;
|
| 218 |
-
console.log("ResponsiveImage: initializing zoom for", img);
|
| 219 |
const isDark =
|
| 220 |
document.documentElement.getAttribute("data-theme") === "dark";
|
| 221 |
const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
|
|
@@ -224,7 +214,6 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 224 |
const instance = window.mediumZoom
|
| 225 |
? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
|
| 226 |
: null;
|
| 227 |
-
console.log("ResponsiveImage: zoom instance created:", instance);
|
| 228 |
if (!instance) return;
|
| 229 |
let onScrollLike;
|
| 230 |
const attachCloseOnScroll = () => {
|
|
@@ -268,7 +257,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 268 |
});
|
| 269 |
};
|
| 270 |
|
| 271 |
-
//
|
| 272 |
const setupGlobalZoomBehavior = () => {
|
| 273 |
img.addEventListener("click", () => {
|
| 274 |
if (img.getAttribute("data-zoomable") === "1") {
|
|
@@ -277,7 +266,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 277 |
.querySelectorAll(".ri-root.zoom-active")
|
| 278 |
.forEach((el) => el.classList.remove("zoom-active"));
|
| 279 |
|
| 280 |
-
//
|
| 281 |
root.classList.add("zoom-active");
|
| 282 |
}
|
| 283 |
});
|
|
@@ -421,26 +410,26 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 421 |
background: var(--primary-color);
|
| 422 |
}
|
| 423 |
|
| 424 |
-
/*
|
| 425 |
:global(.medium-zoom--opened) .ri-root {
|
| 426 |
opacity: 0;
|
| 427 |
z-index: calc(var(--z-base) - 1);
|
| 428 |
transition: opacity 0.3s ease;
|
| 429 |
}
|
| 430 |
|
| 431 |
-
/*
|
| 432 |
:global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
|
| 433 |
opacity: 1;
|
| 434 |
z-index: var(--z-overlay);
|
| 435 |
}
|
| 436 |
|
| 437 |
-
/* Fallback
|
| 438 |
:global(.medium-zoom--opened) .ri-root.zoom-active {
|
| 439 |
opacity: 1 !important;
|
| 440 |
z-index: var(--z-overlay) !important;
|
| 441 |
}
|
| 442 |
|
| 443 |
-
/*
|
| 444 |
:global(.medium-zoom--opened) .img-dl-btn {
|
| 445 |
opacity: 0;
|
| 446 |
z-index: calc(var(--z-base) - 1);
|
|
@@ -453,7 +442,7 @@ const resolvedRel = hasLink ? linkRel || "noopener noreferrer" : undefined;
|
|
| 453 |
transition: opacity 0.3s ease;
|
| 454 |
}
|
| 455 |
|
| 456 |
-
/*
|
| 457 |
:global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
|
| 458 |
opacity: 0;
|
| 459 |
z-index: calc(var(--z-base) - 1);
|
|
|
|
| 175 |
|
| 176 |
<script is:inline>
|
| 177 |
(() => {
|
|
|
|
| 178 |
const scriptEl = document.currentScript;
|
|
|
|
| 179 |
const root = scriptEl ? scriptEl.previousElementSibling : null;
|
|
|
|
| 180 |
if (!root) {
|
| 181 |
+
console.log("Figure script: No root element found, exiting");
|
| 182 |
return;
|
| 183 |
}
|
| 184 |
const img =
|
|
|
|
| 187 |
: root.querySelector
|
| 188 |
? root.querySelector("img")
|
| 189 |
: null;
|
|
|
|
| 190 |
if (!img) {
|
| 191 |
+
console.log("Figure script: No img element found, exiting");
|
| 192 |
return;
|
| 193 |
}
|
| 194 |
|
|
|
|
| 205 |
};
|
| 206 |
|
| 207 |
const initZoomIfNeeded = () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
if (img.getAttribute("data-zoomable") !== "1") return;
|
|
|
|
| 209 |
const isDark =
|
| 210 |
document.documentElement.getAttribute("data-theme") === "dark";
|
| 211 |
const background = isDark ? "rgba(0,0,0,.9)" : "rgba(0,0,0,.85)";
|
|
|
|
| 214 |
const instance = window.mediumZoom
|
| 215 |
? window.mediumZoom(img, { background, margin: 24, scrollOffset: 0 })
|
| 216 |
: null;
|
|
|
|
| 217 |
if (!instance) return;
|
| 218 |
let onScrollLike;
|
| 219 |
const attachCloseOnScroll = () => {
|
|
|
|
| 257 |
});
|
| 258 |
};
|
| 259 |
|
| 260 |
+
// Global zoom management to hide other Figures
|
| 261 |
const setupGlobalZoomBehavior = () => {
|
| 262 |
img.addEventListener("click", () => {
|
| 263 |
if (img.getAttribute("data-zoomable") === "1") {
|
|
|
|
| 266 |
.querySelectorAll(".ri-root.zoom-active")
|
| 267 |
.forEach((el) => el.classList.remove("zoom-active"));
|
| 268 |
|
| 269 |
+
// Add zoom-active to this ri-root
|
| 270 |
root.classList.add("zoom-active");
|
| 271 |
}
|
| 272 |
});
|
|
|
|
| 410 |
background: var(--primary-color);
|
| 411 |
}
|
| 412 |
|
| 413 |
+
/* When an image is zoomed, hide ALL Figures on the page */
|
| 414 |
:global(.medium-zoom--opened) .ri-root {
|
| 415 |
opacity: 0;
|
| 416 |
z-index: calc(var(--z-base) - 1);
|
| 417 |
transition: opacity 0.3s ease;
|
| 418 |
}
|
| 419 |
|
| 420 |
+
/* The currently zoomed image remains visible */
|
| 421 |
:global(.medium-zoom--opened) .ri-root:has(.medium-zoom--opened) {
|
| 422 |
opacity: 1;
|
| 423 |
z-index: var(--z-overlay);
|
| 424 |
}
|
| 425 |
|
| 426 |
+
/* Fallback for browsers without :has() support */
|
| 427 |
:global(.medium-zoom--opened) .ri-root.zoom-active {
|
| 428 |
opacity: 1 !important;
|
| 429 |
z-index: var(--z-overlay) !important;
|
| 430 |
}
|
| 431 |
|
| 432 |
+
/* Specifically hide download button and figcaption during zoom */
|
| 433 |
:global(.medium-zoom--opened) .img-dl-btn {
|
| 434 |
opacity: 0;
|
| 435 |
z-index: calc(var(--z-base) - 1);
|
|
|
|
| 442 |
transition: opacity 0.3s ease;
|
| 443 |
}
|
| 444 |
|
| 445 |
+
/* Even for active zoomed image, hide button and caption for clean experience */
|
| 446 |
:global(.medium-zoom--opened) .ri-root.zoom-active .img-dl-btn {
|
| 447 |
opacity: 0;
|
| 448 |
z-index: calc(var(--z-base) - 1);
|
app/src/components/{MultiImage.astro β MultiFigure.astro}
RENAMED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
// @ts-ignore - types provided by Astro at runtime
|
| 3 |
-
import
|
| 4 |
|
| 5 |
interface ImageItem {
|
| 6 |
/** Source image imported via astro:assets */
|
|
@@ -83,7 +83,7 @@ const gridColumns = getGridColumns();
|
|
| 83 |
>
|
| 84 |
{images.map((image, index) => (
|
| 85 |
<div class="multi-image-item">
|
| 86 |
-
<
|
| 87 |
src={image.src}
|
| 88 |
alt={image.alt}
|
| 89 |
zoomable={image.zoomable ?? zoomable}
|
|
@@ -121,7 +121,7 @@ const gridColumns = getGridColumns();
|
|
| 121 |
>
|
| 122 |
{images.map((image, index) => (
|
| 123 |
<div class="multi-image-item">
|
| 124 |
-
<
|
| 125 |
src={image.src}
|
| 126 |
alt={image.alt}
|
| 127 |
zoomable={image.zoomable ?? zoomable}
|
|
@@ -198,7 +198,7 @@ const gridColumns = getGridColumns();
|
|
| 198 |
z-index: var(--z-overlay);
|
| 199 |
}
|
| 200 |
|
| 201 |
-
/* Fallback
|
| 202 |
:global(.medium-zoom--opened) .multi-image-item.zoom-active {
|
| 203 |
opacity: 1 !important;
|
| 204 |
z-index: var(--z-overlay) !important;
|
|
@@ -261,9 +261,9 @@ const gridColumns = getGridColumns();
|
|
| 261 |
}
|
| 262 |
}
|
| 263 |
|
| 264 |
-
/*
|
| 265 |
.multi-image[data-layout*="column"] .multi-image-item :global(img) {
|
| 266 |
-
height:
|
| 267 |
object-fit: contain;
|
| 268 |
}
|
| 269 |
|
|
@@ -281,9 +281,9 @@ const gridColumns = getGridColumns();
|
|
| 281 |
</style>
|
| 282 |
|
| 283 |
<script>
|
| 284 |
-
// Enhanced medium-zoom integration for
|
| 285 |
document.addEventListener("DOMContentLoaded", () => {
|
| 286 |
-
//
|
| 287 |
const multiImages = document.querySelectorAll(".multi-image");
|
| 288 |
|
| 289 |
multiImages.forEach((multiImage) => {
|
|
@@ -298,7 +298,7 @@ const gridColumns = getGridColumns();
|
|
| 298 |
const activeItem = img.closest(".multi-image-item");
|
| 299 |
const riRoot = img.closest(".ri-root");
|
| 300 |
|
| 301 |
-
// Nettoyer TOUS les zoom-active (
|
| 302 |
document
|
| 303 |
.querySelectorAll(
|
| 304 |
".multi-image-item.zoom-active, .ri-root.zoom-active",
|
|
@@ -328,7 +328,7 @@ const gridColumns = getGridColumns();
|
|
| 328 |
}
|
| 329 |
});
|
| 330 |
|
| 331 |
-
//
|
| 332 |
document.addEventListener("keydown", (e) => {
|
| 333 |
if (e.key === "Escape") {
|
| 334 |
document
|
|
|
|
| 1 |
---
|
| 2 |
// @ts-ignore - types provided by Astro at runtime
|
| 3 |
+
import Figure from "./Figure.astro";
|
| 4 |
|
| 5 |
interface ImageItem {
|
| 6 |
/** Source image imported via astro:assets */
|
|
|
|
| 83 |
>
|
| 84 |
{images.map((image, index) => (
|
| 85 |
<div class="multi-image-item">
|
| 86 |
+
<Figure
|
| 87 |
src={image.src}
|
| 88 |
alt={image.alt}
|
| 89 |
zoomable={image.zoomable ?? zoomable}
|
|
|
|
| 121 |
>
|
| 122 |
{images.map((image, index) => (
|
| 123 |
<div class="multi-image-item">
|
| 124 |
+
<Figure
|
| 125 |
src={image.src}
|
| 126 |
alt={image.alt}
|
| 127 |
zoomable={image.zoomable ?? zoomable}
|
|
|
|
| 198 |
z-index: var(--z-overlay);
|
| 199 |
}
|
| 200 |
|
| 201 |
+
/* Fallback for browsers without :has() support */
|
| 202 |
:global(.medium-zoom--opened) .multi-image-item.zoom-active {
|
| 203 |
opacity: 1 !important;
|
| 204 |
z-index: var(--z-overlay) !important;
|
|
|
|
| 261 |
}
|
| 262 |
}
|
| 263 |
|
| 264 |
+
/* Images maintain natural aspect ratio */
|
| 265 |
.multi-image[data-layout*="column"] .multi-image-item :global(img) {
|
| 266 |
+
height: auto;
|
| 267 |
object-fit: contain;
|
| 268 |
}
|
| 269 |
|
|
|
|
| 281 |
</style>
|
| 282 |
|
| 283 |
<script>
|
| 284 |
+
// Enhanced medium-zoom integration for MultiFigure
|
| 285 |
document.addEventListener("DOMContentLoaded", () => {
|
| 286 |
+
// Improve MultiFigure behavior with medium-zoom
|
| 287 |
const multiImages = document.querySelectorAll(".multi-image");
|
| 288 |
|
| 289 |
multiImages.forEach((multiImage) => {
|
|
|
|
| 298 |
const activeItem = img.closest(".multi-image-item");
|
| 299 |
const riRoot = img.closest(".ri-root");
|
| 300 |
|
| 301 |
+
// Nettoyer TOUS les zoom-active (MultiFigure items et Figure)
|
| 302 |
document
|
| 303 |
.querySelectorAll(
|
| 304 |
".multi-image-item.zoom-active, .ri-root.zoom-active",
|
|
|
|
| 328 |
}
|
| 329 |
});
|
| 330 |
|
| 331 |
+
// Listen for keyboard events to close zoom
|
| 332 |
document.addEventListener("keydown", (e) => {
|
| 333 |
if (e.key === "Escape") {
|
| 334 |
document
|
app/src/components/Palettes.astro
DELETED
|
@@ -1,170 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
const rootId = `palettes-${Math.random().toString(36).slice(2)}`;
|
| 3 |
-
---
|
| 4 |
-
<div class="palettes" id={rootId} style="width:100%; margin: 10px 0;">
|
| 5 |
-
<style is:global>
|
| 6 |
-
.palettes { box-sizing: border-box; }
|
| 7 |
-
.palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; max-width: 100%; }
|
| 8 |
-
.palettes .palette-card { position: relative; display: grid; grid-template-columns: 1fr minmax(0, 220px); align-items: stretch; gap: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; min-height: 60px; }
|
| 9 |
-
.palettes .palette-card__preview { width: 48px; height: 48px; border-radius: 999px; flex: 0 0 auto; background-size: cover; background-position: center; }
|
| 10 |
-
.palettes .palette-card__copy { position: absolute; top: 50%; left: 100%; transform: translateY(-50%); z-index: 3; border-left: none; border-top-left-radius: 0; border-bottom-left-radius: 0; }
|
| 11 |
-
.palettes .palette-card__copy svg { width: 18px; height: 18px; fill: currentColor; display: block; color: inherit; }
|
| 12 |
-
.palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 2px; margin: 0; min-height: 60px; }
|
| 13 |
-
.palettes .palette-card__swatches .sw { width: 100%; min-width: 0; height: auto; border-radius: 0; border: 1px solid var(--border-color); }
|
| 14 |
-
.palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
|
| 15 |
-
.palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
|
| 16 |
-
.palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: flex-start; gap: 12px; min-width: 0; padding-right: 12px; }
|
| 17 |
-
.palettes .palette-card__preview { width: 48px; height: 48px; border-radius: 999px; position: relative; flex: 0 0 auto; overflow: hidden; }
|
| 18 |
-
.palettes .palette-card__preview .dot { position: absolute; width: 4px; height: 4px; background: #fff; border-radius: 999px; box-shadow: 0 0 0 1px var(--border-color); }
|
| 19 |
-
.palettes .palette-card__preview .donut-hole { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 24px; height: 24px; border-radius: 999px; background: var(--surface-bg); box-shadow: 0 0 0 1px var(--border-color) inset; }
|
| 20 |
-
|
| 21 |
-
.palettes .palette-card__content__info { display: flex; flex-direction: column; }
|
| 22 |
-
.palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
|
| 23 |
-
.palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
|
| 24 |
-
|
| 25 |
-
.palettes .palettes__select { width: 100%; max-width: 100%; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
|
| 26 |
-
.palettes .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
|
| 27 |
-
.palettes .palettes__controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 8px 0 14px; }
|
| 28 |
-
.palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1 1 280px; max-width: 100%; }
|
| 29 |
-
.palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; }
|
| 30 |
-
.palettes .palettes__label-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
| 31 |
-
.palettes .ghost-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-color); color: var(--muted-color); background: transparent; font-variant-numeric: tabular-nums; }
|
| 32 |
-
.palettes .palettes__count { display: flex; align-items: center; gap: 8px; max-width: 100%; }
|
| 33 |
-
.palettes .palettes__count input[type="range"] { width: 100%; }
|
| 34 |
-
.palettes .palettes__count output { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted-color); }
|
| 35 |
-
.palettes input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; cursor: pointer; accent-color: var(--primary-color); }
|
| 36 |
-
.palettes input[type="range"]:focus { outline: none; }
|
| 37 |
-
.palettes input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: var(--border-color); border-radius: 999px; }
|
| 38 |
-
.palettes input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; margin-top: -6px; width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
|
| 39 |
-
.palettes input[type="range"]::-moz-range-track { height: 6px; background: var(--border-color); border: none; border-radius: 999px; }
|
| 40 |
-
.palettes input[type="range"]::-moz-range-progress { height: 6px; background: var(--primary-color); border-radius: 999px; }
|
| 41 |
-
.palettes input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
|
| 42 |
-
html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
|
| 43 |
-
html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
|
| 44 |
-
html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) !important; }
|
| 45 |
-
html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) !important; }
|
| 46 |
-
html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) !important; }
|
| 47 |
-
@media (max-width: 1100px) { .palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; } .palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); } .palettes .palette-card__content { border-right: none; padding-right: 0; } .palettes .palette-card__copy { display: none; } }
|
| 48 |
-
</style>
|
| 49 |
-
<div class="palettes__controls">
|
| 50 |
-
<div class="palettes__field">
|
| 51 |
-
<label class="palettes__label" for="cb-select">Color vision simulation</label>
|
| 52 |
-
<select id="cb-select" class="palettes__select">
|
| 53 |
-
<option value="none">Normal color vision β typical for most people</option>
|
| 54 |
-
<option value="achromatopsia">Achromatopsia β no color at all</option>
|
| 55 |
-
<option value="protanopia">Protanopia β reduced/absent reds</option>
|
| 56 |
-
<option value="deuteranopia">Deuteranopia β reduced/absent greens</option>
|
| 57 |
-
<option value="tritanopia">Tritanopia β reduced/absent blues</option>
|
| 58 |
-
</select>
|
| 59 |
-
</div>
|
| 60 |
-
<div class="palettes__field">
|
| 61 |
-
<div class="palettes__label-row">
|
| 62 |
-
<label class="palettes__label" for="color-count">Number of colors</label>
|
| 63 |
-
<output id="color-count-out" for="color-count" class="ghost-badge">8</output>
|
| 64 |
-
</div>
|
| 65 |
-
<div class="palettes__count">
|
| 66 |
-
<input id="color-count" type="range" min="6" max="10" step="1" value="8" aria-label="Number of colors" />
|
| 67 |
-
</div>
|
| 68 |
-
</div>
|
| 69 |
-
</div>
|
| 70 |
-
<div class="palettes__grid"></div>
|
| 71 |
-
<div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
|
| 72 |
-
<svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
|
| 73 |
-
<defs>
|
| 74 |
-
<filter id="cb-protanopia"><feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"/></filter>
|
| 75 |
-
<filter id="cb-deuteranopia"><feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"/></filter>
|
| 76 |
-
<filter id="cb-tritanopia"><feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"/></filter>
|
| 77 |
-
<filter id="cb-achromatopsia"><feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"/></filter>
|
| 78 |
-
</defs>
|
| 79 |
-
</svg>
|
| 80 |
-
</div>
|
| 81 |
-
</div>
|
| 82 |
-
<script type="module" is:inline>
|
| 83 |
-
import '/scripts/color-palettes.js';
|
| 84 |
-
const ROOT_ID = "{rootId}";
|
| 85 |
-
(() => {
|
| 86 |
-
const cards = [
|
| 87 |
-
{ key: 'categorical', title: 'Categorical', desc: 'For <strong>nonβnumeric categories</strong>; <strong>visually distinct</strong> colors.' },
|
| 88 |
-
{ key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
|
| 89 |
-
{ key: 'diverging', title: 'Diverging', desc: 'For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.' }
|
| 90 |
-
];
|
| 91 |
-
const getPaletteColors = (key, count) => {
|
| 92 |
-
const total=Number(count)||6;
|
| 93 |
-
if (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function') {
|
| 94 |
-
return window.ColorPalettes.getColors(key,total) || [];
|
| 95 |
-
}
|
| 96 |
-
return [];
|
| 97 |
-
};
|
| 98 |
-
const render = () => {
|
| 99 |
-
const root = document.getElementById(ROOT_ID) || document.querySelector('.palettes');
|
| 100 |
-
if (!root) return;
|
| 101 |
-
const grid=root.querySelector('.palettes__grid'); if (!grid) return;
|
| 102 |
-
const input=document.getElementById('color-count'); const total=input ? Number(input.value)||6 : 6;
|
| 103 |
-
const html = cards.map(c => {
|
| 104 |
-
const colors=getPaletteColors(c.key,total);
|
| 105 |
-
const swatches=colors.map(col=>`<div class=\"sw\" style=\"background:${col}\"></div>`).join('');
|
| 106 |
-
const baseHex = (window.ColorPalettes && typeof window.ColorPalettes.getPrimary==='function') ? window.ColorPalettes.getPrimary() : (colors[0] || '#FF0000');
|
| 107 |
-
const hueDeg = (()=>{ try { const s=baseHex.replace('#',''); const v=s.length===3?s.split('').map(ch=>ch+ch).join(''):s; const r=parseInt(v.slice(0,2),16)/255, g=parseInt(v.slice(2,4),16)/255, b=parseInt(v.slice(4,6),16)/255; const M=Math.max(r,g,b), m=Math.min(r,g,b), d=M-m; if (d===0) return 0; let h=0; if (M===r) h=((g-b)/d)%6; else if (M===g) h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if (h<0) h+=360; return h; } catch { return 0; } })();
|
| 108 |
-
const gradient = c.key==='categorical'
|
| 109 |
-
? (() => {
|
| 110 |
-
const steps = 60; // smooth hue wheel (fixed orientation)
|
| 111 |
-
const wheel = Array.from({ length: steps }, (_, i) => `hsl(${Math.round((i/steps)*360)}, 100%, 50%)`).join(', ');
|
| 112 |
-
return `conic-gradient(${wheel})`;
|
| 113 |
-
})()
|
| 114 |
-
: (colors.length ? `linear-gradient(90deg, ${colors.join(', ')})` : `linear-gradient(90deg, var(--border-color), var(--border-color))`);
|
| 115 |
-
const previewInner = (()=>{
|
| 116 |
-
if (c.key !== 'categorical' || !colors.length) return '';
|
| 117 |
-
const ring = 18; const cx = 24; const cy = 24; const offset = (hueDeg/360) * 2 * Math.PI;
|
| 118 |
-
return colors.map((col,i)=>{
|
| 119 |
-
const angle = offset + (i/colors.length) * 2 * Math.PI;
|
| 120 |
-
const x = cx + ring * Math.cos(angle);
|
| 121 |
-
const y = cy + ring * Math.sin(angle);
|
| 122 |
-
return `<span class=\"dot\" style=\"left:${x-2}px; top:${y-2}px\"></span>`;
|
| 123 |
-
}).join('');
|
| 124 |
-
})();
|
| 125 |
-
const donutHole = (c.key === 'categorical') ? '<span class=\"donut-hole\"></span>' : '';
|
| 126 |
-
return `
|
| 127 |
-
<div class="palette-card" data-colors="${colors.join(',')}">
|
| 128 |
-
<div class="palette-card__content">
|
| 129 |
-
<div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div>
|
| 130 |
-
<div class="palette-card__content__info">
|
| 131 |
-
<div class="palette-card__title">${c.title}</div>
|
| 132 |
-
<div class="palette-card__desc">${c.desc}</div>
|
| 133 |
-
</div>
|
| 134 |
-
</div>
|
| 135 |
-
<div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
|
| 136 |
-
<button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette">
|
| 137 |
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
| 138 |
-
</button>
|
| 139 |
-
</div>`;
|
| 140 |
-
}).join('');
|
| 141 |
-
grid.innerHTML=html;
|
| 142 |
-
};
|
| 143 |
-
const MODE_TO_CLASS = { protanopia:'cb-protanopia', deuteranopia:'cb-deuteranopia', tritanopia:'cb-tritanopia', achromatopsia:'cb-achromatopsia' };
|
| 144 |
-
const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
|
| 145 |
-
const clearCbClasses = () => { const rootEl=document.documentElement; CLEAR_CLASSES.forEach(cls=>rootEl.classList.remove(cls)); };
|
| 146 |
-
const applyCbClass = (mode) => { clearCbClasses(); const cls=MODE_TO_CLASS[mode]; if (cls) document.documentElement.classList.add(cls); };
|
| 147 |
-
const currentCbMode = () => { const rootEl=document.documentElement; for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { if (rootEl.classList.contains(cls)) return mode; } return 'none'; };
|
| 148 |
-
const setupCbSim = () => { const select=document.getElementById('cb-select'); if (!select) return; try { select.value=currentCbMode(); } catch{} select.addEventListener('change', () => applyCbClass(select.value)); };
|
| 149 |
-
const setupCountControl = () => { const input=document.getElementById('color-count'); const out=document.getElementById('color-count-out'); if (!input) return; const clamp=(n,min,max)=>Math.max(min,Math.min(max,n)); const read=()=>clamp(Number(input.value)||6,6,10); const syncOut=()=>{ if (out) out.textContent=String(read()); }; const onChange=()=>{ syncOut(); render(); }; syncOut(); input.addEventListener('input', onChange); document.addEventListener('palettes:updated', () => { syncOut(); render(); }); };
|
| 150 |
-
let copyDelegationSetup=false; const setupCopyDelegation = () => { if (copyDelegationSetup) return; const grid=document.querySelector('.palettes .palettes__grid'); if (!grid) return; grid.addEventListener('click', async (e) => { const btn = e.target.closest ? e.target.closest('.palette-card__copy') : null; if (!btn) return; const card = btn.closest('.palette-card'); if (!card) return; const colors=(card.dataset.colors||'').split(',').filter(Boolean); const json=JSON.stringify(colors,null,2); try { await navigator.clipboard.writeText(json); const old=btn.innerHTML; btn.innerHTML='<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>'; setTimeout(()=> btn.innerHTML=old, 900); } catch { window.prompt('Copy palette', json); } }); copyDelegationSetup=true; };
|
| 151 |
-
const bootstrap = () => {
|
| 152 |
-
setupCbSim();
|
| 153 |
-
setupCountControl();
|
| 154 |
-
setupCopyDelegation();
|
| 155 |
-
// Render immediately
|
| 156 |
-
render();
|
| 157 |
-
// Re-render on palette updates
|
| 158 |
-
document.addEventListener('palettes:updated', render);
|
| 159 |
-
// Force an immediate notify after listeners are attached (ensures initial render)
|
| 160 |
-
try {
|
| 161 |
-
if (window.ColorPalettes && typeof window.ColorPalettes.notify === 'function') window.ColorPalettes.notify();
|
| 162 |
-
else if (window.ColorPalettes && typeof window.ColorPalettes.refresh === 'function') window.ColorPalettes.refresh();
|
| 163 |
-
} catch {}
|
| 164 |
-
};
|
| 165 |
-
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); } else { bootstrap(); }
|
| 166 |
-
})();
|
| 167 |
-
</script>
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/src/components/Sidenote.astro
CHANGED
|
@@ -13,27 +13,27 @@
|
|
| 13 |
const containers = document.querySelectorAll(".sidenote-container");
|
| 14 |
|
| 15 |
containers.forEach((container) => {
|
| 16 |
-
//
|
| 17 |
const previousElement = container.previousElementSibling;
|
| 18 |
|
| 19 |
if (previousElement) {
|
| 20 |
-
//
|
| 21 |
previousElement.style.position = "relative";
|
| 22 |
|
| 23 |
-
//
|
| 24 |
previousElement.appendChild(container);
|
| 25 |
|
| 26 |
-
// Style
|
| 27 |
container.style.position = "absolute";
|
| 28 |
container.style.top = "0";
|
| 29 |
container.style.right = "-292px"; // 260px width + 32px gap
|
| 30 |
container.style.width = "260px";
|
| 31 |
|
| 32 |
-
//
|
| 33 |
container.style.display = "block";
|
| 34 |
container.style.opacity = "0";
|
| 35 |
|
| 36 |
-
// Fade-in
|
| 37 |
setTimeout(() => {
|
| 38 |
container.style.opacity = "1";
|
| 39 |
}, 10);
|
|
@@ -46,8 +46,8 @@
|
|
| 46 |
.sidenote-container {
|
| 47 |
/* CachΓ© par dΓ©faut, sera affichΓ© par JS */
|
| 48 |
display: none;
|
| 49 |
-
margin:
|
| 50 |
-
/* Transition
|
| 51 |
transition: opacity 0.3s ease-in-out;
|
| 52 |
}
|
| 53 |
|
|
|
|
| 13 |
const containers = document.querySelectorAll(".sidenote-container");
|
| 14 |
|
| 15 |
containers.forEach((container) => {
|
| 16 |
+
// Find the previous element (sibling just before)
|
| 17 |
const previousElement = container.previousElementSibling;
|
| 18 |
|
| 19 |
if (previousElement) {
|
| 20 |
+
// Make the sidenote container relative to the previous element
|
| 21 |
previousElement.style.position = "relative";
|
| 22 |
|
| 23 |
+
// Move the sidenote container as child of the previous element
|
| 24 |
previousElement.appendChild(container);
|
| 25 |
|
| 26 |
+
// Style the container so it positions correctly
|
| 27 |
container.style.position = "absolute";
|
| 28 |
container.style.top = "0";
|
| 29 |
container.style.right = "-292px"; // 260px width + 32px gap
|
| 30 |
container.style.width = "260px";
|
| 31 |
|
| 32 |
+
// Display the container with a fade-in
|
| 33 |
container.style.display = "block";
|
| 34 |
container.style.opacity = "0";
|
| 35 |
|
| 36 |
+
// Fade-in with transition
|
| 37 |
setTimeout(() => {
|
| 38 |
container.style.opacity = "1";
|
| 39 |
}, 10);
|
|
|
|
| 46 |
.sidenote-container {
|
| 47 |
/* CachΓ© par dΓ©faut, sera affichΓ© par JS */
|
| 48 |
display: none;
|
| 49 |
+
margin: 0 ;
|
| 50 |
+
/* Transition for fade-in */
|
| 51 |
transition: opacity 0.3s ease-in-out;
|
| 52 |
}
|
| 53 |
|
app/src/components/demo/ColorPicker.astro
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
<div class="color-picker" style="width:100%; margin: 10px 0;">
|
| 6 |
+
<style>
|
| 7 |
+
.color-picker .picker__stack {
|
| 8 |
+
display: flex;
|
| 9 |
+
flex-direction: column;
|
| 10 |
+
gap: 12px;
|
| 11 |
+
}
|
| 12 |
+
.color-picker .current-card {
|
| 13 |
+
display: grid;
|
| 14 |
+
grid-template-columns: 40% 60%;
|
| 15 |
+
align-items: center;
|
| 16 |
+
gap: 14px;
|
| 17 |
+
padding: 14px 32px 14px 16px;
|
| 18 |
+
border: 1px solid var(--border-color);
|
| 19 |
+
background: var(--surface-bg);
|
| 20 |
+
border-radius: 12px;
|
| 21 |
+
}
|
| 22 |
+
.color-picker .current-left {
|
| 23 |
+
display: flex;
|
| 24 |
+
flex-direction: column;
|
| 25 |
+
gap: 8px;
|
| 26 |
+
min-width: 0;
|
| 27 |
+
}
|
| 28 |
+
.color-picker .current-right {
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
gap: 8px;
|
| 32 |
+
padding-left: 14px;
|
| 33 |
+
border-left: 1px solid var(--border-color);
|
| 34 |
+
}
|
| 35 |
+
.color-picker .current-main {
|
| 36 |
+
display: flex;
|
| 37 |
+
align-items: center;
|
| 38 |
+
gap: 12px;
|
| 39 |
+
min-width: 0;
|
| 40 |
+
}
|
| 41 |
+
.color-picker .current-swatch {
|
| 42 |
+
width: 64px;
|
| 43 |
+
height: 64px;
|
| 44 |
+
border-radius: 8px;
|
| 45 |
+
border: 1px solid var(--border-color);
|
| 46 |
+
}
|
| 47 |
+
.color-picker .current-text {
|
| 48 |
+
display: flex;
|
| 49 |
+
flex-direction: column;
|
| 50 |
+
line-height: 1.2;
|
| 51 |
+
min-width: 0;
|
| 52 |
+
}
|
| 53 |
+
.color-picker .current-name {
|
| 54 |
+
font-size: 14px;
|
| 55 |
+
font-weight: 800;
|
| 56 |
+
color: var(--text-color);
|
| 57 |
+
white-space: nowrap;
|
| 58 |
+
overflow: hidden;
|
| 59 |
+
text-overflow: ellipsis;
|
| 60 |
+
max-width: clamp(140px, 28vw, 260px);
|
| 61 |
+
}
|
| 62 |
+
.color-picker .current-hex,
|
| 63 |
+
.color-picker .current-extra {
|
| 64 |
+
font-size: 11px;
|
| 65 |
+
color: var(--muted-color);
|
| 66 |
+
letter-spacing: 0.02em;
|
| 67 |
+
white-space: nowrap;
|
| 68 |
+
overflow: hidden;
|
| 69 |
+
text-overflow: ellipsis;
|
| 70 |
+
max-width: clamp(140px, 28vw, 260px);
|
| 71 |
+
}
|
| 72 |
+
:global(.color-label) {
|
| 73 |
+
color: var(--muted-color);
|
| 74 |
+
opacity: 0.6 !important;
|
| 75 |
+
}
|
| 76 |
+
:global(.color-value) {
|
| 77 |
+
color: var(--text-color);
|
| 78 |
+
font-weight: 500;
|
| 79 |
+
}
|
| 80 |
+
.color-picker .picker__label {
|
| 81 |
+
font-weight: 700;
|
| 82 |
+
font-size: 12px;
|
| 83 |
+
color: var(--muted-color);
|
| 84 |
+
text-transform: uppercase;
|
| 85 |
+
letter-spacing: 0.02em;
|
| 86 |
+
}
|
| 87 |
+
.color-picker .hue-slider {
|
| 88 |
+
position: relative;
|
| 89 |
+
height: 16px;
|
| 90 |
+
border-radius: 10px;
|
| 91 |
+
border: 1px solid var(--border-color);
|
| 92 |
+
background: linear-gradient(
|
| 93 |
+
to right,
|
| 94 |
+
#f00 0%,
|
| 95 |
+
#ff0 17%,
|
| 96 |
+
#0f0 33%,
|
| 97 |
+
#0ff 50%,
|
| 98 |
+
#00f 67%,
|
| 99 |
+
#f0f 83%,
|
| 100 |
+
#f00 100%
|
| 101 |
+
);
|
| 102 |
+
cursor: ew-resize;
|
| 103 |
+
touch-action: none;
|
| 104 |
+
flex: 1 1 auto;
|
| 105 |
+
min-width: 200px;
|
| 106 |
+
}
|
| 107 |
+
.color-picker .hue-knob {
|
| 108 |
+
position: absolute;
|
| 109 |
+
top: 50%;
|
| 110 |
+
left: 93.6%;
|
| 111 |
+
width: 14px;
|
| 112 |
+
height: 14px;
|
| 113 |
+
border-radius: 50%;
|
| 114 |
+
border: 2px solid #fff;
|
| 115 |
+
transform: translate(-50%, -50%);
|
| 116 |
+
background: var(--surface-bg);
|
| 117 |
+
z-index: 2;
|
| 118 |
+
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
|
| 119 |
+
}
|
| 120 |
+
.color-picker .hue-slider:focus-visible {
|
| 121 |
+
outline: 2px solid var(--primary-color);
|
| 122 |
+
outline-offset: 2px;
|
| 123 |
+
}
|
| 124 |
+
.color-picker .hue-value {
|
| 125 |
+
font-variant-numeric: tabular-nums;
|
| 126 |
+
color: var(--muted-color);
|
| 127 |
+
font-size: 12px;
|
| 128 |
+
}
|
| 129 |
+
@media (max-width: 720px) {
|
| 130 |
+
.color-picker .current-card {
|
| 131 |
+
grid-template-columns: 1fr;
|
| 132 |
+
}
|
| 133 |
+
.color-picker .current-right {
|
| 134 |
+
padding-left: 0;
|
| 135 |
+
border-left: none;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
</style>
|
| 139 |
+
<div class="picker__stack">
|
| 140 |
+
<div class="current-card">
|
| 141 |
+
<div class="current-left">
|
| 142 |
+
<div class="current-main">
|
| 143 |
+
<div
|
| 144 |
+
class="current-swatch"
|
| 145 |
+
aria-label="Current color"
|
| 146 |
+
title="Current color"
|
| 147 |
+
>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="current-text">
|
| 150 |
+
<div class="current-name">β</div>
|
| 151 |
+
<div class="current-extra current-lch">β</div>
|
| 152 |
+
<div class="current-extra current-rgb">β</div>
|
| 153 |
+
<div class="current-hex">β</div>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="current-right">
|
| 158 |
+
<div class="picker__label">Hue</div>
|
| 159 |
+
<div
|
| 160 |
+
class="hue-slider"
|
| 161 |
+
role="slider"
|
| 162 |
+
aria-label="Hue"
|
| 163 |
+
aria-valuemin="0"
|
| 164 |
+
aria-valuemax="360"
|
| 165 |
+
aria-valuenow="214"
|
| 166 |
+
tabindex="0"
|
| 167 |
+
>
|
| 168 |
+
<div class="hue-knob"></div>
|
| 169 |
+
</div>
|
| 170 |
+
<div class="hue-value">214Β°</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
<script>
|
| 176 |
+
(() => {
|
| 177 |
+
const COLOR_NAMES = [
|
| 178 |
+
{ name: "Candy Apple Red", hex: "#ff0800" },
|
| 179 |
+
{ name: "Boiling Magma", hex: "#ff3300" },
|
| 180 |
+
{ name: "Aerospace Orange", hex: "#ff4f00" },
|
| 181 |
+
{ name: "Burtuqali Orange", hex: "#ff6700" },
|
| 182 |
+
{ name: "American Orange", hex: "#ff8b00" },
|
| 183 |
+
{ name: "Cheese", hex: "#ffa600" },
|
| 184 |
+
{ name: "Amber", hex: "#ffbf00" },
|
| 185 |
+
{ name: "Demonic Yellow", hex: "#ffe700" },
|
| 186 |
+
{ name: "Bat-Signal", hex: "#feff00" },
|
| 187 |
+
{ name: "Bitter Lime", hex: "#cfff00" },
|
| 188 |
+
{ name: "Electric Lime", hex: "#ccff00" },
|
| 189 |
+
{ name: "Bright Yellow Green", hex: "#9dff00" },
|
| 190 |
+
{ name: "Lasting Lime", hex: "#88ff00" },
|
| 191 |
+
{ name: "Bright Green", hex: "#66ff00" },
|
| 192 |
+
{ name: "Chlorophyll Green", hex: "#4aff00" },
|
| 193 |
+
{ name: "Green Screen", hex: "#22ff00" },
|
| 194 |
+
{ name: "Electric Pickle", hex: "#00ff04" },
|
| 195 |
+
{ name: "Acid", hex: "#00ff22" },
|
| 196 |
+
{ name: "Lucent Lime", hex: "#00ff33" },
|
| 197 |
+
{ name: "Cathode Green", hex: "#00ff55" },
|
| 198 |
+
{ name: "Booger Buster", hex: "#00ff77" },
|
| 199 |
+
{ name: "Green Gas", hex: "#00ff99" },
|
| 200 |
+
{ name: "Enthusiasm", hex: "#00ffaa" },
|
| 201 |
+
{ name: "Ice Ice Baby", hex: "#00ffdd" },
|
| 202 |
+
{ name: "Master Sword Blue", hex: "#00ffee" },
|
| 203 |
+
{ name: "Agressive Aqua", hex: "#00fbff" },
|
| 204 |
+
{ name: "Vivid Sky Blue", hex: "#00ccff" },
|
| 205 |
+
{ name: "Capri", hex: "#00bfff" },
|
| 206 |
+
{ name: "Sky of Magritte", hex: "#0099ff" },
|
| 207 |
+
{ name: "Azure", hex: "#007fff" },
|
| 208 |
+
{ name: "Blue Ribbon", hex: "#0066ff" },
|
| 209 |
+
{ name: "Blinking Blue", hex: "#0033ff" },
|
| 210 |
+
{ name: "Icelandic Water", hex: "#0011ff" },
|
| 211 |
+
{ name: "Blue", hex: "#0000ff" },
|
| 212 |
+
{ name: "Blue Pencil", hex: "#2200ff" },
|
| 213 |
+
{ name: "Electric Ultramarine", hex: "#3f00ff" },
|
| 214 |
+
{ name: "Aladdin's Feather", hex: "#5500ff" },
|
| 215 |
+
{ name: "Purple Climax", hex: "#8800ff" },
|
| 216 |
+
{ name: "Amethyst Ganzstar", hex: "#8f00ff" },
|
| 217 |
+
{ name: "Electric Purple", hex: "#bf00ff" },
|
| 218 |
+
{ name: "Phlox", hex: "#df00ff" },
|
| 219 |
+
{ name: "Brusque Pink", hex: "#ee00ff" },
|
| 220 |
+
{ name: "Bright Magenta", hex: "#ff08e8" },
|
| 221 |
+
{ name: "Big bang Pink", hex: "#ff00bb" },
|
| 222 |
+
{ name: "Mean Girls Lipstick", hex: "#ff00ae" },
|
| 223 |
+
{ name: "Pink", hex: "#ff0099" },
|
| 224 |
+
{ name: "Hot Flamingoes", hex: "#ff005d" },
|
| 225 |
+
{ name: "Blazing Dragonfruit", hex: "#ff0054" },
|
| 226 |
+
{ name: "Carmine Red", hex: "#ff0038" },
|
| 227 |
+
{ name: "Bright Red", hex: "#ff000d" },
|
| 228 |
+
];
|
| 229 |
+
if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
|
| 230 |
+
|
| 231 |
+
if (!window.__colorPickerBus) {
|
| 232 |
+
window.__colorPickerBus = (() => {
|
| 233 |
+
let hue = 214;
|
| 234 |
+
let adjusting = false;
|
| 235 |
+
const listeners = new Set();
|
| 236 |
+
return {
|
| 237 |
+
get: () => ({ hue, adjusting }),
|
| 238 |
+
publish: (sourceId, nextHue, isAdj) => {
|
| 239 |
+
hue = ((nextHue % 360) + 360) % 360;
|
| 240 |
+
adjusting = !!isAdj;
|
| 241 |
+
listeners.forEach((fn) => {
|
| 242 |
+
try {
|
| 243 |
+
fn({ sourceId, hue, adjusting });
|
| 244 |
+
} catch {}
|
| 245 |
+
});
|
| 246 |
+
},
|
| 247 |
+
subscribe: (fn) => {
|
| 248 |
+
listeners.add(fn);
|
| 249 |
+
return () => listeners.delete(fn);
|
| 250 |
+
},
|
| 251 |
+
};
|
| 252 |
+
})();
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
const bootstrap = () => {
|
| 256 |
+
const root = document.querySelector(".color-picker");
|
| 257 |
+
if (!root || root.dataset.mounted) return;
|
| 258 |
+
root.dataset.mounted = "true";
|
| 259 |
+
const slider = root.querySelector(".hue-slider");
|
| 260 |
+
const knob = root.querySelector(".hue-knob");
|
| 261 |
+
const hueValue = root.querySelector(".hue-value");
|
| 262 |
+
const currentSwatch = root.querySelector(".current-swatch");
|
| 263 |
+
const currentName = root.querySelector(".current-name");
|
| 264 |
+
const currentHex = root.querySelector(".current-hex");
|
| 265 |
+
const currentLch = root.querySelector(".current-lch");
|
| 266 |
+
const currentRgb = root.querySelector(".current-rgb");
|
| 267 |
+
const bus = window.__colorPickerBus;
|
| 268 |
+
const instanceId = Math.random().toString(36).slice(2);
|
| 269 |
+
const getKnobRadius = () => {
|
| 270 |
+
try {
|
| 271 |
+
const w = knob ? knob.getBoundingClientRect().width : 0;
|
| 272 |
+
return w ? w / 2 : 8;
|
| 273 |
+
} catch {
|
| 274 |
+
return 8;
|
| 275 |
+
}
|
| 276 |
+
};
|
| 277 |
+
const hexToHsl = (H) => {
|
| 278 |
+
const s = H.replace("#", "");
|
| 279 |
+
const v =
|
| 280 |
+
s.length === 3
|
| 281 |
+
? s
|
| 282 |
+
.split("")
|
| 283 |
+
.map((ch) => ch + ch)
|
| 284 |
+
.join("")
|
| 285 |
+
: s;
|
| 286 |
+
const bigint = parseInt(v, 16);
|
| 287 |
+
let r = (bigint >> 16) & 255,
|
| 288 |
+
g = (bigint >> 8) & 255,
|
| 289 |
+
b = bigint & 255;
|
| 290 |
+
r /= 255;
|
| 291 |
+
g /= 255;
|
| 292 |
+
b /= 255;
|
| 293 |
+
const max = Math.max(r, g, b),
|
| 294 |
+
min = Math.min(r, g, b);
|
| 295 |
+
let h = 0,
|
| 296 |
+
s2 = 0,
|
| 297 |
+
l = (max + min) / 2;
|
| 298 |
+
if (max !== min) {
|
| 299 |
+
const d = max - min;
|
| 300 |
+
s2 = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
| 301 |
+
switch (max) {
|
| 302 |
+
case r:
|
| 303 |
+
h = (g - b) / d + (g < b ? 6 : 0);
|
| 304 |
+
break;
|
| 305 |
+
case g:
|
| 306 |
+
h = (b - r) / d + 2;
|
| 307 |
+
break;
|
| 308 |
+
default:
|
| 309 |
+
h = (r - g) / d + 4;
|
| 310 |
+
}
|
| 311 |
+
h /= 6;
|
| 312 |
+
}
|
| 313 |
+
return {
|
| 314 |
+
h: Math.round(h * 360),
|
| 315 |
+
s: Math.round(s2 * 100),
|
| 316 |
+
l: Math.round(l * 100),
|
| 317 |
+
};
|
| 318 |
+
};
|
| 319 |
+
const hslToHex = (h, s, l) => {
|
| 320 |
+
s /= 100;
|
| 321 |
+
l /= 100;
|
| 322 |
+
const k = (n) => (n + h / 30) % 12;
|
| 323 |
+
const a = s * Math.min(l, 1 - l);
|
| 324 |
+
const f = (n) =>
|
| 325 |
+
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
| 326 |
+
const toHex = (x) =>
|
| 327 |
+
Math.round(255 * x)
|
| 328 |
+
.toString(16)
|
| 329 |
+
.padStart(2, "0");
|
| 330 |
+
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`.toUpperCase();
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
// OKLCH conversion functions
|
| 334 |
+
const srgbToLinear = (u) =>
|
| 335 |
+
u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
|
| 336 |
+
const linearToSrgb = (u) =>
|
| 337 |
+
u <= 0.0031308
|
| 338 |
+
? 12.92 * u
|
| 339 |
+
: 1.055 * Math.pow(Math.max(0, u), 1 / 2.4) - 0.055;
|
| 340 |
+
const rgbToOklab = (r, g, b) => {
|
| 341 |
+
const rl = srgbToLinear(r),
|
| 342 |
+
gl = srgbToLinear(g),
|
| 343 |
+
bl = srgbToLinear(b);
|
| 344 |
+
const l = Math.cbrt(
|
| 345 |
+
0.4122214708 * rl + 0.5363325363 * gl + 0.0514459929 * bl,
|
| 346 |
+
);
|
| 347 |
+
const m = Math.cbrt(
|
| 348 |
+
0.2119034982 * rl + 0.6806995451 * gl + 0.1073969566 * bl,
|
| 349 |
+
);
|
| 350 |
+
const s = Math.cbrt(
|
| 351 |
+
0.0883024619 * rl + 0.2817188366 * gl + 0.6299787005 * bl,
|
| 352 |
+
);
|
| 353 |
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
| 354 |
+
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
| 355 |
+
const b2 = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
| 356 |
+
return { L, a, b: b2 };
|
| 357 |
+
};
|
| 358 |
+
const oklabToOklch = (L, a, b) => {
|
| 359 |
+
const C = Math.sqrt(a * a + b * b);
|
| 360 |
+
let h = (Math.atan2(b, a) * 180) / Math.PI;
|
| 361 |
+
if (h < 0) h += 360;
|
| 362 |
+
return { L, C, h };
|
| 363 |
+
};
|
| 364 |
+
const hexToOklch = (hex) => {
|
| 365 |
+
const s = hex.replace("#", "");
|
| 366 |
+
const v =
|
| 367 |
+
s.length === 3
|
| 368 |
+
? s
|
| 369 |
+
.split("")
|
| 370 |
+
.map((ch) => ch + ch)
|
| 371 |
+
.join("")
|
| 372 |
+
: s;
|
| 373 |
+
const r = parseInt(v.slice(0, 2), 16) / 255;
|
| 374 |
+
const g = parseInt(v.slice(2, 4), 16) / 255;
|
| 375 |
+
const b = parseInt(v.slice(4, 6), 16) / 255;
|
| 376 |
+
const { L, a, b: bb } = rgbToOklab(r, g, b);
|
| 377 |
+
return oklabToOklch(L, a, bb);
|
| 378 |
+
};
|
| 379 |
+
// Precompute hues for the provided color-name list
|
| 380 |
+
const NAME_HUES = COLOR_NAMES.map((c) => {
|
| 381 |
+
const hh = hexToHsl(c.hex).h || 0;
|
| 382 |
+
return { name: c.name, hue: hh };
|
| 383 |
+
});
|
| 384 |
+
// Pick closest name by circular hue distance; fallback to coarse labels
|
| 385 |
+
const getName = (hex) => {
|
| 386 |
+
const h = hexToHsl(hex).h || 0;
|
| 387 |
+
let bestName = "β";
|
| 388 |
+
let best = 361;
|
| 389 |
+
for (let i = 0; i < NAME_HUES.length; i++) {
|
| 390 |
+
const hh = NAME_HUES[i].hue;
|
| 391 |
+
const d = Math.abs(hh - h);
|
| 392 |
+
const dist = Math.min(d, 360 - d);
|
| 393 |
+
if (dist < best) {
|
| 394 |
+
best = dist;
|
| 395 |
+
bestName = NAME_HUES[i].name;
|
| 396 |
+
}
|
| 397 |
+
}
|
| 398 |
+
if (bestName !== "β") return bestName;
|
| 399 |
+
const labels = [
|
| 400 |
+
"Red",
|
| 401 |
+
"Orange",
|
| 402 |
+
"Yellow",
|
| 403 |
+
"Lime",
|
| 404 |
+
"Green",
|
| 405 |
+
"Cyan",
|
| 406 |
+
"Blue",
|
| 407 |
+
"Indigo",
|
| 408 |
+
"Violet",
|
| 409 |
+
"Magenta",
|
| 410 |
+
];
|
| 411 |
+
const idx = Math.round(((h % 360) / 360) * (labels.length - 1));
|
| 412 |
+
return labels[idx];
|
| 413 |
+
};
|
| 414 |
+
// OKLCH to RGB conversion functions
|
| 415 |
+
const oklchToOklab = (L, C, hDeg) => {
|
| 416 |
+
const h = (hDeg * Math.PI) / 180;
|
| 417 |
+
return { L, a: C * Math.cos(h), b: C * Math.sin(h) };
|
| 418 |
+
};
|
| 419 |
+
const oklabToRgb = (L, a, b) => {
|
| 420 |
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
| 421 |
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
| 422 |
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
| 423 |
+
const l = l_ * l_ * l_;
|
| 424 |
+
const m = m_ * m_ * m_;
|
| 425 |
+
const s = s_ * s_ * s_;
|
| 426 |
+
const r = linearToSrgb(
|
| 427 |
+
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
| 428 |
+
);
|
| 429 |
+
const g = linearToSrgb(
|
| 430 |
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
| 431 |
+
);
|
| 432 |
+
const b3 = linearToSrgb(
|
| 433 |
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
| 434 |
+
);
|
| 435 |
+
return { r, g, b: b3 };
|
| 436 |
+
};
|
| 437 |
+
const oklchToHex = (L, C, h) => {
|
| 438 |
+
const { a, b } = oklchToOklab(L, C, h);
|
| 439 |
+
const rgb = oklabToRgb(L, a, b);
|
| 440 |
+
const toHex = (x) =>
|
| 441 |
+
Math.round(255 * x)
|
| 442 |
+
.toString(16)
|
| 443 |
+
.padStart(2, "0");
|
| 444 |
+
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase();
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
const updateUI = (h, adjusting) => {
|
| 448 |
+
const rect = slider.getBoundingClientRect();
|
| 449 |
+
const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
|
| 450 |
+
const t = Math.max(0, Math.min(1, h / 360));
|
| 451 |
+
const leftPx = r + t * Math.max(0, rect.width - 2 * r);
|
| 452 |
+
if (knob) knob.style.left = (leftPx / rect.width) * 100 + "%";
|
| 453 |
+
if (hueValue) hueValue.innerHTML = `<strong>${Math.round(h)}Β°</strong>`;
|
| 454 |
+
if (slider) slider.setAttribute("aria-valuenow", String(Math.round(h)));
|
| 455 |
+
|
| 456 |
+
// Generate OKLCH color directly (similar to CSS variables)
|
| 457 |
+
const L = 0.75; // 75% lightness
|
| 458 |
+
const C = 0.12; // 12% chroma
|
| 459 |
+
const oklchColor = `oklch(${L} ${C} ${h})`;
|
| 460 |
+
const baseHex = oklchToHex(L, C, h);
|
| 461 |
+
|
| 462 |
+
if (currentSwatch) currentSwatch.style.background = baseHex;
|
| 463 |
+
if (currentName) currentName.textContent = getName(baseHex);
|
| 464 |
+
if (currentHex)
|
| 465 |
+
currentHex.innerHTML = `<span class="color-label">#</span><span class="color-value">${baseHex.replace("#", "")}</span>`;
|
| 466 |
+
|
| 467 |
+
// Display OKLCH values
|
| 468 |
+
if (currentLch)
|
| 469 |
+
currentLch.innerHTML = `<span class="color-label">OKLCH</span> <span class="color-value">${(L * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value">${(C * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value"><strong>${Math.round(h)}</strong></span><span class="color-label">Β°</span>`;
|
| 470 |
+
|
| 471 |
+
if (currentRgb) {
|
| 472 |
+
const hex = baseHex.replace("#", "");
|
| 473 |
+
const R = parseInt(hex.slice(0, 2), 16),
|
| 474 |
+
G = parseInt(hex.slice(2, 4), 16),
|
| 475 |
+
B = parseInt(hex.slice(4, 6), 16);
|
| 476 |
+
currentRgb.innerHTML = `<span class="color-label">RGB</span> <span class="color-value">${R}</span><span class="color-label">,</span> <span class="color-value">${G}</span><span class="color-label">,</span> <span class="color-value">${B}</span>`;
|
| 477 |
+
}
|
| 478 |
+
|
| 479 |
+
// Apply OKLCH color to CSS variables
|
| 480 |
+
const hoverOklch = `oklch(${L} ${C} ${h})`;
|
| 481 |
+
const rootEl = document.documentElement;
|
| 482 |
+
rootEl.style.setProperty("--primary-color", oklchColor);
|
| 483 |
+
rootEl.style.setProperty("--primary-color-hover", hoverOklch);
|
| 484 |
+
};
|
| 485 |
+
|
| 486 |
+
// Update UI position only, without modifying CSS colors
|
| 487 |
+
const updateUIPositionOnly = (h) => {
|
| 488 |
+
const rect = slider.getBoundingClientRect();
|
| 489 |
+
const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
|
| 490 |
+
const t = Math.max(0, Math.min(1, h / 360));
|
| 491 |
+
const leftPx = r + t * Math.max(0, rect.width - 2 * r);
|
| 492 |
+
if (knob) knob.style.left = (leftPx / rect.width) * 100 + "%";
|
| 493 |
+
if (hueValue) hueValue.innerHTML = `<strong>${Math.round(h)}Β°</strong>`;
|
| 494 |
+
if (slider) slider.setAttribute("aria-valuenow", String(Math.round(h)));
|
| 495 |
+
|
| 496 |
+
// Generate OKLCH color for display only
|
| 497 |
+
const L = 0.75; // 75% lightness
|
| 498 |
+
const C = 0.12; // 12% chroma
|
| 499 |
+
const baseHex = oklchToHex(L, C, h);
|
| 500 |
+
|
| 501 |
+
if (currentSwatch) currentSwatch.style.background = baseHex;
|
| 502 |
+
if (currentName) currentName.textContent = getName(baseHex);
|
| 503 |
+
if (currentHex)
|
| 504 |
+
currentHex.innerHTML = `<span class="color-label">#</span><span class="color-value">${baseHex.replace("#", "")}</span>`;
|
| 505 |
+
|
| 506 |
+
// Display OKLCH values
|
| 507 |
+
if (currentLch)
|
| 508 |
+
currentLch.innerHTML = `<span class="color-label">OKLCH</span> <span class="color-value">${(L * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value">${(C * 100).toFixed(1)}</span><span class="color-label">%</span>, <span class="color-value"><strong>${Math.round(h)}</strong></span><span class="color-label">Β°</span>`;
|
| 509 |
+
|
| 510 |
+
if (currentRgb) {
|
| 511 |
+
const hex = baseHex.replace("#", "");
|
| 512 |
+
const R = parseInt(hex.slice(0, 2), 16),
|
| 513 |
+
G = parseInt(hex.slice(2, 4), 16),
|
| 514 |
+
B = parseInt(hex.slice(4, 6), 16);
|
| 515 |
+
currentRgb.innerHTML = `<span class="color-label">RGB</span> <span class="color-value">${R}</span><span class="color-label">,</span> <span class="color-value">${G}</span><span class="color-label">,</span> <span class="color-value">${B}</span>`;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
// DO NOT modify CSS variables - just show the position
|
| 519 |
+
};
|
| 520 |
+
const getHueFromEvent = (ev) => {
|
| 521 |
+
const rect = slider.getBoundingClientRect();
|
| 522 |
+
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
|
| 523 |
+
const x = clientX - rect.left;
|
| 524 |
+
const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
|
| 525 |
+
const effX = Math.max(r, Math.min(rect.width - r, x));
|
| 526 |
+
const denom = Math.max(1, rect.width - 2 * r);
|
| 527 |
+
const t = (effX - r) / denom;
|
| 528 |
+
return t * 360;
|
| 529 |
+
};
|
| 530 |
+
const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => {
|
| 531 |
+
if (sourceId === instanceId) return;
|
| 532 |
+
updateUI(hue, adjusting);
|
| 533 |
+
});
|
| 534 |
+
try {
|
| 535 |
+
let initH = 214;
|
| 536 |
+
|
| 537 |
+
// Try to get OKLCH directly from ColorPalettes first
|
| 538 |
+
if (
|
| 539 |
+
window.ColorPalettes &&
|
| 540 |
+
typeof window.ColorPalettes.getPrimaryOKLCH === "function"
|
| 541 |
+
) {
|
| 542 |
+
const oklch = window.ColorPalettes.getPrimaryOKLCH();
|
| 543 |
+
if (oklch && oklch.h !== undefined) {
|
| 544 |
+
initH = oklch.h; // Use exact OKLCH hue, no conversion!
|
| 545 |
+
}
|
| 546 |
+
} else {
|
| 547 |
+
// Fallback: try to parse OKLCH directly from CSS
|
| 548 |
+
const cssPrimary = getComputedStyle(document.documentElement)
|
| 549 |
+
.getPropertyValue("--primary-color")
|
| 550 |
+
.trim();
|
| 551 |
+
if (cssPrimary && cssPrimary.includes("oklch")) {
|
| 552 |
+
const oklchMatch = cssPrimary.match(/oklch\(([^)]+)\)/);
|
| 553 |
+
if (oklchMatch) {
|
| 554 |
+
const values = oklchMatch[1]
|
| 555 |
+
.split(/\s+/)
|
| 556 |
+
.map((v) => parseFloat(v.trim()));
|
| 557 |
+
if (values.length >= 3) {
|
| 558 |
+
initH = values[2]; // Direct OKLCH hue, no conversion!
|
| 559 |
+
}
|
| 560 |
+
}
|
| 561 |
+
} else if (cssPrimary) {
|
| 562 |
+
// Only convert if it's not OKLCH
|
| 563 |
+
initH = hexToHsl(cssPrimary).h || initH;
|
| 564 |
+
}
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
const { hue: sharedHue } = bus.get();
|
| 568 |
+
// Only update UI position, don't modify CSS colors on initialization
|
| 569 |
+
updateUIPositionOnly(initH ?? sharedHue);
|
| 570 |
+
bus.publish(instanceId, initH ?? sharedHue, false);
|
| 571 |
+
} catch {
|
| 572 |
+
const { hue: sharedHue } = bus.get();
|
| 573 |
+
updateUIPositionOnly(sharedHue);
|
| 574 |
+
}
|
| 575 |
+
const onDown = (ev) => {
|
| 576 |
+
ev.preventDefault();
|
| 577 |
+
const h = getHueFromEvent(ev);
|
| 578 |
+
updateUI(h, true);
|
| 579 |
+
bus.publish(instanceId, h, true);
|
| 580 |
+
const move = (e) => {
|
| 581 |
+
e.preventDefault && e.preventDefault();
|
| 582 |
+
const hh = getHueFromEvent(e);
|
| 583 |
+
updateUI(hh, true);
|
| 584 |
+
bus.publish(instanceId, hh, true);
|
| 585 |
+
};
|
| 586 |
+
const up = () => {
|
| 587 |
+
bus.publish(instanceId, getHueFromEvent(ev), false);
|
| 588 |
+
window.removeEventListener("mousemove", move);
|
| 589 |
+
window.removeEventListener("touchmove", move);
|
| 590 |
+
window.removeEventListener("mouseup", up);
|
| 591 |
+
window.removeEventListener("touchend", up);
|
| 592 |
+
};
|
| 593 |
+
window.addEventListener("mousemove", move, { passive: false });
|
| 594 |
+
window.addEventListener("touchmove", move, { passive: false });
|
| 595 |
+
window.addEventListener("mouseup", up, { once: true });
|
| 596 |
+
window.addEventListener("touchend", up, { once: true });
|
| 597 |
+
};
|
| 598 |
+
if (slider) {
|
| 599 |
+
slider.addEventListener("mousedown", onDown);
|
| 600 |
+
slider.addEventListener("touchstart", onDown, { passive: false });
|
| 601 |
+
slider.addEventListener("keydown", (e) => {
|
| 602 |
+
const step = e.shiftKey ? 10 : 2;
|
| 603 |
+
if (e.key === "ArrowLeft") {
|
| 604 |
+
e.preventDefault();
|
| 605 |
+
const { hue } = bus.get();
|
| 606 |
+
const h = hue - step;
|
| 607 |
+
updateUI(h, true);
|
| 608 |
+
bus.publish(instanceId, h, true);
|
| 609 |
+
bus.publish(instanceId, h, false);
|
| 610 |
+
}
|
| 611 |
+
if (e.key === "ArrowRight") {
|
| 612 |
+
e.preventDefault();
|
| 613 |
+
const { hue } = bus.get();
|
| 614 |
+
const h = hue + step;
|
| 615 |
+
updateUI(h, true);
|
| 616 |
+
bus.publish(instanceId, h, true);
|
| 617 |
+
bus.publish(instanceId, h, false);
|
| 618 |
+
}
|
| 619 |
+
});
|
| 620 |
+
}
|
| 621 |
+
const ro = new MutationObserver(() => {
|
| 622 |
+
if (!document.body.contains(root)) {
|
| 623 |
+
unsubscribe && unsubscribe();
|
| 624 |
+
ro.disconnect();
|
| 625 |
+
}
|
| 626 |
+
});
|
| 627 |
+
ro.observe(document.body, { childList: true, subtree: true });
|
| 628 |
+
};
|
| 629 |
+
if (document.readyState === "loading")
|
| 630 |
+
document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
|
| 631 |
+
else bootstrap();
|
| 632 |
+
})();
|
| 633 |
+
</script>
|
app/src/components/demo/Palettes.astro
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
const rootId = `palettes-${Math.random().toString(36).slice(2)}`;
|
| 3 |
+
---
|
| 4 |
+
|
| 5 |
+
<div class="palettes" id={rootId} style="width:100%; margin: 10px 0;">
|
| 6 |
+
<style is:global>
|
| 7 |
+
.palettes {
|
| 8 |
+
box-sizing: border-box;
|
| 9 |
+
}
|
| 10 |
+
.palettes .palettes__grid {
|
| 11 |
+
display: grid;
|
| 12 |
+
grid-template-columns: 1fr;
|
| 13 |
+
gap: 12px;
|
| 14 |
+
max-width: 100%;
|
| 15 |
+
}
|
| 16 |
+
.palettes .palette-card {
|
| 17 |
+
position: relative;
|
| 18 |
+
display: grid;
|
| 19 |
+
grid-template-columns: 1fr minmax(0, 220px);
|
| 20 |
+
align-items: stretch;
|
| 21 |
+
gap: 12px;
|
| 22 |
+
border: 1px solid var(--border-color);
|
| 23 |
+
border-radius: 10px;
|
| 24 |
+
background: var(--surface-bg);
|
| 25 |
+
padding: 12px;
|
| 26 |
+
transition:
|
| 27 |
+
box-shadow 0.18s ease,
|
| 28 |
+
transform 0.18s ease,
|
| 29 |
+
border-color 0.18s ease;
|
| 30 |
+
min-height: 60px;
|
| 31 |
+
}
|
| 32 |
+
.palettes .palette-card__preview {
|
| 33 |
+
width: 48px;
|
| 34 |
+
height: 48px;
|
| 35 |
+
border-radius: 999px;
|
| 36 |
+
flex: 0 0 auto;
|
| 37 |
+
background-size: cover;
|
| 38 |
+
background-position: center;
|
| 39 |
+
}
|
| 40 |
+
.palettes .palette-card__copy {
|
| 41 |
+
position: absolute;
|
| 42 |
+
top: 50%;
|
| 43 |
+
left: 100%;
|
| 44 |
+
transform: translateY(-50%);
|
| 45 |
+
z-index: 3;
|
| 46 |
+
border-left: none;
|
| 47 |
+
border-top-left-radius: 0;
|
| 48 |
+
border-bottom-left-radius: 0;
|
| 49 |
+
}
|
| 50 |
+
.palettes .palette-card__copy svg {
|
| 51 |
+
width: 18px;
|
| 52 |
+
height: 18px;
|
| 53 |
+
fill: currentColor;
|
| 54 |
+
display: block;
|
| 55 |
+
color: inherit;
|
| 56 |
+
}
|
| 57 |
+
.palettes .palette-card__swatches {
|
| 58 |
+
display: grid;
|
| 59 |
+
grid-template-columns: repeat(6, minmax(0, 1fr));
|
| 60 |
+
grid-auto-rows: 1fr;
|
| 61 |
+
gap: 2px;
|
| 62 |
+
margin: 0;
|
| 63 |
+
min-height: 60px;
|
| 64 |
+
}
|
| 65 |
+
.palettes .palette-card__swatches .sw {
|
| 66 |
+
width: 100%;
|
| 67 |
+
min-width: 0;
|
| 68 |
+
height: auto;
|
| 69 |
+
border-radius: 0;
|
| 70 |
+
border: 1px solid var(--border-color);
|
| 71 |
+
}
|
| 72 |
+
.palettes .palette-card__swatches .sw:first-child {
|
| 73 |
+
border-top-left-radius: 8px;
|
| 74 |
+
border-bottom-left-radius: 8px;
|
| 75 |
+
}
|
| 76 |
+
.palettes .palette-card__swatches .sw:last-child {
|
| 77 |
+
border-top-right-radius: 8px;
|
| 78 |
+
border-bottom-right-radius: 8px;
|
| 79 |
+
}
|
| 80 |
+
.palettes .palette-card__content {
|
| 81 |
+
display: flex;
|
| 82 |
+
flex-direction: row;
|
| 83 |
+
align-items: center;
|
| 84 |
+
justify-content: flex-start;
|
| 85 |
+
gap: 12px;
|
| 86 |
+
min-width: 0;
|
| 87 |
+
padding-right: 12px;
|
| 88 |
+
}
|
| 89 |
+
.palettes .palette-card__preview {
|
| 90 |
+
width: 48px;
|
| 91 |
+
height: 48px;
|
| 92 |
+
border-radius: 999px;
|
| 93 |
+
position: relative;
|
| 94 |
+
flex: 0 0 auto;
|
| 95 |
+
overflow: hidden;
|
| 96 |
+
}
|
| 97 |
+
.palettes .palette-card__preview .dot {
|
| 98 |
+
position: absolute;
|
| 99 |
+
width: 4px;
|
| 100 |
+
height: 4px;
|
| 101 |
+
background: #fff;
|
| 102 |
+
border-radius: 999px;
|
| 103 |
+
box-shadow: 0 0 6px rgba(0, 0, 0, 1);
|
| 104 |
+
}
|
| 105 |
+
.palettes .palette-card__preview .donut-hole {
|
| 106 |
+
position: absolute;
|
| 107 |
+
left: 50%;
|
| 108 |
+
top: 50%;
|
| 109 |
+
transform: translate(-50%, -50%);
|
| 110 |
+
width: 24px;
|
| 111 |
+
height: 24px;
|
| 112 |
+
border-radius: 999px;
|
| 113 |
+
background: var(--surface-bg);
|
| 114 |
+
box-shadow: 0 0 0 1px var(--border-color) inset;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.palettes .palette-card__content__info {
|
| 118 |
+
display: flex;
|
| 119 |
+
flex-direction: column;
|
| 120 |
+
}
|
| 121 |
+
.palettes .palette-card__title {
|
| 122 |
+
text-align: left;
|
| 123 |
+
font-weight: 800;
|
| 124 |
+
font-size: 15px;
|
| 125 |
+
}
|
| 126 |
+
.palettes .palette-card__desc {
|
| 127 |
+
text-align: left;
|
| 128 |
+
color: var(--muted-color);
|
| 129 |
+
line-height: 1.5;
|
| 130 |
+
font-size: 12px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.palettes .palettes__select {
|
| 134 |
+
width: 100%;
|
| 135 |
+
max-width: 100%;
|
| 136 |
+
border: 1px solid var(--border-color);
|
| 137 |
+
background: var(--surface-bg);
|
| 138 |
+
color: var(--text-color);
|
| 139 |
+
padding: 8px 10px;
|
| 140 |
+
border-radius: 8px;
|
| 141 |
+
}
|
| 142 |
+
.palettes .sr-only {
|
| 143 |
+
position: absolute;
|
| 144 |
+
width: 1px;
|
| 145 |
+
height: 1px;
|
| 146 |
+
padding: 0;
|
| 147 |
+
margin: -1px;
|
| 148 |
+
overflow: hidden;
|
| 149 |
+
clip: rect(0, 0, 1px, 1px);
|
| 150 |
+
white-space: nowrap;
|
| 151 |
+
border: 0;
|
| 152 |
+
}
|
| 153 |
+
.palettes .palettes__controls {
|
| 154 |
+
display: flex;
|
| 155 |
+
flex-wrap: wrap;
|
| 156 |
+
gap: 16px;
|
| 157 |
+
align-items: center;
|
| 158 |
+
margin: 8px 0 14px;
|
| 159 |
+
}
|
| 160 |
+
.palettes .palettes__field {
|
| 161 |
+
display: flex;
|
| 162 |
+
flex-direction: column;
|
| 163 |
+
gap: 6px;
|
| 164 |
+
min-width: 0;
|
| 165 |
+
flex: 1 1 280px;
|
| 166 |
+
max-width: 100%;
|
| 167 |
+
}
|
| 168 |
+
.palettes .palettes__label {
|
| 169 |
+
font-size: 12px;
|
| 170 |
+
color: var(--muted-color);
|
| 171 |
+
font-weight: 800;
|
| 172 |
+
}
|
| 173 |
+
.palettes .palettes__label-row {
|
| 174 |
+
display: flex;
|
| 175 |
+
align-items: center;
|
| 176 |
+
justify-content: space-between;
|
| 177 |
+
gap: 10px;
|
| 178 |
+
}
|
| 179 |
+
.palettes .ghost-badge {
|
| 180 |
+
font-size: 11px;
|
| 181 |
+
padding: 1px 6px;
|
| 182 |
+
border-radius: 999px;
|
| 183 |
+
border: 1px solid var(--border-color);
|
| 184 |
+
color: var(--muted-color);
|
| 185 |
+
background: transparent;
|
| 186 |
+
font-variant-numeric: tabular-nums;
|
| 187 |
+
}
|
| 188 |
+
.palettes .palettes__count {
|
| 189 |
+
display: flex;
|
| 190 |
+
align-items: center;
|
| 191 |
+
gap: 8px;
|
| 192 |
+
max-width: 100%;
|
| 193 |
+
}
|
| 194 |
+
.palettes .palettes__count input[type="range"] {
|
| 195 |
+
width: 100%;
|
| 196 |
+
}
|
| 197 |
+
.palettes .palettes__count output {
|
| 198 |
+
min-width: 28px;
|
| 199 |
+
text-align: center;
|
| 200 |
+
font-variant-numeric: tabular-nums;
|
| 201 |
+
font-size: 12px;
|
| 202 |
+
color: var(--muted-color);
|
| 203 |
+
}
|
| 204 |
+
.palettes input[type="range"] {
|
| 205 |
+
-webkit-appearance: none;
|
| 206 |
+
appearance: none;
|
| 207 |
+
height: 24px;
|
| 208 |
+
background: transparent;
|
| 209 |
+
cursor: pointer;
|
| 210 |
+
accent-color: var(--primary-color);
|
| 211 |
+
}
|
| 212 |
+
.palettes input[type="range"]:focus {
|
| 213 |
+
outline: none;
|
| 214 |
+
}
|
| 215 |
+
.palettes input[type="range"]::-webkit-slider-runnable-track {
|
| 216 |
+
height: 6px;
|
| 217 |
+
background: var(--border-color);
|
| 218 |
+
border-radius: 999px;
|
| 219 |
+
}
|
| 220 |
+
.palettes input[type="range"]::-webkit-slider-thumb {
|
| 221 |
+
-webkit-appearance: none;
|
| 222 |
+
appearance: none;
|
| 223 |
+
margin-top: -6px;
|
| 224 |
+
width: 18px;
|
| 225 |
+
height: 18px;
|
| 226 |
+
background: var(--primary-color);
|
| 227 |
+
border: 2px solid var(--surface-bg);
|
| 228 |
+
border-radius: 50%;
|
| 229 |
+
}
|
| 230 |
+
.palettes input[type="range"]::-moz-range-track {
|
| 231 |
+
height: 6px;
|
| 232 |
+
background: var(--border-color);
|
| 233 |
+
border: none;
|
| 234 |
+
border-radius: 999px;
|
| 235 |
+
}
|
| 236 |
+
.palettes input[type="range"]::-moz-range-progress {
|
| 237 |
+
height: 6px;
|
| 238 |
+
background: var(--primary-color);
|
| 239 |
+
border-radius: 999px;
|
| 240 |
+
}
|
| 241 |
+
.palettes input[type="range"]::-moz-range-thumb {
|
| 242 |
+
width: 18px;
|
| 243 |
+
height: 18px;
|
| 244 |
+
background: var(--primary-color);
|
| 245 |
+
border: 2px solid var(--surface-bg);
|
| 246 |
+
border-radius: 50%;
|
| 247 |
+
}
|
| 248 |
+
html.cb-grayscale,
|
| 249 |
+
body.cb-grayscale {
|
| 250 |
+
filter: grayscale(1) !important;
|
| 251 |
+
}
|
| 252 |
+
html.cb-protanopia,
|
| 253 |
+
body.cb-protanopia {
|
| 254 |
+
filter: url(#cb-protanopia) !important;
|
| 255 |
+
}
|
| 256 |
+
html.cb-deuteranopia,
|
| 257 |
+
body.cb-deuteranopia {
|
| 258 |
+
filter: url(#cb-deuteranopia) !important;
|
| 259 |
+
}
|
| 260 |
+
html.cb-tritanopia,
|
| 261 |
+
body.cb-tritanopia {
|
| 262 |
+
filter: url(#cb-tritanopia) !important;
|
| 263 |
+
}
|
| 264 |
+
html.cb-achromatopsia,
|
| 265 |
+
body.cb-achromatopsia {
|
| 266 |
+
filter: url(#cb-achromatopsia) !important;
|
| 267 |
+
}
|
| 268 |
+
@media (max-width: 1100px) {
|
| 269 |
+
.palettes .palette-card {
|
| 270 |
+
grid-template-columns: 1fr;
|
| 271 |
+
align-items: stretch;
|
| 272 |
+
gap: 10px;
|
| 273 |
+
}
|
| 274 |
+
.palettes .palette-card__swatches {
|
| 275 |
+
grid-template-columns: repeat(6, minmax(0, 1fr));
|
| 276 |
+
}
|
| 277 |
+
.palettes .palette-card__content {
|
| 278 |
+
border-right: none;
|
| 279 |
+
padding-right: 0;
|
| 280 |
+
}
|
| 281 |
+
.palettes .palette-card__copy {
|
| 282 |
+
display: none;
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
</style>
|
| 286 |
+
<div class="palettes__controls">
|
| 287 |
+
<div class="palettes__field">
|
| 288 |
+
<label class="palettes__label" for="cb-select"
|
| 289 |
+
>Color vision simulation</label
|
| 290 |
+
>
|
| 291 |
+
<select id="cb-select" class="palettes__select">
|
| 292 |
+
<option value="none"
|
| 293 |
+
>Normal color vision β typical for most people</option
|
| 294 |
+
>
|
| 295 |
+
<option value="achromatopsia">Achromatopsia β no color at all</option>
|
| 296 |
+
<option value="protanopia">Protanopia β reduced/absent reds</option>
|
| 297 |
+
<option value="deuteranopia"
|
| 298 |
+
>Deuteranopia β reduced/absent greens</option
|
| 299 |
+
>
|
| 300 |
+
<option value="tritanopia">Tritanopia β reduced/absent blues</option>
|
| 301 |
+
</select>
|
| 302 |
+
</div>
|
| 303 |
+
<div class="palettes__field">
|
| 304 |
+
<div class="palettes__label-row">
|
| 305 |
+
<label class="palettes__label" for="color-count">Number of colors</label
|
| 306 |
+
>
|
| 307 |
+
<output id="color-count-out" for="color-count" class="ghost-badge"
|
| 308 |
+
>8</output
|
| 309 |
+
>
|
| 310 |
+
</div>
|
| 311 |
+
<div class="palettes__count">
|
| 312 |
+
<input
|
| 313 |
+
id="color-count"
|
| 314 |
+
type="range"
|
| 315 |
+
min="6"
|
| 316 |
+
max="10"
|
| 317 |
+
step="1"
|
| 318 |
+
value="8"
|
| 319 |
+
aria-label="Number of colors"
|
| 320 |
+
/>
|
| 321 |
+
</div>
|
| 322 |
+
</div>
|
| 323 |
+
</div>
|
| 324 |
+
<div class="palettes__grid"></div>
|
| 325 |
+
<div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
|
| 326 |
+
<svg
|
| 327 |
+
aria-hidden="true"
|
| 328 |
+
focusable="false"
|
| 329 |
+
width="0"
|
| 330 |
+
height="0"
|
| 331 |
+
style="position:absolute; left:-9999px; overflow:hidden;"
|
| 332 |
+
>
|
| 333 |
+
<defs>
|
| 334 |
+
<filter id="cb-protanopia"
|
| 335 |
+
><feColorMatrix
|
| 336 |
+
type="matrix"
|
| 337 |
+
values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"
|
| 338 |
+
></feColorMatrix></filter
|
| 339 |
+
>
|
| 340 |
+
<filter id="cb-deuteranopia"
|
| 341 |
+
><feColorMatrix
|
| 342 |
+
type="matrix"
|
| 343 |
+
values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"
|
| 344 |
+
></feColorMatrix></filter
|
| 345 |
+
>
|
| 346 |
+
<filter id="cb-tritanopia"
|
| 347 |
+
><feColorMatrix
|
| 348 |
+
type="matrix"
|
| 349 |
+
values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"
|
| 350 |
+
></feColorMatrix></filter
|
| 351 |
+
>
|
| 352 |
+
<filter id="cb-achromatopsia"
|
| 353 |
+
><feColorMatrix
|
| 354 |
+
type="matrix"
|
| 355 |
+
values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"
|
| 356 |
+
></feColorMatrix></filter
|
| 357 |
+
>
|
| 358 |
+
</defs>
|
| 359 |
+
</svg>
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
<script type="module" is:inline>
|
| 363 |
+
import "/scripts/color-palettes.js";
|
| 364 |
+
const ROOT_ID = "{rootId}";
|
| 365 |
+
(() => {
|
| 366 |
+
const cards = [
|
| 367 |
+
{
|
| 368 |
+
key: "categorical",
|
| 369 |
+
title: "Categorical",
|
| 370 |
+
desc: "For <strong>nonβnumeric categories</strong>; <strong>visually distinct</strong> colors.",
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
key: "sequential",
|
| 374 |
+
title: "Sequential",
|
| 375 |
+
desc: "For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.",
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
key: "diverging",
|
| 379 |
+
title: "Diverging",
|
| 380 |
+
desc: "For numeric scales with negative and positive; Opposing extremes with smooth contrast around a neutral midpoint.",
|
| 381 |
+
},
|
| 382 |
+
];
|
| 383 |
+
const getPaletteColors = (key, count) => {
|
| 384 |
+
const total = Number(count) || 6;
|
| 385 |
+
if (
|
| 386 |
+
window.ColorPalettes &&
|
| 387 |
+
typeof window.ColorPalettes.getColors === "function"
|
| 388 |
+
) {
|
| 389 |
+
return window.ColorPalettes.getColors(key, total) || [];
|
| 390 |
+
}
|
| 391 |
+
return [];
|
| 392 |
+
};
|
| 393 |
+
const render = () => {
|
| 394 |
+
const root =
|
| 395 |
+
document.getElementById(ROOT_ID) || document.querySelector(".palettes");
|
| 396 |
+
if (!root) return;
|
| 397 |
+
const grid = root.querySelector(".palettes__grid");
|
| 398 |
+
if (!grid) return;
|
| 399 |
+
const input = document.getElementById("color-count");
|
| 400 |
+
const total = input ? Number(input.value) || 6 : 6;
|
| 401 |
+
const html = cards
|
| 402 |
+
.map((c) => {
|
| 403 |
+
const colors = getPaletteColors(c.key, total);
|
| 404 |
+
const swatches = colors
|
| 405 |
+
.map(
|
| 406 |
+
(col) => `<div class=\"sw\" style=\"background:${col}\"></div>`,
|
| 407 |
+
)
|
| 408 |
+
.join("");
|
| 409 |
+
const baseHex =
|
| 410 |
+
window.ColorPalettes &&
|
| 411 |
+
typeof window.ColorPalettes.getPrimary === "function"
|
| 412 |
+
? window.ColorPalettes.getPrimary()
|
| 413 |
+
: colors[0] || "#FF0000";
|
| 414 |
+
const hueDeg = (() => {
|
| 415 |
+
try {
|
| 416 |
+
const s = baseHex.replace("#", "");
|
| 417 |
+
const v =
|
| 418 |
+
s.length === 3
|
| 419 |
+
? s
|
| 420 |
+
.split("")
|
| 421 |
+
.map((ch) => ch + ch)
|
| 422 |
+
.join("")
|
| 423 |
+
: s;
|
| 424 |
+
const r = parseInt(v.slice(0, 2), 16) / 255,
|
| 425 |
+
g = parseInt(v.slice(2, 4), 16) / 255,
|
| 426 |
+
b = parseInt(v.slice(4, 6), 16) / 255;
|
| 427 |
+
const M = Math.max(r, g, b),
|
| 428 |
+
m = Math.min(r, g, b),
|
| 429 |
+
d = M - m;
|
| 430 |
+
if (d === 0) return 0;
|
| 431 |
+
let h = 0;
|
| 432 |
+
if (M === r) h = ((g - b) / d) % 6;
|
| 433 |
+
else if (M === g) h = (b - r) / d + 2;
|
| 434 |
+
else h = (r - g) / d + 4;
|
| 435 |
+
h *= 60;
|
| 436 |
+
if (h < 0) h += 360;
|
| 437 |
+
return h;
|
| 438 |
+
} catch {
|
| 439 |
+
return 0;
|
| 440 |
+
}
|
| 441 |
+
})();
|
| 442 |
+
const gradient =
|
| 443 |
+
c.key === "categorical"
|
| 444 |
+
? (() => {
|
| 445 |
+
const steps = 60; // smooth hue wheel (fixed orientation)
|
| 446 |
+
const wheel = Array.from(
|
| 447 |
+
{ length: steps },
|
| 448 |
+
(_, i) =>
|
| 449 |
+
`hsl(${Math.round((i / steps) * 360)}, 100%, 50%)`,
|
| 450 |
+
).join(", ");
|
| 451 |
+
return `conic-gradient(${wheel})`;
|
| 452 |
+
})()
|
| 453 |
+
: colors.length
|
| 454 |
+
? `linear-gradient(90deg, ${colors.join(", ")})`
|
| 455 |
+
: `linear-gradient(90deg, var(--border-color), var(--border-color))`;
|
| 456 |
+
const previewInner = (() => {
|
| 457 |
+
if (c.key !== "categorical" || !colors.length) return "";
|
| 458 |
+
const ring = 18;
|
| 459 |
+
const cx = 24;
|
| 460 |
+
const cy = 24;
|
| 461 |
+
const offset = (hueDeg / 360) * 2 * Math.PI;
|
| 462 |
+
return colors
|
| 463 |
+
.map((col, i) => {
|
| 464 |
+
const angle = offset + (i / colors.length) * 2 * Math.PI;
|
| 465 |
+
const x = cx + ring * Math.cos(angle);
|
| 466 |
+
const y = cy + ring * Math.sin(angle);
|
| 467 |
+
return `<span class=\"dot\" style=\"left:${x - 2}px; top:${y - 2}px\"></span>`;
|
| 468 |
+
})
|
| 469 |
+
.join("");
|
| 470 |
+
})();
|
| 471 |
+
const donutHole =
|
| 472 |
+
c.key === "categorical" ? '<span class="donut-hole"></span>' : "";
|
| 473 |
+
return `
|
| 474 |
+
<div class="palette-card" data-colors="${colors.join(",")}">
|
| 475 |
+
<div class="palette-card__content">
|
| 476 |
+
<div class=\"palette-card__preview\" aria-hidden=\"true\" style=\"background:${gradient}\">${previewInner}${donutHole}</div>
|
| 477 |
+
<div class="palette-card__content__info">
|
| 478 |
+
<div class="palette-card__title">${c.title}</div>
|
| 479 |
+
<div class="palette-card__desc">${c.desc}</div>
|
| 480 |
+
</div>
|
| 481 |
+
</div>
|
| 482 |
+
<div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
|
| 483 |
+
<button class="palette-card__copy button--ghost" type="button" aria-label="Copy palette">
|
| 484 |
+
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
| 485 |
+
</button>
|
| 486 |
+
</div>`;
|
| 487 |
+
})
|
| 488 |
+
.join("");
|
| 489 |
+
grid.innerHTML = html;
|
| 490 |
+
};
|
| 491 |
+
const MODE_TO_CLASS = {
|
| 492 |
+
protanopia: "cb-protanopia",
|
| 493 |
+
deuteranopia: "cb-deuteranopia",
|
| 494 |
+
tritanopia: "cb-tritanopia",
|
| 495 |
+
achromatopsia: "cb-achromatopsia",
|
| 496 |
+
};
|
| 497 |
+
const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
|
| 498 |
+
const clearCbClasses = () => {
|
| 499 |
+
const rootEl = document.documentElement;
|
| 500 |
+
CLEAR_CLASSES.forEach((cls) => rootEl.classList.remove(cls));
|
| 501 |
+
};
|
| 502 |
+
const applyCbClass = (mode) => {
|
| 503 |
+
clearCbClasses();
|
| 504 |
+
const cls = MODE_TO_CLASS[mode];
|
| 505 |
+
if (cls) document.documentElement.classList.add(cls);
|
| 506 |
+
};
|
| 507 |
+
const currentCbMode = () => {
|
| 508 |
+
const rootEl = document.documentElement;
|
| 509 |
+
for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) {
|
| 510 |
+
if (rootEl.classList.contains(cls)) return mode;
|
| 511 |
+
}
|
| 512 |
+
return "none";
|
| 513 |
+
};
|
| 514 |
+
const setupCbSim = () => {
|
| 515 |
+
const select = document.getElementById("cb-select");
|
| 516 |
+
if (!select) return;
|
| 517 |
+
try {
|
| 518 |
+
select.value = currentCbMode();
|
| 519 |
+
} catch {}
|
| 520 |
+
select.addEventListener("change", () => applyCbClass(select.value));
|
| 521 |
+
};
|
| 522 |
+
const setupCountControl = () => {
|
| 523 |
+
const input = document.getElementById("color-count");
|
| 524 |
+
const out = document.getElementById("color-count-out");
|
| 525 |
+
if (!input) return;
|
| 526 |
+
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
| 527 |
+
const read = () => clamp(Number(input.value) || 6, 6, 10);
|
| 528 |
+
const syncOut = () => {
|
| 529 |
+
if (out) out.textContent = String(read());
|
| 530 |
+
};
|
| 531 |
+
const onChange = () => {
|
| 532 |
+
syncOut();
|
| 533 |
+
render();
|
| 534 |
+
};
|
| 535 |
+
syncOut();
|
| 536 |
+
input.addEventListener("input", onChange);
|
| 537 |
+
document.addEventListener("palettes:updated", () => {
|
| 538 |
+
syncOut();
|
| 539 |
+
render();
|
| 540 |
+
});
|
| 541 |
+
};
|
| 542 |
+
let copyDelegationSetup = false;
|
| 543 |
+
const setupCopyDelegation = () => {
|
| 544 |
+
if (copyDelegationSetup) return;
|
| 545 |
+
const grid = document.querySelector(".palettes .palettes__grid");
|
| 546 |
+
if (!grid) return;
|
| 547 |
+
grid.addEventListener("click", async (e) => {
|
| 548 |
+
const btn = e.target.closest
|
| 549 |
+
? e.target.closest(".palette-card__copy")
|
| 550 |
+
: null;
|
| 551 |
+
if (!btn) return;
|
| 552 |
+
const card = btn.closest(".palette-card");
|
| 553 |
+
if (!card) return;
|
| 554 |
+
const colors = (card.dataset.colors || "").split(",").filter(Boolean);
|
| 555 |
+
const json = JSON.stringify(colors, null, 2);
|
| 556 |
+
try {
|
| 557 |
+
await navigator.clipboard.writeText(json);
|
| 558 |
+
const old = btn.innerHTML;
|
| 559 |
+
btn.innerHTML =
|
| 560 |
+
'<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
|
| 561 |
+
setTimeout(() => (btn.innerHTML = old), 900);
|
| 562 |
+
} catch {
|
| 563 |
+
window.prompt("Copy palette", json);
|
| 564 |
+
}
|
| 565 |
+
});
|
| 566 |
+
copyDelegationSetup = true;
|
| 567 |
+
};
|
| 568 |
+
const bootstrap = () => {
|
| 569 |
+
setupCbSim();
|
| 570 |
+
setupCountControl();
|
| 571 |
+
setupCopyDelegation();
|
| 572 |
+
// Render immediately
|
| 573 |
+
render();
|
| 574 |
+
// Re-render on palette updates
|
| 575 |
+
document.addEventListener("palettes:updated", render);
|
| 576 |
+
// Force an immediate notify after listeners are attached (ensures initial render)
|
| 577 |
+
try {
|
| 578 |
+
if (
|
| 579 |
+
window.ColorPalettes &&
|
| 580 |
+
typeof window.ColorPalettes.notify === "function"
|
| 581 |
+
)
|
| 582 |
+
window.ColorPalettes.notify();
|
| 583 |
+
else if (
|
| 584 |
+
window.ColorPalettes &&
|
| 585 |
+
typeof window.ColorPalettes.refresh === "function"
|
| 586 |
+
)
|
| 587 |
+
window.ColorPalettes.refresh();
|
| 588 |
+
} catch {}
|
| 589 |
+
};
|
| 590 |
+
if (document.readyState === "loading") {
|
| 591 |
+
document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
|
| 592 |
+
} else {
|
| 593 |
+
bootstrap();
|
| 594 |
+
}
|
| 595 |
+
})();
|
| 596 |
+
</script>
|
app/src/components/trackio/Trackio.svelte
CHANGED
|
@@ -1,14 +1,20 @@
|
|
| 1 |
<script>
|
| 2 |
-
import * as d3 from
|
| 3 |
-
import { formatAbbrev, smoothMetricData } from
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
export let normalizeLoss = true;
|
| 13 |
export let logScaleX = false;
|
| 14 |
export let smoothing = false;
|
|
@@ -17,99 +23,143 @@
|
|
| 17 |
let gridEl;
|
| 18 |
let legendItems = [];
|
| 19 |
const cellsDef = [
|
| 20 |
-
{ metric:
|
| 21 |
-
{ metric:
|
| 22 |
-
{ metric:
|
| 23 |
-
{ metric:
|
| 24 |
-
{ metric:
|
| 25 |
];
|
| 26 |
let preparedData = {};
|
| 27 |
let colorsByRun = {};
|
| 28 |
-
|
| 29 |
// Variables for data management (will be initialized in onMount)
|
| 30 |
let dataByMetric = new Map();
|
| 31 |
let metricsToDraw = [];
|
| 32 |
let currentRunList = [];
|
| 33 |
let cycleIdx = 2;
|
| 34 |
-
|
| 35 |
// Dynamic color palette using color-palettes.js helper
|
| 36 |
-
let dynamicPalette = [
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const updateDynamicPalette = () => {
|
| 39 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
try {
|
| 41 |
-
dynamicPalette = window.ColorPalettes.getColors(
|
|
|
|
|
|
|
|
|
|
| 42 |
} catch (e) {
|
| 43 |
-
console.warn(
|
| 44 |
// Keep fallback palette
|
| 45 |
}
|
| 46 |
}
|
| 47 |
};
|
| 48 |
-
|
| 49 |
const colorForRun = (name) => {
|
| 50 |
const idx = currentRunList.indexOf(name);
|
| 51 |
-
return idx >= 0 ? dynamicPalette[idx % dynamicPalette.length] :
|
| 52 |
};
|
| 53 |
-
|
| 54 |
|
| 55 |
// Jitter function - generates completely new data with new runs
|
| 56 |
-
function jitterData(){
|
| 57 |
-
console.log(
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
// Generate new random data with weighted probability for fewer runs
|
| 60 |
// Higher probability for 2-3 runs, lower for 4-5-6 runs
|
| 61 |
const rand = Math.random();
|
| 62 |
let wantRuns;
|
| 63 |
-
if (rand < 0.4)
|
| 64 |
-
|
| 65 |
-
else if (rand < 0.
|
| 66 |
-
|
| 67 |
-
else
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
// Use realistic ML training step counts
|
| 69 |
const stepsCount = Random.trainingSteps();
|
| 70 |
const runsSim = generateRunNames(wantRuns, stepsCount);
|
| 71 |
-
const steps = Array.from({length: stepsCount}, (_,i)=> i+1);
|
| 72 |
const nextByMetric = new Map();
|
| 73 |
-
const TARGET_METRICS = [
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
// Initialize data structure
|
| 76 |
TARGET_METRICS.forEach((tgt) => {
|
| 77 |
const map = {};
|
| 78 |
-
runsSim.forEach((r) => {
|
|
|
|
|
|
|
| 79 |
nextByMetric.set(tgt, map);
|
| 80 |
});
|
| 81 |
-
|
| 82 |
// Generate curves for each run
|
| 83 |
-
runsSim.forEach(run => {
|
| 84 |
const curves = genCurves(stepsCount);
|
| 85 |
-
steps.forEach((s,i)=>{
|
| 86 |
-
nextByMetric.get(
|
| 87 |
-
nextByMetric
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
nextByMetric
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
});
|
| 92 |
});
|
| 93 |
-
|
| 94 |
// Update all reactive data
|
| 95 |
-
nextByMetric.forEach((v,k)=> dataByMetric.set(k,v));
|
| 96 |
metricsToDraw = TARGET_METRICS;
|
| 97 |
currentRunList = runsSim.slice();
|
| 98 |
updateDynamicPalette(); // Generate new colors based on run count
|
| 99 |
-
legendItems = currentRunList.map((name) => ({
|
|
|
|
|
|
|
|
|
|
| 100 |
updatePreparedData();
|
| 101 |
-
colorsByRun = Object.fromEntries(
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
// Public API: allow external theme switch
|
| 107 |
-
function setTheme(name){
|
| 108 |
-
variant = name ===
|
| 109 |
updateThemeClass();
|
| 110 |
-
|
| 111 |
// Debug log for font application
|
| 112 |
-
if (typeof window !==
|
| 113 |
console.log(`Theme switched to: ${variant}`);
|
| 114 |
if (hostEl) {
|
| 115 |
const computedStyle = getComputedStyle(hostEl);
|
|
@@ -135,41 +185,67 @@
|
|
| 135 |
|
| 136 |
// Public API: generate massive test dataset
|
| 137 |
function generateMassiveDataset(steps = null, runs = 3) {
|
| 138 |
-
console.log(
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
const result = generateMassiveTestDataset(steps, runs);
|
| 141 |
-
|
| 142 |
// Update reactive data with massive dataset
|
| 143 |
result.dataByMetric.forEach((v, k) => dataByMetric.set(k, v));
|
| 144 |
-
metricsToDraw = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
currentRunList = result.runNames.slice();
|
| 146 |
updateDynamicPalette();
|
| 147 |
-
legendItems = currentRunList.map((name) => ({
|
|
|
|
|
|
|
|
|
|
| 148 |
updatePreparedData();
|
| 149 |
-
colorsByRun = Object.fromEntries(
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
console.log(`π Total data points: ${result.totalPoints.toLocaleString()}`);
|
| 153 |
console.log(`π― Description: ${result.description}`);
|
| 154 |
-
|
| 155 |
return result;
|
| 156 |
}
|
| 157 |
|
| 158 |
// Public API: add live data point for simulation
|
| 159 |
function addLiveDataPoint(runName, dataPoint) {
|
| 160 |
console.log(`Adding live data point for run "${runName}":`, dataPoint);
|
| 161 |
-
|
| 162 |
// Add run to currentRunList if it doesn't exist
|
| 163 |
if (!currentRunList.includes(runName)) {
|
| 164 |
currentRunList = [...currentRunList, runName];
|
| 165 |
updateDynamicPalette();
|
| 166 |
-
colorsByRun = Object.fromEntries(
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
-
|
| 170 |
// Initialize data structures for the run if needed
|
| 171 |
-
const TARGET_METRICS = [
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
if (!dataByMetric.has(metric)) {
|
| 174 |
dataByMetric.set(metric, {});
|
| 175 |
}
|
|
@@ -178,114 +254,125 @@
|
|
| 178 |
metricData[runName] = [];
|
| 179 |
}
|
| 180 |
});
|
| 181 |
-
|
| 182 |
// Add the new data points to each metric
|
| 183 |
const step = dataPoint.step;
|
| 184 |
-
|
| 185 |
// Add epoch data
|
| 186 |
-
const epochData = dataByMetric.get(
|
| 187 |
epochData[runName].push({ step, value: step });
|
| 188 |
-
|
| 189 |
// Add accuracy data (train and val get the same value for simplicity)
|
| 190 |
if (dataPoint.accuracy !== undefined) {
|
| 191 |
-
const trainAccData = dataByMetric.get(
|
| 192 |
-
const valAccData = dataByMetric.get(
|
| 193 |
-
|
| 194 |
// Add some noise between train and val accuracy
|
| 195 |
const trainAcc = dataPoint.accuracy;
|
| 196 |
-
const valAcc = Math.max(
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
| 198 |
trainAccData[runName].push({ step, value: trainAcc });
|
| 199 |
valAccData[runName].push({ step, value: valAcc });
|
| 200 |
}
|
| 201 |
-
|
| 202 |
// Add loss data (train and val get the same value for simplicity)
|
| 203 |
if (dataPoint.loss !== undefined) {
|
| 204 |
-
const trainLossData = dataByMetric.get(
|
| 205 |
-
const valLossData = dataByMetric.get(
|
| 206 |
-
|
| 207 |
// Add some noise between train and val loss
|
| 208 |
const trainLoss = dataPoint.loss;
|
| 209 |
const valLoss = dataPoint.loss + 0.05 + Math.random() * 0.1;
|
| 210 |
-
|
| 211 |
trainLossData[runName].push({ step, value: trainLoss });
|
| 212 |
valLossData[runName].push({ step, value: valLoss });
|
| 213 |
}
|
| 214 |
-
|
| 215 |
// Update all metrics to draw
|
| 216 |
metricsToDraw = TARGET_METRICS;
|
| 217 |
-
|
| 218 |
// Update prepared data with new values
|
| 219 |
updatePreparedData();
|
| 220 |
-
|
| 221 |
-
console.log(
|
|
|
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
// Update prepared data with optional smoothing
|
| 225 |
let preparedRawData = {}; // Store original data for background display
|
| 226 |
-
|
| 227 |
function updatePreparedData() {
|
| 228 |
-
const TARGET_METRICS = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
let dataToUse = {};
|
| 230 |
let rawDataToStore = {};
|
| 231 |
-
|
| 232 |
-
TARGET_METRICS.forEach(metric => {
|
| 233 |
const rawData = dataByMetric.get(metric);
|
| 234 |
if (rawData) {
|
| 235 |
// Store original data
|
| 236 |
rawDataToStore[metric] = rawData;
|
| 237 |
-
|
| 238 |
// Apply smoothing if enabled (except for epoch which should stay exact)
|
| 239 |
-
dataToUse[metric] =
|
| 240 |
-
|
| 241 |
-
|
|
|
|
| 242 |
}
|
| 243 |
});
|
| 244 |
-
|
| 245 |
preparedData = dataToUse;
|
| 246 |
preparedRawData = rawDataToStore;
|
| 247 |
console.log(`Prepared data updated, smoothing: ${smoothing}`);
|
| 248 |
}
|
| 249 |
|
| 250 |
-
function updateThemeClass(){
|
| 251 |
if (!hostEl) return;
|
| 252 |
-
hostEl.classList.toggle(
|
| 253 |
-
hostEl.classList.toggle(
|
| 254 |
-
hostEl.setAttribute(
|
| 255 |
}
|
| 256 |
|
| 257 |
$: updateThemeClass();
|
| 258 |
|
| 259 |
-
|
| 260 |
// Chart logic now handled by Cell.svelte
|
| 261 |
-
|
| 262 |
// Fullscreen navigation state
|
| 263 |
let currentFullscreenIndex = 0;
|
| 264 |
let isModalOpen = false;
|
| 265 |
-
|
| 266 |
function handleNavigate(newIndex) {
|
| 267 |
currentFullscreenIndex = newIndex;
|
| 268 |
}
|
| 269 |
-
|
| 270 |
function openModal(index) {
|
| 271 |
currentFullscreenIndex = index;
|
| 272 |
isModalOpen = true;
|
| 273 |
}
|
| 274 |
-
|
| 275 |
function closeModal() {
|
| 276 |
isModalOpen = false;
|
| 277 |
}
|
| 278 |
-
|
| 279 |
// Prepare all charts data for navigation
|
| 280 |
-
$: allChartsData = cellsDef.map(c => ({
|
| 281 |
metricKey: c.metric,
|
| 282 |
titleText: c.title,
|
| 283 |
metricData: (preparedData && preparedData[c.metric]) || {},
|
| 284 |
-
rawMetricData: (preparedRawData && preparedRawData[c.metric]) || {}
|
| 285 |
}));
|
| 286 |
-
|
| 287 |
// Color function for the modal
|
| 288 |
-
$: modalColorForRun = (name) => colorsByRun[name] ||
|
| 289 |
|
| 290 |
let cleanup = null;
|
| 291 |
onMount(() => {
|
|
@@ -293,66 +380,97 @@
|
|
| 293 |
hostEl.__setTheme = setTheme;
|
| 294 |
|
| 295 |
// Jitter & Simulate functions
|
| 296 |
-
function rebuildLegend(){
|
| 297 |
updateDynamicPalette(); // Update colors when adding new data
|
| 298 |
-
legendItems = currentRunList.map((name) => ({
|
|
|
|
|
|
|
|
|
|
| 299 |
}
|
| 300 |
-
|
| 301 |
-
function simulateData(){
|
| 302 |
// Generate new random data with weighted probability for fewer runs
|
| 303 |
// Higher probability for 2-3 runs, lower for 4-5-6 runs
|
| 304 |
const rand = Math.random();
|
| 305 |
let wantRuns;
|
| 306 |
-
if (rand < 0.4)
|
| 307 |
-
|
| 308 |
-
else if (rand < 0.
|
| 309 |
-
|
| 310 |
-
else
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
// Use realistic ML training step counts with cycling scenarios
|
| 312 |
let stepsCount;
|
| 313 |
if (cycleIdx === 0) {
|
| 314 |
-
stepsCount = Random.trainingStepsForScenario(
|
| 315 |
} else if (cycleIdx === 1) {
|
| 316 |
-
stepsCount = Random.trainingStepsForScenario(
|
| 317 |
} else if (cycleIdx === 2) {
|
| 318 |
-
stepsCount = Random.trainingStepsForScenario(
|
| 319 |
} else if (cycleIdx === 3) {
|
| 320 |
-
stepsCount = Random.trainingStepsForScenario(
|
| 321 |
} else if (cycleIdx === 4) {
|
| 322 |
-
stepsCount = Random.trainingStepsForScenario(
|
| 323 |
} else if (cycleIdx === 5) {
|
| 324 |
-
stepsCount = Random.trainingStepsForScenario(
|
| 325 |
} else {
|
| 326 |
stepsCount = Random.trainingSteps(); // Full range for variety
|
| 327 |
}
|
| 328 |
cycleIdx = (cycleIdx + 1) % 7; // Cycle through 7 scenarios now
|
| 329 |
-
|
| 330 |
const runsSim = generateRunNames(wantRuns, stepsCount);
|
| 331 |
-
const steps = Array.from({length: stepsCount}, (_,i)=> i+1);
|
| 332 |
const nextByMetric = new Map();
|
| 333 |
-
const TARGET_METRICS = [
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
mList.forEach((tgt) => {
|
| 336 |
const map = {};
|
| 337 |
-
runsSim.forEach((r) => {
|
|
|
|
|
|
|
| 338 |
nextByMetric.set(tgt, map);
|
| 339 |
});
|
| 340 |
-
runsSim.forEach(run => {
|
| 341 |
const curves = genCurves(stepsCount);
|
| 342 |
-
steps.forEach((s,i)=>{
|
| 343 |
-
if (mList.includes(
|
| 344 |
-
|
| 345 |
-
if (mList.includes(
|
| 346 |
-
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
});
|
| 349 |
});
|
| 350 |
-
nextByMetric.forEach((v,k)=> dataByMetric.set(k,v));
|
| 351 |
currentRunList = runsSim.slice();
|
| 352 |
rebuildLegend();
|
| 353 |
updatePreparedData();
|
| 354 |
updateDynamicPalette(); // Update colors when rebuilding
|
| 355 |
-
colorsByRun = Object.fromEntries(
|
|
|
|
|
|
|
| 356 |
}
|
| 357 |
// No need for event listeners anymore - we'll use reactive statement
|
| 358 |
|
|
@@ -360,105 +478,158 @@
|
|
| 360 |
simulateData();
|
| 361 |
// Svelte Cells will react to preparedData/colorsByRun updates
|
| 362 |
|
| 363 |
-
cleanup = () => {
|
| 364 |
// No cleanup needed for reactive statements
|
| 365 |
};
|
| 366 |
});
|
| 367 |
|
| 368 |
-
onDestroy(() => {
|
|
|
|
|
|
|
| 369 |
|
| 370 |
// Expose instance for debugging and external theme control
|
| 371 |
onMount(() => {
|
| 372 |
-
window.trackioInstance = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
if (hostEl) {
|
| 374 |
-
hostEl.__trackioInstance = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
}
|
| 376 |
-
|
| 377 |
// Initialize dynamic palette
|
| 378 |
updateDynamicPalette();
|
| 379 |
-
|
| 380 |
// Listen for palette updates from color-palettes.js
|
| 381 |
const handlePaletteUpdate = () => {
|
| 382 |
updateDynamicPalette();
|
| 383 |
// Rebuild legend and colors if needed
|
| 384 |
if (currentRunList.length > 0) {
|
| 385 |
-
legendItems = currentRunList.map((name) => ({
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
}
|
| 388 |
};
|
| 389 |
-
|
| 390 |
-
document.addEventListener(
|
| 391 |
-
|
| 392 |
// Cleanup listener on destroy
|
| 393 |
return () => {
|
| 394 |
-
document.removeEventListener(
|
| 395 |
};
|
| 396 |
});
|
| 397 |
|
| 398 |
// React to jitter trigger from store
|
| 399 |
$: {
|
| 400 |
-
console.log(
|
|
|
|
|
|
|
|
|
|
| 401 |
if ($jitterTrigger > 0) {
|
| 402 |
-
console.log(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
jitterData();
|
| 404 |
}
|
| 405 |
}
|
| 406 |
|
| 407 |
// Legend ghost helpers (hover effects)
|
| 408 |
-
function ghostRun(run){
|
| 409 |
try {
|
| 410 |
-
hostEl.classList.add(
|
| 411 |
-
|
| 412 |
// Ghost the chart lines and points
|
| 413 |
-
hostEl.querySelectorAll(
|
| 414 |
-
cell
|
| 415 |
-
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
});
|
| 418 |
-
|
| 419 |
// Ghost the legend items
|
| 420 |
-
hostEl.querySelectorAll(
|
| 421 |
-
const itemRun = item.getAttribute(
|
| 422 |
-
item.classList.toggle(
|
| 423 |
});
|
| 424 |
-
} catch(_) {}
|
| 425 |
}
|
| 426 |
-
function clearGhost(){
|
| 427 |
try {
|
| 428 |
-
hostEl.classList.remove(
|
| 429 |
-
|
| 430 |
// Clear ghost from chart lines and points
|
| 431 |
-
hostEl.querySelectorAll(
|
| 432 |
-
cell
|
| 433 |
-
|
| 434 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
});
|
| 436 |
-
|
| 437 |
// Clear ghost from legend items
|
| 438 |
-
hostEl.querySelectorAll(
|
| 439 |
-
item.classList.remove(
|
| 440 |
});
|
| 441 |
-
} catch(_) {}
|
| 442 |
}
|
| 443 |
</script>
|
| 444 |
|
| 445 |
<div class="trackio theme--classic" bind:this={hostEl} data-variant={variant}>
|
| 446 |
<div class="trackio__header">
|
| 447 |
-
<Legend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
</div>
|
| 449 |
<div class="trackio__grid" bind:this={gridEl}>
|
| 450 |
{#each cellsDef as c, i}
|
| 451 |
-
<Cell
|
| 452 |
-
metricKey={c.metric}
|
| 453 |
-
titleText={c.title}
|
| 454 |
-
wide={c.wide}
|
| 455 |
-
{variant}
|
| 456 |
-
{normalizeLoss}
|
| 457 |
-
{logScaleX}
|
| 458 |
-
{smoothing}
|
| 459 |
-
metricData={(preparedData && preparedData[c.metric]) || {}}
|
| 460 |
-
rawMetricData={(preparedRawData && preparedRawData[c.metric]) || {}}
|
| 461 |
-
colorForRun={(name)=> colorsByRun[name] ||
|
| 462 |
{hostEl}
|
| 463 |
currentIndex={i}
|
| 464 |
onOpenModal={openModal}
|
|
@@ -467,9 +638,17 @@
|
|
| 467 |
</div>
|
| 468 |
<div class="trackio__footer">
|
| 469 |
<small>
|
| 470 |
-
Built with <a
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
<span class="separator">β’</span>
|
| 472 |
-
<a
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
</small>
|
| 474 |
</div>
|
| 475 |
</div>
|
|
@@ -477,7 +656,7 @@
|
|
| 477 |
<!-- Centralized Fullscreen Modal -->
|
| 478 |
<FullscreenModal
|
| 479 |
visible={isModalOpen}
|
| 480 |
-
title={allChartsData[currentFullscreenIndex]?.titleText ||
|
| 481 |
metricData={allChartsData[currentFullscreenIndex]?.metricData || {}}
|
| 482 |
rawMetricData={allChartsData[currentFullscreenIndex]?.rawMetricData || {}}
|
| 483 |
colorForRun={modalColorForRun}
|
|
@@ -485,8 +664,8 @@
|
|
| 485 |
{logScaleX}
|
| 486 |
{smoothing}
|
| 487 |
{normalizeLoss}
|
| 488 |
-
metricKey={allChartsData[currentFullscreenIndex]?.metricKey ||
|
| 489 |
-
titleText={allChartsData[currentFullscreenIndex]?.titleText ||
|
| 490 |
currentIndex={currentFullscreenIndex}
|
| 491 |
totalCharts={cellsDef.length}
|
| 492 |
onNavigate={handleNavigate}
|
|
@@ -499,12 +678,13 @@
|
|
| 499 |
========================= */
|
| 500 |
|
| 501 |
/* Font imports for themes - ensure Roboto Mono is loaded for Oblivion theme */
|
| 502 |
-
@import url(
|
| 503 |
-
|
| 504 |
/* Fallback font-face declaration */
|
| 505 |
@font-face {
|
| 506 |
-
font-family:
|
| 507 |
-
src: url(
|
|
|
|
| 508 |
font-weight: 400;
|
| 509 |
font-style: normal;
|
| 510 |
font-display: swap;
|
|
@@ -515,31 +695,37 @@
|
|
| 515 |
position: relative;
|
| 516 |
--z-tooltip: 50;
|
| 517 |
--z-overlay: 99999999;
|
| 518 |
-
|
| 519 |
/* Typography */
|
| 520 |
-
--trackio-font-family: var(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
--trackio-font-weight-normal: 400;
|
| 522 |
--trackio-font-weight-medium: 600;
|
| 523 |
--trackio-font-weight-bold: 700;
|
| 524 |
-
|
| 525 |
/* Apply font-family to root element */
|
| 526 |
font-family: var(--trackio-font-family);
|
| 527 |
-
|
| 528 |
/* Base color system for Classic theme */
|
| 529 |
--trackio-base: #323232;
|
| 530 |
--trackio-primary: var(--trackio-base);
|
| 531 |
--trackio-dim: color-mix(in srgb, var(--trackio-base) 28%, transparent);
|
| 532 |
--trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
|
| 533 |
--trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
|
| 534 |
-
|
| 535 |
/* Chart rendering */
|
| 536 |
-
--trackio-chart-grid-type:
|
| 537 |
--trackio-chart-axis-stroke: var(--trackio-dim);
|
| 538 |
--trackio-chart-axis-text: var(--trackio-text);
|
| 539 |
--trackio-chart-grid-stroke: var(--trackio-subtle);
|
| 540 |
--trackio-chart-grid-opacity: 1;
|
| 541 |
}
|
| 542 |
-
|
| 543 |
/* Dark mode overrides for Classic theme */
|
| 544 |
:global([data-theme="dark"]) .trackio.theme--classic {
|
| 545 |
--trackio-base: #ffffff;
|
|
@@ -547,28 +733,28 @@
|
|
| 547 |
--trackio-dim: color-mix(in srgb, var(--trackio-base) 25%, transparent);
|
| 548 |
--trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
|
| 549 |
--trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
|
| 550 |
-
|
| 551 |
/* Cell background for dark mode */
|
| 552 |
--trackio-cell-background: rgba(255, 255, 255, 0.03);
|
| 553 |
}
|
| 554 |
-
|
| 555 |
.trackio.theme--classic {
|
| 556 |
/* Cell styling */
|
| 557 |
--trackio-cell-background: rgba(0, 0, 0, 0.02);
|
| 558 |
--trackio-cell-border: var(--border-color, rgba(0, 0, 0, 0.1));
|
| 559 |
--trackio-cell-corner-inset: 0px;
|
| 560 |
--trackio-cell-gap: 12px;
|
| 561 |
-
|
| 562 |
/* Typography */
|
| 563 |
--trackio-text-primary: var(--text-color, rgba(0, 0, 0, 0.9));
|
| 564 |
--trackio-text-secondary: var(--muted-color, rgba(0, 0, 0, 0.6));
|
| 565 |
-
--trackio-text-accent: var(--primary-color
|
| 566 |
-
|
| 567 |
/* Tooltip */
|
| 568 |
--trackio-tooltip-background: var(--surface-bg, white);
|
| 569 |
--trackio-tooltip-border: var(--border-color, rgba(0, 0, 0, 0.1));
|
| 570 |
--trackio-tooltip-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
| 571 |
-
|
| 572 |
/* Legend */
|
| 573 |
--trackio-legend-text: var(--text-color, rgba(0, 0, 0, 0.9));
|
| 574 |
--trackio-legend-swatch-border: var(--border-color, rgba(0, 0, 0, 0.1));
|
|
@@ -577,14 +763,14 @@
|
|
| 577 |
/* Dark mode adjustments */
|
| 578 |
:global([data-theme="dark"]) .trackio {
|
| 579 |
--trackio-chart-axis-stroke: rgba(255, 255, 255, 0.18);
|
| 580 |
-
--trackio-chart-axis-text: rgba(255, 255, 255, 0.
|
| 581 |
--trackio-chart-grid-stroke: rgba(255, 255, 255, 0.08);
|
| 582 |
}
|
| 583 |
|
| 584 |
/* =========================
|
| 585 |
THEME: CLASSIC (Default)
|
| 586 |
========================= */
|
| 587 |
-
|
| 588 |
.trackio.theme--classic {
|
| 589 |
/* Keep default values - no overrides needed */
|
| 590 |
}
|
|
@@ -592,64 +778,96 @@
|
|
| 592 |
/* =========================
|
| 593 |
THEME: OBLIVION
|
| 594 |
========================= */
|
| 595 |
-
|
| 596 |
.trackio.theme--oblivion {
|
| 597 |
/* Core oblivion color system - Light mode: darker colors for visibility */
|
| 598 |
--trackio-oblivion-base: #2a2a2a;
|
| 599 |
--trackio-oblivion-primary: var(--trackio-oblivion-base);
|
| 600 |
-
--trackio-oblivion-dim: color-mix(
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
/* Chart rendering overrides */
|
| 605 |
-
--trackio-chart-grid-type:
|
| 606 |
--trackio-chart-axis-stroke: var(--trackio-oblivion-dim);
|
| 607 |
--trackio-chart-axis-text: var(--trackio-oblivion-primary);
|
| 608 |
--trackio-chart-grid-stroke: var(--trackio-oblivion-dim);
|
| 609 |
--trackio-chart-grid-opacity: 0.6;
|
| 610 |
}
|
| 611 |
-
|
| 612 |
/* Dark mode overrides for Oblivion theme */
|
| 613 |
:global([data-theme="dark"]) .trackio.theme--oblivion {
|
| 614 |
--trackio-oblivion-base: #ffffff;
|
| 615 |
--trackio-oblivion-primary: var(--trackio-oblivion-base);
|
| 616 |
-
--trackio-oblivion-dim: color-mix(
|
| 617 |
-
|
| 618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
}
|
| 620 |
-
|
| 621 |
.trackio.theme--oblivion {
|
| 622 |
/* Cell styling overrides */
|
| 623 |
--trackio-cell-background: var(--trackio-oblivion-subtle);
|
| 624 |
--trackio-cell-border: var(--trackio-oblivion-dim);
|
| 625 |
--trackio-cell-corner-inset: 6px;
|
| 626 |
--trackio-cell-gap: 0px;
|
| 627 |
-
|
| 628 |
/* HUD-specific variables */
|
| 629 |
--trackio-oblivion-hud-gap: 10px;
|
| 630 |
--trackio-oblivion-hud-corner-size: 8px;
|
| 631 |
-
--trackio-oblivion-hud-bg-gradient:
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
/* Typography overrides */
|
| 636 |
--trackio-text-primary: var(--trackio-oblivion-primary);
|
| 637 |
--trackio-text-secondary: var(--trackio-oblivion-dim);
|
| 638 |
--trackio-text-accent: var(--trackio-oblivion-primary);
|
| 639 |
-
|
| 640 |
/* Tooltip overrides */
|
| 641 |
--trackio-tooltip-background: var(--trackio-oblivion-subtle);
|
| 642 |
--trackio-tooltip-border: var(--trackio-oblivion-dim);
|
| 643 |
-
--trackio-tooltip-shadow:
|
| 644 |
-
|
| 645 |
0 2px 8px color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent);
|
| 646 |
-
|
| 647 |
/* Legend overrides */
|
| 648 |
--trackio-legend-text: var(--trackio-oblivion-primary);
|
| 649 |
--trackio-legend-swatch-border: var(--trackio-oblivion-dim);
|
| 650 |
-
|
| 651 |
/* Font styling overrides */
|
| 652 |
-
--trackio-font-family:
|
|
|
|
| 653 |
font-family: var(--trackio-font-family) !important;
|
| 654 |
color: var(--trackio-text-primary);
|
| 655 |
}
|
|
@@ -657,29 +875,42 @@
|
|
| 657 |
/* Force Roboto Mono application in Oblivion theme */
|
| 658 |
.trackio.theme--oblivion,
|
| 659 |
.trackio.theme--oblivion * {
|
| 660 |
-
font-family:
|
|
|
|
| 661 |
}
|
| 662 |
-
|
| 663 |
/* Specific overrides for different elements in Oblivion */
|
| 664 |
.trackio.theme--oblivion .cell-title,
|
| 665 |
.trackio.theme--oblivion .legend-bottom,
|
| 666 |
.trackio.theme--oblivion .legend-title,
|
| 667 |
.trackio.theme--oblivion .item {
|
| 668 |
-
font-family:
|
|
|
|
| 669 |
}
|
| 670 |
|
| 671 |
/* Dark mode adjustments for Oblivion */
|
| 672 |
:global([data-theme="dark"]) .trackio.theme--oblivion {
|
| 673 |
--trackio-oblivion-base: #ffffff;
|
| 674 |
-
--trackio-oblivion-hud-bg-gradient:
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
0 2px 8px color-mix(in srgb, black 10%, transparent);
|
| 682 |
-
|
| 683 |
background: #0f1115;
|
| 684 |
}
|
| 685 |
|
|
@@ -692,9 +923,11 @@
|
|
| 692 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 693 |
gap: var(--trackio-cell-gap);
|
| 694 |
}
|
| 695 |
-
|
| 696 |
@media (max-width: 980px) {
|
| 697 |
-
.trackio__grid {
|
|
|
|
|
|
|
| 698 |
}
|
| 699 |
|
| 700 |
.trackio__header {
|
|
@@ -712,28 +945,37 @@
|
|
| 712 |
.trackio .axes line {
|
| 713 |
stroke: var(--trackio-chart-axis-stroke);
|
| 714 |
}
|
| 715 |
-
|
| 716 |
.trackio .axes text {
|
| 717 |
fill: var(--trackio-chart-axis-text);
|
| 718 |
font-family: var(--trackio-font-family);
|
| 719 |
}
|
| 720 |
-
|
| 721 |
/* Force font-family for SVG text in Oblivion */
|
| 722 |
.trackio.theme--oblivion .axes text {
|
| 723 |
-
font-family:
|
|
|
|
| 724 |
}
|
| 725 |
-
|
| 726 |
.trackio .grid line {
|
| 727 |
stroke: var(--trackio-chart-grid-stroke);
|
| 728 |
opacity: var(--trackio-chart-grid-opacity);
|
| 729 |
}
|
| 730 |
|
| 731 |
/* Grid type switching */
|
| 732 |
-
.trackio .grid-dots {
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
.trackio.theme--oblivion .cell-bg,
|
| 736 |
-
.trackio.theme--oblivion .cell-corners {
|
|
|
|
|
|
|
| 737 |
|
| 738 |
/* =========================
|
| 739 |
FOOTER
|
|
@@ -777,8 +1019,7 @@
|
|
| 777 |
}
|
| 778 |
|
| 779 |
.trackio.theme--oblivion .trackio__footer small {
|
| 780 |
-
font-family:
|
|
|
|
| 781 |
}
|
| 782 |
</style>
|
| 783 |
-
|
| 784 |
-
|
|
|
|
| 1 |
<script>
|
| 2 |
+
import * as d3 from "d3";
|
| 3 |
+
import { formatAbbrev, smoothMetricData } from "./core/chart-utils.js";
|
| 4 |
+
import {
|
| 5 |
+
generateRunNames,
|
| 6 |
+
genCurves,
|
| 7 |
+
Random,
|
| 8 |
+
Performance,
|
| 9 |
+
generateMassiveTestDataset,
|
| 10 |
+
} from "./core/data-generator.js";
|
| 11 |
+
import Legend from "./components/Legend.svelte";
|
| 12 |
+
import Cell from "./components/Cell.svelte";
|
| 13 |
+
import FullscreenModal from "./components/FullscreenModal.svelte";
|
| 14 |
+
import { onMount, onDestroy } from "svelte";
|
| 15 |
+
import { jitterTrigger } from "./core/store.js";
|
| 16 |
+
|
| 17 |
+
export let variant = "classic"; // 'classic' | 'oblivion'
|
| 18 |
export let normalizeLoss = true;
|
| 19 |
export let logScaleX = false;
|
| 20 |
export let smoothing = false;
|
|
|
|
| 23 |
let gridEl;
|
| 24 |
let legendItems = [];
|
| 25 |
const cellsDef = [
|
| 26 |
+
{ metric: "epoch", title: "Epoch" },
|
| 27 |
+
{ metric: "train_accuracy", title: "Train accuracy" },
|
| 28 |
+
{ metric: "train_loss", title: "Train loss" },
|
| 29 |
+
{ metric: "val_accuracy", title: "Val accuracy" },
|
| 30 |
+
{ metric: "val_loss", title: "Val loss", wide: true },
|
| 31 |
];
|
| 32 |
let preparedData = {};
|
| 33 |
let colorsByRun = {};
|
| 34 |
+
|
| 35 |
// Variables for data management (will be initialized in onMount)
|
| 36 |
let dataByMetric = new Map();
|
| 37 |
let metricsToDraw = [];
|
| 38 |
let currentRunList = [];
|
| 39 |
let cycleIdx = 2;
|
| 40 |
+
|
| 41 |
// Dynamic color palette using color-palettes.js helper
|
| 42 |
+
let dynamicPalette = [
|
| 43 |
+
"#0ea5e9",
|
| 44 |
+
"#8b5cf6",
|
| 45 |
+
"#f59e0b",
|
| 46 |
+
"#ef4444",
|
| 47 |
+
"#10b981",
|
| 48 |
+
"#f97316",
|
| 49 |
+
"#3b82f6",
|
| 50 |
+
"#8b5ad6",
|
| 51 |
+
]; // fallback
|
| 52 |
+
|
| 53 |
const updateDynamicPalette = () => {
|
| 54 |
+
if (
|
| 55 |
+
typeof window !== "undefined" &&
|
| 56 |
+
window.ColorPalettes &&
|
| 57 |
+
currentRunList.length > 0
|
| 58 |
+
) {
|
| 59 |
try {
|
| 60 |
+
dynamicPalette = window.ColorPalettes.getColors(
|
| 61 |
+
"categorical",
|
| 62 |
+
currentRunList.length,
|
| 63 |
+
);
|
| 64 |
} catch (e) {
|
| 65 |
+
console.warn("Failed to generate dynamic palette:", e);
|
| 66 |
// Keep fallback palette
|
| 67 |
}
|
| 68 |
}
|
| 69 |
};
|
| 70 |
+
|
| 71 |
const colorForRun = (name) => {
|
| 72 |
const idx = currentRunList.indexOf(name);
|
| 73 |
+
return idx >= 0 ? dynamicPalette[idx % dynamicPalette.length] : "#999";
|
| 74 |
};
|
|
|
|
| 75 |
|
| 76 |
// Jitter function - generates completely new data with new runs
|
| 77 |
+
function jitterData() {
|
| 78 |
+
console.log(
|
| 79 |
+
"jitterData called - generating new data with random number of runs",
|
| 80 |
+
); // Debug log
|
| 81 |
+
|
| 82 |
// Generate new random data with weighted probability for fewer runs
|
| 83 |
// Higher probability for 2-3 runs, lower for 4-5-6 runs
|
| 84 |
const rand = Math.random();
|
| 85 |
let wantRuns;
|
| 86 |
+
if (rand < 0.4)
|
| 87 |
+
wantRuns = 2; // 40% chance
|
| 88 |
+
else if (rand < 0.7)
|
| 89 |
+
wantRuns = 3; // 30% chance
|
| 90 |
+
else if (rand < 0.85)
|
| 91 |
+
wantRuns = 4; // 15% chance
|
| 92 |
+
else if (rand < 0.95)
|
| 93 |
+
wantRuns = 5; // 10% chance
|
| 94 |
+
else wantRuns = 6; // 5% chance
|
| 95 |
// Use realistic ML training step counts
|
| 96 |
const stepsCount = Random.trainingSteps();
|
| 97 |
const runsSim = generateRunNames(wantRuns, stepsCount);
|
| 98 |
+
const steps = Array.from({ length: stepsCount }, (_, i) => i + 1);
|
| 99 |
const nextByMetric = new Map();
|
| 100 |
+
const TARGET_METRICS = [
|
| 101 |
+
"epoch",
|
| 102 |
+
"train_accuracy",
|
| 103 |
+
"train_loss",
|
| 104 |
+
"val_accuracy",
|
| 105 |
+
"val_loss",
|
| 106 |
+
];
|
| 107 |
+
|
| 108 |
// Initialize data structure
|
| 109 |
TARGET_METRICS.forEach((tgt) => {
|
| 110 |
const map = {};
|
| 111 |
+
runsSim.forEach((r) => {
|
| 112 |
+
map[r] = [];
|
| 113 |
+
});
|
| 114 |
nextByMetric.set(tgt, map);
|
| 115 |
});
|
| 116 |
+
|
| 117 |
// Generate curves for each run
|
| 118 |
+
runsSim.forEach((run) => {
|
| 119 |
const curves = genCurves(stepsCount);
|
| 120 |
+
steps.forEach((s, i) => {
|
| 121 |
+
nextByMetric.get("epoch")[run].push({ step: s, value: s });
|
| 122 |
+
nextByMetric
|
| 123 |
+
.get("train_accuracy")
|
| 124 |
+
[run].push({ step: s, value: curves.accTrain[i] });
|
| 125 |
+
nextByMetric
|
| 126 |
+
.get("val_accuracy")
|
| 127 |
+
[run].push({ step: s, value: curves.accVal[i] });
|
| 128 |
+
nextByMetric
|
| 129 |
+
.get("train_loss")
|
| 130 |
+
[run].push({ step: s, value: curves.lossTrain[i] });
|
| 131 |
+
nextByMetric
|
| 132 |
+
.get("val_loss")
|
| 133 |
+
[run].push({ step: s, value: curves.lossVal[i] });
|
| 134 |
});
|
| 135 |
});
|
| 136 |
+
|
| 137 |
// Update all reactive data
|
| 138 |
+
nextByMetric.forEach((v, k) => dataByMetric.set(k, v));
|
| 139 |
metricsToDraw = TARGET_METRICS;
|
| 140 |
currentRunList = runsSim.slice();
|
| 141 |
updateDynamicPalette(); // Generate new colors based on run count
|
| 142 |
+
legendItems = currentRunList.map((name) => ({
|
| 143 |
+
name,
|
| 144 |
+
color: colorForRun(name),
|
| 145 |
+
}));
|
| 146 |
updatePreparedData();
|
| 147 |
+
colorsByRun = Object.fromEntries(
|
| 148 |
+
currentRunList.map((name) => [name, colorForRun(name)]),
|
| 149 |
+
);
|
| 150 |
+
|
| 151 |
+
console.log(
|
| 152 |
+
`jitterData completed - generated ${wantRuns} runs with ${stepsCount} steps`,
|
| 153 |
+
); // Debug log
|
| 154 |
}
|
| 155 |
|
| 156 |
// Public API: allow external theme switch
|
| 157 |
+
function setTheme(name) {
|
| 158 |
+
variant = name === "oblivion" ? "oblivion" : "classic";
|
| 159 |
updateThemeClass();
|
| 160 |
+
|
| 161 |
// Debug log for font application
|
| 162 |
+
if (typeof window !== "undefined") {
|
| 163 |
console.log(`Theme switched to: ${variant}`);
|
| 164 |
if (hostEl) {
|
| 165 |
const computedStyle = getComputedStyle(hostEl);
|
|
|
|
| 185 |
|
| 186 |
// Public API: generate massive test dataset
|
| 187 |
function generateMassiveDataset(steps = null, runs = 3) {
|
| 188 |
+
console.log(
|
| 189 |
+
"π§ͺ Generating massive test dataset for sampling validation...",
|
| 190 |
+
);
|
| 191 |
+
|
| 192 |
const result = generateMassiveTestDataset(steps, runs);
|
| 193 |
+
|
| 194 |
// Update reactive data with massive dataset
|
| 195 |
result.dataByMetric.forEach((v, k) => dataByMetric.set(k, v));
|
| 196 |
+
metricsToDraw = [
|
| 197 |
+
"epoch",
|
| 198 |
+
"train_accuracy",
|
| 199 |
+
"train_loss",
|
| 200 |
+
"val_accuracy",
|
| 201 |
+
"val_loss",
|
| 202 |
+
];
|
| 203 |
currentRunList = result.runNames.slice();
|
| 204 |
updateDynamicPalette();
|
| 205 |
+
legendItems = currentRunList.map((name) => ({
|
| 206 |
+
name,
|
| 207 |
+
color: colorForRun(name),
|
| 208 |
+
}));
|
| 209 |
updatePreparedData();
|
| 210 |
+
colorsByRun = Object.fromEntries(
|
| 211 |
+
currentRunList.map((name) => [name, colorForRun(name)]),
|
| 212 |
+
);
|
| 213 |
+
|
| 214 |
+
console.log(
|
| 215 |
+
`β
Massive dataset loaded: ${result.stepCount} steps Γ ${result.runNames.length} runs`,
|
| 216 |
+
);
|
| 217 |
console.log(`π Total data points: ${result.totalPoints.toLocaleString()}`);
|
| 218 |
console.log(`π― Description: ${result.description}`);
|
| 219 |
+
|
| 220 |
return result;
|
| 221 |
}
|
| 222 |
|
| 223 |
// Public API: add live data point for simulation
|
| 224 |
function addLiveDataPoint(runName, dataPoint) {
|
| 225 |
console.log(`Adding live data point for run "${runName}":`, dataPoint);
|
| 226 |
+
|
| 227 |
// Add run to currentRunList if it doesn't exist
|
| 228 |
if (!currentRunList.includes(runName)) {
|
| 229 |
currentRunList = [...currentRunList, runName];
|
| 230 |
updateDynamicPalette();
|
| 231 |
+
colorsByRun = Object.fromEntries(
|
| 232 |
+
currentRunList.map((name) => [name, colorForRun(name)]),
|
| 233 |
+
);
|
| 234 |
+
legendItems = currentRunList.map((name) => ({
|
| 235 |
+
name,
|
| 236 |
+
color: colorForRun(name),
|
| 237 |
+
}));
|
| 238 |
}
|
| 239 |
+
|
| 240 |
// Initialize data structures for the run if needed
|
| 241 |
+
const TARGET_METRICS = [
|
| 242 |
+
"epoch",
|
| 243 |
+
"train_accuracy",
|
| 244 |
+
"train_loss",
|
| 245 |
+
"val_accuracy",
|
| 246 |
+
"val_loss",
|
| 247 |
+
];
|
| 248 |
+
TARGET_METRICS.forEach((metric) => {
|
| 249 |
if (!dataByMetric.has(metric)) {
|
| 250 |
dataByMetric.set(metric, {});
|
| 251 |
}
|
|
|
|
| 254 |
metricData[runName] = [];
|
| 255 |
}
|
| 256 |
});
|
| 257 |
+
|
| 258 |
// Add the new data points to each metric
|
| 259 |
const step = dataPoint.step;
|
| 260 |
+
|
| 261 |
// Add epoch data
|
| 262 |
+
const epochData = dataByMetric.get("epoch");
|
| 263 |
epochData[runName].push({ step, value: step });
|
| 264 |
+
|
| 265 |
// Add accuracy data (train and val get the same value for simplicity)
|
| 266 |
if (dataPoint.accuracy !== undefined) {
|
| 267 |
+
const trainAccData = dataByMetric.get("train_accuracy");
|
| 268 |
+
const valAccData = dataByMetric.get("val_accuracy");
|
| 269 |
+
|
| 270 |
// Add some noise between train and val accuracy
|
| 271 |
const trainAcc = dataPoint.accuracy;
|
| 272 |
+
const valAcc = Math.max(
|
| 273 |
+
0,
|
| 274 |
+
Math.min(1, dataPoint.accuracy - 0.01 - Math.random() * 0.03),
|
| 275 |
+
);
|
| 276 |
+
|
| 277 |
trainAccData[runName].push({ step, value: trainAcc });
|
| 278 |
valAccData[runName].push({ step, value: valAcc });
|
| 279 |
}
|
| 280 |
+
|
| 281 |
// Add loss data (train and val get the same value for simplicity)
|
| 282 |
if (dataPoint.loss !== undefined) {
|
| 283 |
+
const trainLossData = dataByMetric.get("train_loss");
|
| 284 |
+
const valLossData = dataByMetric.get("val_loss");
|
| 285 |
+
|
| 286 |
// Add some noise between train and val loss
|
| 287 |
const trainLoss = dataPoint.loss;
|
| 288 |
const valLoss = dataPoint.loss + 0.05 + Math.random() * 0.1;
|
| 289 |
+
|
| 290 |
trainLossData[runName].push({ step, value: trainLoss });
|
| 291 |
valLossData[runName].push({ step, value: valLoss });
|
| 292 |
}
|
| 293 |
+
|
| 294 |
// Update all metrics to draw
|
| 295 |
metricsToDraw = TARGET_METRICS;
|
| 296 |
+
|
| 297 |
// Update prepared data with new values
|
| 298 |
updatePreparedData();
|
| 299 |
+
|
| 300 |
+
console.log(
|
| 301 |
+
`Live data point added successfully. Total runs: ${currentRunList.length}`,
|
| 302 |
+
);
|
| 303 |
}
|
| 304 |
|
| 305 |
// Update prepared data with optional smoothing
|
| 306 |
let preparedRawData = {}; // Store original data for background display
|
| 307 |
+
|
| 308 |
function updatePreparedData() {
|
| 309 |
+
const TARGET_METRICS = [
|
| 310 |
+
"epoch",
|
| 311 |
+
"train_accuracy",
|
| 312 |
+
"train_loss",
|
| 313 |
+
"val_accuracy",
|
| 314 |
+
"val_loss",
|
| 315 |
+
];
|
| 316 |
let dataToUse = {};
|
| 317 |
let rawDataToStore = {};
|
| 318 |
+
|
| 319 |
+
TARGET_METRICS.forEach((metric) => {
|
| 320 |
const rawData = dataByMetric.get(metric);
|
| 321 |
if (rawData) {
|
| 322 |
// Store original data
|
| 323 |
rawDataToStore[metric] = rawData;
|
| 324 |
+
|
| 325 |
// Apply smoothing if enabled (except for epoch which should stay exact)
|
| 326 |
+
dataToUse[metric] =
|
| 327 |
+
smoothing && metric !== "epoch"
|
| 328 |
+
? smoothMetricData(rawData, 5) // Window size of 5
|
| 329 |
+
: rawData;
|
| 330 |
}
|
| 331 |
});
|
| 332 |
+
|
| 333 |
preparedData = dataToUse;
|
| 334 |
preparedRawData = rawDataToStore;
|
| 335 |
console.log(`Prepared data updated, smoothing: ${smoothing}`);
|
| 336 |
}
|
| 337 |
|
| 338 |
+
function updateThemeClass() {
|
| 339 |
if (!hostEl) return;
|
| 340 |
+
hostEl.classList.toggle("theme--classic", variant === "classic");
|
| 341 |
+
hostEl.classList.toggle("theme--oblivion", variant === "oblivion");
|
| 342 |
+
hostEl.setAttribute("data-variant", variant);
|
| 343 |
}
|
| 344 |
|
| 345 |
$: updateThemeClass();
|
| 346 |
|
|
|
|
| 347 |
// Chart logic now handled by Cell.svelte
|
| 348 |
+
|
| 349 |
// Fullscreen navigation state
|
| 350 |
let currentFullscreenIndex = 0;
|
| 351 |
let isModalOpen = false;
|
| 352 |
+
|
| 353 |
function handleNavigate(newIndex) {
|
| 354 |
currentFullscreenIndex = newIndex;
|
| 355 |
}
|
| 356 |
+
|
| 357 |
function openModal(index) {
|
| 358 |
currentFullscreenIndex = index;
|
| 359 |
isModalOpen = true;
|
| 360 |
}
|
| 361 |
+
|
| 362 |
function closeModal() {
|
| 363 |
isModalOpen = false;
|
| 364 |
}
|
| 365 |
+
|
| 366 |
// Prepare all charts data for navigation
|
| 367 |
+
$: allChartsData = cellsDef.map((c) => ({
|
| 368 |
metricKey: c.metric,
|
| 369 |
titleText: c.title,
|
| 370 |
metricData: (preparedData && preparedData[c.metric]) || {},
|
| 371 |
+
rawMetricData: (preparedRawData && preparedRawData[c.metric]) || {},
|
| 372 |
}));
|
| 373 |
+
|
| 374 |
// Color function for the modal
|
| 375 |
+
$: modalColorForRun = (name) => colorsByRun[name] || "#999";
|
| 376 |
|
| 377 |
let cleanup = null;
|
| 378 |
onMount(() => {
|
|
|
|
| 380 |
hostEl.__setTheme = setTheme;
|
| 381 |
|
| 382 |
// Jitter & Simulate functions
|
| 383 |
+
function rebuildLegend() {
|
| 384 |
updateDynamicPalette(); // Update colors when adding new data
|
| 385 |
+
legendItems = currentRunList.map((name) => ({
|
| 386 |
+
name,
|
| 387 |
+
color: colorForRun(name),
|
| 388 |
+
}));
|
| 389 |
}
|
| 390 |
+
|
| 391 |
+
function simulateData() {
|
| 392 |
// Generate new random data with weighted probability for fewer runs
|
| 393 |
// Higher probability for 2-3 runs, lower for 4-5-6 runs
|
| 394 |
const rand = Math.random();
|
| 395 |
let wantRuns;
|
| 396 |
+
if (rand < 0.4)
|
| 397 |
+
wantRuns = 2; // 40% chance
|
| 398 |
+
else if (rand < 0.7)
|
| 399 |
+
wantRuns = 3; // 30% chance
|
| 400 |
+
else if (rand < 0.85)
|
| 401 |
+
wantRuns = 4; // 15% chance
|
| 402 |
+
else if (rand < 0.95)
|
| 403 |
+
wantRuns = 5; // 10% chance
|
| 404 |
+
else wantRuns = 6; // 5% chance
|
| 405 |
// Use realistic ML training step counts with cycling scenarios
|
| 406 |
let stepsCount;
|
| 407 |
if (cycleIdx === 0) {
|
| 408 |
+
stepsCount = Random.trainingStepsForScenario("prototyping");
|
| 409 |
} else if (cycleIdx === 1) {
|
| 410 |
+
stepsCount = Random.trainingStepsForScenario("development");
|
| 411 |
} else if (cycleIdx === 2) {
|
| 412 |
+
stepsCount = Random.trainingStepsForScenario("production");
|
| 413 |
} else if (cycleIdx === 3) {
|
| 414 |
+
stepsCount = Random.trainingStepsForScenario("research");
|
| 415 |
} else if (cycleIdx === 4) {
|
| 416 |
+
stepsCount = Random.trainingStepsForScenario("llm");
|
| 417 |
} else if (cycleIdx === 5) {
|
| 418 |
+
stepsCount = Random.trainingStepsForScenario("massive");
|
| 419 |
} else {
|
| 420 |
stepsCount = Random.trainingSteps(); // Full range for variety
|
| 421 |
}
|
| 422 |
cycleIdx = (cycleIdx + 1) % 7; // Cycle through 7 scenarios now
|
| 423 |
+
|
| 424 |
const runsSim = generateRunNames(wantRuns, stepsCount);
|
| 425 |
+
const steps = Array.from({ length: stepsCount }, (_, i) => i + 1);
|
| 426 |
const nextByMetric = new Map();
|
| 427 |
+
const TARGET_METRICS = [
|
| 428 |
+
"epoch",
|
| 429 |
+
"train_accuracy",
|
| 430 |
+
"train_loss",
|
| 431 |
+
"val_accuracy",
|
| 432 |
+
"val_loss",
|
| 433 |
+
];
|
| 434 |
+
const mList =
|
| 435 |
+
metricsToDraw && metricsToDraw.length ? metricsToDraw : TARGET_METRICS;
|
| 436 |
mList.forEach((tgt) => {
|
| 437 |
const map = {};
|
| 438 |
+
runsSim.forEach((r) => {
|
| 439 |
+
map[r] = [];
|
| 440 |
+
});
|
| 441 |
nextByMetric.set(tgt, map);
|
| 442 |
});
|
| 443 |
+
runsSim.forEach((run) => {
|
| 444 |
const curves = genCurves(stepsCount);
|
| 445 |
+
steps.forEach((s, i) => {
|
| 446 |
+
if (mList.includes("epoch"))
|
| 447 |
+
nextByMetric.get("epoch")[run].push({ step: s, value: s });
|
| 448 |
+
if (mList.includes("train_accuracy"))
|
| 449 |
+
nextByMetric
|
| 450 |
+
.get("train_accuracy")
|
| 451 |
+
[run].push({ step: s, value: curves.accTrain[i] });
|
| 452 |
+
if (mList.includes("val_accuracy"))
|
| 453 |
+
nextByMetric
|
| 454 |
+
.get("val_accuracy")
|
| 455 |
+
[run].push({ step: s, value: curves.accVal[i] });
|
| 456 |
+
if (mList.includes("train_loss"))
|
| 457 |
+
nextByMetric
|
| 458 |
+
.get("train_loss")
|
| 459 |
+
[run].push({ step: s, value: curves.lossTrain[i] });
|
| 460 |
+
if (mList.includes("val_loss"))
|
| 461 |
+
nextByMetric
|
| 462 |
+
.get("val_loss")
|
| 463 |
+
[run].push({ step: s, value: curves.lossVal[i] });
|
| 464 |
});
|
| 465 |
});
|
| 466 |
+
nextByMetric.forEach((v, k) => dataByMetric.set(k, v));
|
| 467 |
currentRunList = runsSim.slice();
|
| 468 |
rebuildLegend();
|
| 469 |
updatePreparedData();
|
| 470 |
updateDynamicPalette(); // Update colors when rebuilding
|
| 471 |
+
colorsByRun = Object.fromEntries(
|
| 472 |
+
currentRunList.map((name) => [name, colorForRun(name)]),
|
| 473 |
+
);
|
| 474 |
}
|
| 475 |
// No need for event listeners anymore - we'll use reactive statement
|
| 476 |
|
|
|
|
| 478 |
simulateData();
|
| 479 |
// Svelte Cells will react to preparedData/colorsByRun updates
|
| 480 |
|
| 481 |
+
cleanup = () => {
|
| 482 |
// No cleanup needed for reactive statements
|
| 483 |
};
|
| 484 |
});
|
| 485 |
|
| 486 |
+
onDestroy(() => {
|
| 487 |
+
if (cleanup) cleanup();
|
| 488 |
+
});
|
| 489 |
|
| 490 |
// Expose instance for debugging and external theme control
|
| 491 |
onMount(() => {
|
| 492 |
+
window.trackioInstance = {
|
| 493 |
+
jitterData,
|
| 494 |
+
addLiveDataPoint,
|
| 495 |
+
generateMassiveDataset,
|
| 496 |
+
};
|
| 497 |
if (hostEl) {
|
| 498 |
+
hostEl.__trackioInstance = {
|
| 499 |
+
setTheme,
|
| 500 |
+
setLogScaleX,
|
| 501 |
+
setSmoothing,
|
| 502 |
+
jitterData,
|
| 503 |
+
addLiveDataPoint,
|
| 504 |
+
generateMassiveDataset,
|
| 505 |
+
};
|
| 506 |
}
|
| 507 |
+
|
| 508 |
// Initialize dynamic palette
|
| 509 |
updateDynamicPalette();
|
| 510 |
+
|
| 511 |
// Listen for palette updates from color-palettes.js
|
| 512 |
const handlePaletteUpdate = () => {
|
| 513 |
updateDynamicPalette();
|
| 514 |
// Rebuild legend and colors if needed
|
| 515 |
if (currentRunList.length > 0) {
|
| 516 |
+
legendItems = currentRunList.map((name) => ({
|
| 517 |
+
name,
|
| 518 |
+
color: colorForRun(name),
|
| 519 |
+
}));
|
| 520 |
+
colorsByRun = Object.fromEntries(
|
| 521 |
+
currentRunList.map((name) => [name, colorForRun(name)]),
|
| 522 |
+
);
|
| 523 |
}
|
| 524 |
};
|
| 525 |
+
|
| 526 |
+
document.addEventListener("palettes:updated", handlePaletteUpdate);
|
| 527 |
+
|
| 528 |
// Cleanup listener on destroy
|
| 529 |
return () => {
|
| 530 |
+
document.removeEventListener("palettes:updated", handlePaletteUpdate);
|
| 531 |
};
|
| 532 |
});
|
| 533 |
|
| 534 |
// React to jitter trigger from store
|
| 535 |
$: {
|
| 536 |
+
console.log(
|
| 537 |
+
"Reactive statement triggered, jitterTrigger value:",
|
| 538 |
+
$jitterTrigger,
|
| 539 |
+
);
|
| 540 |
if ($jitterTrigger > 0) {
|
| 541 |
+
console.log(
|
| 542 |
+
"Jitter trigger activated:",
|
| 543 |
+
$jitterTrigger,
|
| 544 |
+
"calling jitterData()",
|
| 545 |
+
);
|
| 546 |
jitterData();
|
| 547 |
}
|
| 548 |
}
|
| 549 |
|
| 550 |
// Legend ghost helpers (hover effects)
|
| 551 |
+
function ghostRun(run) {
|
| 552 |
try {
|
| 553 |
+
hostEl.classList.add("hovering");
|
| 554 |
+
|
| 555 |
// Ghost the chart lines and points
|
| 556 |
+
hostEl.querySelectorAll(".cell").forEach((cell) => {
|
| 557 |
+
cell
|
| 558 |
+
.querySelectorAll("svg .lines path.run-line")
|
| 559 |
+
.forEach((p) =>
|
| 560 |
+
p.classList.toggle("ghost", p.getAttribute("data-run") !== run),
|
| 561 |
+
);
|
| 562 |
+
cell
|
| 563 |
+
.querySelectorAll("svg .lines path.raw-line")
|
| 564 |
+
.forEach((p) =>
|
| 565 |
+
p.classList.toggle("ghost", p.getAttribute("data-run") !== run),
|
| 566 |
+
);
|
| 567 |
+
cell
|
| 568 |
+
.querySelectorAll("svg .points circle.pt")
|
| 569 |
+
.forEach((c) =>
|
| 570 |
+
c.classList.toggle("ghost", c.getAttribute("data-run") !== run),
|
| 571 |
+
);
|
| 572 |
});
|
| 573 |
+
|
| 574 |
// Ghost the legend items
|
| 575 |
+
hostEl.querySelectorAll(".legend-bottom .item").forEach((item) => {
|
| 576 |
+
const itemRun = item.getAttribute("data-run");
|
| 577 |
+
item.classList.toggle("ghost", itemRun !== run);
|
| 578 |
});
|
| 579 |
+
} catch (_) {}
|
| 580 |
}
|
| 581 |
+
function clearGhost() {
|
| 582 |
try {
|
| 583 |
+
hostEl.classList.remove("hovering");
|
| 584 |
+
|
| 585 |
// Clear ghost from chart lines and points
|
| 586 |
+
hostEl.querySelectorAll(".cell").forEach((cell) => {
|
| 587 |
+
cell
|
| 588 |
+
.querySelectorAll("svg .lines path.run-line")
|
| 589 |
+
.forEach((p) => p.classList.remove("ghost"));
|
| 590 |
+
cell
|
| 591 |
+
.querySelectorAll("svg .lines path.raw-line")
|
| 592 |
+
.forEach((p) => p.classList.remove("ghost"));
|
| 593 |
+
cell
|
| 594 |
+
.querySelectorAll("svg .points circle.pt")
|
| 595 |
+
.forEach((c) => c.classList.remove("ghost"));
|
| 596 |
});
|
| 597 |
+
|
| 598 |
// Clear ghost from legend items
|
| 599 |
+
hostEl.querySelectorAll(".legend-bottom .item").forEach((item) => {
|
| 600 |
+
item.classList.remove("ghost");
|
| 601 |
});
|
| 602 |
+
} catch (_) {}
|
| 603 |
}
|
| 604 |
</script>
|
| 605 |
|
| 606 |
<div class="trackio theme--classic" bind:this={hostEl} data-variant={variant}>
|
| 607 |
<div class="trackio__header">
|
| 608 |
+
<Legend
|
| 609 |
+
items={legendItems}
|
| 610 |
+
on:legend-hover={(e) => {
|
| 611 |
+
const run = e?.detail?.name;
|
| 612 |
+
if (!run) return;
|
| 613 |
+
ghostRun(run);
|
| 614 |
+
}}
|
| 615 |
+
on:legend-leave={() => {
|
| 616 |
+
clearGhost();
|
| 617 |
+
}}
|
| 618 |
+
/>
|
| 619 |
</div>
|
| 620 |
<div class="trackio__grid" bind:this={gridEl}>
|
| 621 |
{#each cellsDef as c, i}
|
| 622 |
+
<Cell
|
| 623 |
+
metricKey={c.metric}
|
| 624 |
+
titleText={c.title}
|
| 625 |
+
wide={c.wide}
|
| 626 |
+
{variant}
|
| 627 |
+
{normalizeLoss}
|
| 628 |
+
{logScaleX}
|
| 629 |
+
{smoothing}
|
| 630 |
+
metricData={(preparedData && preparedData[c.metric]) || {}}
|
| 631 |
+
rawMetricData={(preparedRawData && preparedRawData[c.metric]) || {}}
|
| 632 |
+
colorForRun={(name) => colorsByRun[name] || "#999"}
|
| 633 |
{hostEl}
|
| 634 |
currentIndex={i}
|
| 635 |
onOpenModal={openModal}
|
|
|
|
| 638 |
</div>
|
| 639 |
<div class="trackio__footer">
|
| 640 |
<small>
|
| 641 |
+
Built with <a
|
| 642 |
+
href="https://github.com/huggingface/trackio"
|
| 643 |
+
target="_blank"
|
| 644 |
+
rel="noopener noreferrer">TrackIO</a
|
| 645 |
+
>
|
| 646 |
<span class="separator">β’</span>
|
| 647 |
+
<a
|
| 648 |
+
href="https://huggingface.co/docs/hub/spaces-sdks-docker"
|
| 649 |
+
target="_blank"
|
| 650 |
+
rel="noopener noreferrer">Use via API</a
|
| 651 |
+
>
|
| 652 |
</small>
|
| 653 |
</div>
|
| 654 |
</div>
|
|
|
|
| 656 |
<!-- Centralized Fullscreen Modal -->
|
| 657 |
<FullscreenModal
|
| 658 |
visible={isModalOpen}
|
| 659 |
+
title={allChartsData[currentFullscreenIndex]?.titleText || ""}
|
| 660 |
metricData={allChartsData[currentFullscreenIndex]?.metricData || {}}
|
| 661 |
rawMetricData={allChartsData[currentFullscreenIndex]?.rawMetricData || {}}
|
| 662 |
colorForRun={modalColorForRun}
|
|
|
|
| 664 |
{logScaleX}
|
| 665 |
{smoothing}
|
| 666 |
{normalizeLoss}
|
| 667 |
+
metricKey={allChartsData[currentFullscreenIndex]?.metricKey || ""}
|
| 668 |
+
titleText={allChartsData[currentFullscreenIndex]?.titleText || ""}
|
| 669 |
currentIndex={currentFullscreenIndex}
|
| 670 |
totalCharts={cellsDef.length}
|
| 671 |
onNavigate={handleNavigate}
|
|
|
|
| 678 |
========================= */
|
| 679 |
|
| 680 |
/* Font imports for themes - ensure Roboto Mono is loaded for Oblivion theme */
|
| 681 |
+
@import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap");
|
| 682 |
+
|
| 683 |
/* Fallback font-face declaration */
|
| 684 |
@font-face {
|
| 685 |
+
font-family: "Roboto Mono Fallback";
|
| 686 |
+
src: url("https://fonts.gstatic.com/s/robotomono/v23/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW4AJi8SJQt.woff2")
|
| 687 |
+
format("woff2");
|
| 688 |
font-weight: 400;
|
| 689 |
font-style: normal;
|
| 690 |
font-display: swap;
|
|
|
|
| 695 |
position: relative;
|
| 696 |
--z-tooltip: 50;
|
| 697 |
--z-overlay: 99999999;
|
| 698 |
+
|
| 699 |
/* Typography */
|
| 700 |
+
--trackio-font-family: var(
|
| 701 |
+
--font-mono,
|
| 702 |
+
ui-monospace,
|
| 703 |
+
SFMono-Regular,
|
| 704 |
+
Menlo,
|
| 705 |
+
monospace
|
| 706 |
+
);
|
| 707 |
--trackio-font-weight-normal: 400;
|
| 708 |
--trackio-font-weight-medium: 600;
|
| 709 |
--trackio-font-weight-bold: 700;
|
| 710 |
+
|
| 711 |
/* Apply font-family to root element */
|
| 712 |
font-family: var(--trackio-font-family);
|
| 713 |
+
|
| 714 |
/* Base color system for Classic theme */
|
| 715 |
--trackio-base: #323232;
|
| 716 |
--trackio-primary: var(--trackio-base);
|
| 717 |
--trackio-dim: color-mix(in srgb, var(--trackio-base) 28%, transparent);
|
| 718 |
--trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
|
| 719 |
--trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
|
| 720 |
+
|
| 721 |
/* Chart rendering */
|
| 722 |
+
--trackio-chart-grid-type: "lines"; /* 'lines' | 'dots' */
|
| 723 |
--trackio-chart-axis-stroke: var(--trackio-dim);
|
| 724 |
--trackio-chart-axis-text: var(--trackio-text);
|
| 725 |
--trackio-chart-grid-stroke: var(--trackio-subtle);
|
| 726 |
--trackio-chart-grid-opacity: 1;
|
| 727 |
}
|
| 728 |
+
|
| 729 |
/* Dark mode overrides for Classic theme */
|
| 730 |
:global([data-theme="dark"]) .trackio.theme--classic {
|
| 731 |
--trackio-base: #ffffff;
|
|
|
|
| 733 |
--trackio-dim: color-mix(in srgb, var(--trackio-base) 25%, transparent);
|
| 734 |
--trackio-text: color-mix(in srgb, var(--trackio-base) 60%, transparent);
|
| 735 |
--trackio-subtle: color-mix(in srgb, var(--trackio-base) 8%, transparent);
|
| 736 |
+
|
| 737 |
/* Cell background for dark mode */
|
| 738 |
--trackio-cell-background: rgba(255, 255, 255, 0.03);
|
| 739 |
}
|
| 740 |
+
|
| 741 |
.trackio.theme--classic {
|
| 742 |
/* Cell styling */
|
| 743 |
--trackio-cell-background: rgba(0, 0, 0, 0.02);
|
| 744 |
--trackio-cell-border: var(--border-color, rgba(0, 0, 0, 0.1));
|
| 745 |
--trackio-cell-corner-inset: 0px;
|
| 746 |
--trackio-cell-gap: 12px;
|
| 747 |
+
|
| 748 |
/* Typography */
|
| 749 |
--trackio-text-primary: var(--text-color, rgba(0, 0, 0, 0.9));
|
| 750 |
--trackio-text-secondary: var(--muted-color, rgba(0, 0, 0, 0.6));
|
| 751 |
+
--trackio-text-accent: var(--primary-color);
|
| 752 |
+
|
| 753 |
/* Tooltip */
|
| 754 |
--trackio-tooltip-background: var(--surface-bg, white);
|
| 755 |
--trackio-tooltip-border: var(--border-color, rgba(0, 0, 0, 0.1));
|
| 756 |
--trackio-tooltip-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
| 757 |
+
|
| 758 |
/* Legend */
|
| 759 |
--trackio-legend-text: var(--text-color, rgba(0, 0, 0, 0.9));
|
| 760 |
--trackio-legend-swatch-border: var(--border-color, rgba(0, 0, 0, 0.1));
|
|
|
|
| 763 |
/* Dark mode adjustments */
|
| 764 |
:global([data-theme="dark"]) .trackio {
|
| 765 |
--trackio-chart-axis-stroke: rgba(255, 255, 255, 0.18);
|
| 766 |
+
--trackio-chart-axis-text: rgba(255, 255, 255, 0.6);
|
| 767 |
--trackio-chart-grid-stroke: rgba(255, 255, 255, 0.08);
|
| 768 |
}
|
| 769 |
|
| 770 |
/* =========================
|
| 771 |
THEME: CLASSIC (Default)
|
| 772 |
========================= */
|
| 773 |
+
|
| 774 |
.trackio.theme--classic {
|
| 775 |
/* Keep default values - no overrides needed */
|
| 776 |
}
|
|
|
|
| 778 |
/* =========================
|
| 779 |
THEME: OBLIVION
|
| 780 |
========================= */
|
| 781 |
+
|
| 782 |
.trackio.theme--oblivion {
|
| 783 |
/* Core oblivion color system - Light mode: darker colors for visibility */
|
| 784 |
--trackio-oblivion-base: #2a2a2a;
|
| 785 |
--trackio-oblivion-primary: var(--trackio-oblivion-base);
|
| 786 |
+
--trackio-oblivion-dim: color-mix(
|
| 787 |
+
in srgb,
|
| 788 |
+
var(--trackio-oblivion-base) 30%,
|
| 789 |
+
transparent
|
| 790 |
+
);
|
| 791 |
+
--trackio-oblivion-subtle: color-mix(
|
| 792 |
+
in srgb,
|
| 793 |
+
var(--trackio-oblivion-base) 8%,
|
| 794 |
+
transparent
|
| 795 |
+
);
|
| 796 |
+
--trackio-oblivion-ghost: color-mix(
|
| 797 |
+
in srgb,
|
| 798 |
+
var(--trackio-oblivion-base) 4%,
|
| 799 |
+
transparent
|
| 800 |
+
);
|
| 801 |
+
|
| 802 |
/* Chart rendering overrides */
|
| 803 |
+
--trackio-chart-grid-type: "dots";
|
| 804 |
--trackio-chart-axis-stroke: var(--trackio-oblivion-dim);
|
| 805 |
--trackio-chart-axis-text: var(--trackio-oblivion-primary);
|
| 806 |
--trackio-chart-grid-stroke: var(--trackio-oblivion-dim);
|
| 807 |
--trackio-chart-grid-opacity: 0.6;
|
| 808 |
}
|
| 809 |
+
|
| 810 |
/* Dark mode overrides for Oblivion theme */
|
| 811 |
:global([data-theme="dark"]) .trackio.theme--oblivion {
|
| 812 |
--trackio-oblivion-base: #ffffff;
|
| 813 |
--trackio-oblivion-primary: var(--trackio-oblivion-base);
|
| 814 |
+
--trackio-oblivion-dim: color-mix(
|
| 815 |
+
in srgb,
|
| 816 |
+
var(--trackio-oblivion-base) 25%,
|
| 817 |
+
transparent
|
| 818 |
+
);
|
| 819 |
+
--trackio-oblivion-subtle: color-mix(
|
| 820 |
+
in srgb,
|
| 821 |
+
var(--trackio-oblivion-base) 8%,
|
| 822 |
+
transparent
|
| 823 |
+
);
|
| 824 |
+
--trackio-oblivion-ghost: color-mix(
|
| 825 |
+
in srgb,
|
| 826 |
+
var(--trackio-oblivion-base) 4%,
|
| 827 |
+
transparent
|
| 828 |
+
);
|
| 829 |
}
|
| 830 |
+
|
| 831 |
.trackio.theme--oblivion {
|
| 832 |
/* Cell styling overrides */
|
| 833 |
--trackio-cell-background: var(--trackio-oblivion-subtle);
|
| 834 |
--trackio-cell-border: var(--trackio-oblivion-dim);
|
| 835 |
--trackio-cell-corner-inset: 6px;
|
| 836 |
--trackio-cell-gap: 0px;
|
| 837 |
+
|
| 838 |
/* HUD-specific variables */
|
| 839 |
--trackio-oblivion-hud-gap: 10px;
|
| 840 |
--trackio-oblivion-hud-corner-size: 8px;
|
| 841 |
+
--trackio-oblivion-hud-bg-gradient: radial-gradient(
|
| 842 |
+
1200px 200px at 20% -10%,
|
| 843 |
+
var(--trackio-oblivion-ghost),
|
| 844 |
+
transparent 80%
|
| 845 |
+
),
|
| 846 |
+
radial-gradient(
|
| 847 |
+
900px 200px at 80% 110%,
|
| 848 |
+
var(--trackio-oblivion-ghost),
|
| 849 |
+
transparent 80%
|
| 850 |
+
);
|
| 851 |
+
|
| 852 |
/* Typography overrides */
|
| 853 |
--trackio-text-primary: var(--trackio-oblivion-primary);
|
| 854 |
--trackio-text-secondary: var(--trackio-oblivion-dim);
|
| 855 |
--trackio-text-accent: var(--trackio-oblivion-primary);
|
| 856 |
+
|
| 857 |
/* Tooltip overrides */
|
| 858 |
--trackio-tooltip-background: var(--trackio-oblivion-subtle);
|
| 859 |
--trackio-tooltip-border: var(--trackio-oblivion-dim);
|
| 860 |
+
--trackio-tooltip-shadow: 0 8px 32px
|
| 861 |
+
color-mix(in srgb, var(--trackio-oblivion-base) 8%, transparent),
|
| 862 |
0 2px 8px color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent);
|
| 863 |
+
|
| 864 |
/* Legend overrides */
|
| 865 |
--trackio-legend-text: var(--trackio-oblivion-primary);
|
| 866 |
--trackio-legend-swatch-border: var(--trackio-oblivion-dim);
|
| 867 |
+
|
| 868 |
/* Font styling overrides */
|
| 869 |
+
--trackio-font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
|
| 870 |
+
SFMono-Regular, Menlo, monospace;
|
| 871 |
font-family: var(--trackio-font-family) !important;
|
| 872 |
color: var(--trackio-text-primary);
|
| 873 |
}
|
|
|
|
| 875 |
/* Force Roboto Mono application in Oblivion theme */
|
| 876 |
.trackio.theme--oblivion,
|
| 877 |
.trackio.theme--oblivion * {
|
| 878 |
+
font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
|
| 879 |
+
SFMono-Regular, Menlo, monospace !important;
|
| 880 |
}
|
| 881 |
+
|
| 882 |
/* Specific overrides for different elements in Oblivion */
|
| 883 |
.trackio.theme--oblivion .cell-title,
|
| 884 |
.trackio.theme--oblivion .legend-bottom,
|
| 885 |
.trackio.theme--oblivion .legend-title,
|
| 886 |
.trackio.theme--oblivion .item {
|
| 887 |
+
font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
|
| 888 |
+
SFMono-Regular, Menlo, monospace !important;
|
| 889 |
}
|
| 890 |
|
| 891 |
/* Dark mode adjustments for Oblivion */
|
| 892 |
:global([data-theme="dark"]) .trackio.theme--oblivion {
|
| 893 |
--trackio-oblivion-base: #ffffff;
|
| 894 |
+
--trackio-oblivion-hud-bg-gradient: radial-gradient(
|
| 895 |
+
1400px 260px at 20% -10%,
|
| 896 |
+
color-mix(in srgb, var(--trackio-oblivion-base) 6.5%, transparent),
|
| 897 |
+
transparent 80%
|
| 898 |
+
),
|
| 899 |
+
radial-gradient(
|
| 900 |
+
1100px 240px at 80% 110%,
|
| 901 |
+
color-mix(in srgb, var(--trackio-oblivion-base) 6%, transparent),
|
| 902 |
+
transparent 80%
|
| 903 |
+
),
|
| 904 |
+
linear-gradient(
|
| 905 |
+
180deg,
|
| 906 |
+
color-mix(in srgb, var(--trackio-oblivion-base) 3.5%, transparent),
|
| 907 |
+
transparent 45%
|
| 908 |
+
);
|
| 909 |
+
|
| 910 |
+
--trackio-tooltip-shadow: 0 8px 32px
|
| 911 |
+
color-mix(in srgb, var(--trackio-oblivion-base) 5%, transparent),
|
| 912 |
0 2px 8px color-mix(in srgb, black 10%, transparent);
|
| 913 |
+
|
| 914 |
background: #0f1115;
|
| 915 |
}
|
| 916 |
|
|
|
|
| 923 |
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 924 |
gap: var(--trackio-cell-gap);
|
| 925 |
}
|
| 926 |
+
|
| 927 |
@media (max-width: 980px) {
|
| 928 |
+
.trackio__grid {
|
| 929 |
+
grid-template-columns: 1fr;
|
| 930 |
+
}
|
| 931 |
}
|
| 932 |
|
| 933 |
.trackio__header {
|
|
|
|
| 945 |
.trackio .axes line {
|
| 946 |
stroke: var(--trackio-chart-axis-stroke);
|
| 947 |
}
|
| 948 |
+
|
| 949 |
.trackio .axes text {
|
| 950 |
fill: var(--trackio-chart-axis-text);
|
| 951 |
font-family: var(--trackio-font-family);
|
| 952 |
}
|
| 953 |
+
|
| 954 |
/* Force font-family for SVG text in Oblivion */
|
| 955 |
.trackio.theme--oblivion .axes text {
|
| 956 |
+
font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
|
| 957 |
+
SFMono-Regular, Menlo, monospace !important;
|
| 958 |
}
|
| 959 |
+
|
| 960 |
.trackio .grid line {
|
| 961 |
stroke: var(--trackio-chart-grid-stroke);
|
| 962 |
opacity: var(--trackio-chart-grid-opacity);
|
| 963 |
}
|
| 964 |
|
| 965 |
/* Grid type switching */
|
| 966 |
+
.trackio .grid-dots {
|
| 967 |
+
display: none;
|
| 968 |
+
}
|
| 969 |
+
.trackio.theme--oblivion .grid {
|
| 970 |
+
display: none;
|
| 971 |
+
}
|
| 972 |
+
.trackio.theme--oblivion .grid-dots {
|
| 973 |
+
display: block;
|
| 974 |
+
}
|
| 975 |
.trackio.theme--oblivion .cell-bg,
|
| 976 |
+
.trackio.theme--oblivion .cell-corners {
|
| 977 |
+
display: block;
|
| 978 |
+
}
|
| 979 |
|
| 980 |
/* =========================
|
| 981 |
FOOTER
|
|
|
|
| 1019 |
}
|
| 1020 |
|
| 1021 |
.trackio.theme--oblivion .trackio__footer small {
|
| 1022 |
+
font-family: "Roboto Mono", "Roboto Mono Fallback", ui-monospace,
|
| 1023 |
+
SFMono-Regular, Menlo, monospace !important;
|
| 1024 |
}
|
| 1025 |
</style>
|
|
|
|
|
|
app/src/components/{TrackioWrapper.astro β trackio/TrackioWrapper.astro}
RENAMED
|
@@ -1,12 +1,15 @@
|
|
| 1 |
---
|
| 2 |
// TrackioWrapper.astro
|
| 3 |
-
import Trackio from
|
| 4 |
---
|
| 5 |
|
| 6 |
<!-- Ensure Roboto Mono is loaded for Oblivion theme -->
|
| 7 |
-
<link rel="preconnect" href="https://fonts.googleapis.com"
|
| 8 |
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin
|
| 9 |
-
<link
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
<div class="trackio-wrapper">
|
| 12 |
<div class="trackio-controls">
|
|
@@ -20,11 +23,11 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 20 |
</div>
|
| 21 |
<div class="scale-controls">
|
| 22 |
<label>
|
| 23 |
-
<input type="checkbox" id="log-scale-x" checked
|
| 24 |
Log Scale X
|
| 25 |
</label>
|
| 26 |
<label>
|
| 27 |
-
<input type="checkbox" id="smooth-data" checked
|
| 28 |
Smooth
|
| 29 |
</label>
|
| 30 |
</div>
|
|
@@ -33,15 +36,24 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 33 |
<button class="button button--ghost" type="button" id="randomize-btn">
|
| 34 |
Randomize Data
|
| 35 |
</button>
|
| 36 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
Live Run
|
| 38 |
</button>
|
| 39 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
Stop
|
| 41 |
</button>
|
| 42 |
</div>
|
| 43 |
</div>
|
| 44 |
-
|
| 45 |
<div class="trackio-container">
|
| 46 |
<Trackio client:load variant="classic" logScaleX={true} smoothing={true} />
|
| 47 |
</div>
|
|
@@ -49,245 +61,284 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 49 |
|
| 50 |
<script>
|
| 51 |
// @ts-nocheck
|
| 52 |
-
document.addEventListener(
|
| 53 |
-
const themeSelect = document.getElementById(
|
| 54 |
-
const randomizeBtn = document.getElementById(
|
| 55 |
-
const startSimulationBtn = document.getElementById(
|
| 56 |
-
const stopSimulationBtn = document.getElementById(
|
| 57 |
-
const logScaleXCheckbox = document.getElementById(
|
| 58 |
-
const smoothDataCheckbox = document.getElementById(
|
| 59 |
-
const trackioContainer = document.querySelector(
|
| 60 |
-
|
| 61 |
-
if (
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
let simulationInterval = null;
|
| 66 |
let currentSimulationRun = null;
|
| 67 |
let currentStep = 0;
|
| 68 |
-
|
| 69 |
// Import the store function
|
| 70 |
-
const { triggerJitter } = await import(
|
| 71 |
-
|
| 72 |
// Theme change handler
|
| 73 |
-
themeSelect.addEventListener(
|
| 74 |
const target = e.target;
|
| 75 |
-
if (!target || !(
|
| 76 |
-
|
| 77 |
const newVariant = target.value;
|
| 78 |
console.log(`Theme changed to: ${newVariant}`); // Debug log
|
| 79 |
-
|
| 80 |
// Find the trackio element and call setTheme on the Svelte instance
|
| 81 |
const trackioEl = debugTrackioState();
|
| 82 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 83 |
-
console.log(
|
| 84 |
trackioEl.__trackioInstance.setTheme(newVariant);
|
| 85 |
} else {
|
| 86 |
-
console.warn(
|
| 87 |
}
|
| 88 |
});
|
| 89 |
|
| 90 |
// Log scale X change handler
|
| 91 |
-
logScaleXCheckbox.addEventListener(
|
| 92 |
const target = e.target;
|
| 93 |
-
if (!target || !(
|
| 94 |
-
|
| 95 |
const isLogScale = target.checked;
|
| 96 |
console.log(`Log scale X changed to: ${isLogScale}`); // Debug log
|
| 97 |
-
|
| 98 |
// Find the trackio element and call setLogScaleX on the Svelte instance
|
| 99 |
const trackioEl = debugTrackioState();
|
| 100 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 101 |
-
console.log(
|
| 102 |
trackioEl.__trackioInstance.setLogScaleX(isLogScale);
|
| 103 |
} else {
|
| 104 |
-
console.warn(
|
| 105 |
}
|
| 106 |
});
|
| 107 |
|
| 108 |
// Smooth data change handler
|
| 109 |
-
smoothDataCheckbox.addEventListener(
|
| 110 |
const target = e.target;
|
| 111 |
-
if (!target || !(
|
| 112 |
-
|
| 113 |
const isSmooth = target.checked;
|
| 114 |
console.log(`Smooth data changed to: ${isSmooth}`); // Debug log
|
| 115 |
-
|
| 116 |
// Find the trackio element and call setSmoothing on the Svelte instance
|
| 117 |
const trackioEl = debugTrackioState();
|
| 118 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 119 |
-
console.log(
|
| 120 |
trackioEl.__trackioInstance.setSmoothing(isSmooth);
|
| 121 |
} else {
|
| 122 |
-
console.warn(
|
| 123 |
}
|
| 124 |
});
|
| 125 |
-
|
| 126 |
// Debug function to check trackio state
|
| 127 |
function debugTrackioState() {
|
| 128 |
-
const trackioEl = trackioContainer.querySelector(
|
| 129 |
-
console.log(
|
| 130 |
container: !!trackioContainer,
|
| 131 |
trackioEl: !!trackioEl,
|
| 132 |
hasInstance: !!(trackioEl && trackioEl.__trackioInstance),
|
| 133 |
-
availableMethods:
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
| 135 |
});
|
| 136 |
return trackioEl;
|
| 137 |
}
|
| 138 |
-
|
| 139 |
// Initialize with default checked states - increased delay and retry logic
|
| 140 |
function initializeTrackio(attempt = 1) {
|
| 141 |
console.log(`π Initializing Trackio (attempt ${attempt})`);
|
| 142 |
-
|
| 143 |
const trackioEl = debugTrackioState();
|
| 144 |
-
|
| 145 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 146 |
-
console.log(
|
| 147 |
-
|
| 148 |
if (logScaleXCheckbox.checked) {
|
| 149 |
-
console.log(
|
| 150 |
trackioEl.__trackioInstance.setLogScaleX(true);
|
| 151 |
}
|
| 152 |
-
|
| 153 |
if (smoothDataCheckbox.checked) {
|
| 154 |
-
console.log(
|
| 155 |
trackioEl.__trackioInstance.setSmoothing(true);
|
| 156 |
}
|
| 157 |
} else {
|
| 158 |
-
console.log(
|
| 159 |
if (attempt < 10) {
|
| 160 |
setTimeout(() => initializeTrackio(attempt + 1), 200 * attempt);
|
| 161 |
} else {
|
| 162 |
-
console.error(
|
| 163 |
}
|
| 164 |
}
|
| 165 |
}
|
| 166 |
-
|
| 167 |
// Start initialization
|
| 168 |
setTimeout(() => initializeTrackio(), 100);
|
| 169 |
-
|
| 170 |
-
//
|
| 171 |
function generateSimulatedValue(step, metric) {
|
| 172 |
const baseProgress = Math.min(1, step / 100); // Normalise sur 100 steps
|
| 173 |
-
|
| 174 |
-
if (metric ===
|
| 175 |
-
// Loss
|
| 176 |
const baseLoss = 2.0 * Math.exp(-0.05 * step);
|
| 177 |
const noise = (Math.random() - 0.5) * 0.2;
|
| 178 |
return Math.max(0.01, baseLoss + noise);
|
| 179 |
-
} else if (metric ===
|
| 180 |
-
// Accuracy
|
| 181 |
const baseAcc = 0.1 + 0.8 * (1 - Math.exp(-0.04 * step));
|
| 182 |
const noise = (Math.random() - 0.5) * 0.05;
|
| 183 |
return Math.max(0, Math.min(1, baseAcc + noise));
|
| 184 |
}
|
| 185 |
return Math.random();
|
| 186 |
}
|
| 187 |
-
|
| 188 |
-
//
|
| 189 |
function startSimulation() {
|
| 190 |
if (simulationInterval) {
|
| 191 |
clearInterval(simulationInterval);
|
| 192 |
}
|
| 193 |
-
|
| 194 |
// GΓ©nΓ©rer un nouveau nom de run
|
| 195 |
-
const adjectives = [
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
|
| 199 |
currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
|
| 200 |
currentStep = 1; // Commencer Γ step 1
|
| 201 |
-
|
| 202 |
console.log(`Starting simulation for run: ${currentSimulationRun}`);
|
| 203 |
-
|
| 204 |
// Interface UI
|
| 205 |
-
startSimulationBtn.style.display =
|
| 206 |
-
stopSimulationBtn.style.display =
|
| 207 |
startSimulationBtn.disabled = true;
|
| 208 |
-
|
| 209 |
// Ajouter le premier point
|
| 210 |
addSimulationStep();
|
| 211 |
-
|
| 212 |
// Continuer chaque seconde
|
| 213 |
simulationInterval = setInterval(() => {
|
| 214 |
currentStep++;
|
| 215 |
addSimulationStep();
|
| 216 |
-
|
| 217 |
-
//
|
| 218 |
if (currentStep > 200) {
|
| 219 |
stopSimulation();
|
| 220 |
}
|
| 221 |
}, 1000); // Chaque seconde
|
| 222 |
}
|
| 223 |
-
|
| 224 |
-
//
|
| 225 |
function addSimulationStep() {
|
| 226 |
-
const trackioEl = trackioContainer.querySelector(
|
| 227 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 228 |
const newDataPoint = {
|
| 229 |
step: currentStep,
|
| 230 |
-
loss: generateSimulatedValue(currentStep,
|
| 231 |
-
accuracy: generateSimulatedValue(currentStep,
|
| 232 |
};
|
| 233 |
-
|
| 234 |
-
console.log(
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
// Ajouter le point via l'instance Trackio
|
| 237 |
-
if (
|
| 238 |
-
trackioEl.__trackioInstance.addLiveDataPoint
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
} else {
|
| 240 |
-
console.warn(
|
| 241 |
}
|
| 242 |
}
|
| 243 |
}
|
| 244 |
-
|
| 245 |
-
//
|
| 246 |
function stopSimulation() {
|
| 247 |
if (simulationInterval) {
|
| 248 |
clearInterval(simulationInterval);
|
| 249 |
simulationInterval = null;
|
| 250 |
}
|
| 251 |
-
|
| 252 |
console.log(`Stopping simulation for run: ${currentSimulationRun}`);
|
| 253 |
-
|
| 254 |
// Interface UI
|
| 255 |
-
startSimulationBtn.style.display =
|
| 256 |
-
stopSimulationBtn.style.display =
|
| 257 |
startSimulationBtn.disabled = false;
|
| 258 |
-
|
| 259 |
currentSimulationRun = null;
|
| 260 |
currentStep = 0;
|
| 261 |
}
|
| 262 |
-
|
| 263 |
-
// Event listeners
|
| 264 |
-
startSimulationBtn.addEventListener(
|
| 265 |
-
stopSimulationBtn.addEventListener(
|
| 266 |
-
|
| 267 |
// ArrΓͺter la simulation si l'utilisateur quitte la page
|
| 268 |
-
window.addEventListener(
|
| 269 |
-
|
| 270 |
// Randomize data handler - now uses the store
|
| 271 |
-
randomizeBtn.addEventListener(
|
| 272 |
-
console.log(
|
| 273 |
-
|
| 274 |
// ArrΓͺter la simulation en cours si elle tourne
|
| 275 |
if (simulationInterval) {
|
| 276 |
stopSimulation();
|
| 277 |
}
|
| 278 |
-
|
| 279 |
// Add vibration animation
|
| 280 |
-
randomizeBtn.classList.add(
|
| 281 |
setTimeout(() => {
|
| 282 |
-
randomizeBtn.classList.remove(
|
| 283 |
}, 600);
|
| 284 |
-
|
| 285 |
// Test direct window approach as well
|
| 286 |
-
if (
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
window.trackioInstance.jitterData();
|
| 289 |
} else {
|
| 290 |
-
console.log(
|
| 291 |
triggerJitter();
|
| 292 |
}
|
| 293 |
});
|
|
@@ -299,7 +350,7 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 299 |
width: 100%;
|
| 300 |
margin: 0px 0 20px 0;
|
| 301 |
}
|
| 302 |
-
|
| 303 |
.trackio-controls {
|
| 304 |
display: flex;
|
| 305 |
justify-content: space-between;
|
|
@@ -310,21 +361,21 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 310 |
gap: 16px;
|
| 311 |
flex-wrap: nowrap;
|
| 312 |
}
|
| 313 |
-
|
| 314 |
.controls-left {
|
| 315 |
display: flex;
|
| 316 |
align-items: center;
|
| 317 |
gap: 24px;
|
| 318 |
flex-wrap: wrap;
|
| 319 |
}
|
| 320 |
-
|
| 321 |
.controls-right {
|
| 322 |
display: flex;
|
| 323 |
align-items: center;
|
| 324 |
gap: 12px;
|
| 325 |
flex-wrap: wrap;
|
| 326 |
}
|
| 327 |
-
|
| 328 |
.btn-randomize {
|
| 329 |
display: inline-flex;
|
| 330 |
align-items: center;
|
|
@@ -339,16 +390,16 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 339 |
cursor: pointer;
|
| 340 |
transition: all 0.15s ease;
|
| 341 |
}
|
| 342 |
-
|
| 343 |
.btn-randomize:hover {
|
| 344 |
background: var(--accent-hover, #005a9e);
|
| 345 |
transform: translateY(-1px);
|
| 346 |
}
|
| 347 |
-
|
| 348 |
.btn-randomize:active {
|
| 349 |
transform: translateY(0);
|
| 350 |
}
|
| 351 |
-
|
| 352 |
.theme-selector {
|
| 353 |
display: flex;
|
| 354 |
align-items: center;
|
|
@@ -357,12 +408,12 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 357 |
flex-shrink: 0;
|
| 358 |
white-space: nowrap;
|
| 359 |
}
|
| 360 |
-
|
| 361 |
.theme-selector label {
|
| 362 |
font-weight: 500;
|
| 363 |
color: var(--text-color);
|
| 364 |
}
|
| 365 |
-
|
| 366 |
.theme-select {
|
| 367 |
padding: 6px 12px;
|
| 368 |
border: 1px solid var(--border-color);
|
|
@@ -373,12 +424,12 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 373 |
cursor: pointer;
|
| 374 |
transition: border-color 0.15s ease;
|
| 375 |
}
|
| 376 |
-
|
| 377 |
.theme-select:focus {
|
| 378 |
outline: none;
|
| 379 |
border-color: var(--accent-color, #007acc);
|
| 380 |
}
|
| 381 |
-
|
| 382 |
.scale-controls {
|
| 383 |
display: flex;
|
| 384 |
align-items: center;
|
|
@@ -386,53 +437,74 @@ import Trackio from './trackio/Trackio.svelte';
|
|
| 386 |
flex-shrink: 0;
|
| 387 |
white-space: nowrap;
|
| 388 |
}
|
| 389 |
-
|
| 390 |
-
/*
|
| 391 |
@keyframes vibrate {
|
| 392 |
-
0% {
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
}
|
| 404 |
-
|
| 405 |
.button.vibrating {
|
| 406 |
animation: vibrate 0.6s ease-in-out;
|
| 407 |
}
|
| 408 |
-
|
| 409 |
.trackio-container {
|
| 410 |
width: 100%;
|
| 411 |
margin-top: 10px;
|
| 412 |
border: 1px solid var(--border-color);
|
| 413 |
padding: 24px 12px;
|
| 414 |
-
|
| 415 |
}
|
| 416 |
-
|
| 417 |
@media (max-width: 768px) {
|
| 418 |
.trackio-controls {
|
| 419 |
flex-direction: column;
|
| 420 |
align-items: stretch;
|
| 421 |
gap: 12px;
|
| 422 |
}
|
| 423 |
-
|
| 424 |
.controls-left {
|
| 425 |
flex-direction: column;
|
| 426 |
align-items: stretch;
|
| 427 |
gap: 12px;
|
| 428 |
}
|
| 429 |
-
|
| 430 |
.theme-selector {
|
| 431 |
justify-content: space-between;
|
| 432 |
}
|
| 433 |
-
|
| 434 |
.scale-controls {
|
| 435 |
justify-content: space-between;
|
| 436 |
}
|
| 437 |
}
|
| 438 |
-
</style>
|
|
|
|
| 1 |
---
|
| 2 |
// TrackioWrapper.astro
|
| 3 |
+
import Trackio from "./Trackio.svelte";
|
| 4 |
---
|
| 5 |
|
| 6 |
<!-- Ensure Roboto Mono is loaded for Oblivion theme -->
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;600;700&display=swap"
|
| 11 |
+
rel="stylesheet"
|
| 12 |
+
/>
|
| 13 |
|
| 14 |
<div class="trackio-wrapper">
|
| 15 |
<div class="trackio-controls">
|
|
|
|
| 23 |
</div>
|
| 24 |
<div class="scale-controls">
|
| 25 |
<label>
|
| 26 |
+
<input type="checkbox" id="log-scale-x" checked />
|
| 27 |
Log Scale X
|
| 28 |
</label>
|
| 29 |
<label>
|
| 30 |
+
<input type="checkbox" id="smooth-data" checked />
|
| 31 |
Smooth
|
| 32 |
</label>
|
| 33 |
</div>
|
|
|
|
| 36 |
<button class="button button--ghost" type="button" id="randomize-btn">
|
| 37 |
Randomize Data
|
| 38 |
</button>
|
| 39 |
+
<button
|
| 40 |
+
class="button button--primary"
|
| 41 |
+
type="button"
|
| 42 |
+
id="start-simulation-btn"
|
| 43 |
+
>
|
| 44 |
Live Run
|
| 45 |
</button>
|
| 46 |
+
<button
|
| 47 |
+
class="button button--danger"
|
| 48 |
+
type="button"
|
| 49 |
+
id="stop-simulation-btn"
|
| 50 |
+
style="display: none;"
|
| 51 |
+
>
|
| 52 |
Stop
|
| 53 |
</button>
|
| 54 |
</div>
|
| 55 |
</div>
|
| 56 |
+
|
| 57 |
<div class="trackio-container">
|
| 58 |
<Trackio client:load variant="classic" logScaleX={true} smoothing={true} />
|
| 59 |
</div>
|
|
|
|
| 61 |
|
| 62 |
<script>
|
| 63 |
// @ts-nocheck
|
| 64 |
+
document.addEventListener("DOMContentLoaded", async () => {
|
| 65 |
+
const themeSelect = document.getElementById("theme-select");
|
| 66 |
+
const randomizeBtn = document.getElementById("randomize-btn");
|
| 67 |
+
const startSimulationBtn = document.getElementById("start-simulation-btn");
|
| 68 |
+
const stopSimulationBtn = document.getElementById("stop-simulation-btn");
|
| 69 |
+
const logScaleXCheckbox = document.getElementById("log-scale-x");
|
| 70 |
+
const smoothDataCheckbox = document.getElementById("smooth-data");
|
| 71 |
+
const trackioContainer = document.querySelector(".trackio-container");
|
| 72 |
+
|
| 73 |
+
if (
|
| 74 |
+
!themeSelect ||
|
| 75 |
+
!randomizeBtn ||
|
| 76 |
+
!startSimulationBtn ||
|
| 77 |
+
!stopSimulationBtn ||
|
| 78 |
+
!logScaleXCheckbox ||
|
| 79 |
+
!smoothDataCheckbox ||
|
| 80 |
+
!trackioContainer
|
| 81 |
+
)
|
| 82 |
+
return;
|
| 83 |
+
|
| 84 |
+
// Variables for simulation
|
| 85 |
let simulationInterval = null;
|
| 86 |
let currentSimulationRun = null;
|
| 87 |
let currentStep = 0;
|
| 88 |
+
|
| 89 |
// Import the store function
|
| 90 |
+
const { triggerJitter } = await import("./core/store.js");
|
| 91 |
+
|
| 92 |
// Theme change handler
|
| 93 |
+
themeSelect.addEventListener("change", (e) => {
|
| 94 |
const target = e.target;
|
| 95 |
+
if (!target || !("value" in target)) return;
|
| 96 |
+
|
| 97 |
const newVariant = target.value;
|
| 98 |
console.log(`Theme changed to: ${newVariant}`); // Debug log
|
| 99 |
+
|
| 100 |
// Find the trackio element and call setTheme on the Svelte instance
|
| 101 |
const trackioEl = debugTrackioState();
|
| 102 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 103 |
+
console.log("β
Calling setTheme on Trackio instance");
|
| 104 |
trackioEl.__trackioInstance.setTheme(newVariant);
|
| 105 |
} else {
|
| 106 |
+
console.warn("β No Trackio instance found for theme change");
|
| 107 |
}
|
| 108 |
});
|
| 109 |
|
| 110 |
// Log scale X change handler
|
| 111 |
+
logScaleXCheckbox.addEventListener("change", (e) => {
|
| 112 |
const target = e.target;
|
| 113 |
+
if (!target || !("checked" in target)) return;
|
| 114 |
+
|
| 115 |
const isLogScale = target.checked;
|
| 116 |
console.log(`Log scale X changed to: ${isLogScale}`); // Debug log
|
| 117 |
+
|
| 118 |
// Find the trackio element and call setLogScaleX on the Svelte instance
|
| 119 |
const trackioEl = debugTrackioState();
|
| 120 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 121 |
+
console.log("β
Calling setLogScaleX on Trackio instance");
|
| 122 |
trackioEl.__trackioInstance.setLogScaleX(isLogScale);
|
| 123 |
} else {
|
| 124 |
+
console.warn("β Trackio instance not found for log scale change");
|
| 125 |
}
|
| 126 |
});
|
| 127 |
|
| 128 |
// Smooth data change handler
|
| 129 |
+
smoothDataCheckbox.addEventListener("change", (e) => {
|
| 130 |
const target = e.target;
|
| 131 |
+
if (!target || !("checked" in target)) return;
|
| 132 |
+
|
| 133 |
const isSmooth = target.checked;
|
| 134 |
console.log(`Smooth data changed to: ${isSmooth}`); // Debug log
|
| 135 |
+
|
| 136 |
// Find the trackio element and call setSmoothing on the Svelte instance
|
| 137 |
const trackioEl = debugTrackioState();
|
| 138 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 139 |
+
console.log("β
Calling setSmoothing on Trackio instance");
|
| 140 |
trackioEl.__trackioInstance.setSmoothing(isSmooth);
|
| 141 |
} else {
|
| 142 |
+
console.warn("β Trackio instance not found for smooth change");
|
| 143 |
}
|
| 144 |
});
|
| 145 |
+
|
| 146 |
// Debug function to check trackio state
|
| 147 |
function debugTrackioState() {
|
| 148 |
+
const trackioEl = trackioContainer.querySelector(".trackio");
|
| 149 |
+
console.log("π Debug Trackio state:", {
|
| 150 |
container: !!trackioContainer,
|
| 151 |
trackioEl: !!trackioEl,
|
| 152 |
hasInstance: !!(trackioEl && trackioEl.__trackioInstance),
|
| 153 |
+
availableMethods:
|
| 154 |
+
trackioEl && trackioEl.__trackioInstance
|
| 155 |
+
? Object.keys(trackioEl.__trackioInstance)
|
| 156 |
+
: "none",
|
| 157 |
+
windowInstance: !!window.trackioInstance,
|
| 158 |
});
|
| 159 |
return trackioEl;
|
| 160 |
}
|
| 161 |
+
|
| 162 |
// Initialize with default checked states - increased delay and retry logic
|
| 163 |
function initializeTrackio(attempt = 1) {
|
| 164 |
console.log(`π Initializing Trackio (attempt ${attempt})`);
|
| 165 |
+
|
| 166 |
const trackioEl = debugTrackioState();
|
| 167 |
+
|
| 168 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 169 |
+
console.log("β
Trackio instance found, applying initial settings");
|
| 170 |
+
|
| 171 |
if (logScaleXCheckbox.checked) {
|
| 172 |
+
console.log("Initializing with log scale X enabled");
|
| 173 |
trackioEl.__trackioInstance.setLogScaleX(true);
|
| 174 |
}
|
| 175 |
+
|
| 176 |
if (smoothDataCheckbox.checked) {
|
| 177 |
+
console.log("Initializing with smoothing enabled");
|
| 178 |
trackioEl.__trackioInstance.setSmoothing(true);
|
| 179 |
}
|
| 180 |
} else {
|
| 181 |
+
console.log("β Trackio instance not ready yet");
|
| 182 |
if (attempt < 10) {
|
| 183 |
setTimeout(() => initializeTrackio(attempt + 1), 200 * attempt);
|
| 184 |
} else {
|
| 185 |
+
console.error("Failed to initialize Trackio after 10 attempts");
|
| 186 |
}
|
| 187 |
}
|
| 188 |
}
|
| 189 |
+
|
| 190 |
// Start initialization
|
| 191 |
setTimeout(() => initializeTrackio(), 100);
|
| 192 |
+
|
| 193 |
+
// Function to generate a new simulated metric value
|
| 194 |
function generateSimulatedValue(step, metric) {
|
| 195 |
const baseProgress = Math.min(1, step / 100); // Normalise sur 100 steps
|
| 196 |
+
|
| 197 |
+
if (metric === "loss") {
|
| 198 |
+
// Loss that decreases with noise
|
| 199 |
const baseLoss = 2.0 * Math.exp(-0.05 * step);
|
| 200 |
const noise = (Math.random() - 0.5) * 0.2;
|
| 201 |
return Math.max(0.01, baseLoss + noise);
|
| 202 |
+
} else if (metric === "accuracy") {
|
| 203 |
+
// Accuracy that increases with noise
|
| 204 |
const baseAcc = 0.1 + 0.8 * (1 - Math.exp(-0.04 * step));
|
| 205 |
const noise = (Math.random() - 0.5) * 0.05;
|
| 206 |
return Math.max(0, Math.min(1, baseAcc + noise));
|
| 207 |
}
|
| 208 |
return Math.random();
|
| 209 |
}
|
| 210 |
+
|
| 211 |
+
// Handler to start simulation
|
| 212 |
function startSimulation() {
|
| 213 |
if (simulationInterval) {
|
| 214 |
clearInterval(simulationInterval);
|
| 215 |
}
|
| 216 |
+
|
| 217 |
// GΓ©nΓ©rer un nouveau nom de run
|
| 218 |
+
const adjectives = [
|
| 219 |
+
"live",
|
| 220 |
+
"real-time",
|
| 221 |
+
"streaming",
|
| 222 |
+
"dynamic",
|
| 223 |
+
"active",
|
| 224 |
+
"running",
|
| 225 |
+
];
|
| 226 |
+
const nouns = [
|
| 227 |
+
"experiment",
|
| 228 |
+
"trial",
|
| 229 |
+
"session",
|
| 230 |
+
"training",
|
| 231 |
+
"run",
|
| 232 |
+
"test",
|
| 233 |
+
];
|
| 234 |
+
const randomAdj =
|
| 235 |
+
adjectives[Math.floor(Math.random() * adjectives.length)];
|
| 236 |
const randomNoun = nouns[Math.floor(Math.random() * nouns.length)];
|
| 237 |
currentSimulationRun = `${randomAdj}-${randomNoun}-${Date.now().toString().slice(-4)}`;
|
| 238 |
currentStep = 1; // Commencer Γ step 1
|
| 239 |
+
|
| 240 |
console.log(`Starting simulation for run: ${currentSimulationRun}`);
|
| 241 |
+
|
| 242 |
// Interface UI
|
| 243 |
+
startSimulationBtn.style.display = "none";
|
| 244 |
+
stopSimulationBtn.style.display = "inline-flex";
|
| 245 |
startSimulationBtn.disabled = true;
|
| 246 |
+
|
| 247 |
// Ajouter le premier point
|
| 248 |
addSimulationStep();
|
| 249 |
+
|
| 250 |
// Continuer chaque seconde
|
| 251 |
simulationInterval = setInterval(() => {
|
| 252 |
currentStep++;
|
| 253 |
addSimulationStep();
|
| 254 |
+
|
| 255 |
+
// Stop after 200 steps to avoid infinity
|
| 256 |
if (currentStep > 200) {
|
| 257 |
stopSimulation();
|
| 258 |
}
|
| 259 |
}, 1000); // Chaque seconde
|
| 260 |
}
|
| 261 |
+
|
| 262 |
+
// Function to add a new data point
|
| 263 |
function addSimulationStep() {
|
| 264 |
+
const trackioEl = trackioContainer.querySelector(".trackio");
|
| 265 |
if (trackioEl && trackioEl.__trackioInstance) {
|
| 266 |
const newDataPoint = {
|
| 267 |
step: currentStep,
|
| 268 |
+
loss: generateSimulatedValue(currentStep, "loss"),
|
| 269 |
+
accuracy: generateSimulatedValue(currentStep, "accuracy"),
|
| 270 |
};
|
| 271 |
+
|
| 272 |
+
console.log(
|
| 273 |
+
`Adding simulation step ${currentStep} for run ${currentSimulationRun}:`,
|
| 274 |
+
newDataPoint,
|
| 275 |
+
);
|
| 276 |
+
|
| 277 |
// Ajouter le point via l'instance Trackio
|
| 278 |
+
if (
|
| 279 |
+
typeof trackioEl.__trackioInstance.addLiveDataPoint === "function"
|
| 280 |
+
) {
|
| 281 |
+
trackioEl.__trackioInstance.addLiveDataPoint(
|
| 282 |
+
currentSimulationRun,
|
| 283 |
+
newDataPoint,
|
| 284 |
+
);
|
| 285 |
} else {
|
| 286 |
+
console.warn("addLiveDataPoint method not found on Trackio instance");
|
| 287 |
}
|
| 288 |
}
|
| 289 |
}
|
| 290 |
+
|
| 291 |
+
// Handler to stop simulation
|
| 292 |
function stopSimulation() {
|
| 293 |
if (simulationInterval) {
|
| 294 |
clearInterval(simulationInterval);
|
| 295 |
simulationInterval = null;
|
| 296 |
}
|
| 297 |
+
|
| 298 |
console.log(`Stopping simulation for run: ${currentSimulationRun}`);
|
| 299 |
+
|
| 300 |
// Interface UI
|
| 301 |
+
startSimulationBtn.style.display = "inline-flex";
|
| 302 |
+
stopSimulationBtn.style.display = "none";
|
| 303 |
startSimulationBtn.disabled = false;
|
| 304 |
+
|
| 305 |
currentSimulationRun = null;
|
| 306 |
currentStep = 0;
|
| 307 |
}
|
| 308 |
+
|
| 309 |
+
// Event listeners for simulation buttons
|
| 310 |
+
startSimulationBtn.addEventListener("click", startSimulation);
|
| 311 |
+
stopSimulationBtn.addEventListener("click", stopSimulation);
|
| 312 |
+
|
| 313 |
// ArrΓͺter la simulation si l'utilisateur quitte la page
|
| 314 |
+
window.addEventListener("beforeunload", stopSimulation);
|
| 315 |
+
|
| 316 |
// Randomize data handler - now uses the store
|
| 317 |
+
randomizeBtn.addEventListener("click", () => {
|
| 318 |
+
console.log("Randomize button clicked - triggering jitter via store"); // Debug log
|
| 319 |
+
|
| 320 |
// ArrΓͺter la simulation en cours si elle tourne
|
| 321 |
if (simulationInterval) {
|
| 322 |
stopSimulation();
|
| 323 |
}
|
| 324 |
+
|
| 325 |
// Add vibration animation
|
| 326 |
+
randomizeBtn.classList.add("vibrating");
|
| 327 |
setTimeout(() => {
|
| 328 |
+
randomizeBtn.classList.remove("vibrating");
|
| 329 |
}, 600);
|
| 330 |
+
|
| 331 |
// Test direct window approach as well
|
| 332 |
+
if (
|
| 333 |
+
window.trackioInstance &&
|
| 334 |
+
typeof window.trackioInstance.jitterData === "function"
|
| 335 |
+
) {
|
| 336 |
+
console.log(
|
| 337 |
+
"Found window.trackioInstance, calling jitterData directly",
|
| 338 |
+
); // Debug log
|
| 339 |
window.trackioInstance.jitterData();
|
| 340 |
} else {
|
| 341 |
+
console.log("No window.trackioInstance found, using store trigger"); // Debug log
|
| 342 |
triggerJitter();
|
| 343 |
}
|
| 344 |
});
|
|
|
|
| 350 |
width: 100%;
|
| 351 |
margin: 0px 0 20px 0;
|
| 352 |
}
|
| 353 |
+
|
| 354 |
.trackio-controls {
|
| 355 |
display: flex;
|
| 356 |
justify-content: space-between;
|
|
|
|
| 361 |
gap: 16px;
|
| 362 |
flex-wrap: nowrap;
|
| 363 |
}
|
| 364 |
+
|
| 365 |
.controls-left {
|
| 366 |
display: flex;
|
| 367 |
align-items: center;
|
| 368 |
gap: 24px;
|
| 369 |
flex-wrap: wrap;
|
| 370 |
}
|
| 371 |
+
|
| 372 |
.controls-right {
|
| 373 |
display: flex;
|
| 374 |
align-items: center;
|
| 375 |
gap: 12px;
|
| 376 |
flex-wrap: wrap;
|
| 377 |
}
|
| 378 |
+
|
| 379 |
.btn-randomize {
|
| 380 |
display: inline-flex;
|
| 381 |
align-items: center;
|
|
|
|
| 390 |
cursor: pointer;
|
| 391 |
transition: all 0.15s ease;
|
| 392 |
}
|
| 393 |
+
|
| 394 |
.btn-randomize:hover {
|
| 395 |
background: var(--accent-hover, #005a9e);
|
| 396 |
transform: translateY(-1px);
|
| 397 |
}
|
| 398 |
+
|
| 399 |
.btn-randomize:active {
|
| 400 |
transform: translateY(0);
|
| 401 |
}
|
| 402 |
+
|
| 403 |
.theme-selector {
|
| 404 |
display: flex;
|
| 405 |
align-items: center;
|
|
|
|
| 408 |
flex-shrink: 0;
|
| 409 |
white-space: nowrap;
|
| 410 |
}
|
| 411 |
+
|
| 412 |
.theme-selector label {
|
| 413 |
font-weight: 500;
|
| 414 |
color: var(--text-color);
|
| 415 |
}
|
| 416 |
+
|
| 417 |
.theme-select {
|
| 418 |
padding: 6px 12px;
|
| 419 |
border: 1px solid var(--border-color);
|
|
|
|
| 424 |
cursor: pointer;
|
| 425 |
transition: border-color 0.15s ease;
|
| 426 |
}
|
| 427 |
+
|
| 428 |
.theme-select:focus {
|
| 429 |
outline: none;
|
| 430 |
border-color: var(--accent-color, #007acc);
|
| 431 |
}
|
| 432 |
+
|
| 433 |
.scale-controls {
|
| 434 |
display: flex;
|
| 435 |
align-items: center;
|
|
|
|
| 437 |
flex-shrink: 0;
|
| 438 |
white-space: nowrap;
|
| 439 |
}
|
| 440 |
+
|
| 441 |
+
/* Vibration animation for button */
|
| 442 |
@keyframes vibrate {
|
| 443 |
+
0% {
|
| 444 |
+
transform: translateX(0);
|
| 445 |
+
}
|
| 446 |
+
10% {
|
| 447 |
+
transform: translateX(-2px) rotate(-1deg);
|
| 448 |
+
}
|
| 449 |
+
20% {
|
| 450 |
+
transform: translateX(2px) rotate(1deg);
|
| 451 |
+
}
|
| 452 |
+
30% {
|
| 453 |
+
transform: translateX(-2px) rotate(-1deg);
|
| 454 |
+
}
|
| 455 |
+
40% {
|
| 456 |
+
transform: translateX(2px) rotate(1deg);
|
| 457 |
+
}
|
| 458 |
+
50% {
|
| 459 |
+
transform: translateX(-1px) rotate(-0.5deg);
|
| 460 |
+
}
|
| 461 |
+
60% {
|
| 462 |
+
transform: translateX(1px) rotate(0.5deg);
|
| 463 |
+
}
|
| 464 |
+
70% {
|
| 465 |
+
transform: translateX(-1px) rotate(-0.5deg);
|
| 466 |
+
}
|
| 467 |
+
80% {
|
| 468 |
+
transform: translateX(1px) rotate(0.5deg);
|
| 469 |
+
}
|
| 470 |
+
90% {
|
| 471 |
+
transform: translateX(-0.5px) rotate(-0.25deg);
|
| 472 |
+
}
|
| 473 |
+
100% {
|
| 474 |
+
transform: translateX(0) rotate(0);
|
| 475 |
+
}
|
| 476 |
}
|
| 477 |
+
|
| 478 |
.button.vibrating {
|
| 479 |
animation: vibrate 0.6s ease-in-out;
|
| 480 |
}
|
| 481 |
+
|
| 482 |
.trackio-container {
|
| 483 |
width: 100%;
|
| 484 |
margin-top: 10px;
|
| 485 |
border: 1px solid var(--border-color);
|
| 486 |
padding: 24px 12px;
|
|
|
|
| 487 |
}
|
| 488 |
+
|
| 489 |
@media (max-width: 768px) {
|
| 490 |
.trackio-controls {
|
| 491 |
flex-direction: column;
|
| 492 |
align-items: stretch;
|
| 493 |
gap: 12px;
|
| 494 |
}
|
| 495 |
+
|
| 496 |
.controls-left {
|
| 497 |
flex-direction: column;
|
| 498 |
align-items: stretch;
|
| 499 |
gap: 12px;
|
| 500 |
}
|
| 501 |
+
|
| 502 |
.theme-selector {
|
| 503 |
justify-content: space-between;
|
| 504 |
}
|
| 505 |
+
|
| 506 |
.scale-controls {
|
| 507 |
justify-content: space-between;
|
| 508 |
}
|
| 509 |
}
|
| 510 |
+
</style>
|
app/src/components/trackio/core/adaptive-sampler.js
CHANGED
|
@@ -7,24 +7,24 @@
|
|
| 7 |
export class AdaptiveSampler {
|
| 8 |
constructor(options = {}) {
|
| 9 |
this.options = {
|
| 10 |
-
maxPoints: 400, //
|
| 11 |
-
targetPoints: 200, //
|
| 12 |
-
preserveFeatures: true, //
|
| 13 |
adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod'
|
| 14 |
-
smoothingWindow: 3, //
|
| 15 |
...options
|
| 16 |
};
|
| 17 |
}
|
| 18 |
|
| 19 |
/**
|
| 20 |
-
*
|
| 21 |
*/
|
| 22 |
needsSampling(dataLength) {
|
| 23 |
return dataLength > this.options.maxPoints;
|
| 24 |
}
|
| 25 |
|
| 26 |
/**
|
| 27 |
-
*
|
| 28 |
*/
|
| 29 |
sampleSeries(data, strategy = null) {
|
| 30 |
if (!Array.isArray(data) || data.length === 0) {
|
|
@@ -234,11 +234,11 @@ export class AdaptiveSampler {
|
|
| 234 |
const index = Math.floor(logProgress * (totalLength - 1));
|
| 235 |
indices.push(Math.max(1, Math.min(totalLength - 2, index)));
|
| 236 |
}
|
| 237 |
-
return [...new Set(indices)]; //
|
| 238 |
}
|
| 239 |
|
| 240 |
/**
|
| 241 |
-
*
|
| 242 |
*/
|
| 243 |
sampleByVariation(data, targetPoints) {
|
| 244 |
const variations = [];
|
|
@@ -290,7 +290,7 @@ export class AdaptiveSampler {
|
|
| 290 |
* Reconstruit les données complètes pour une zone spécifique (pour le zoom)
|
| 291 |
*/
|
| 292 |
getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
|
| 293 |
-
//
|
| 294 |
// quand l'utilisateur zoom sur une zone spΓ©cifique
|
| 295 |
const startIdx = originalData.findIndex(d => d.step >= startStep);
|
| 296 |
const endIdx = originalData.findIndex(d => d.step > endStep);
|
|
|
|
| 7 |
export class AdaptiveSampler {
|
| 8 |
constructor(options = {}) {
|
| 9 |
this.options = {
|
| 10 |
+
maxPoints: 400, // Threshold to trigger sampling
|
| 11 |
+
targetPoints: 200, // Target number of points after sampling
|
| 12 |
+
preserveFeatures: true, // Preserve important peaks/valleys
|
| 13 |
adaptiveStrategy: 'smart', // 'uniform', 'smart', 'lod'
|
| 14 |
+
smoothingWindow: 3, // Window for feature detection
|
| 15 |
...options
|
| 16 |
};
|
| 17 |
}
|
| 18 |
|
| 19 |
/**
|
| 20 |
+
* Determine if sampling is necessary
|
| 21 |
*/
|
| 22 |
needsSampling(dataLength) {
|
| 23 |
return dataLength > this.options.maxPoints;
|
| 24 |
}
|
| 25 |
|
| 26 |
/**
|
| 27 |
+
* Main entry point for sampling
|
| 28 |
*/
|
| 29 |
sampleSeries(data, strategy = null) {
|
| 30 |
if (!Array.isArray(data) || data.length === 0) {
|
|
|
|
| 234 |
const index = Math.floor(logProgress * (totalLength - 1));
|
| 235 |
indices.push(Math.max(1, Math.min(totalLength - 2, index)));
|
| 236 |
}
|
| 237 |
+
return [...new Set(indices)]; // Remove duplicates
|
| 238 |
}
|
| 239 |
|
| 240 |
/**
|
| 241 |
+
* Sampling based on local variation
|
| 242 |
*/
|
| 243 |
sampleByVariation(data, targetPoints) {
|
| 244 |
const variations = [];
|
|
|
|
| 290 |
* Reconstruit les données complètes pour une zone spécifique (pour le zoom)
|
| 291 |
*/
|
| 292 |
getFullDataForRange(originalData, samplingInfo, startStep, endStep) {
|
| 293 |
+
// This method would allow recovering more details
|
| 294 |
// quand l'utilisateur zoom sur une zone spΓ©cifique
|
| 295 |
const startIdx = originalData.findIndex(d => d.step >= startStep);
|
| 296 |
const endIdx = originalData.findIndex(d => d.step > endStep);
|
app/src/content/assets/data/data.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6bbaf02f1b470da41754e3828e81e76ef386d9b3cfb8b57dcc7cbfd4225956cc
|
| 3 |
+
size 14121778
|
.devcontainer/devcontainer.json β app/src/content/assets/data/font-sprite-mapping.json
RENAMED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ea1b487ebafe8d495737674a7eb6492b06551aeb97de79728afeb4aba7c39f29
|
| 3 |
+
size 9766
|
app/src/content/assets/data/font-sprite.svg
ADDED
|
|
app/src/content/assets/data/font_manifest.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7a587c5fd3fb85fdd26d485f57ef3e4feb8370593d46f57289b55f873beac4b4
|
| 3 |
+
size 153794
|
app/src/content/assets/data/typography_data.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:403e0095f2bcaa963cdfbb8d00a3695565623764d6f87b890bf58d7a2304acfc
|
| 3 |
+
size 68739
|
app/src/content/assets/sprites/font-sprite.svg
ADDED
|
|
app/src/content/chapters/demo/best-pratices.mdx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
|
| 2 |
import visualPoster from '../../assets/image/visual-vocabulary-poster.png';
|
| 3 |
import Note from '../../../components/Note.astro';
|
| 4 |
-
import
|
| 5 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 6 |
import Sidenote from '../../../components/Sidenote.astro';
|
| 7 |
|
|
@@ -44,7 +44,7 @@ Favor **concise captions** and callouts that clarify what to look at and why it
|
|
| 44 |
|
| 45 |
Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from **analytical task** to **chart types**.
|
| 46 |
|
| 47 |
-
<
|
| 48 |
src={visualPoster}
|
| 49 |
alt="Visual Vocabulary: choosing the right chart by task"
|
| 50 |
linkHref="https://raw.githubusercontent.com/Financial-Times/chart-doctor/refs/heads/main/visual-vocabulary/poster.png"
|
|
|
|
| 1 |
|
| 2 |
import visualPoster from '../../assets/image/visual-vocabulary-poster.png';
|
| 3 |
import Note from '../../../components/Note.astro';
|
| 4 |
+
import Figure from '../../../components/Figure.astro';
|
| 5 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 6 |
import Sidenote from '../../../components/Sidenote.astro';
|
| 7 |
|
|
|
|
| 44 |
|
| 45 |
Picking the right visualization depends on your goal (compare values, show distribution, part-to-whole, trends, relationships, etc.). The Visual Vocabulary poster below provides a concise mapping from **analytical task** to **chart types**.
|
| 46 |
|
| 47 |
+
<Figure
|
| 48 |
src={visualPoster}
|
| 49 |
alt="Visual Vocabulary: choosing the right chart by task"
|
| 50 |
linkHref="https://raw.githubusercontent.com/Financial-Times/chart-doctor/refs/heads/main/visual-vocabulary/poster.png"
|
app/src/content/chapters/demo/components.mdx
CHANGED
|
@@ -7,8 +7,8 @@ import Wide from '../../../components/Wide.astro';
|
|
| 7 |
import Note from '../../../components/Note.astro';
|
| 8 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 9 |
import Accordion from '../../../components/Accordion.astro';
|
| 10 |
-
import Figure from '../../../components/
|
| 11 |
-
import SubFigures from '../../../components/
|
| 12 |
import Quote from '../../../components/Quote.astro';
|
| 13 |
|
| 14 |
## Components
|
|
@@ -20,8 +20,8 @@ import Quote from '../../../components/Quote.astro';
|
|
| 20 |
To use any component in your MDX file, add the import statement at the top:
|
| 21 |
|
| 22 |
```mdx
|
| 23 |
-
import Figure from '../components/
|
| 24 |
-
import SubFigures from '../components/
|
| 25 |
import Note from '../components/Note.astro';
|
| 26 |
|
| 27 |
# Your content
|
|
@@ -94,7 +94,7 @@ Here are the components that are available:
|
|
| 94 |
|
| 95 |
<Accordion title="Code example">
|
| 96 |
```mdx
|
| 97 |
-
import Figure from '../../../components/
|
| 98 |
import myImage from './assets/image/placeholder.jpg'
|
| 99 |
|
| 100 |
<Figure src={myImage} alt="Optimized figure with caption" />
|
|
@@ -304,8 +304,8 @@ import Note from '../../../components/Note.astro'
|
|
| 304 |
|
| 305 |
Elegant quotes with optional source attribution.
|
| 306 |
|
| 307 |
-
<Quote source="Geoffrey Hinton, <a href='https://
|
| 308 |
-
|
| 309 |
</Quote>
|
| 310 |
|
| 311 |
| Prop | Required | Type | Description |
|
|
@@ -316,8 +316,8 @@ Elegant quotes with optional source attribution.
|
|
| 316 |
```mdx
|
| 317 |
import Quote from '../../../components/Quote.astro'
|
| 318 |
|
| 319 |
-
<Quote source="Geoffrey Hinton, <a href='https://
|
| 320 |
-
|
| 321 |
</Quote>
|
| 322 |
```
|
| 323 |
</Accordion>
|
|
|
|
| 7 |
import Note from '../../../components/Note.astro';
|
| 8 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 9 |
import Accordion from '../../../components/Accordion.astro';
|
| 10 |
+
import Figure from '../../../components/Figure.astro';
|
| 11 |
+
import SubFigures from '../../../components/MultiFigure.astro';
|
| 12 |
import Quote from '../../../components/Quote.astro';
|
| 13 |
|
| 14 |
## Components
|
|
|
|
| 20 |
To use any component in your MDX file, add the import statement at the top:
|
| 21 |
|
| 22 |
```mdx
|
| 23 |
+
import Figure from '../components/Figure.astro';
|
| 24 |
+
import SubFigures from '../components/MultiFigure.astro';
|
| 25 |
import Note from '../components/Note.astro';
|
| 26 |
|
| 27 |
# Your content
|
|
|
|
| 94 |
|
| 95 |
<Accordion title="Code example">
|
| 96 |
```mdx
|
| 97 |
+
import Figure from '../../../components/Figure.astro'
|
| 98 |
import myImage from './assets/image/placeholder.jpg'
|
| 99 |
|
| 100 |
<Figure src={myImage} alt="Optimized figure with caption" />
|
|
|
|
| 304 |
|
| 305 |
Elegant quotes with optional source attribution.
|
| 306 |
|
| 307 |
+
<Quote source="Geoffrey Hinton, <a href='https://www.nature.com/articles/323533a0'>Learning representations by back-propagating errors</a>">
|
| 308 |
+
Backpropagation allows neural networks to discover their own internal representations of data.
|
| 309 |
</Quote>
|
| 310 |
|
| 311 |
| Prop | Required | Type | Description |
|
|
|
|
| 316 |
```mdx
|
| 317 |
import Quote from '../../../components/Quote.astro'
|
| 318 |
|
| 319 |
+
<Quote source="Geoffrey Hinton, <a href='https://www.nature.com/articles/323533a0'>Learning representations by back-propagating errors</a>">
|
| 320 |
+
Backpropagation allows neural networks to discover their own internal representations of data.
|
| 321 |
</Quote>
|
| 322 |
```
|
| 323 |
</Accordion>
|
app/src/content/chapters/demo/debug-components.mdx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import Accordion from '../../../components/Accordion.astro';
|
| 2 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 3 |
-
import
|
| 4 |
import Wide from '../../../components/Wide.astro';
|
| 5 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 6 |
import Note from '../../../components/Note.astro';
|
|
|
|
| 1 |
import Accordion from '../../../components/Accordion.astro';
|
| 2 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 3 |
+
import Figure from '../../../components/Figure.astro';
|
| 4 |
import Wide from '../../../components/Wide.astro';
|
| 5 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 6 |
import Note from '../../../components/Note.astro';
|
app/src/content/chapters/demo/introduction.mdx
CHANGED
|
@@ -2,12 +2,13 @@ import Sidenote from "../../../components/Sidenote.astro";
|
|
| 2 |
|
| 3 |
Welcome to this singleβpage **research article template**. It helps you publish **clear**, **modern**, and **interactive technical writing** with **minimal setup**.
|
| 4 |
|
| 5 |
-
Grounded in up to date good practices in web dev, it favors **interactive explanations**, **clear notation**, and **inspectable examples** over static snapshots.
|
| 6 |
-
|
| 7 |
<Sidenote>
|
| 8 |
Reading time: 20β25 minutes.
|
| 9 |
</Sidenote>
|
| 10 |
|
|
|
|
|
|
|
|
|
|
| 11 |
#### Features
|
| 12 |
|
| 13 |
<div className="tag-list">
|
|
|
|
| 2 |
|
| 3 |
Welcome to this singleβpage **research article template**. It helps you publish **clear**, **modern**, and **interactive technical writing** with **minimal setup**.
|
| 4 |
|
|
|
|
|
|
|
| 5 |
<Sidenote>
|
| 6 |
Reading time: 20β25 minutes.
|
| 7 |
</Sidenote>
|
| 8 |
|
| 9 |
+
Grounded in up to date good practices in web dev, it favors **interactive explanations**, **clear notation**, and **inspectable examples** over static snapshots.
|
| 10 |
+
|
| 11 |
+
|
| 12 |
#### Features
|
| 13 |
|
| 14 |
<div className="tag-list">
|
app/src/content/chapters/demo/markdown.mdx
CHANGED
|
@@ -6,7 +6,7 @@ import Wide from '../../../components/Wide.astro';
|
|
| 6 |
import Note from '../../../components/Note.astro';
|
| 7 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 8 |
import Accordion from '../../../components/Accordion.astro';
|
| 9 |
-
import
|
| 10 |
|
| 11 |
## Markdown
|
| 12 |
|
|
@@ -301,7 +301,7 @@ In research articles, you may have to make references to anything. They are basi
|
|
| 301 |
<HtmlEmbed id="neural-network-mnist-like"/>
|
| 302 |
[Chart 1](#neural-network-mnist-like)
|
| 303 |
|
| 304 |
-
<
|
| 305 |
[Fig 1](#placeholder-image)
|
| 306 |
```
|
| 307 |
</Accordion>
|
|
|
|
| 6 |
import Note from '../../../components/Note.astro';
|
| 7 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 8 |
import Accordion from '../../../components/Accordion.astro';
|
| 9 |
+
import Figure from '../../../components/Figure.astro';
|
| 10 |
|
| 11 |
## Markdown
|
| 12 |
|
|
|
|
| 301 |
<HtmlEmbed id="neural-network-mnist-like"/>
|
| 302 |
[Chart 1](#neural-network-mnist-like)
|
| 303 |
|
| 304 |
+
<Figure id="placeholder-image" src="..."/>
|
| 305 |
[Fig 1](#placeholder-image)
|
| 306 |
```
|
| 307 |
</Accordion>
|
app/src/content/chapters/demo/vibe-coding-charts.mdx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 2 |
import Note from '../../../components/Note.astro';
|
| 3 |
-
import TrackioWrapper from '../../../components/TrackioWrapper.astro';
|
|
|
|
| 4 |
|
| 5 |
## Vibe coding charts
|
| 6 |
|
|
@@ -27,15 +28,19 @@ I want you to code a d3 chart that visualizes the data.
|
|
| 27 |
4. Once the chart created, iterate with littles adjustments to make it better.
|
| 28 |
5. And that's it! π
|
| 29 |
|
| 30 |
-
###
|
| 31 |
|
| 32 |
-
|
| 33 |
|
| 34 |
<HtmlEmbed
|
|
|
|
| 35 |
title="d3-benchmark: LLM Benchmark"
|
| 36 |
src="d3-benchmark.html" desc={`Figure 1: Grouped bar chart comparing model scores across benchmarks (MMLU, GSM8K, HellaSwag, TruthfulQA, ARCβC). Each group is a benchmark; colors encode models; values are accuracy/score (higher is better).`} />
|
|
|
|
|
|
|
| 37 |
---
|
| 38 |
-
|
|
|
|
| 39 |
src="d3-line.html"
|
| 40 |
title="d3-line: Average Ranking of Models"
|
| 41 |
desc='Figure 2: Average Ranking of Models trained with internally deduplicated / merged samples. No clear benefit in merging can be seen with respect to model performance.<br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'
|
|
@@ -44,33 +49,77 @@ They can be found in the `app/src/content/embeds` folder and you can also use th
|
|
| 44 |
defaultMetric: ['average_rank']
|
| 45 |
}}
|
| 46 |
/>
|
|
|
|
| 47 |
---
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
title="d3-neural-network: MNIST-like Neural Network"
|
| 52 |
-
desc={`Figure 3: Interactive MNIST-like neural network. Draw a digit on the left canvas; activations propagate through hidden layers (node size/opacity reflect activation). The right side displays class probabilities (0β9) with the top class emphasized.`}
|
| 53 |
-
/>
|
| 54 |
---
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
| 62 |
</p>`}
|
| 63 |
-
|
|
|
|
|
|
|
| 64 |
---
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
---
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
---
|
| 73 |
-
|
|
|
|
|
|
|
| 74 |
|
| 75 |
{/* ### Trackio redesign experiment
|
| 76 |
<TrackioWrapper /> */}
|
|
|
|
| 1 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 2 |
import Note from '../../../components/Note.astro';
|
| 3 |
+
import TrackioWrapper from '../../../components/trackio/TrackioWrapper.astro';
|
| 4 |
+
import Sidenote from '../../../components/Sidenote.astro';
|
| 5 |
|
| 6 |
## Vibe coding charts
|
| 7 |
|
|
|
|
| 28 |
4. Once the chart created, iterate with littles adjustments to make it better.
|
| 29 |
5. And that's it! π
|
| 30 |
|
| 31 |
+
### Base examples
|
| 32 |
|
| 33 |
+
These are fundamental chart types that serve as building blocks for more complex visualizations. You can find all the source code in `app/src/content/embeds`.
|
| 34 |
|
| 35 |
<HtmlEmbed
|
| 36 |
+
id="fig1"
|
| 37 |
title="d3-benchmark: LLM Benchmark"
|
| 38 |
src="d3-benchmark.html" desc={`Figure 1: Grouped bar chart comparing model scores across benchmarks (MMLU, GSM8K, HellaSwag, TruthfulQA, ARCβC). Each group is a benchmark; colors encode models; values are accuracy/score (higher is better).`} />
|
| 39 |
+
<Sidenote> Example of <a href="#color-palettes" target="_blank">categorical color palette</a> </Sidenote>
|
| 40 |
+
|
| 41 |
---
|
| 42 |
+
<HtmlEmbed
|
| 43 |
+
id="fig2"
|
| 44 |
src="d3-line.html"
|
| 45 |
title="d3-line: Average Ranking of Models"
|
| 46 |
desc='Figure 2: Average Ranking of Models trained with internally deduplicated / merged samples. No clear benefit in merging can be seen with respect to model performance.<br/> Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'
|
|
|
|
| 49 |
defaultMetric: ['average_rank']
|
| 50 |
}}
|
| 51 |
/>
|
| 52 |
+
|
| 53 |
---
|
| 54 |
+
<HtmlEmbed id="fig4" src="d3-pie.html" title="d3-pie: Simple pie chart" frameless desc={'Figure 4: Simple pie chart showing data distribution across categories.<br/>Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'} />
|
| 55 |
+
|
| 56 |
+
|
|
|
|
|
|
|
|
|
|
| 57 |
---
|
| 58 |
+
<HtmlEmbed
|
| 59 |
+
id="fig3"
|
| 60 |
+
src="d3-matrix.html"
|
| 61 |
+
title="d3-matrix: Baseline and Ξ (Improved β Baseline)"
|
| 62 |
+
frameless
|
| 63 |
+
desc={`<p>
|
| 64 |
+
Figure 3: Left: baseline matrix (row-normalized, sequential palette).
|
| 65 |
+
Right: Ξ (Improved β Baseline) in percentage points, using a diverging palette centered at 0 to highlight improvements vs degradations.
|
| 66 |
</p>`}
|
| 67 |
+
/>
|
| 68 |
+
<Sidenote> Example of <a href="#color-palettes" target="_blank">sequential</a> and <a href="#color-palettes" target="_blank">diverging color palette</a> </Sidenote>
|
| 69 |
+
|
| 70 |
---
|
| 71 |
+
|
| 72 |
+
<HtmlEmbed
|
| 73 |
+
id="fig8"
|
| 74 |
+
src="d3-equation-editor.html"
|
| 75 |
+
frameless
|
| 76 |
+
title="Interactive Mathematical Function Plotter"
|
| 77 |
+
desc={`Figure 8: Interactive <strong>equation editor</strong> with real-time function plotting. Edit mathematical expressions and see their graphs update instantly. Supports common functions (sin, cos, exp, etc.) with customizable domain range.`}
|
| 78 |
+
/>
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
### Advanced examples
|
| 82 |
+
|
| 83 |
+
These are more complex, interactive visualizations that demonstrate advanced D3 capabilities and real-world applications.
|
| 84 |
+
|
| 85 |
+
<HtmlEmbed
|
| 86 |
+
id="fig5"
|
| 87 |
+
src="d3-neural-network.html"
|
| 88 |
+
id="neural-network-mnist-like"
|
| 89 |
+
frameless
|
| 90 |
+
title="MNIST-like Neural Network"
|
| 91 |
+
desc={`Figure 5: Interactive MNIST-like neural network. Draw a digit on the left canvas; activations propagate through hidden layers (node size/opacity reflect activation). The right side displays class probabilities (0β9) with the top class emphasized.`}
|
| 92 |
+
/>
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
<HtmlEmbed
|
| 96 |
+
id="fig6"
|
| 97 |
+
src="arxiv/arxiv.html"
|
| 98 |
+
title="arXiv: Research Paper Clustering"
|
| 99 |
+
desc={`Figure 6: Interactive visualization of ~8k recent arXiv submissions via UMAP dimensionality reduction. Each point represents a research paper positioned by semantic similarity. Colors indicate academic categories (cs.AI, cs.LG, cs.CV, etc.).`}
|
| 100 |
+
data="data.json"
|
| 101 |
+
frameless
|
| 102 |
+
align="center"
|
| 103 |
+
/>
|
| 104 |
+
|
| 105 |
+
<Sidenote>
|
| 106 |
+
Recent arXiv publications (~8k papers from last month) via TF-IDF embeddings projected to 2D via UMAP.
|
| 107 |
+
</Sidenote>
|
| 108 |
---
|
| 109 |
+
|
| 110 |
+
<HtmlEmbed
|
| 111 |
+
id="fig7"
|
| 112 |
+
src="d3-umap-typography.html"
|
| 113 |
+
title="Visual Similarity of Typefaces"
|
| 114 |
+
desc={`Figure 7: Interactive 2D visualization of 382 <strong>Google Fonts</strong> clustered by visual similarity via <strong>UMAP</strong>. Each point represents a typeface positioned based on pixel-level differences computed from font matrices.`}
|
| 115 |
+
frameless
|
| 116 |
+
align="center"
|
| 117 |
+
/>
|
| 118 |
+
|
| 119 |
---
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
<HtmlEmbed id="fig9" src="d3-pie-quad.html" title="d3-pie-quad: Quad donuts by metric" align="center" frameless desc={'Figure 9: Quad view: Answer Tokens, Number of Samples, Number of Turns, Number of Images.<br/>Credit: <a href="https://huggingface.co/spaces/HuggingFaceM4/FineVision" target="_blank">FineVision</a>'} />
|
| 123 |
|
| 124 |
{/* ### Trackio redesign experiment
|
| 125 |
<TrackioWrapper /> */}
|
app/src/content/chapters/demo/writing-your-content.mdx
CHANGED
|
@@ -6,8 +6,8 @@ import Wide from '../../../components/Wide.astro';
|
|
| 6 |
import Note from '../../../components/Note.astro';
|
| 7 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 8 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 9 |
-
import ColorPicker from '../../../components/ColorPicker.astro';
|
| 10 |
-
import Palettes from '../../../components/Palettes.astro';
|
| 11 |
import audioDemo from '../../assets/audio/audio-example.wav';
|
| 12 |
import Accordion from '../../../components/Accordion.astro';
|
| 13 |
|
|
@@ -87,7 +87,7 @@ Your story. Write your content here.
|
|
| 87 |
<small className="muted">**Content** in app/src/content/article.mdx</small>
|
| 88 |
```mdx
|
| 89 |
import placeholder from '../../assets/image/placeholder.png'
|
| 90 |
-
import
|
| 91 |
import Sidenote from '../../../components/Sidenote.astro'
|
| 92 |
|
| 93 |
This paragraph is written in Markdown.
|
|
@@ -95,7 +95,7 @@ This paragraph is written in Markdown.
|
|
| 95 |
<Sidenote>
|
| 96 |
A short callout inserted via a component.
|
| 97 |
</Sidenote>
|
| 98 |
-
<
|
| 99 |
|
| 100 |
This paragraph is also written in Markdown.
|
| 101 |
```
|
|
@@ -144,6 +144,7 @@ Use the **color picker** below to see how the primary color affects the theme.
|
|
| 144 |
|
| 145 |
Here is a suggestion of **color palettes** for your **data visualizations** that align with your **brand identity**. These palettes are generated from your `--primary-color`.
|
| 146 |
|
|
|
|
| 147 |
<Palettes />
|
| 148 |
<br/>
|
| 149 |
**Use color with care.**
|
|
|
|
| 6 |
import Note from '../../../components/Note.astro';
|
| 7 |
import FullWidth from '../../../components/FullWidth.astro';
|
| 8 |
import HtmlEmbed from '../../../components/HtmlEmbed.astro';
|
| 9 |
+
import ColorPicker from '../../../components/demo/ColorPicker.astro';
|
| 10 |
+
import Palettes from '../../../components/demo/Palettes.astro';
|
| 11 |
import audioDemo from '../../assets/audio/audio-example.wav';
|
| 12 |
import Accordion from '../../../components/Accordion.astro';
|
| 13 |
|
|
|
|
| 87 |
<small className="muted">**Content** in app/src/content/article.mdx</small>
|
| 88 |
```mdx
|
| 89 |
import placeholder from '../../assets/image/placeholder.png'
|
| 90 |
+
import Figure from '../../../components/Figure.astro'
|
| 91 |
import Sidenote from '../../../components/Sidenote.astro'
|
| 92 |
|
| 93 |
This paragraph is written in Markdown.
|
|
|
|
| 95 |
<Sidenote>
|
| 96 |
A short callout inserted via a component.
|
| 97 |
</Sidenote>
|
| 98 |
+
<Figure src={placeholder} alt="Sample image with optimization" />
|
| 99 |
|
| 100 |
This paragraph is also written in Markdown.
|
| 101 |
```
|
|
|
|
| 144 |
|
| 145 |
Here is a suggestion of **color palettes** for your **data visualizations** that align with your **brand identity**. These palettes are generated from your `--primary-color`.
|
| 146 |
|
| 147 |
+
|
| 148 |
<Palettes />
|
| 149 |
<br/>
|
| 150 |
**Use color with care.**
|
app/src/content/chapters/your-first-chapter.mdx
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# this is an example chapter
|
| 2 |
+
|
app/src/content/embeds/arxiv/arxiv.html
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="arxiv-umap"></div>
|
| 2 |
+
|
| 3 |
+
<style>
|
| 4 |
+
.arxiv-umap {
|
| 5 |
+
position: relative;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.arxiv-umap svg {
|
| 9 |
+
display: block;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
/* Tooltip styling comme d3-scatter */
|
| 13 |
+
.arxiv-umap .d3-tooltip {
|
| 14 |
+
z-index: 20;
|
| 15 |
+
backdrop-filter: saturate(1.12) blur(8px);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.arxiv-umap .d3-tooltip__inner {
|
| 19 |
+
display: flex;
|
| 20 |
+
flex-direction: column;
|
| 21 |
+
gap: 8px;
|
| 22 |
+
min-width: 280px;
|
| 23 |
+
max-width: 400px;
|
| 24 |
+
max-height: 90vh;
|
| 25 |
+
overflow-y: auto;
|
| 26 |
+
word-wrap: break-word;
|
| 27 |
+
word-break: break-word;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.arxiv-umap .paper-header {
|
| 31 |
+
background: linear-gradient(135deg, var(--surface-bg), var(--code-bg));
|
| 32 |
+
padding: 12px;
|
| 33 |
+
margin: -10px -12px 8px -12px;
|
| 34 |
+
border-radius: 8px 8px 0 0;
|
| 35 |
+
border-bottom: 1px solid var(--border-color);
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.arxiv-umap .paper-title {
|
| 39 |
+
font-weight: 700;
|
| 40 |
+
font-size: 14px;
|
| 41 |
+
line-height: 1.4;
|
| 42 |
+
margin-bottom: 8px;
|
| 43 |
+
display: -webkit-box;
|
| 44 |
+
-webkit-line-clamp: 3;
|
| 45 |
+
-webkit-box-orient: vertical;
|
| 46 |
+
overflow: hidden;
|
| 47 |
+
text-overflow: ellipsis;
|
| 48 |
+
color: var(--text-color);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.arxiv-umap .paper-meta {
|
| 52 |
+
display: flex;
|
| 53 |
+
align-items: center;
|
| 54 |
+
justify-content: space-between;
|
| 55 |
+
margin-bottom: 6px;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.arxiv-umap .paper-category {
|
| 59 |
+
display: flex;
|
| 60 |
+
align-items: center;
|
| 61 |
+
gap: 6px;
|
| 62 |
+
font-size: 11px;
|
| 63 |
+
color: var(--muted-color);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
.arxiv-umap .paper-badges {
|
| 67 |
+
display: flex;
|
| 68 |
+
gap: 6px;
|
| 69 |
+
align-items: center;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.arxiv-umap .paper-authors {
|
| 73 |
+
font-size: 11px;
|
| 74 |
+
color: var(--muted-color);
|
| 75 |
+
font-style: italic;
|
| 76 |
+
margin-bottom: 0;
|
| 77 |
+
display: -webkit-box;
|
| 78 |
+
-webkit-line-clamp: 2;
|
| 79 |
+
-webkit-box-orient: vertical;
|
| 80 |
+
overflow: hidden;
|
| 81 |
+
opacity: 0.9;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.arxiv-umap .paper-abstract {
|
| 85 |
+
font-size: 11px;
|
| 86 |
+
line-height: 1.4;
|
| 87 |
+
color: var(--text-color);
|
| 88 |
+
padding-top: 0;
|
| 89 |
+
display: -webkit-box;
|
| 90 |
+
-webkit-line-clamp: 6;
|
| 91 |
+
-webkit-box-orient: vertical;
|
| 92 |
+
overflow: hidden;
|
| 93 |
+
text-overflow: ellipsis;
|
| 94 |
+
max-height: none;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
.arxiv-umap .paper-year {
|
| 98 |
+
background: var(--primary-color);
|
| 99 |
+
color: white;
|
| 100 |
+
padding: 3px 8px;
|
| 101 |
+
border-radius: 12px;
|
| 102 |
+
font-size: 10px;
|
| 103 |
+
font-weight: 700;
|
| 104 |
+
letter-spacing: 0.5px;
|
| 105 |
+
white-space: nowrap;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.arxiv-umap .paper-id {
|
| 109 |
+
background: var(--border-color);
|
| 110 |
+
color: var(--muted-color);
|
| 111 |
+
padding: 2px 6px;
|
| 112 |
+
border-radius: 4px;
|
| 113 |
+
font-size: 9px;
|
| 114 |
+
font-weight: 500;
|
| 115 |
+
font-family: monospace;
|
| 116 |
+
white-space: nowrap;
|
| 117 |
+
}
|
| 118 |
+
</style>
|
| 119 |
+
|
| 120 |
+
<script>
|
| 121 |
+
(() => {
|
| 122 |
+
const ensureD3 = (cb) => {
|
| 123 |
+
if (window.d3 && typeof window.d3.select === 'function') return cb();
|
| 124 |
+
let s = document.getElementById('d3-cdn-script');
|
| 125 |
+
if (!s) { s = document.createElement('script'); s.id = 'd3-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js'; document.head.appendChild(s); }
|
| 126 |
+
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
|
| 127 |
+
s.addEventListener('load', onReady, { once: true });
|
| 128 |
+
if (window.d3) onReady();
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const bootstrap = () => {
|
| 132 |
+
const scriptEl = document.currentScript;
|
| 133 |
+
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 134 |
+
if (!(container && container.classList && container.classList.contains('arxiv-umap'))) {
|
| 135 |
+
const cs = Array.from(document.querySelectorAll('.arxiv-umap')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
|
| 136 |
+
container = cs[cs.length - 1] || null;
|
| 137 |
+
}
|
| 138 |
+
if (!container) return;
|
| 139 |
+
if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
|
| 140 |
+
|
| 141 |
+
// Tooltip
|
| 142 |
+
container.style.position = container.style.position || 'relative';
|
| 143 |
+
let tip = container.querySelector('.d3-tooltip'); let tipInner;
|
| 144 |
+
if (!tip) {
|
| 145 |
+
tip = document.createElement('div'); tip.className = 'd3-tooltip';
|
| 146 |
+
Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '10px 12px', borderRadius: '12px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)', opacity: '0', transition: 'opacity .12s ease', backdropFilter: 'saturate(1.12) blur(8px)' });
|
| 147 |
+
tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip);
|
| 148 |
+
} else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
|
| 149 |
+
|
| 150 |
+
// SVG
|
| 151 |
+
const svg = d3.select(container).append('svg').attr('width', '100%').style('display', 'block');
|
| 152 |
+
const gRoot = svg.append('g');
|
| 153 |
+
const gDots = gRoot.append('g').attr('class', 'dots');
|
| 154 |
+
const gCentroids = gRoot.append('g').attr('class', 'centroids');
|
| 155 |
+
|
| 156 |
+
// State & scales
|
| 157 |
+
let width = 800, height = 360; const margin = { top: 8, right: 12, bottom: 8, left: 12 };
|
| 158 |
+
const x = d3.scaleLinear();
|
| 159 |
+
const y = d3.scaleLinear();
|
| 160 |
+
const color = d3.scaleOrdinal();
|
| 161 |
+
const radius = () => 3;
|
| 162 |
+
let isDarkMode = false;
|
| 163 |
+
|
| 164 |
+
// Beautiful category labels
|
| 165 |
+
const categoryLabels = {
|
| 166 |
+
'cs': 'Computer Science',
|
| 167 |
+
'math': 'Mathematics',
|
| 168 |
+
'physics': 'Physics',
|
| 169 |
+
'stat': 'Statistics',
|
| 170 |
+
'eess': 'Electrical Engineering',
|
| 171 |
+
'econ': 'Economics',
|
| 172 |
+
'q-bio': 'Quantitative Biology',
|
| 173 |
+
'q-fin': 'Quantitative Finance',
|
| 174 |
+
'astro-ph': 'Astrophysics',
|
| 175 |
+
'cond-mat': 'Condensed Matter',
|
| 176 |
+
'gr-qc': 'General Relativity',
|
| 177 |
+
'hep-ex': 'High Energy Physics - Experiment',
|
| 178 |
+
'hep-lat': 'High Energy Physics - Lattice',
|
| 179 |
+
'hep-ph': 'High Energy Physics - Phenomenology',
|
| 180 |
+
'hep-th': 'High Energy Physics - Theory',
|
| 181 |
+
'math-ph': 'Mathematical Physics',
|
| 182 |
+
'nlin': 'Nonlinear Sciences',
|
| 183 |
+
'nucl-ex': 'Nuclear Experiment',
|
| 184 |
+
'nucl-th': 'Nuclear Theory',
|
| 185 |
+
'quant-ph': 'Quantum Physics'
|
| 186 |
+
};
|
| 187 |
+
|
| 188 |
+
function getCategoryLabel(category) {
|
| 189 |
+
return categoryLabels[category] || category;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// Inverse mapping: from family names to domain codes for colors
|
| 193 |
+
const familyToDomainCode = {
|
| 194 |
+
'Computer Science': 'cs',
|
| 195 |
+
'Physics': 'physics',
|
| 196 |
+
'Astrophysics': 'astro-ph',
|
| 197 |
+
'Condensed Matter': 'cond-mat',
|
| 198 |
+
'Quantum Physics': 'quant-ph',
|
| 199 |
+
'Mathematics': 'math',
|
| 200 |
+
'Statistics': 'stat',
|
| 201 |
+
'Mathematical Physics': 'math-ph',
|
| 202 |
+
'Engineering': 'eess',
|
| 203 |
+
'Biology': 'q-bio',
|
| 204 |
+
'Economics': 'econ',
|
| 205 |
+
'Finance': 'q-fin',
|
| 206 |
+
'General Relativity': 'gr-qc',
|
| 207 |
+
'Particle Physics': 'hep-ph',
|
| 208 |
+
'Nonlinear Sciences': 'nlin',
|
| 209 |
+
'Nuclear Physics': 'nucl-ex'
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
function getFamilyColor(familyName) {
|
| 213 |
+
const domainCode = familyToDomainCode[familyName] || familyName;
|
| 214 |
+
return color(domainCode) || 'var(--text-color)';
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function getDotStrokeColor(fillColor = null) {
|
| 218 |
+
if (!fillColor) return 'var(--muted-color)';
|
| 219 |
+
|
| 220 |
+
let resolvedColor = fillColor;
|
| 221 |
+
if (fillColor.startsWith('var(')) {
|
| 222 |
+
const tempEl = document.createElement('div');
|
| 223 |
+
tempEl.style.color = fillColor;
|
| 224 |
+
document.body.appendChild(tempEl);
|
| 225 |
+
resolvedColor = getComputedStyle(tempEl).color;
|
| 226 |
+
document.body.removeChild(tempEl);
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
try {
|
| 230 |
+
const colorObj = d3.color(resolvedColor);
|
| 231 |
+
if (!colorObj) return 'var(--muted-color)';
|
| 232 |
+
|
| 233 |
+
return isDarkMode ?
|
| 234 |
+
colorObj.darker(0.3).toString() :
|
| 235 |
+
colorObj.brighter(0.8).toString();
|
| 236 |
+
} catch {
|
| 237 |
+
return 'var(--muted-color)';
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Data loading
|
| 242 |
+
async function fetchFirstAvailable(paths) {
|
| 243 |
+
for (const p of paths) {
|
| 244 |
+
try {
|
| 245 |
+
const res = await fetch(p, { cache: 'no-cache' });
|
| 246 |
+
if (res.ok) { return await res.json(); }
|
| 247 |
+
} catch (e) { }
|
| 248 |
+
}
|
| 249 |
+
throw new Error('Failed to load data from provided paths');
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
let data = [];
|
| 253 |
+
let categories = [];
|
| 254 |
+
let centroids = [];
|
| 255 |
+
|
| 256 |
+
// Mapping des domaines vers les 9 grandes familles
|
| 257 |
+
const domainToFamily = {
|
| 258 |
+
'cs': 'Computer Science',
|
| 259 |
+
'physics': 'Physics',
|
| 260 |
+
'astro-ph': 'Astrophysics',
|
| 261 |
+
'cond-mat': 'Condensed Matter',
|
| 262 |
+
'quant-ph': 'Quantum Physics',
|
| 263 |
+
'math': 'Mathematics',
|
| 264 |
+
'stat': 'Statistics',
|
| 265 |
+
'math-ph': 'Mathematical Physics',
|
| 266 |
+
'eess': 'Engineering',
|
| 267 |
+
'q-bio': 'Biology',
|
| 268 |
+
'econ': 'Economics',
|
| 269 |
+
'q-fin': 'Finance',
|
| 270 |
+
'gr-qc': 'General Relativity',
|
| 271 |
+
'hep-ex': 'Particle Physics',
|
| 272 |
+
'hep-lat': 'Particle Physics',
|
| 273 |
+
'hep-ph': 'Particle Physics',
|
| 274 |
+
'hep-th': 'Particle Physics',
|
| 275 |
+
'nlin': 'Nonlinear Sciences',
|
| 276 |
+
'nucl-ex': 'Nuclear Physics',
|
| 277 |
+
'nucl-th': 'Nuclear Physics'
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
function calculateCentroids(data) {
|
| 281 |
+
// Group by the 9 major scientific families
|
| 282 |
+
const groups = d3.group(data, d => {
|
| 283 |
+
const category = d.primary_category;
|
| 284 |
+
const fullDomain = category.split('.')[0]; // Keep full domain like "astro-ph", "cond-mat"
|
| 285 |
+
return domainToFamily[fullDomain] || domainToFamily[fullDomain.split('-')[0]] || 'Other Sciences';
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
centroids = Array.from(groups.entries()).map(([family, points]) => {
|
| 289 |
+
// Calculate local density for each point
|
| 290 |
+
const densities = points.map(point => {
|
| 291 |
+
const neighbors = points.filter(p => {
|
| 292 |
+
const distance = Math.sqrt(
|
| 293 |
+
Math.pow(p.x - point.x, 2) + Math.pow(p.y - point.y, 2)
|
| 294 |
+
);
|
| 295 |
+
return distance < 0.1; // Rayon de voisinage
|
| 296 |
+
});
|
| 297 |
+
return neighbors.length; // DensitΓ© = nombre de voisins
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
// Centroid pondΓ©rΓ© par la densitΓ©
|
| 301 |
+
const totalWeight = d3.sum(densities);
|
| 302 |
+
const x = d3.sum(points, (d, i) => d.x * densities[i]) / totalWeight;
|
| 303 |
+
const y = d3.sum(points, (d, i) => d.y * densities[i]) / totalWeight;
|
| 304 |
+
|
| 305 |
+
// Maximum density point for information
|
| 306 |
+
const maxDensityIndex = d3.maxIndex(densities);
|
| 307 |
+
const densityCenter = points[maxDensityIndex];
|
| 308 |
+
|
| 309 |
+
return {
|
| 310 |
+
category: family,
|
| 311 |
+
x,
|
| 312 |
+
y,
|
| 313 |
+
count: points.length,
|
| 314 |
+
density: totalWeight / points.length, // DensitΓ© moyenne
|
| 315 |
+
maxDensityPoint: densityCenter
|
| 316 |
+
};
|
| 317 |
+
}).filter(centroid => centroid.count >= 100); // Only show top 9 families
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function updateScales(data) {
|
| 321 |
+
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 322 |
+
isDarkMode = !!isDark;
|
| 323 |
+
|
| 324 |
+
width = container.clientWidth || 800; height = Math.max(260, Math.round(width / 3)); svg.attr('width', width).attr('height', height);
|
| 325 |
+
const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 326 |
+
|
| 327 |
+
const xExtent = d3.extent(data, d => d.x);
|
| 328 |
+
const yExtent = d3.extent(data, d => d.y);
|
| 329 |
+
x.domain([xExtent[0], xExtent[1]]).range([0, innerWidth]).nice();
|
| 330 |
+
y.domain([yExtent[0], yExtent[1]]).range([innerHeight, 0]).nice();
|
| 331 |
+
|
| 332 |
+
return { innerWidth, innerHeight };
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
// Helper function to shuffle array with fixed seed
|
| 336 |
+
function shuffleArray(array, seed = 5) {
|
| 337 |
+
const shuffled = [...array];
|
| 338 |
+
// Simple seeded random number generator
|
| 339 |
+
let rng = seed;
|
| 340 |
+
const seededRandom = () => {
|
| 341 |
+
rng = (rng * 9301 + 49297) % 233280;
|
| 342 |
+
return rng / 233280;
|
| 343 |
+
};
|
| 344 |
+
|
| 345 |
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
| 346 |
+
const j = Math.floor(seededRandom() * (i + 1));
|
| 347 |
+
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
| 348 |
+
}
|
| 349 |
+
return shuffled;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function refreshPalette() {
|
| 353 |
+
try {
|
| 354 |
+
const cats = categories && categories.length ? categories.length : 6;
|
| 355 |
+
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 356 |
+
const arr = window.ColorPalettes.getColors('categorical', cats) || [];
|
| 357 |
+
if (arr && arr.length) {
|
| 358 |
+
// Randomize color order
|
| 359 |
+
const shuffledColors = shuffleArray(arr);
|
| 360 |
+
color.range(shuffledColors);
|
| 361 |
+
return;
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
// fallback with randomization
|
| 365 |
+
const fallbackColors = (d3.schemeTableau10 ? d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab']).slice(0, cats);
|
| 366 |
+
const shuffledFallback = shuffleArray(fallbackColors);
|
| 367 |
+
color.range(shuffledFallback);
|
| 368 |
+
} catch {
|
| 369 |
+
const cats = categories && categories.length ? categories.length : 6;
|
| 370 |
+
const fallbackColors = (d3.schemeTableau10 ? d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab']).slice(0, cats);
|
| 371 |
+
const shuffledFallback = shuffleArray(fallbackColors);
|
| 372 |
+
color.range(shuffledFallback);
|
| 373 |
+
}
|
| 374 |
+
try { if (data && data.length) draw(); } catch { }
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
function draw() {
|
| 378 |
+
if (!data || !data.length) return;
|
| 379 |
+
const { innerWidth, innerHeight } = updateScales(data);
|
| 380 |
+
const fillFor = d => {
|
| 381 |
+
const category = d.primary_category;
|
| 382 |
+
// Extract main prefix - take everything before first dot
|
| 383 |
+
const mainCategory = category.split('.')[0];
|
| 384 |
+
return color(mainCategory);
|
| 385 |
+
};
|
| 386 |
+
|
| 387 |
+
// Calculate centroids
|
| 388 |
+
calculateCentroids(data);
|
| 389 |
+
|
| 390 |
+
// Points
|
| 391 |
+
const dots = gDots.selectAll('circle.dot').data(data, (d, i) => d.id || i);
|
| 392 |
+
dots.enter().append('circle').attr('class', 'dot')
|
| 393 |
+
.attr('cx', d => x(d.x)).attr('cy', d => y(d.y)).attr('r', radius())
|
| 394 |
+
.attr('fill', fillFor).attr('fill-opacity', 0.85)
|
| 395 |
+
.attr('stroke', d => getDotStrokeColor(fillFor(d))).attr('stroke-width', '0.75px')
|
| 396 |
+
.on('mouseenter', function (ev, d) {
|
| 397 |
+
d3.select(this).style('stroke', 'var(--text-color)').style('stroke-width', '1.5px').attr('fill-opacity', 1);
|
| 398 |
+
const swatch = `<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><circle cx="5" cy="5" r="5" fill="${fillFor(d)}" /></svg>`;
|
| 399 |
+
// Keep title as is, let CSS handle truncation
|
| 400 |
+
const title = d.title || 'Untitled Paper';
|
| 401 |
+
|
| 402 |
+
// Format authors nicely
|
| 403 |
+
const authorsText = d.authors && d.authors.length > 0 ?
|
| 404 |
+
(d.authors.length <= 3 ? d.authors.join(', ') : `${d.authors.slice(0, 2).join(', ')} et al. (${d.authors.length} authors)`) :
|
| 405 |
+
'Unknown authors';
|
| 406 |
+
|
| 407 |
+
// Get abstract if available, let CSS handle truncation with line-clamp
|
| 408 |
+
const abstract = d.abstract || 'No abstract available';
|
| 409 |
+
|
| 410 |
+
// Extract arXiv ID if available
|
| 411 |
+
const arxivId = d.url ? d.url.match(/abs\/([^\/]+)$/)?.[1] || '' : '';
|
| 412 |
+
|
| 413 |
+
tipInner.innerHTML = `
|
| 414 |
+
<div class="paper-header">
|
| 415 |
+
<div class="paper-title">${title}</div>
|
| 416 |
+
<div class="paper-meta">
|
| 417 |
+
<div class="paper-category">
|
| 418 |
+
${swatch}
|
| 419 |
+
<span>${d.primary_category}</span>
|
| 420 |
+
</div>
|
| 421 |
+
<div class="paper-badges">
|
| 422 |
+
${arxivId ? `<span class="paper-id">${arxivId}</span>` : ''}
|
| 423 |
+
</div>
|
| 424 |
+
</div>
|
| 425 |
+
<div class="paper-authors">${authorsText}</div>
|
| 426 |
+
</div>
|
| 427 |
+
<div class="paper-abstract">${abstract}</div>`;
|
| 428 |
+
tip.style.opacity = '1';
|
| 429 |
+
})
|
| 430 |
+
.on('mousemove', function (ev) { const [mx, my] = d3.pointer(ev, container); const ox = 12, oy = 12; tip.style.transform = `translate(${Math.round(mx + ox)}px, ${Math.round(my + oy)}px)`; })
|
| 431 |
+
.on('mouseleave', function (ev, d) { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px, -9999px)'; d3.select(this).style('stroke', getDotStrokeColor(fillFor(d))).style('stroke-width', '0.75px').attr('fill-opacity', 0.85); })
|
| 432 |
+
.on('click', function (ev, d) { if (d.url) window.open(d.url, '_blank'); })
|
| 433 |
+
.merge(dots)
|
| 434 |
+
.transition().duration(180)
|
| 435 |
+
.attr('cx', d => x(d.x)).attr('cy', d => y(d.y)).attr('r', radius())
|
| 436 |
+
.attr('fill', fillFor).attr('fill-opacity', 0.85)
|
| 437 |
+
.attr('stroke', d => getDotStrokeColor(fillFor(d))).attr('stroke-width', '0.75px');
|
| 438 |
+
dots.exit().remove();
|
| 439 |
+
|
| 440 |
+
// Centroids with labels (d3-scatter style)
|
| 441 |
+
const nodes = centroids.map((c) => ({
|
| 442 |
+
category: c.category,
|
| 443 |
+
count: c.count,
|
| 444 |
+
targetX: x(c.x),
|
| 445 |
+
targetY: y(c.y),
|
| 446 |
+
x: x(c.x),
|
| 447 |
+
y: y(c.y),
|
| 448 |
+
width: Math.max(18, (String(c.category || '').length || 6) * 11),
|
| 449 |
+
height: 16
|
| 450 |
+
}));
|
| 451 |
+
|
| 452 |
+
if (nodes.length > 1) {
|
| 453 |
+
const sim = d3.forceSimulation(nodes)
|
| 454 |
+
.force('x', d3.forceX((d) => d.targetX).strength(0.9))
|
| 455 |
+
.force('y', d3.forceY((d) => d.targetY).strength(0.9))
|
| 456 |
+
.force('collide', d3.forceCollide((d) => Math.hypot(d.width / 2, d.height / 2) + 15))
|
| 457 |
+
.stop();
|
| 458 |
+
for (let i = 0; i < 650; i++) sim.tick();
|
| 459 |
+
const maxOffset = 45;
|
| 460 |
+
nodes.forEach((n) => {
|
| 461 |
+
const dx = n.x - n.targetX, dy = n.y - n.targetY; const dist = Math.hypot(dx, dy);
|
| 462 |
+
if (dist > maxOffset && dist > 0) { const s = maxOffset / dist; n.x = n.targetX + dx * s; n.y = n.targetY + dy * s; }
|
| 463 |
+
});
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
const labels = gCentroids.selectAll('g.centroid').data(nodes, d => d.category || 'Unknown');
|
| 467 |
+
const enter = labels.enter().append('g').attr('class', 'centroid').attr('pointer-events', 'none');
|
| 468 |
+
enter.append('text').attr('class', 'label-bg').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle');
|
| 469 |
+
enter.append('text').attr('class', 'label-fg').attr('text-anchor', 'middle').attr('dominant-baseline', 'middle');
|
| 470 |
+
const merged = enter.merge(labels);
|
| 471 |
+
merged
|
| 472 |
+
.transition().duration(180)
|
| 473 |
+
.attr('transform', d => `translate(${Math.round(d.x)}, ${Math.round(d.y)})`)
|
| 474 |
+
.each(function (d) {
|
| 475 |
+
const base = getFamilyColor(d.category || 'Unknown');
|
| 476 |
+
const bgNode = this.querySelector('text.label-bg');
|
| 477 |
+
const fgNode = this.querySelector('text.label-fg');
|
| 478 |
+
if (bgNode) {
|
| 479 |
+
bgNode.textContent = getCategoryLabel(d.category);
|
| 480 |
+
bgNode.style.setProperty('fill', "var(--page-bg)", 'important');
|
| 481 |
+
bgNode.style.setProperty('stroke', "var(--page-bg)");
|
| 482 |
+
bgNode.style.setProperty('stroke-width', '8px');
|
| 483 |
+
bgNode.style.setProperty('paint-order', 'stroke fill');
|
| 484 |
+
bgNode.style.setProperty('font-weight', '800');
|
| 485 |
+
bgNode.style.setProperty('font-size', '16px');
|
| 486 |
+
}
|
| 487 |
+
if (fgNode) {
|
| 488 |
+
fgNode.textContent = getCategoryLabel(d.category);
|
| 489 |
+
fgNode.style.setProperty('fill', base, 'important');
|
| 490 |
+
fgNode.style.setProperty('font-weight', '800');
|
| 491 |
+
fgNode.style.setProperty('font-size', '16px');
|
| 492 |
+
}
|
| 493 |
+
});
|
| 494 |
+
labels.exit().remove();
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Load data
|
| 498 |
+
let mountEl = container;
|
| 499 |
+
while (mountEl && !mountEl.getAttribute?.('data-datafiles') && !mountEl.getAttribute?.('data-config')) {
|
| 500 |
+
mountEl = mountEl.parentElement;
|
| 501 |
+
}
|
| 502 |
+
let providedData = null;
|
| 503 |
+
try {
|
| 504 |
+
const attr = mountEl && mountEl.getAttribute ? mountEl.getAttribute('data-datafiles') : null;
|
| 505 |
+
if (attr && attr.trim()) {
|
| 506 |
+
providedData = attr.trim();
|
| 507 |
+
}
|
| 508 |
+
} catch (_) { }
|
| 509 |
+
const DEFAULT_JSON = '/data/data.json';
|
| 510 |
+
const ensureDataPrefix = (p) => {
|
| 511 |
+
if (typeof p !== 'string' || !p) return p;
|
| 512 |
+
return p.includes('/') ? p : `/data/${p}`;
|
| 513 |
+
};
|
| 514 |
+
const JSON_PATHS = providedData ? [ensureDataPrefix(providedData)] : [
|
| 515 |
+
DEFAULT_JSON,
|
| 516 |
+
'./assets/data/data.json',
|
| 517 |
+
'../assets/data/data.json',
|
| 518 |
+
'../../assets/data/data.json'
|
| 519 |
+
];
|
| 520 |
+
const fetchFirstAvailableJson = async (paths) => {
|
| 521 |
+
for (const p of paths) { try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) return await r.json(); } catch (_) { } }
|
| 522 |
+
throw new Error('JSON not found: data.json');
|
| 523 |
+
};
|
| 524 |
+
|
| 525 |
+
fetchFirstAvailableJson(JSON_PATHS).then(rawData => {
|
| 526 |
+
// Show only 1 point out of 5 for performance
|
| 527 |
+
data = rawData.filter((_, index) => index % 1 === 0);
|
| 528 |
+
console.log(`π Affichage de ${data.length} points (1 sur 1) sur ${rawData.length} total`);
|
| 529 |
+
// Extract main prefixes (math, cs, physics, etc.)
|
| 530 |
+
categories = Array.from(new Set(data.map(d => {
|
| 531 |
+
const category = d.primary_category;
|
| 532 |
+
// For categories like "math-ph", take only "math"
|
| 533 |
+
if (category.includes('-')) {
|
| 534 |
+
return category.split('-')[0];
|
| 535 |
+
}
|
| 536 |
+
// For categories like "cs.AI", take only "cs"
|
| 537 |
+
if (category.includes('.')) {
|
| 538 |
+
return category.split('.')[0];
|
| 539 |
+
}
|
| 540 |
+
// Otherwise, return the complete category
|
| 541 |
+
return category;
|
| 542 |
+
}).filter(Boolean)));
|
| 543 |
+
color.domain(categories);
|
| 544 |
+
refreshPalette();
|
| 545 |
+
draw();
|
| 546 |
+
}).catch(e => {
|
| 547 |
+
console.error('Failed to load data:', e);
|
| 548 |
+
gRoot.append('text').attr('x', width / 2).attr('y', height / 2).attr('text-anchor', 'middle').attr('fill', '#e74c3c').text('Failed to load data');
|
| 549 |
+
});
|
| 550 |
+
|
| 551 |
+
// Resize
|
| 552 |
+
if (window.ResizeObserver) {
|
| 553 |
+
const ro = new ResizeObserver(() => draw());
|
| 554 |
+
ro.observe(container);
|
| 555 |
+
} else {
|
| 556 |
+
window.addEventListener('resize', draw);
|
| 557 |
+
}
|
| 558 |
+
};
|
| 559 |
+
|
| 560 |
+
if (document.readyState === 'loading') {
|
| 561 |
+
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
|
| 562 |
+
} else {
|
| 563 |
+
ensureD3(bootstrap);
|
| 564 |
+
}
|
| 565 |
+
})();
|
| 566 |
+
</script>
|
app/src/content/embeds/arxiv/fetch_arxiv_api.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to retrieve papers from the arXiv API
|
| 4 |
+
Optimized for natural representation of scientific domains
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
import xml.etree.ElementTree as ET
|
| 9 |
+
import json
|
| 10 |
+
import time
|
| 11 |
+
import os
|
| 12 |
+
from urllib.parse import quote
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from collections import Counter
|
| 15 |
+
import random
|
| 16 |
+
|
| 17 |
+
class ArxivFetcher:
|
| 18 |
+
def __init__(self):
|
| 19 |
+
self.base_url = "http://export.arxiv.org/api/query"
|
| 20 |
+
self.delay = 3 # Delay between requests (respecting API limits)
|
| 21 |
+
|
| 22 |
+
def fetch_by_category(self, categories, max_per_category=500, total_max=15000):
|
| 23 |
+
"""Retrieve papers by category with global limit"""
|
| 24 |
+
print(f"π Retrieval by category (max {max_per_category} per cat, {total_max} total)")
|
| 25 |
+
|
| 26 |
+
all_papers = []
|
| 27 |
+
|
| 28 |
+
for i, category in enumerate(categories):
|
| 29 |
+
if len(all_papers) >= total_max:
|
| 30 |
+
break
|
| 31 |
+
|
| 32 |
+
print(f" [{i+1}/{len(categories)}] {category}...")
|
| 33 |
+
|
| 34 |
+
# Dynamic calculation of number to retrieve
|
| 35 |
+
remaining = total_max - len(all_papers)
|
| 36 |
+
fetch_count = min(max_per_category, remaining)
|
| 37 |
+
|
| 38 |
+
papers = self._fetch_category(category, fetch_count)
|
| 39 |
+
all_papers.extend(papers)
|
| 40 |
+
|
| 41 |
+
print(f" β
{len(papers)} papers retrieved (total: {len(all_papers)})")
|
| 42 |
+
|
| 43 |
+
# Delay between categories
|
| 44 |
+
if i < len(categories) - 1:
|
| 45 |
+
time.sleep(self.delay)
|
| 46 |
+
|
| 47 |
+
return all_papers[:total_max]
|
| 48 |
+
|
| 49 |
+
def fetch_recent_papers(self, days_back=30, max_results=15000):
|
| 50 |
+
"""Retrieve recent papers from the last days"""
|
| 51 |
+
print(f"π
Retrieving papers from the last {days_back} days")
|
| 52 |
+
|
| 53 |
+
# End date: today
|
| 54 |
+
end_date = datetime.now()
|
| 55 |
+
# Start date: X days ago
|
| 56 |
+
start_date = end_date - timedelta(days=days_back)
|
| 57 |
+
|
| 58 |
+
# Format arXiv: YYYYMMDDHHMM
|
| 59 |
+
date_query = f"submittedDate:[{start_date.strftime('%Y%m%d%H%M')} TO {end_date.strftime('%Y%m%d%H%M')}]"
|
| 60 |
+
|
| 61 |
+
return self._fetch_with_query(date_query, max_results)
|
| 62 |
+
|
| 63 |
+
def _fetch_category(self, category, max_results):
|
| 64 |
+
"""Retrieve papers from a specific category"""
|
| 65 |
+
query = f"cat:{category}"
|
| 66 |
+
return self._fetch_with_query(query, max_results)
|
| 67 |
+
|
| 68 |
+
def _fetch_with_query(self, query, max_results):
|
| 69 |
+
"""Generic method to retrieve with a query"""
|
| 70 |
+
papers = []
|
| 71 |
+
start = 0
|
| 72 |
+
batch_size = min(1000, max_results) # arXiv limits to 1000 per request
|
| 73 |
+
|
| 74 |
+
while len(papers) < max_results:
|
| 75 |
+
remaining = max_results - len(papers)
|
| 76 |
+
current_batch = min(batch_size, remaining)
|
| 77 |
+
|
| 78 |
+
params = {
|
| 79 |
+
'search_query': query,
|
| 80 |
+
'start': start,
|
| 81 |
+
'max_results': current_batch,
|
| 82 |
+
'sortBy': 'submittedDate',
|
| 83 |
+
'sortOrder': 'descending'
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
response = requests.get(self.base_url, params=params, timeout=30)
|
| 88 |
+
response.raise_for_status()
|
| 89 |
+
|
| 90 |
+
batch_papers = self._parse_response(response.text)
|
| 91 |
+
if not batch_papers:
|
| 92 |
+
print(f" β οΈ No results for start={start}")
|
| 93 |
+
break
|
| 94 |
+
|
| 95 |
+
papers.extend(batch_papers)
|
| 96 |
+
start += len(batch_papers)
|
| 97 |
+
|
| 98 |
+
print(f" π Batch {len(batch_papers)} papers (total: {len(papers)})")
|
| 99 |
+
|
| 100 |
+
# Delay between requests
|
| 101 |
+
time.sleep(self.delay)
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f" β Error: {e}")
|
| 105 |
+
break
|
| 106 |
+
|
| 107 |
+
return papers[:max_results]
|
| 108 |
+
|
| 109 |
+
def _parse_response(self, xml_content):
|
| 110 |
+
"""Parse arXiv XML response"""
|
| 111 |
+
papers = []
|
| 112 |
+
|
| 113 |
+
try:
|
| 114 |
+
root = ET.fromstring(xml_content)
|
| 115 |
+
|
| 116 |
+
# arXiv Namespace
|
| 117 |
+
ns = {'atom': 'http://www.w3.org/2005/Atom',
|
| 118 |
+
'arxiv': 'http://arxiv.org/schemas/atom'}
|
| 119 |
+
|
| 120 |
+
entries = root.findall('atom:entry', ns)
|
| 121 |
+
|
| 122 |
+
for entry in entries:
|
| 123 |
+
try:
|
| 124 |
+
# ID arXiv
|
| 125 |
+
arxiv_id = entry.find('atom:id', ns).text.split('/')[-1]
|
| 126 |
+
|
| 127 |
+
# Titre
|
| 128 |
+
title = entry.find('atom:title', ns).text.strip()
|
| 129 |
+
title = ' '.join(title.split()) # Clean spaces
|
| 130 |
+
|
| 131 |
+
# RΓ©sumΓ©
|
| 132 |
+
summary = entry.find('atom:summary', ns).text.strip()
|
| 133 |
+
summary = ' '.join(summary.split())[:500] # Limit size
|
| 134 |
+
|
| 135 |
+
# Auteurs
|
| 136 |
+
authors = []
|
| 137 |
+
for author in entry.findall('atom:author', ns):
|
| 138 |
+
name = author.find('atom:name', ns)
|
| 139 |
+
if name is not None:
|
| 140 |
+
authors.append(name.text.strip())
|
| 141 |
+
|
| 142 |
+
# CatΓ©gories
|
| 143 |
+
categories = []
|
| 144 |
+
primary_category = None
|
| 145 |
+
|
| 146 |
+
for category in entry.findall('atom:category', ns):
|
| 147 |
+
term = category.get('term')
|
| 148 |
+
if term:
|
| 149 |
+
categories.append(term)
|
| 150 |
+
|
| 151 |
+
# Primary category
|
| 152 |
+
primary_cat = entry.find('arxiv:primary_category', ns)
|
| 153 |
+
if primary_cat is not None:
|
| 154 |
+
primary_category = primary_cat.get('term')
|
| 155 |
+
elif categories:
|
| 156 |
+
primary_category = categories[0]
|
| 157 |
+
|
| 158 |
+
# Publication date
|
| 159 |
+
published = entry.find('atom:published', ns)
|
| 160 |
+
published_date = published.text if published is not None else None
|
| 161 |
+
|
| 162 |
+
paper = {
|
| 163 |
+
'id': arxiv_id,
|
| 164 |
+
'title': title,
|
| 165 |
+
'summary': summary,
|
| 166 |
+
'authors': authors,
|
| 167 |
+
'categories': categories,
|
| 168 |
+
'primary_category': primary_category,
|
| 169 |
+
'published': published_date
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
papers.append(paper)
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
print(f" β οΈ Error parsing entry: {e}")
|
| 176 |
+
continue
|
| 177 |
+
|
| 178 |
+
except ET.ParseError as e:
|
| 179 |
+
print(f"β XML parsing error: {e}")
|
| 180 |
+
|
| 181 |
+
return papers
|
| 182 |
+
|
| 183 |
+
def save_papers(papers, filename):
|
| 184 |
+
"""Save papers to JSON"""
|
| 185 |
+
with open(filename, 'w', encoding='utf-8') as f:
|
| 186 |
+
json.dump(papers, f, indent=2, ensure_ascii=False)
|
| 187 |
+
|
| 188 |
+
size_mb = os.path.getsize(filename) / 1024 / 1024
|
| 189 |
+
print(f"πΎ Saved: {filename} ({len(papers)} papers, {size_mb:.1f} MB)")
|
| 190 |
+
|
| 191 |
+
def main():
|
| 192 |
+
"""Main arXiv data retrieval"""
|
| 193 |
+
print("π ArXiv Data Fetcher - Version OptimisΓ©e")
|
| 194 |
+
print("=" * 50)
|
| 195 |
+
|
| 196 |
+
fetcher = ArxivFetcher()
|
| 197 |
+
|
| 198 |
+
# Simple approach: 1 month of recent data
|
| 199 |
+
print("\nπ
SIMPLE APPROACH: 1 month of recent data")
|
| 200 |
+
print("π― Objective: retrieve everything available from the last month")
|
| 201 |
+
print("β‘ Without representativeness constraint - just natural data")
|
| 202 |
+
|
| 203 |
+
# Try with different periods to find data
|
| 204 |
+
monthly_papers = None
|
| 205 |
+
for days in [30, 60, 90, 120]: # 1, 2, 3, 4 months
|
| 206 |
+
print(f"\nπ Attempt: {days} days...")
|
| 207 |
+
monthly_papers = fetcher.fetch_recent_papers(days_back=days, max_results=15000)
|
| 208 |
+
if monthly_papers and len(monthly_papers) > 1000:
|
| 209 |
+
print(f"β
{len(monthly_papers)} papers found over {days} days")
|
| 210 |
+
break
|
| 211 |
+
elif monthly_papers:
|
| 212 |
+
print(f"β οΈ Only {len(monthly_papers)} papers over {days} days")
|
| 213 |
+
else:
|
| 214 |
+
print(f"β No papers found over {days} days")
|
| 215 |
+
|
| 216 |
+
if not monthly_papers:
|
| 217 |
+
print("\nπ Fallback: retrieval by popular categories")
|
| 218 |
+
# If no recent data, just take popular categories
|
| 219 |
+
popular_categories = [
|
| 220 |
+
'cs.LG', 'cs.AI', 'cs.CV', 'cs.CL', 'cs.CR', 'cs.RO', 'cs.HC',
|
| 221 |
+
'physics.comp-ph', 'physics.data-an', 'physics.optics',
|
| 222 |
+
'math.ST', 'math.NA', 'math.OC', 'math.PR',
|
| 223 |
+
'stat.ML', 'stat.ME', 'stat.AP',
|
| 224 |
+
'eess.AS', 'eess.IV', 'eess.SP',
|
| 225 |
+
'q-bio.QM', 'q-bio.BM', 'astro-ph.CO'
|
| 226 |
+
]
|
| 227 |
+
|
| 228 |
+
monthly_papers = fetcher.fetch_by_category(
|
| 229 |
+
categories=popular_categories,
|
| 230 |
+
max_per_category=500,
|
| 231 |
+
total_max=15000
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
if monthly_papers:
|
| 235 |
+
save_papers(monthly_papers, "arxiv_monthly_papers.json")
|
| 236 |
+
|
| 237 |
+
# Statistiques finales
|
| 238 |
+
from collections import Counter
|
| 239 |
+
|
| 240 |
+
# Check paper structure
|
| 241 |
+
sample_keys = list(monthly_papers[0].keys()) if monthly_papers else []
|
| 242 |
+
category_key = 'primary_category' if 'primary_category' in sample_keys else 'categories'
|
| 243 |
+
|
| 244 |
+
domains = []
|
| 245 |
+
for paper in monthly_papers:
|
| 246 |
+
if category_key in paper:
|
| 247 |
+
cat = paper[category_key]
|
| 248 |
+
if isinstance(cat, list) and cat:
|
| 249 |
+
domains.append(cat[0].split('.')[0])
|
| 250 |
+
elif isinstance(cat, str):
|
| 251 |
+
domains.append(cat.split('.')[0])
|
| 252 |
+
|
| 253 |
+
domain_counts = Counter(domains)
|
| 254 |
+
|
| 255 |
+
print(f"\nπ Natural distribution ({len(monthly_papers)} papers):")
|
| 256 |
+
for domain, count in domain_counts.most_common():
|
| 257 |
+
percentage = count / len(monthly_papers) * 100
|
| 258 |
+
print(f" {domain}: {count} papers ({percentage:.1f}%)")
|
| 259 |
+
else:
|
| 260 |
+
print("β Complete retrieval failure")
|
| 261 |
+
|
| 262 |
+
print("\nπ Retrieval completed!")
|
| 263 |
+
print("π Files created:")
|
| 264 |
+
for filename in ["arxiv_monthly_papers.json"]:
|
| 265 |
+
if os.path.exists(filename):
|
| 266 |
+
size = os.path.getsize(filename) / 1024 / 1024 # MB
|
| 267 |
+
print(f" - {filename} ({size:.1f} MB)")
|
| 268 |
+
|
| 269 |
+
if __name__ == "__main__":
|
| 270 |
+
main()
|
app/src/content/embeds/arxiv/generate_umap.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
UMAP Generator for arXiv papers
|
| 4 |
+
Creates 2D and 3D projections with density-weighted centroids
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 11 |
+
from sklearn.decomposition import TruncatedSVD
|
| 12 |
+
import umap
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from collections import Counter
|
| 17 |
+
|
| 18 |
+
def load_papers(filename="arxiv_monthly_papers.json"):
|
| 19 |
+
"""Load papers from JSON file"""
|
| 20 |
+
if not os.path.exists(filename):
|
| 21 |
+
print(f"β File {filename} not found!")
|
| 22 |
+
print("π‘ Run fetch_arxiv_api.py first")
|
| 23 |
+
return None
|
| 24 |
+
|
| 25 |
+
with open(filename, 'r', encoding='utf-8') as f:
|
| 26 |
+
papers = json.load(f)
|
| 27 |
+
|
| 28 |
+
print(f"π {len(papers)} papers loaded from {filename}")
|
| 29 |
+
return papers
|
| 30 |
+
|
| 31 |
+
def preprocess_papers(papers, sample_rate=5):
|
| 32 |
+
"""Preprocess papers and sample if necessary"""
|
| 33 |
+
print(f"π Preprocessing papers...")
|
| 34 |
+
|
| 35 |
+
# Filter papers with missing data
|
| 36 |
+
valid_papers = []
|
| 37 |
+
for paper in papers:
|
| 38 |
+
if (paper.get('title') and
|
| 39 |
+
paper.get('summary') and
|
| 40 |
+
paper.get('primary_category')):
|
| 41 |
+
valid_papers.append(paper)
|
| 42 |
+
|
| 43 |
+
print(f"β
{len(valid_papers)} valid papers after filtering")
|
| 44 |
+
|
| 45 |
+
# Sampling for performance (1 out of N)
|
| 46 |
+
if sample_rate > 1:
|
| 47 |
+
sampled_papers = valid_papers[::sample_rate]
|
| 48 |
+
print(f"π Sampling 1/{sample_rate}: {len(sampled_papers)} papers retained")
|
| 49 |
+
return sampled_papers
|
| 50 |
+
|
| 51 |
+
return valid_papers
|
| 52 |
+
|
| 53 |
+
def create_embeddings(papers, max_features=5000, n_components=50):
|
| 54 |
+
"""Create TF-IDF + SVD embeddings of papers"""
|
| 55 |
+
print(f"π’ Creating embeddings (max_features={max_features}, n_components={n_components})")
|
| 56 |
+
|
| 57 |
+
# Combine title and summary
|
| 58 |
+
texts = []
|
| 59 |
+
for paper in papers:
|
| 60 |
+
title = paper.get('title', '').strip()
|
| 61 |
+
summary = paper.get('summary', '').strip()
|
| 62 |
+
combined = f"{title} {summary}"
|
| 63 |
+
texts.append(combined)
|
| 64 |
+
|
| 65 |
+
# TF-IDF
|
| 66 |
+
print(" π TF-IDF vectorization...")
|
| 67 |
+
tfidf = TfidfVectorizer(
|
| 68 |
+
max_features=max_features,
|
| 69 |
+
stop_words='english',
|
| 70 |
+
ngram_range=(1, 2),
|
| 71 |
+
min_df=2,
|
| 72 |
+
max_df=0.95
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
tfidf_matrix = tfidf.fit_transform(texts)
|
| 76 |
+
print(f" β
TF-IDF: {tfidf_matrix.shape}")
|
| 77 |
+
|
| 78 |
+
# Dimensionality reduction with SVD
|
| 79 |
+
print(f" π SVD reduction to {n_components} dimensions...")
|
| 80 |
+
svd = TruncatedSVD(n_components=n_components, random_state=42)
|
| 81 |
+
embeddings = svd.fit_transform(tfidf_matrix)
|
| 82 |
+
|
| 83 |
+
print(f" β
Final embeddings: {embeddings.shape}")
|
| 84 |
+
print(f" π Explained variance: {svd.explained_variance_ratio_.sum():.3f}")
|
| 85 |
+
|
| 86 |
+
return embeddings
|
| 87 |
+
|
| 88 |
+
def map_to_families(papers):
|
| 89 |
+
"""Map categories to 9 main scientific families"""
|
| 90 |
+
|
| 91 |
+
# Mapping to 9 scientific families
|
| 92 |
+
domain_to_family = {
|
| 93 |
+
'cs': 'Computer Science',
|
| 94 |
+
'math': 'Mathematics',
|
| 95 |
+
'physics': 'Physics',
|
| 96 |
+
'stat': 'Statistics',
|
| 97 |
+
'q-bio': 'Biology',
|
| 98 |
+
'eess': 'Engineering',
|
| 99 |
+
'astro-ph': 'Astrophysics',
|
| 100 |
+
'cond-mat': 'Condensed Matter',
|
| 101 |
+
'nucl': 'Nuclear Physics'
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
families = []
|
| 105 |
+
for paper in papers:
|
| 106 |
+
primary_cat = paper.get('primary_category', '')
|
| 107 |
+
if primary_cat:
|
| 108 |
+
domain = primary_cat.split('.')[0]
|
| 109 |
+
family = domain_to_family.get(domain, 'Other')
|
| 110 |
+
else:
|
| 111 |
+
family = 'Other'
|
| 112 |
+
families.append(family)
|
| 113 |
+
|
| 114 |
+
family_counts = Counter(families)
|
| 115 |
+
print(f"π Distribution by family:")
|
| 116 |
+
for family, count in family_counts.most_common():
|
| 117 |
+
print(f" {family}: {count} papers")
|
| 118 |
+
|
| 119 |
+
return families
|
| 120 |
+
|
| 121 |
+
def generate_umap_projection(embeddings, families, n_neighbors=50, min_dist=0.1, spread=0.5, n_components=2):
|
| 122 |
+
"""Generate UMAP projection"""
|
| 123 |
+
print(f"π― UMAP projection (n_neighbors={n_neighbors}, min_dist={min_dist}, spread={spread}, n_components={n_components})")
|
| 124 |
+
|
| 125 |
+
# Configuration UMAP
|
| 126 |
+
reducer = umap.UMAP(
|
| 127 |
+
n_neighbors=n_neighbors,
|
| 128 |
+
min_dist=min_dist,
|
| 129 |
+
spread=spread,
|
| 130 |
+
n_components=n_components,
|
| 131 |
+
random_state=42,
|
| 132 |
+
metric='cosine'
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# Projection
|
| 136 |
+
projection = reducer.fit_transform(embeddings)
|
| 137 |
+
print(f"β
Projection UMAP: {projection.shape}")
|
| 138 |
+
|
| 139 |
+
return projection
|
| 140 |
+
|
| 141 |
+
def calculate_density_weighted_centroids(projection, families, families_list):
|
| 142 |
+
"""Calculate density-weighted centroids"""
|
| 143 |
+
print("π― Calculating density-weighted centroids...")
|
| 144 |
+
|
| 145 |
+
centroids = {}
|
| 146 |
+
|
| 147 |
+
for family in families_list:
|
| 148 |
+
# Points of this family
|
| 149 |
+
family_mask = np.array(families) == family
|
| 150 |
+
family_points = projection[family_mask]
|
| 151 |
+
|
| 152 |
+
if len(family_points) < 30: # Filter families too small
|
| 153 |
+
continue
|
| 154 |
+
|
| 155 |
+
if projection.shape[1] == 2: # 2D
|
| 156 |
+
# Calculate 2D density
|
| 157 |
+
densities = []
|
| 158 |
+
for point in family_points:
|
| 159 |
+
distances = np.linalg.norm(family_points - point, axis=1)
|
| 160 |
+
density = np.sum(distances < np.percentile(distances, 20)) # Local density
|
| 161 |
+
densities.append(density)
|
| 162 |
+
|
| 163 |
+
densities = np.array(densities)
|
| 164 |
+
weights = densities / densities.sum()
|
| 165 |
+
|
| 166 |
+
# Weighted centroid
|
| 167 |
+
centroid_x = np.sum(family_points[:, 0] * weights)
|
| 168 |
+
centroid_y = np.sum(family_points[:, 1] * weights)
|
| 169 |
+
|
| 170 |
+
centroids[family] = {
|
| 171 |
+
'x': float(centroid_x),
|
| 172 |
+
'y': float(centroid_y),
|
| 173 |
+
'count': len(family_points)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
else: # 3D
|
| 177 |
+
# Calculate 3D density
|
| 178 |
+
densities = []
|
| 179 |
+
for point in family_points:
|
| 180 |
+
distances = np.linalg.norm(family_points - point, axis=1)
|
| 181 |
+
density = np.sum(distances < np.percentile(distances, 20))
|
| 182 |
+
densities.append(density)
|
| 183 |
+
|
| 184 |
+
densities = np.array(densities)
|
| 185 |
+
weights = densities / densities.sum()
|
| 186 |
+
|
| 187 |
+
# Weighted centroid
|
| 188 |
+
centroid_x = np.sum(family_points[:, 0] * weights)
|
| 189 |
+
centroid_y = np.sum(family_points[:, 1] * weights)
|
| 190 |
+
centroid_z = np.sum(family_points[:, 2] * weights)
|
| 191 |
+
|
| 192 |
+
centroids[family] = {
|
| 193 |
+
'x': float(centroid_x),
|
| 194 |
+
'y': float(centroid_y),
|
| 195 |
+
'z': float(centroid_z),
|
| 196 |
+
'count': len(family_points)
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
print(f"β
{len(centroids)} centroids calculated")
|
| 200 |
+
return centroids
|
| 201 |
+
|
| 202 |
+
def save_visualization_data(papers, projection, families, centroids, output_prefix):
|
| 203 |
+
"""Save visualization data"""
|
| 204 |
+
|
| 205 |
+
# Prepare data
|
| 206 |
+
viz_data = []
|
| 207 |
+
for i, paper in enumerate(papers):
|
| 208 |
+
if projection.shape[1] == 2: # 2D
|
| 209 |
+
point = {
|
| 210 |
+
'id': paper.get('id', f'paper_{i}'),
|
| 211 |
+
'title': paper.get('title', ''),
|
| 212 |
+
'summary': paper.get('summary', '')[:200] + '...',
|
| 213 |
+
'authors': ', '.join(paper.get('authors', [])[:3]), # Max 3 authors
|
| 214 |
+
'category': paper.get('primary_category', ''),
|
| 215 |
+
'family': families[i],
|
| 216 |
+
'x': float(projection[i, 0]),
|
| 217 |
+
'y': float(projection[i, 1])
|
| 218 |
+
}
|
| 219 |
+
else: # 3D
|
| 220 |
+
point = {
|
| 221 |
+
'id': paper.get('id', f'paper_{i}'),
|
| 222 |
+
'title': paper.get('title', ''),
|
| 223 |
+
'summary': paper.get('summary', '')[:200] + '...',
|
| 224 |
+
'authors': ', '.join(paper.get('authors', [])[:3]),
|
| 225 |
+
'category': paper.get('primary_category', ''),
|
| 226 |
+
'family': families[i],
|
| 227 |
+
'x': float(projection[i, 0]),
|
| 228 |
+
'y': float(projection[i, 1]),
|
| 229 |
+
'z': float(projection[i, 2])
|
| 230 |
+
}
|
| 231 |
+
viz_data.append(point)
|
| 232 |
+
|
| 233 |
+
# Add centroids
|
| 234 |
+
viz_data_with_centroids = {
|
| 235 |
+
'points': viz_data,
|
| 236 |
+
'centroids': centroids,
|
| 237 |
+
'metadata': {
|
| 238 |
+
'total_papers': len(papers),
|
| 239 |
+
'dimensions': projection.shape[1],
|
| 240 |
+
'families': list(set(families)),
|
| 241 |
+
'generated': datetime.now().isoformat()
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
# Save
|
| 246 |
+
output_file = f"{output_prefix}.json"
|
| 247 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
| 248 |
+
json.dump(viz_data_with_centroids, f, indent=2, ensure_ascii=False)
|
| 249 |
+
|
| 250 |
+
size_mb = os.path.getsize(output_file) / 1024 / 1024
|
| 251 |
+
print(f"πΎ Data saved: {output_file} ({size_mb:.1f} MB)")
|
| 252 |
+
|
| 253 |
+
return output_file
|
| 254 |
+
|
| 255 |
+
def main():
|
| 256 |
+
"""Main UMAP generation pipeline"""
|
| 257 |
+
print("π ArXiv UMAP Generator")
|
| 258 |
+
print("=" * 40)
|
| 259 |
+
|
| 260 |
+
# 1. Data loading
|
| 261 |
+
papers = load_papers()
|
| 262 |
+
if not papers:
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
# 2. Preprocessing
|
| 266 |
+
papers = preprocess_papers(papers, sample_rate=5) # 1 point out of 5
|
| 267 |
+
|
| 268 |
+
# 3. Mapping to families
|
| 269 |
+
families = map_to_families(papers)
|
| 270 |
+
families_list = list(set(families))
|
| 271 |
+
|
| 272 |
+
# 4. Embedding creation
|
| 273 |
+
embeddings = create_embeddings(papers, max_features=3000, n_components=50)
|
| 274 |
+
|
| 275 |
+
# 5. UMAP projection generation
|
| 276 |
+
|
| 277 |
+
# UMAP 2D
|
| 278 |
+
print("\nπ― Generating 2D UMAP...")
|
| 279 |
+
projection_2d = generate_umap_projection(
|
| 280 |
+
embeddings, families,
|
| 281 |
+
n_neighbors=50, min_dist=0.8, spread=1.0, n_components=2
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
centroids_2d = calculate_density_weighted_centroids(projection_2d, families, families_list)
|
| 285 |
+
|
| 286 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 287 |
+
output_2d = save_visualization_data(
|
| 288 |
+
papers, projection_2d, families, centroids_2d,
|
| 289 |
+
f"arxiv_umap_viz_2d_{timestamp}"
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# UMAP 3D
|
| 293 |
+
print("\nπ― Generating 3D UMAP...")
|
| 294 |
+
projection_3d = generate_umap_projection(
|
| 295 |
+
embeddings, families,
|
| 296 |
+
n_neighbors=50, min_dist=0.8, spread=1.0, n_components=3
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
centroids_3d = calculate_density_weighted_centroids(projection_3d, families, families_list)
|
| 300 |
+
|
| 301 |
+
output_3d = save_visualization_data(
|
| 302 |
+
papers, projection_3d, families, centroids_3d,
|
| 303 |
+
f"arxiv_umap_viz_3d_{timestamp}"
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# Automatic copy to content/assets/data
|
| 307 |
+
import shutil
|
| 308 |
+
source_file = output_2d # Use 2D by default
|
| 309 |
+
target_dir = "../../assets/data"
|
| 310 |
+
target_file = os.path.join(target_dir, "data.json")
|
| 311 |
+
|
| 312 |
+
try:
|
| 313 |
+
# Create directory if necessary
|
| 314 |
+
os.makedirs(target_dir, exist_ok=True)
|
| 315 |
+
shutil.copy2(source_file, target_file)
|
| 316 |
+
print(f"\nβ
AUTOMATIC COPY SUCCESSFUL!")
|
| 317 |
+
print(f"π {source_file} β {target_file}")
|
| 318 |
+
except Exception as e:
|
| 319 |
+
print(f"\nβ οΈ Automatic copy failed: {e}")
|
| 320 |
+
|
| 321 |
+
print(f"\nπ Generation completed!")
|
| 322 |
+
print(f"π Files created:")
|
| 323 |
+
for f in [output_2d, output_3d]:
|
| 324 |
+
if os.path.exists(f):
|
| 325 |
+
size = os.path.getsize(f) / 1024 / 1024
|
| 326 |
+
print(f" - {f} ({size:.1f} MB)")
|
| 327 |
+
|
| 328 |
+
if __name__ == "__main__":
|
| 329 |
+
main()
|
app/src/content/embeds/banner.html
CHANGED
|
@@ -1,267 +1,258 @@
|
|
| 1 |
<div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div>
|
| 2 |
<script>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
const armIndices = Int16Array.from({ length: numPoints }, () => Math.floor(Math.random() * numArms));
|
| 39 |
-
const armOffsets = Float64Array.from(armIndices, (k) => k * (twoPi / numArms));
|
| 40 |
-
const theta = Float64Array.from(t, (tv, i) => tv + armOffsets[i] + d3.randomNormal.source(Math.random)(0, angleJitter)());
|
| 41 |
-
const rNorm = Float64Array.from(t, (tv) => Math.pow(tv / (twoPi * numTurns), 0.9));
|
| 42 |
-
const noiseScale = (rn) => posNoise * (0.8 + 0.6 * rn);
|
| 43 |
-
const noiseX = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
|
| 44 |
-
const noiseY = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
const rB = Float64Array.from({ length: bulgePoints }, () => Math.pow(Math.random(), 2.2) * 0.22);
|
| 52 |
-
const noiseXB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
|
| 53 |
-
const noiseYB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
|
| 54 |
-
const xBulge = Float64Array.from(phiB, (ph, i) => cx + a * rB[i] * Math.cos(ph) + noiseXB[i]);
|
| 55 |
-
const yBulge = Float64Array.from(phiB, (ph, i) => cy + b * rB[i] * Math.sin(ph) + noiseYB[i]);
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
const sizesPx = Zraw.map((z) => (z + 1) * 5); // 5..10 px (diameter)
|
| 67 |
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
if (z < 0.75) return 'a dot';
|
| 74 |
-
return 'biiig dot';
|
| 75 |
-
};
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
const c1 = d3.rgb(206, 192, 250); // rgb(206, 192, 250)
|
| 83 |
-
const c2 = d3.rgb(232, 137, 171); // rgb(232, 137, 171)
|
| 84 |
-
const interp01 = d3.interpolateRgb(c0, c1);
|
| 85 |
-
const interp12 = d3.interpolateRgb(c1, c2);
|
| 86 |
-
const colorFor = (v) => {
|
| 87 |
-
const t = Math.max(0, Math.min(1, v));
|
| 88 |
-
return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);
|
| 89 |
-
};
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
| 101 |
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
|
| 108 |
-
const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)';
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
-
// Group for points (no blend mode for better print/PDF visibility)
|
| 112 |
-
const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points');
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
let tip = container.querySelector('.d3-tooltip');
|
| 117 |
-
let tipInner;
|
| 118 |
-
if (!tip) {
|
| 119 |
-
tip = document.createElement('div');
|
| 120 |
-
tip.className = 'd3-tooltip';
|
| 121 |
-
Object.assign(tip.style, {
|
| 122 |
-
position: 'absolute',
|
| 123 |
-
top: '0px',
|
| 124 |
-
left: '0px',
|
| 125 |
-
transform: 'translate(-9999px, -9999px)',
|
| 126 |
-
pointerEvents: 'none',
|
| 127 |
-
padding: '10px 12px',
|
| 128 |
-
borderRadius: '12px',
|
| 129 |
-
fontSize: '12px',
|
| 130 |
-
lineHeight: '1.35',
|
| 131 |
-
border: '1px solid var(--border-color)',
|
| 132 |
-
background: 'var(--surface-bg)',
|
| 133 |
-
color: 'var(--text-color)',
|
| 134 |
-
boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
|
| 135 |
-
opacity: '0',
|
| 136 |
-
transition: 'opacity .12s ease',
|
| 137 |
-
backdropFilter: 'saturate(1.12) blur(8px)',
|
| 138 |
-
zIndex: '20'
|
| 139 |
-
});
|
| 140 |
-
tipInner = document.createElement('div');
|
| 141 |
-
tipInner.className = 'd3-tooltip__inner';
|
| 142 |
-
Object.assign(tipInner.style, {
|
| 143 |
-
textAlign: 'left',
|
| 144 |
-
display: 'flex',
|
| 145 |
-
flexDirection: 'column',
|
| 146 |
-
gap: '6px',
|
| 147 |
-
minWidth: '220px'
|
| 148 |
-
});
|
| 149 |
-
tip.appendChild(tipInner);
|
| 150 |
-
container.appendChild(tip);
|
| 151 |
-
} else {
|
| 152 |
-
tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
|
| 153 |
-
}
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
.attr('r', (i) => sizesPx[i] / 2)
|
| 167 |
-
.attr('fill', (i) => colorFor(Zraw[i]))
|
| 168 |
-
.attr('fill-opacity', 0.9)
|
| 169 |
-
.attr('stroke', strokeColor)
|
| 170 |
-
.attr('stroke-width', 0.4)
|
| 171 |
-
.on('mouseenter', function(ev, i) {
|
| 172 |
-
d3.select(this).raise()
|
| 173 |
-
.style('filter', `drop-shadow(0 0 8px ${glowColor})`)
|
| 174 |
-
.transition().duration(120).ease(d3.easeCubicOut)
|
| 175 |
-
.attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
|
| 176 |
-
.attr('stroke-width', 1.4)
|
| 177 |
-
.attr('r', (sizesPx[i] / 2) * 1.25)
|
| 178 |
-
.attr('fill-opacity', 1);
|
| 179 |
-
const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
|
| 180 |
-
const type = i < lenSpiral ? 'spiral' : 'bulge';
|
| 181 |
-
const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
|
| 182 |
-
tipInner.innerHTML =
|
| 183 |
-
`<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
|
| 184 |
-
`<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
|
| 185 |
-
`<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
|
| 186 |
-
`<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
|
| 187 |
-
`<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
|
| 188 |
-
tip.style.opacity = '1';
|
| 189 |
-
})
|
| 190 |
-
.on('mousemove', (ev, i) => {
|
| 191 |
-
const [mx, my] = d3.pointer(ev, container);
|
| 192 |
-
const offsetX = 10, offsetY = 12;
|
| 193 |
-
tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
|
| 194 |
-
})
|
| 195 |
-
.on('mouseleave', function() {
|
| 196 |
-
tip.style.opacity = '0';
|
| 197 |
-
tip.style.transform = 'translate(-9999px, -9999px)';
|
| 198 |
-
d3.select(this)
|
| 199 |
-
.style('filter', null)
|
| 200 |
-
.transition().duration(120).ease(d3.easeCubicOut)
|
| 201 |
-
.attr('stroke', strokeColor)
|
| 202 |
-
.attr('stroke-width', 0.4)
|
| 203 |
-
.attr('r', (i2) => sizesPx[i2] / 2)
|
| 204 |
-
.attr('fill-opacity', 0.9);
|
| 205 |
-
}),
|
| 206 |
-
(update) => update
|
| 207 |
-
.attr('cx', (i) => xScale(X[i]))
|
| 208 |
-
.attr('cy', (i) => yScale(Y[i]))
|
| 209 |
-
.attr('r', (i) => sizesPx[i] / 2)
|
| 210 |
-
.attr('fill', (i) => colorFor(Zraw[i]))
|
| 211 |
-
.attr('fill-opacity', 0.9)
|
| 212 |
-
.attr('stroke', strokeColor)
|
| 213 |
-
.attr('stroke-width', 0.4)
|
| 214 |
-
.on('mouseenter', function(ev, i) {
|
| 215 |
-
d3.select(this).raise()
|
| 216 |
-
.style('filter', `drop-shadow(0 0 8px ${glowColor})`)
|
| 217 |
-
.transition().duration(120).ease(d3.easeCubicOut)
|
| 218 |
-
.attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
|
| 219 |
-
.attr('stroke-width', 1.4)
|
| 220 |
-
.attr('r', (sizesPx[i] / 2) * 1.25)
|
| 221 |
-
.attr('fill-opacity', 1);
|
| 222 |
-
const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
|
| 223 |
-
const type = i < lenSpiral ? 'spiral' : 'bulge';
|
| 224 |
-
const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
|
| 225 |
-
tipInner.innerHTML =
|
| 226 |
-
`<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
|
| 227 |
-
`<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
|
| 228 |
-
`<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
|
| 229 |
-
`<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
|
| 230 |
-
`<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
|
| 231 |
-
tip.style.opacity = '1';
|
| 232 |
-
})
|
| 233 |
-
.on('mousemove', (ev, i) => {
|
| 234 |
-
const [mx, my] = d3.pointer(ev, container);
|
| 235 |
-
const offsetX = 10, offsetY = 12;
|
| 236 |
-
tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
|
| 237 |
-
})
|
| 238 |
-
.on('mouseleave', function() {
|
| 239 |
-
tip.style.opacity = '0';
|
| 240 |
-
tip.style.transform = 'translate(-9999px, -9999px)';
|
| 241 |
-
d3.select(this)
|
| 242 |
-
.style('filter', null)
|
| 243 |
-
.transition().duration(120).ease(d3.easeCubicOut)
|
| 244 |
-
.attr('stroke', strokeColor)
|
| 245 |
-
.attr('stroke-width', 0.4)
|
| 246 |
-
.attr('r', (i2) => sizesPx[i2] / 2)
|
| 247 |
-
.attr('fill-opacity', 0.9);
|
| 248 |
-
})
|
| 249 |
-
);
|
| 250 |
-
};
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div>
|
| 2 |
<script>
|
| 3 |
+
(() => {
|
| 4 |
+
const ensureD3 = (cb) => {
|
| 5 |
+
if (window.d3 && typeof window.d3.select === 'function') return cb();
|
| 6 |
+
let s = document.getElementById('d3-cdn-script');
|
| 7 |
+
if (!s) {
|
| 8 |
+
s = document.createElement('script');
|
| 9 |
+
s.id = 'd3-cdn-script';
|
| 10 |
+
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
|
| 11 |
+
document.head.appendChild(s);
|
| 12 |
+
}
|
| 13 |
+
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
|
| 14 |
+
s.addEventListener('load', onReady, { once: true });
|
| 15 |
+
if (window.d3) onReady();
|
| 16 |
+
};
|
| 17 |
|
| 18 |
+
const bootstrap = () => {
|
| 19 |
+
const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
|
| 20 |
+
const container = (mount && mount.querySelector && mount.querySelector('.d3-galaxy')) || document.querySelector('.d3-galaxy');
|
| 21 |
+
if (!container) return;
|
| 22 |
+
if (container.dataset) {
|
| 23 |
+
if (container.dataset.mounted === 'true') return;
|
| 24 |
+
container.dataset.mounted = 'true';
|
| 25 |
+
}
|
| 26 |
+
// Scene params (match previous Plotly ranges)
|
| 27 |
+
const cx = 1.5, cy = 0.5;
|
| 28 |
+
const a = 1.3, b = 0.45;
|
| 29 |
+
const numPoints = 3000;
|
| 30 |
+
const numArms = 3;
|
| 31 |
+
const numTurns = 2.1;
|
| 32 |
+
const angleJitter = 0.12;
|
| 33 |
+
const posNoise = 0.015;
|
| 34 |
|
| 35 |
+
// Circle size settings
|
| 36 |
+
const minCircleSize = 4; // minimum diameter in pixels
|
| 37 |
+
const maxCircleSize = 12; // maximum diameter in pixels
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
// Generate spiral + bulge
|
| 40 |
+
const twoPi = Math.PI * 2;
|
| 41 |
+
const t = Float64Array.from({ length: numPoints }, () => Math.random() * (twoPi * numTurns));
|
| 42 |
+
const armIndices = Int16Array.from({ length: numPoints }, () => Math.floor(Math.random() * numArms));
|
| 43 |
+
const armOffsets = Float64Array.from(armIndices, (k) => k * (twoPi / numArms));
|
| 44 |
+
const theta = Float64Array.from(t, (tv, i) => tv + armOffsets[i] + d3.randomNormal.source(Math.random)(0, angleJitter)());
|
| 45 |
+
const rNorm = Float64Array.from(t, (tv) => Math.pow(tv / (twoPi * numTurns), 0.9));
|
| 46 |
+
const noiseScale = (rn) => posNoise * (0.8 + 0.6 * rn);
|
| 47 |
+
const noiseX = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
|
| 48 |
+
const noiseY = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))());
|
| 49 |
|
| 50 |
+
const xSpiral = Float64Array.from(theta, (th, i) => cx + a * rNorm[i] * Math.cos(th) + noiseX[i]);
|
| 51 |
+
const ySpiral = Float64Array.from(theta, (th, i) => cy + b * rNorm[i] * Math.sin(th) + noiseY[i]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
+
const bulgePoints = Math.floor(0.18 * numPoints);
|
| 54 |
+
const phiB = Float64Array.from({ length: bulgePoints }, () => twoPi * Math.random());
|
| 55 |
+
const rB = Float64Array.from({ length: bulgePoints }, () => Math.pow(Math.random(), 2.2) * 0.22);
|
| 56 |
+
const noiseXB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
|
| 57 |
+
const noiseYB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)());
|
| 58 |
+
const xBulge = Float64Array.from(phiB, (ph, i) => cx + a * rB[i] * Math.cos(ph) + noiseXB[i]);
|
| 59 |
+
const yBulge = Float64Array.from(phiB, (ph, i) => cy + b * rB[i] * Math.sin(ph) + noiseYB[i]);
|
| 60 |
|
| 61 |
+
// Concatenate
|
| 62 |
+
const X = Array.from(xSpiral).concat(Array.from(xBulge));
|
| 63 |
+
const Y = Array.from(ySpiral).concat(Array.from(yBulge));
|
| 64 |
+
const lenSpiral = xSpiral.length;
|
|
|
|
| 65 |
|
| 66 |
+
const zSpiral = Array.from(rNorm, (rn) => 1 - rn);
|
| 67 |
+
const maxRB = rB && rB.length ? (window.d3 && d3.max ? d3.max(rB) : Math.max.apply(null, Array.from(rB))) : 1;
|
| 68 |
+
const zBulge = Array.from(rB, (rb) => 1 - (maxRB ? rb / maxRB : 0));
|
| 69 |
+
const Zraw = zSpiral.concat(zBulge);
|
| 70 |
+
const sizesPx = Zraw.map((z) => minCircleSize + z * (maxCircleSize - minCircleSize)); // diameter in pixels
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
// Labels (same categories as Python version)
|
| 73 |
+
const labelOf = (i) => {
|
| 74 |
+
const z = Zraw[i];
|
| 75 |
+
if (z < 0.25) return 'tiny star';
|
| 76 |
+
if (z < 0.5) return 'small star';
|
| 77 |
+
if (z < 0.75) return 'medium star';
|
| 78 |
+
return 'large star';
|
| 79 |
+
};
|
| 80 |
|
| 81 |
+
// Sort by size ascending for z-index: small first, big last
|
| 82 |
+
const idx = d3.range(X.length).sort((i, j) => sizesPx[i] - sizesPx[j]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
+
// Colors: piecewise gradient [0 -> 0.5 -> 1]
|
| 85 |
+
const c0 = d3.rgb(78, 165, 183); // rgb(78, 165, 183)
|
| 86 |
+
const c1 = d3.rgb(206, 192, 250); // rgb(206, 192, 250)
|
| 87 |
+
const c2 = d3.rgb(232, 137, 171); // rgb(232, 137, 171)
|
| 88 |
+
const interp01 = d3.interpolateRgb(c0, c1);
|
| 89 |
+
const interp12 = d3.interpolateRgb(c1, c2);
|
| 90 |
+
const colorFor = (v) => {
|
| 91 |
+
const t = Math.max(0, Math.min(1, v));
|
| 92 |
+
return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5);
|
| 93 |
+
};
|
| 94 |
|
| 95 |
+
// Create SVG
|
| 96 |
+
const svg = d3.select(container).append('svg')
|
| 97 |
+
.attr('width', '100%')
|
| 98 |
+
.style('display', 'block')
|
| 99 |
+
.style('cursor', 'crosshair');
|
| 100 |
|
| 101 |
+
const render = () => {
|
| 102 |
+
const width = container.clientWidth || 800;
|
| 103 |
+
const height = Math.max(260, Math.round(width / 3)); // keep ~3:1, min height
|
| 104 |
+
svg.attr('width', width).attr('height', height);
|
| 105 |
|
| 106 |
+
const xScale = d3.scaleLinear().domain([0, 3]).range([0, width]);
|
| 107 |
+
const yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]);
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
// Subtle stroke color depending on theme
|
| 110 |
+
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 111 |
+
const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
|
| 112 |
+
const glowColor = isDark ? 'rgba(255,255,255,0.35)' : 'rgba(0,0,0,0.25)';
|
| 113 |
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
// Group for points (no blend mode for better print/PDF visibility)
|
| 116 |
+
const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
// Ensure container can host an absolute tooltip
|
| 119 |
+
container.style.position = container.style.position || 'relative';
|
| 120 |
+
let tip = container.querySelector('.d3-tooltip');
|
| 121 |
+
let tipInner;
|
| 122 |
+
if (!tip) {
|
| 123 |
+
tip = document.createElement('div');
|
| 124 |
+
tip.className = 'd3-tooltip';
|
| 125 |
+
Object.assign(tip.style, {
|
| 126 |
+
position: 'absolute',
|
| 127 |
+
top: '0px',
|
| 128 |
+
left: '0px',
|
| 129 |
+
transform: 'translate(-9999px, -9999px)',
|
| 130 |
+
pointerEvents: 'none',
|
| 131 |
+
padding: '10px 12px',
|
| 132 |
+
borderRadius: '12px',
|
| 133 |
+
fontSize: '12px',
|
| 134 |
+
lineHeight: '1.35',
|
| 135 |
+
border: '1px solid var(--border-color)',
|
| 136 |
+
background: 'var(--surface-bg)',
|
| 137 |
+
color: 'var(--text-color)',
|
| 138 |
+
boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
|
| 139 |
+
opacity: '0',
|
| 140 |
+
transition: 'opacity .12s ease',
|
| 141 |
+
backdropFilter: 'saturate(1.12) blur(8px)',
|
| 142 |
+
zIndex: '20'
|
| 143 |
+
});
|
| 144 |
+
tipInner = document.createElement('div');
|
| 145 |
+
tipInner.className = 'd3-tooltip__inner';
|
| 146 |
+
Object.assign(tipInner.style, {
|
| 147 |
+
textAlign: 'left',
|
| 148 |
+
display: 'flex',
|
| 149 |
+
flexDirection: 'column',
|
| 150 |
+
gap: '6px',
|
| 151 |
+
minWidth: '220px'
|
| 152 |
+
});
|
| 153 |
+
tip.appendChild(tipInner);
|
| 154 |
+
container.appendChild(tip);
|
| 155 |
+
} else {
|
| 156 |
+
tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
|
| 157 |
+
}
|
| 158 |
|
| 159 |
+
// Final filter: remove small dots very close to the galaxy center (after placement)
|
| 160 |
+
const centerHoleRadius = 0.48; // elliptical radius threshold
|
| 161 |
+
const smallSizeThreshold = 7.5; // same notion as Python size cut
|
| 162 |
+
const rTotal = idx.map((i) => Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2));
|
| 163 |
+
const idxFiltered = idx.filter((i, k) => !(rTotal[k] <= centerHoleRadius && sizesPx[i] < smallSizeThreshold));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
const sel = g.selectAll('circle').data(idxFiltered, (i) => i);
|
| 166 |
+
sel.join(
|
| 167 |
+
(enter) => enter.append('circle')
|
| 168 |
+
.attr('cx', (i) => xScale(X[i]))
|
| 169 |
+
.attr('cy', (i) => yScale(Y[i]))
|
| 170 |
+
.attr('r', (i) => sizesPx[i] / 2)
|
| 171 |
+
.attr('fill', (i) => colorFor(Zraw[i]))
|
| 172 |
+
.attr('fill-opacity', 0.9)
|
| 173 |
+
.on('mouseenter', function (ev, i) {
|
| 174 |
+
d3.select(this).raise()
|
| 175 |
+
.style('filter', `drop-shadow(0 0 8px ${glowColor})`)
|
| 176 |
+
.transition().duration(120).ease(d3.easeCubicOut)
|
| 177 |
+
.attr('r', (sizesPx[i] / 2) * 1.25)
|
| 178 |
+
.attr('fill-opacity', 1);
|
| 179 |
+
const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
|
| 180 |
+
const type = i < lenSpiral ? 'spiral' : 'bulge';
|
| 181 |
+
const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
|
| 182 |
+
tipInner.innerHTML =
|
| 183 |
+
`<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
|
| 184 |
+
`<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
|
| 185 |
+
`<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
|
| 186 |
+
`<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
|
| 187 |
+
`<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
|
| 188 |
+
tip.style.opacity = '1';
|
| 189 |
+
})
|
| 190 |
+
.on('mousemove', (ev, i) => {
|
| 191 |
+
const [mx, my] = d3.pointer(ev, container);
|
| 192 |
+
const offsetX = 10, offsetY = 12;
|
| 193 |
+
tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
|
| 194 |
+
})
|
| 195 |
+
.on('mouseleave', function () {
|
| 196 |
+
tip.style.opacity = '0';
|
| 197 |
+
tip.style.transform = 'translate(-9999px, -9999px)';
|
| 198 |
+
d3.select(this)
|
| 199 |
+
.style('filter', null)
|
| 200 |
+
.transition().duration(120).ease(d3.easeCubicOut)
|
| 201 |
+
.attr('r', (i2) => sizesPx[i2] / 2)
|
| 202 |
+
.attr('fill-opacity', 0.9);
|
| 203 |
+
}),
|
| 204 |
+
(update) => update
|
| 205 |
+
.attr('cx', (i) => xScale(X[i]))
|
| 206 |
+
.attr('cy', (i) => yScale(Y[i]))
|
| 207 |
+
.attr('r', (i) => sizesPx[i] / 2)
|
| 208 |
+
.attr('fill', (i) => colorFor(Zraw[i]))
|
| 209 |
+
.attr('fill-opacity', 0.9)
|
| 210 |
+
.on('mouseenter', function (ev, i) {
|
| 211 |
+
d3.select(this).raise()
|
| 212 |
+
.style('filter', `drop-shadow(0 0 8px ${glowColor})`)
|
| 213 |
+
.transition().duration(120).ease(d3.easeCubicOut)
|
| 214 |
+
.attr('r', (sizesPx[i] / 2) * 1.25)
|
| 215 |
+
.attr('fill-opacity', 1);
|
| 216 |
+
const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2);
|
| 217 |
+
const type = i < lenSpiral ? 'spiral' : 'bulge';
|
| 218 |
+
const arm = i < lenSpiral ? (armIndices[i] + 1) : null;
|
| 219 |
+
tipInner.innerHTML =
|
| 220 |
+
`<div style="font-weight:800;letter-spacing:.1px;"><strong>${labelOf(i)}</strong></div>` +
|
| 221 |
+
`<div style="font-size:11px;color:var(--muted-color);margin-top:-4px;margin-bottom:2px;letter-spacing:.1px;"><strong>Type</strong> ${type}${arm ? ` (Arm ${arm})` : ''}</div>` +
|
| 222 |
+
`<div style="padding-top:6px;border-top:1px solid var(--border-color);"><strong>Position</strong> X ${X[i].toFixed(2)} Β· <strong>Y</strong> ${Y[i].toFixed(2)}</div>` +
|
| 223 |
+
`<div><strong>Distance</strong> Radius ${r.toFixed(3)} Β· <strong>Z</strong> ${Zraw[i].toFixed(3)}</div>` +
|
| 224 |
+
`<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>`;
|
| 225 |
+
tip.style.opacity = '1';
|
| 226 |
+
})
|
| 227 |
+
.on('mousemove', (ev, i) => {
|
| 228 |
+
const [mx, my] = d3.pointer(ev, container);
|
| 229 |
+
const offsetX = 10, offsetY = 12;
|
| 230 |
+
tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
|
| 231 |
+
})
|
| 232 |
+
.on('mouseleave', function () {
|
| 233 |
+
tip.style.opacity = '0';
|
| 234 |
+
tip.style.transform = 'translate(-9999px, -9999px)';
|
| 235 |
+
d3.select(this)
|
| 236 |
+
.style('filter', null)
|
| 237 |
+
.transition().duration(120).ease(d3.easeCubicOut)
|
| 238 |
+
.attr('r', (i2) => sizesPx[i2] / 2)
|
| 239 |
+
.attr('fill-opacity', 0.9);
|
| 240 |
+
})
|
| 241 |
+
);
|
| 242 |
+
};
|
| 243 |
|
| 244 |
+
// First render + resize
|
| 245 |
+
if (window.ResizeObserver) {
|
| 246 |
+
const ro = new ResizeObserver(() => render());
|
| 247 |
+
ro.observe(container);
|
| 248 |
+
} else {
|
| 249 |
+
window.addEventListener('resize', render);
|
| 250 |
+
}
|
| 251 |
+
render();
|
| 252 |
+
};
|
| 253 |
|
| 254 |
+
if (document.readyState === 'loading') {
|
| 255 |
+
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
|
| 256 |
+
} else { ensureD3(bootstrap); }
|
| 257 |
+
})();
|
| 258 |
+
</script>
|
app/src/content/embeds/d3-bar.html
CHANGED
|
@@ -1,51 +1,195 @@
|
|
| 1 |
-
<div class="d3-bar"
|
| 2 |
<style>
|
| 3 |
-
.d3-bar .controls {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
/* Header (legend + controls) placed after chart */
|
| 11 |
-
.d3-bar .chart-header {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
/* Apply axis/tick/grid purely via CSS */
|
| 21 |
.d3-bar .axes path,
|
| 22 |
-
.d3-bar .axes line {
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
/* Tooltip improvements */
|
| 26 |
-
.d3-bar .d3-tooltip {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
/* Hover/transition styling for bars and legend */
|
| 28 |
-
.d3-bar .bars path.bar {
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
.d3-bar .
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
/* Chart card wrapper */
|
| 35 |
-
.d3-bar .chart-card {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
/* Layout adjustments to give controls more space */
|
| 37 |
.d3-bar .chart-header {
|
| 38 |
padding-left: 8px;
|
| 39 |
padding-right: 8px;
|
| 40 |
gap: 20px;
|
| 41 |
}
|
|
|
|
| 42 |
.d3-bar .controls {
|
| 43 |
justify-content: flex-start;
|
| 44 |
min-width: 320px;
|
| 45 |
}
|
|
|
|
| 46 |
.d3-bar .controls .control-group {
|
| 47 |
min-width: 150px;
|
| 48 |
}
|
|
|
|
| 49 |
.d3-bar .controls select {
|
| 50 |
font-size: 13px;
|
| 51 |
min-width: 160px;
|
|
@@ -69,13 +213,13 @@
|
|
| 69 |
if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
|
| 70 |
|
| 71 |
// Data, matching bar.py
|
| 72 |
-
const seqLabels = ["1024","2048","4096","8192"];
|
| 73 |
-
const seqScale = [1,2,4,8];
|
| 74 |
-
const componentKeys = ['parameters','gradients','optimizer','activations'];
|
| 75 |
-
const modelSizes = ["1B","3B","8B","70B","405B"];
|
| 76 |
-
const paramsMem = { "1B":4.0, "3B":13.3, "8B":26.0, "70B":244.0, "405B":1520.0 };
|
| 77 |
-
const actCoeff = { "1B":3.6, "3B":9.3, "8B":46.2, "70B":145.7, "405B":1519.9 };
|
| 78 |
-
const recomputeModes = ["none","selective","full"];
|
| 79 |
|
| 80 |
const activationsCurve = (sizeKey, mode) => {
|
| 81 |
const coeff = actCoeff[sizeKey];
|
|
@@ -87,7 +231,7 @@
|
|
| 87 |
const stackFor = (sizeKey, mode) => {
|
| 88 |
const p = seqScale.map(() => paramsMem[sizeKey]);
|
| 89 |
const g = seqScale.map(() => paramsMem[sizeKey]);
|
| 90 |
-
const o = seqScale.map(() => 2*paramsMem[sizeKey]);
|
| 91 |
const a = activationsCurve(sizeKey, mode);
|
| 92 |
return { parameters: p, gradients: g, optimizer: o, activations: a };
|
| 93 |
};
|
|
@@ -121,16 +265,16 @@
|
|
| 121 |
const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card);
|
| 122 |
// Place header after the chart card
|
| 123 |
container.appendChild(header);
|
| 124 |
-
const svg = d3.select(card).append('svg').attr('width','100%').style('display','block');
|
| 125 |
const gRoot = svg.append('g');
|
| 126 |
-
const gGrid = gRoot.append('g').attr('class','grid');
|
| 127 |
-
const gAxes = gRoot.append('g').attr('class','axes');
|
| 128 |
-
const gBars = gRoot.append('g').attr('class','bars');
|
| 129 |
|
| 130 |
// Tooltip
|
| 131 |
container.style.position = container.style.position || 'relative';
|
| 132 |
let tip = container.querySelector('.d3-tooltip'); let tipInner;
|
| 133 |
-
if (!tip) { tip = document.createElement('div'); tip.className = 'd3-tooltip'; Object.assign(tip.style,{ position:'absolute', top:'0px', left:'0px', transform:'translate(-9999px, -9999px)', pointerEvents:'none', padding:'8px 10px', borderRadius:'8px', fontSize:'12px', lineHeight:'1.35', border:'1px solid var(--border-color)', background:'var(--surface-bg)', color:'var(--text-color)', boxShadow:'0 4px 24px rgba(0,0,0,.18)', opacity:'0', transition:'opacity .12s ease' }); tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign='left'; tip.appendChild(tipInner); container.appendChild(tip); } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
|
| 134 |
|
| 135 |
// State
|
| 136 |
let currentSize = modelSizes[0];
|
|
@@ -138,32 +282,32 @@
|
|
| 138 |
selRecomp.value = currentMode;
|
| 139 |
|
| 140 |
// Layout & scales
|
| 141 |
-
let width=800, height=360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
|
| 142 |
const x0 = d3.scaleBand().paddingInner(0.25).paddingOuter(0.1); // groups (seq)
|
| 143 |
const y = d3.scaleLinear();
|
| 144 |
-
function getCategoricalColors(count){
|
| 145 |
try {
|
| 146 |
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 147 |
return window.ColorPalettes.getColors('categorical', count);
|
| 148 |
}
|
| 149 |
-
} catch(_) {}
|
| 150 |
-
const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|
| 151 |
-
const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7','#f28e2b','#e15759','#76b7b2','#59a14f','#edc948','#b07aa1','#ff9da7','#9c755f','#bab0ab'];
|
| 152 |
const pool = [primary, ...tableau];
|
| 153 |
-
const arr = []; for (let i=0;i<count;i++){ arr.push(pool[i % pool.length]); }
|
| 154 |
return arr;
|
| 155 |
}
|
| 156 |
const palette = getCategoricalColors(componentKeys.length);
|
| 157 |
-
const colorMap = new Map(componentKeys.map((k,i)=>[k, palette[i]]));
|
| 158 |
const colorOf = (key) => colorMap.get(key) || 'var(--primary-color)';
|
| 159 |
|
| 160 |
-
function yMax(sizeKey, mode){
|
| 161 |
const s = Y[mode][sizeKey];
|
| 162 |
-
let max = 0; for (let i=0;i<seqLabels.length;i++){ const sum = s.parameters[i]+s.gradients[i]+s.optimizer[i]+s.activations[i]; if (sum>max) max=sum; }
|
| 163 |
-
return max*1.05;
|
| 164 |
}
|
| 165 |
|
| 166 |
-
function renderLegend(){
|
| 167 |
legendItems.innerHTML = componentKeys.map((key, i) => {
|
| 168 |
const color = palette[i];
|
| 169 |
return `<span class="item" data-key="${key}"><span class=\"swatch\" style=\"background:${color}\"></span><span>${key}</span></span>`;
|
|
@@ -183,8 +327,8 @@
|
|
| 183 |
});
|
| 184 |
}
|
| 185 |
|
| 186 |
-
function updateScales(){
|
| 187 |
-
width = container.clientWidth || 800; height = Math.max(260, Math.round(width/3)); svg.attr('width', width).attr('height', height);
|
| 188 |
const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 189 |
|
| 190 |
x0.domain(seqLabels).range([0, innerWidth]);
|
|
@@ -193,26 +337,26 @@
|
|
| 193 |
// Grid
|
| 194 |
gGrid.selectAll('*').remove();
|
| 195 |
gGrid.selectAll('line').data(y.ticks(6)).join('line')
|
| 196 |
-
.attr('x1', 0).attr('x2', innerWidth).attr('y1', (d)=>y(d)).attr('y2', (d)=>y(d))
|
| 197 |
.attr('stroke', 'var(--grid-color)').attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
|
| 198 |
|
| 199 |
// Axes
|
| 200 |
gAxes.selectAll('*').remove();
|
| 201 |
-
gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g)=>{ g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size','12px'); });
|
| 202 |
-
gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g)=>{ g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size','12px'); });
|
| 203 |
|
| 204 |
// Axis labels
|
| 205 |
-
gAxes.append('text').attr('class','axis-label axis-label--x').attr('x', innerWidth/2).attr('y', innerHeight + 44).attr('text-anchor','middle').text('Sequence Length');
|
| 206 |
-
gAxes.append('text').attr('class','axis-label axis-label--y').attr('text-anchor','middle').attr('transform', `translate(${-52},${innerHeight/2}) rotate(-90)`).text('Memory (GB)');
|
| 207 |
|
| 208 |
renderLegend();
|
| 209 |
|
| 210 |
return { innerWidth, innerHeight };
|
| 211 |
}
|
| 212 |
|
| 213 |
-
function drawBars(){
|
| 214 |
const stacks = Y[currentMode][currentSize];
|
| 215 |
-
const series = componentKeys.map((key, i)=>({ key, color: palette[i], values: stacks[key] }));
|
| 216 |
// Stack values
|
| 217 |
const stacked = seqLabels.map((label, i) => {
|
| 218 |
let acc = 0; const items = [];
|
|
@@ -229,9 +373,9 @@
|
|
| 229 |
const { innerWidth, innerHeight } = updateScales();
|
| 230 |
|
| 231 |
const bandWidth = x0.bandwidth();
|
| 232 |
-
const groups = gBars.selectAll('g.bar-group').data(stacked, d=>d.label);
|
| 233 |
-
const groupsEnter = groups.enter().append('g').attr('class','bar-group');
|
| 234 |
-
groupsEnter.merge(groups).attr('transform', (d)
|
| 235 |
groups.exit().remove();
|
| 236 |
|
| 237 |
// Helper to draw per-corner rounded rectangle path
|
|
@@ -252,11 +396,11 @@
|
|
| 252 |
+ 'Z';
|
| 253 |
};
|
| 254 |
|
| 255 |
-
const bars = groupsEnter.merge(groups).selectAll('path.bar').data(d=>d.items, d=>d.key);
|
| 256 |
-
bars.enter().append('path').attr('class','bar')
|
| 257 |
-
.attr('d', (d)=> roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
|
| 258 |
-
.attr('fill', (d)=>d.color)
|
| 259 |
-
.on('mouseenter', function(ev, d){
|
| 260 |
container.classList.add('hovering');
|
| 261 |
gBars.selectAll('path.bar').classed('ghost', (dd) => !(dd && dd.key === d.key));
|
| 262 |
const pct = d.total > 0 ? (d.value / d.total * 100) : 0;
|
|
@@ -274,7 +418,7 @@
|
|
| 274 |
if (li) li.classList.add('hovered');
|
| 275 |
legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== d.key));
|
| 276 |
})
|
| 277 |
-
.on('mousemove', function(ev, d){
|
| 278 |
const [mx, my] = d3.pointer(ev, container);
|
| 279 |
const offsetX = 12, offsetY = 12;
|
| 280 |
const maxX = (container.clientWidth || 0) - (tip.offsetWidth + 6);
|
|
@@ -283,35 +427,33 @@
|
|
| 283 |
const ty = Math.max(0, Math.min(my + offsetY, maxY));
|
| 284 |
tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
|
| 285 |
})
|
| 286 |
-
.on('mouseleave', function(){
|
| 287 |
-
tip.style.opacity='0';
|
| 288 |
-
tip.style.transform='translate(-9999px, -9999px)';
|
| 289 |
container.classList.remove('hovering');
|
| 290 |
gBars.selectAll('path.bar').classed('ghost', false).classed('highlight', false);
|
| 291 |
legendItems.querySelectorAll('.item').forEach(it => { it.classList.remove('hovered'); it.classList.remove('ghost'); });
|
| 292 |
})
|
| 293 |
.merge(bars)
|
| 294 |
.transition().duration(200)
|
| 295 |
-
.attr('d', (d)=> roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
|
| 296 |
-
.attr('fill', (d)=>d.color);
|
| 297 |
bars.exit().remove();
|
| 298 |
}
|
| 299 |
|
| 300 |
-
function update(){ drawBars(); }
|
| 301 |
|
| 302 |
// Boot
|
| 303 |
update();
|
| 304 |
// controls already appended to footer; populate control groups
|
| 305 |
controls.appendChild(groupSize); controls.appendChild(groupRecomp);
|
| 306 |
-
selSize.addEventListener('change', (e)=>{ currentSize = e.target.value; update(); });
|
| 307 |
-
selRecomp.addEventListener('change', (e)=>{ currentMode = e.target.value; update(); });
|
| 308 |
|
| 309 |
const rerender = () => { update(); };
|
| 310 |
-
if (window.ResizeObserver) { const ro = new ResizeObserver(()=>rerender()); ro.observe(container); } else { window.addEventListener('resize', rerender); }
|
| 311 |
};
|
| 312 |
|
| 313 |
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
|
| 314 |
})();
|
| 315 |
-
</script>
|
| 316 |
-
|
| 317 |
-
|
|
|
|
| 1 |
+
<div class="d3-bar"></div>
|
| 2 |
<style>
|
| 3 |
+
.d3-bar .controls {
|
| 4 |
+
margin-top: 0;
|
| 5 |
+
display: flex;
|
| 6 |
+
gap: 16px;
|
| 7 |
+
align-items: center;
|
| 8 |
+
justify-content: flex-end;
|
| 9 |
+
flex-wrap: wrap;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.d3-bar .controls .control-group {
|
| 13 |
+
display: flex;
|
| 14 |
+
flex-direction: column;
|
| 15 |
+
align-items: flex-start;
|
| 16 |
+
gap: 6px;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.d3-bar .controls label {
|
| 20 |
+
font-size: 12px;
|
| 21 |
+
color: var(--text-color);
|
| 22 |
+
font-weight: 700;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.d3-bar .controls select {
|
| 26 |
+
font-size: 12px;
|
| 27 |
+
padding: 8px 28px 8px 10px;
|
| 28 |
+
border: 1px solid var(--border-color);
|
| 29 |
+
border-radius: 8px;
|
| 30 |
+
background-color: var(--surface-bg);
|
| 31 |
+
color: var(--text-color);
|
| 32 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 33 |
+
background-repeat: no-repeat;
|
| 34 |
+
background-position: right 8px center;
|
| 35 |
+
background-size: 12px;
|
| 36 |
+
-webkit-appearance: none;
|
| 37 |
+
-moz-appearance: none;
|
| 38 |
+
appearance: none;
|
| 39 |
+
cursor: pointer;
|
| 40 |
+
transition: border-color .15s ease, box-shadow .15s ease;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
[data-theme="dark"] .d3-bar .controls select {
|
| 44 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.d3-bar .controls select:hover {
|
| 48 |
+
border-color: var(--primary-color);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.d3-bar .controls select:focus {
|
| 52 |
+
border-color: var(--primary-color);
|
| 53 |
+
box-shadow: 0 0 0 3px rgba(232, 137, 171, .25);
|
| 54 |
+
outline: none;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
/* Header (legend + controls) placed after chart */
|
| 58 |
+
.d3-bar .chart-header {
|
| 59 |
+
display: flex;
|
| 60 |
+
align-items: flex-start;
|
| 61 |
+
justify-content: flex-start;
|
| 62 |
+
gap: 12px;
|
| 63 |
+
margin: 8px 0 0 0;
|
| 64 |
+
flex-wrap: wrap;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.d3-bar .legend-bottom {
|
| 68 |
+
display: flex;
|
| 69 |
+
flex-direction: column;
|
| 70 |
+
align-items: flex-start;
|
| 71 |
+
gap: 6px;
|
| 72 |
+
font-size: 12px;
|
| 73 |
+
color: var(--text-color);
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.d3-bar .legend-bottom .legend-title {
|
| 77 |
+
font-size: 12px;
|
| 78 |
+
font-weight: 700;
|
| 79 |
+
color: var(--text-color);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.d3-bar .legend-bottom .items {
|
| 83 |
+
display: flex;
|
| 84 |
+
flex-wrap: wrap;
|
| 85 |
+
gap: 8px 14px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.d3-bar .legend-bottom .item {
|
| 89 |
+
display: inline-flex;
|
| 90 |
+
align-items: center;
|
| 91 |
+
gap: 6px;
|
| 92 |
+
white-space: nowrap;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.d3-bar .legend-bottom .swatch {
|
| 96 |
+
width: 14px;
|
| 97 |
+
height: 14px;
|
| 98 |
+
border-radius: 3px;
|
| 99 |
+
border: 1px solid var(--border-color);
|
| 100 |
+
display: inline-block;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.d3-bar.hovering .legend-bottom .item.ghost {
|
| 104 |
+
opacity: .35;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.d3-bar.hovering .bars path.ghost {
|
| 108 |
+
opacity: .35;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.d3-bar .axis-label {
|
| 112 |
+
fill: var(--text-color);
|
| 113 |
+
font-size: 12px;
|
| 114 |
+
font-weight: 700;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
/* Apply axis/tick/grid purely via CSS */
|
| 118 |
.d3-bar .axes path,
|
| 119 |
+
.d3-bar .axes line {
|
| 120 |
+
stroke: var(--axis-color);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.d3-bar .axes text {
|
| 124 |
+
fill: var(--tick-color);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.d3-bar .grid line {
|
| 128 |
+
stroke: var(--grid-color);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
/* Tooltip improvements */
|
| 132 |
+
.d3-bar .d3-tooltip {
|
| 133 |
+
z-index: var(--z-tooltip);
|
| 134 |
+
backdrop-filter: saturate(1.12) blur(8px);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
/* Hover/transition styling for bars and legend */
|
| 138 |
+
.d3-bar .bars path.bar {
|
| 139 |
+
transition: opacity .12s ease, stroke .12s ease, stroke-width .12s ease;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.d3-bar .bars path.bar.highlight {
|
| 143 |
+
stroke: none;
|
| 144 |
+
stroke-width: 0;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.d3-bar.hovering .bars path.ghost {
|
| 148 |
+
opacity: .25;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.d3-bar .legend-bottom .item.hovered {
|
| 152 |
+
color: inherit;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.d3-bar .legend-bottom .item.hovered .swatch {
|
| 156 |
+
border-color: var(--border-color);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.d3-bar .d3-tooltip .swatch {
|
| 160 |
+
width: 12px;
|
| 161 |
+
height: 12px;
|
| 162 |
+
border-radius: 3px;
|
| 163 |
+
border: 1px solid var(--border-color);
|
| 164 |
+
display: inline-block;
|
| 165 |
+
margin-right: 6px;
|
| 166 |
+
vertical-align: -2px;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
/* Chart card wrapper */
|
| 170 |
+
.d3-bar .chart-card {
|
| 171 |
+
background: var(--surface-bg);
|
| 172 |
+
border: 1px solid var(--border-color);
|
| 173 |
+
border-radius: 10px;
|
| 174 |
+
padding: 8px;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
/* Layout adjustments to give controls more space */
|
| 178 |
.d3-bar .chart-header {
|
| 179 |
padding-left: 8px;
|
| 180 |
padding-right: 8px;
|
| 181 |
gap: 20px;
|
| 182 |
}
|
| 183 |
+
|
| 184 |
.d3-bar .controls {
|
| 185 |
justify-content: flex-start;
|
| 186 |
min-width: 320px;
|
| 187 |
}
|
| 188 |
+
|
| 189 |
.d3-bar .controls .control-group {
|
| 190 |
min-width: 150px;
|
| 191 |
}
|
| 192 |
+
|
| 193 |
.d3-bar .controls select {
|
| 194 |
font-size: 13px;
|
| 195 |
min-width: 160px;
|
|
|
|
| 213 |
if (container.dataset) { if (container.dataset.mounted === 'true') return; container.dataset.mounted = 'true'; }
|
| 214 |
|
| 215 |
// Data, matching bar.py
|
| 216 |
+
const seqLabels = ["1024", "2048", "4096", "8192"];
|
| 217 |
+
const seqScale = [1, 2, 4, 8];
|
| 218 |
+
const componentKeys = ['parameters', 'gradients', 'optimizer', 'activations'];
|
| 219 |
+
const modelSizes = ["1B", "3B", "8B", "70B", "405B"];
|
| 220 |
+
const paramsMem = { "1B": 4.0, "3B": 13.3, "8B": 26.0, "70B": 244.0, "405B": 1520.0 };
|
| 221 |
+
const actCoeff = { "1B": 3.6, "3B": 9.3, "8B": 46.2, "70B": 145.7, "405B": 1519.9 };
|
| 222 |
+
const recomputeModes = ["none", "selective", "full"];
|
| 223 |
|
| 224 |
const activationsCurve = (sizeKey, mode) => {
|
| 225 |
const coeff = actCoeff[sizeKey];
|
|
|
|
| 231 |
const stackFor = (sizeKey, mode) => {
|
| 232 |
const p = seqScale.map(() => paramsMem[sizeKey]);
|
| 233 |
const g = seqScale.map(() => paramsMem[sizeKey]);
|
| 234 |
+
const o = seqScale.map(() => 2 * paramsMem[sizeKey]);
|
| 235 |
const a = activationsCurve(sizeKey, mode);
|
| 236 |
return { parameters: p, gradients: g, optimizer: o, activations: a };
|
| 237 |
};
|
|
|
|
| 265 |
const card = document.createElement('div'); card.className = 'chart-card'; container.appendChild(card);
|
| 266 |
// Place header after the chart card
|
| 267 |
container.appendChild(header);
|
| 268 |
+
const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
|
| 269 |
const gRoot = svg.append('g');
|
| 270 |
+
const gGrid = gRoot.append('g').attr('class', 'grid');
|
| 271 |
+
const gAxes = gRoot.append('g').attr('class', 'axes');
|
| 272 |
+
const gBars = gRoot.append('g').attr('class', 'bars');
|
| 273 |
|
| 274 |
// Tooltip
|
| 275 |
container.style.position = container.style.position || 'relative';
|
| 276 |
let tip = container.querySelector('.d3-tooltip'); let tipInner;
|
| 277 |
+
if (!tip) { tip = document.createElement('div'); tip.className = 'd3-tooltip'; Object.assign(tip.style, { position: 'absolute', top: '0px', left: '0px', transform: 'translate(-9999px, -9999px)', pointerEvents: 'none', padding: '8px 10px', borderRadius: '8px', fontSize: '12px', lineHeight: '1.35', border: '1px solid var(--border-color)', background: 'var(--surface-bg)', color: 'var(--text-color)', boxShadow: '0 4px 24px rgba(0,0,0,.18)', opacity: '0', transition: 'opacity .12s ease' }); tipInner = document.createElement('div'); tipInner.className = 'd3-tooltip__inner'; tipInner.style.textAlign = 'left'; tip.appendChild(tipInner); container.appendChild(tip); } else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
|
| 278 |
|
| 279 |
// State
|
| 280 |
let currentSize = modelSizes[0];
|
|
|
|
| 282 |
selRecomp.value = currentMode;
|
| 283 |
|
| 284 |
// Layout & scales
|
| 285 |
+
let width = 800, height = 360; const margin = { top: 16, right: 28, bottom: 56, left: 64 };
|
| 286 |
const x0 = d3.scaleBand().paddingInner(0.25).paddingOuter(0.1); // groups (seq)
|
| 287 |
const y = d3.scaleLinear();
|
| 288 |
+
function getCategoricalColors(count) {
|
| 289 |
try {
|
| 290 |
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 291 |
return window.ColorPalettes.getColors('categorical', count);
|
| 292 |
}
|
| 293 |
+
} catch (_) { }
|
| 294 |
+
const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
|
| 295 |
+
const tableau = (window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab'];
|
| 296 |
const pool = [primary, ...tableau];
|
| 297 |
+
const arr = []; for (let i = 0; i < count; i++) { arr.push(pool[i % pool.length]); }
|
| 298 |
return arr;
|
| 299 |
}
|
| 300 |
const palette = getCategoricalColors(componentKeys.length);
|
| 301 |
+
const colorMap = new Map(componentKeys.map((k, i) => [k, palette[i]]));
|
| 302 |
const colorOf = (key) => colorMap.get(key) || 'var(--primary-color)';
|
| 303 |
|
| 304 |
+
function yMax(sizeKey, mode) {
|
| 305 |
const s = Y[mode][sizeKey];
|
| 306 |
+
let max = 0; for (let i = 0; i < seqLabels.length; i++) { const sum = s.parameters[i] + s.gradients[i] + s.optimizer[i] + s.activations[i]; if (sum > max) max = sum; }
|
| 307 |
+
return max * 1.05;
|
| 308 |
}
|
| 309 |
|
| 310 |
+
function renderLegend() {
|
| 311 |
legendItems.innerHTML = componentKeys.map((key, i) => {
|
| 312 |
const color = palette[i];
|
| 313 |
return `<span class="item" data-key="${key}"><span class=\"swatch\" style=\"background:${color}\"></span><span>${key}</span></span>`;
|
|
|
|
| 327 |
});
|
| 328 |
}
|
| 329 |
|
| 330 |
+
function updateScales() {
|
| 331 |
+
width = container.clientWidth || 800; height = Math.max(260, Math.round(width / 3)); svg.attr('width', width).attr('height', height);
|
| 332 |
const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom; gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 333 |
|
| 334 |
x0.domain(seqLabels).range([0, innerWidth]);
|
|
|
|
| 337 |
// Grid
|
| 338 |
gGrid.selectAll('*').remove();
|
| 339 |
gGrid.selectAll('line').data(y.ticks(6)).join('line')
|
| 340 |
+
.attr('x1', 0).attr('x2', innerWidth).attr('y1', (d) => y(d)).attr('y2', (d) => y(d))
|
| 341 |
.attr('stroke', 'var(--grid-color)').attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
|
| 342 |
|
| 343 |
// Axes
|
| 344 |
gAxes.selectAll('*').remove();
|
| 345 |
+
gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(d3.axisBottom(x0)).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); });
|
| 346 |
+
gAxes.append('g').call(d3.axisLeft(y).ticks(6).tickFormat(d3.format('~f'))).call((g) => { g.selectAll('path, line').attr('stroke', 'var(--axis-color)'); g.selectAll('text').attr('fill', 'var(--tick-color)').style('font-size', '12px'); });
|
| 347 |
|
| 348 |
// Axis labels
|
| 349 |
+
gAxes.append('text').attr('class', 'axis-label axis-label--x').attr('x', innerWidth / 2).attr('y', innerHeight + 44).attr('text-anchor', 'middle').text('Sequence Length');
|
| 350 |
+
gAxes.append('text').attr('class', 'axis-label axis-label--y').attr('text-anchor', 'middle').attr('transform', `translate(${-52},${innerHeight / 2}) rotate(-90)`).text('Memory (GB)');
|
| 351 |
|
| 352 |
renderLegend();
|
| 353 |
|
| 354 |
return { innerWidth, innerHeight };
|
| 355 |
}
|
| 356 |
|
| 357 |
+
function drawBars() {
|
| 358 |
const stacks = Y[currentMode][currentSize];
|
| 359 |
+
const series = componentKeys.map((key, i) => ({ key, color: palette[i], values: stacks[key] }));
|
| 360 |
// Stack values
|
| 361 |
const stacked = seqLabels.map((label, i) => {
|
| 362 |
let acc = 0; const items = [];
|
|
|
|
| 373 |
const { innerWidth, innerHeight } = updateScales();
|
| 374 |
|
| 375 |
const bandWidth = x0.bandwidth();
|
| 376 |
+
const groups = gBars.selectAll('g.bar-group').data(stacked, d => d.label);
|
| 377 |
+
const groupsEnter = groups.enter().append('g').attr('class', 'bar-group');
|
| 378 |
+
groupsEnter.merge(groups).attr('transform', (d) => `translate(${x0(d.label)},0)`);
|
| 379 |
groups.exit().remove();
|
| 380 |
|
| 381 |
// Helper to draw per-corner rounded rectangle path
|
|
|
|
| 396 |
+ 'Z';
|
| 397 |
};
|
| 398 |
|
| 399 |
+
const bars = groupsEnter.merge(groups).selectAll('path.bar').data(d => d.items, d => d.key);
|
| 400 |
+
bars.enter().append('path').attr('class', 'bar')
|
| 401 |
+
.attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
|
| 402 |
+
.attr('fill', (d) => d.color)
|
| 403 |
+
.on('mouseenter', function (ev, d) {
|
| 404 |
container.classList.add('hovering');
|
| 405 |
gBars.selectAll('path.bar').classed('ghost', (dd) => !(dd && dd.key === d.key));
|
| 406 |
const pct = d.total > 0 ? (d.value / d.total * 100) : 0;
|
|
|
|
| 418 |
if (li) li.classList.add('hovered');
|
| 419 |
legendItems.querySelectorAll('.item').forEach(it => it.classList.toggle('ghost', it.getAttribute('data-key') !== d.key));
|
| 420 |
})
|
| 421 |
+
.on('mousemove', function (ev, d) {
|
| 422 |
const [mx, my] = d3.pointer(ev, container);
|
| 423 |
const offsetX = 12, offsetY = 12;
|
| 424 |
const maxX = (container.clientWidth || 0) - (tip.offsetWidth + 6);
|
|
|
|
| 427 |
const ty = Math.max(0, Math.min(my + offsetY, maxY));
|
| 428 |
tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
|
| 429 |
})
|
| 430 |
+
.on('mouseleave', function () {
|
| 431 |
+
tip.style.opacity = '0';
|
| 432 |
+
tip.style.transform = 'translate(-9999px, -9999px)';
|
| 433 |
container.classList.remove('hovering');
|
| 434 |
gBars.selectAll('path.bar').classed('ghost', false).classed('highlight', false);
|
| 435 |
legendItems.querySelectorAll('.item').forEach(it => { it.classList.remove('hovered'); it.classList.remove('ghost'); });
|
| 436 |
})
|
| 437 |
.merge(bars)
|
| 438 |
.transition().duration(200)
|
| 439 |
+
.attr('d', (d) => roundedPath(0, y(d.y1), bandWidth, Math.max(0.5, y(d.y0) - y(d.y1)), d.isTop, d.isBottom))
|
| 440 |
+
.attr('fill', (d) => d.color);
|
| 441 |
bars.exit().remove();
|
| 442 |
}
|
| 443 |
|
| 444 |
+
function update() { drawBars(); }
|
| 445 |
|
| 446 |
// Boot
|
| 447 |
update();
|
| 448 |
// controls already appended to footer; populate control groups
|
| 449 |
controls.appendChild(groupSize); controls.appendChild(groupRecomp);
|
| 450 |
+
selSize.addEventListener('change', (e) => { currentSize = e.target.value; update(); });
|
| 451 |
+
selRecomp.addEventListener('change', (e) => { currentMode = e.target.value; update(); });
|
| 452 |
|
| 453 |
const rerender = () => { update(); };
|
| 454 |
+
if (window.ResizeObserver) { const ro = new ResizeObserver(() => rerender()); ro.observe(container); } else { window.addEventListener('resize', rerender); }
|
| 455 |
};
|
| 456 |
|
| 457 |
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
|
| 458 |
})();
|
| 459 |
+
</script>
|
|
|
|
|
|
app/src/content/embeds/d3-equation-editor.html
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="d3-equation-editor"></div>
|
| 2 |
+
<style>
|
| 3 |
+
.d3-equation-editor {
|
| 4 |
+
position: relative;
|
| 5 |
+
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
.d3-equation-editor .chart-card {
|
| 9 |
+
background: var(--surface-bg);
|
| 10 |
+
border: 1px solid var(--border-color);
|
| 11 |
+
border-radius: 10px;
|
| 12 |
+
padding: 8px;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
.d3-equation-editor .chart-header {
|
| 17 |
+
display: flex;
|
| 18 |
+
align-items: flex-start;
|
| 19 |
+
justify-content: flex-start;
|
| 20 |
+
gap: 24px;
|
| 21 |
+
margin: 16px 0 0 0;
|
| 22 |
+
flex-wrap: wrap;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.d3-equation-editor .controls {
|
| 26 |
+
display: flex;
|
| 27 |
+
flex-direction: column;
|
| 28 |
+
gap: 24px;
|
| 29 |
+
align-items: flex-start;
|
| 30 |
+
justify-content: flex-start;
|
| 31 |
+
width: 100%;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.d3-equation-editor .controls .control-group {
|
| 35 |
+
display: flex;
|
| 36 |
+
flex-direction: column;
|
| 37 |
+
align-items: flex-start;
|
| 38 |
+
gap: 8px;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.d3-equation-editor .controls .control-group.equation-group {
|
| 42 |
+
width: 100%;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
.d3-equation-editor .controls .input-row {
|
| 46 |
+
display: flex;
|
| 47 |
+
gap: 24px;
|
| 48 |
+
align-items: flex-start;
|
| 49 |
+
justify-content: flex-start;
|
| 50 |
+
flex-wrap: wrap;
|
| 51 |
+
width: 100%;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.d3-equation-editor .controls .input-row .control-group.equation-group {
|
| 55 |
+
flex: 1;
|
| 56 |
+
min-width: 300px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.d3-equation-editor .controls .control-group.domain-group {
|
| 60 |
+
flex: 0 0 240px;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
.d3-equation-editor .controls label {
|
| 64 |
+
font-size: 13px;
|
| 65 |
+
color: var(--text-color);
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
letter-spacing: -0.01em;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.d3-equation-editor .controls input[type="text"] {
|
| 71 |
+
font-size: 17px;
|
| 72 |
+
font-weight: 400;
|
| 73 |
+
padding: 14px 18px;
|
| 74 |
+
border: 1.5px solid var(--primary-color);
|
| 75 |
+
border-radius: var(--button-radius);
|
| 76 |
+
background-color: var(--surface-bg);
|
| 77 |
+
color: var(--text-color);
|
| 78 |
+
cursor: text;
|
| 79 |
+
transition: all .2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
| 80 |
+
width: 100%;
|
| 81 |
+
font-family: 'SF Mono', 'Monaco', 'Menlo', 'Consolas', monospace;
|
| 82 |
+
line-height: 1.2;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
[data-theme="dark"] .d3-equation-editor .controls input[type="text"] {
|
| 86 |
+
border: 1.5px solid var(--primary-color);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
.d3-equation-editor .controls input[type="text"]:hover {
|
| 91 |
+
border-color: rgba(0, 123, 255, 0.3);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.d3-equation-editor .controls input[type="text"]:focus {
|
| 95 |
+
border-color: rgba(0, 123, 255, 0.6);
|
| 96 |
+
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
| 97 |
+
outline: none;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
[data-theme="dark"] .d3-equation-editor .controls input[type="text"]:hover {
|
| 101 |
+
border-color: rgba(10, 132, 255, 0.4);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
[data-theme="dark"] .d3-equation-editor .controls input[type="text"]:focus {
|
| 105 |
+
border-color: rgba(10, 132, 255, 0.7);
|
| 106 |
+
box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.15);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.d3-equation-editor .controls input[type="range"] {
|
| 110 |
+
-webkit-appearance: none;
|
| 111 |
+
appearance: none;
|
| 112 |
+
height: 6px;
|
| 113 |
+
border-radius: 3px;
|
| 114 |
+
background: var(--border-color);
|
| 115 |
+
outline: none;
|
| 116 |
+
cursor: pointer;
|
| 117 |
+
width: 100%;
|
| 118 |
+
transition: background 0.2s ease;
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.d3-equation-editor .controls input[type="range"]:hover {
|
| 122 |
+
background: var(--muted-color);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.d3-equation-editor .controls input[type="range"]::-webkit-slider-thumb {
|
| 126 |
+
-webkit-appearance: none;
|
| 127 |
+
appearance: none;
|
| 128 |
+
width: 18px;
|
| 129 |
+
height: 18px;
|
| 130 |
+
border-radius: 50%;
|
| 131 |
+
background: var(--primary-color);
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
border: 2px solid var(--page-bg);
|
| 134 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 135 |
+
transition: all 0.2s ease;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.d3-equation-editor .controls input[type="range"]::-webkit-slider-thumb:hover {
|
| 139 |
+
background: var(--primary-color-hover);
|
| 140 |
+
transform: scale(1.1);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
.d3-equation-editor .controls input[type="range"]::-moz-range-thumb {
|
| 144 |
+
width: 18px;
|
| 145 |
+
height: 18px;
|
| 146 |
+
border-radius: 50%;
|
| 147 |
+
background: var(--primary-color);
|
| 148 |
+
cursor: pointer;
|
| 149 |
+
border: 2px solid var(--page-bg);
|
| 150 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
| 151 |
+
transition: all 0.2s ease;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.d3-equation-editor .controls input[type="range"]::-moz-range-thumb:hover {
|
| 155 |
+
background: var(--primary-color-hover);
|
| 156 |
+
transform: scale(1.1);
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.d3-equation-editor .legend-bottom {
|
| 160 |
+
display: flex;
|
| 161 |
+
flex-direction: column;
|
| 162 |
+
align-items: flex-start;
|
| 163 |
+
gap: 6px;
|
| 164 |
+
font-size: 12px;
|
| 165 |
+
color: var(--text-color);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.d3-equation-editor .legend-bottom .legend-title {
|
| 169 |
+
font-size: 12px;
|
| 170 |
+
font-weight: 700;
|
| 171 |
+
color: var(--text-color);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.d3-equation-editor .legend-bottom .items {
|
| 175 |
+
display: flex;
|
| 176 |
+
flex-wrap: wrap;
|
| 177 |
+
gap: 8px 14px;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.d3-equation-editor .legend-bottom .item {
|
| 181 |
+
display: inline-flex;
|
| 182 |
+
align-items: center;
|
| 183 |
+
gap: 6px;
|
| 184 |
+
white-space: nowrap;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.d3-equation-editor .legend-bottom .swatch {
|
| 188 |
+
width: 14px;
|
| 189 |
+
height: 14px;
|
| 190 |
+
border-radius: 3px;
|
| 191 |
+
border: 1px solid var(--border-color);
|
| 192 |
+
display: inline-block;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.d3-equation-editor .axis-label {
|
| 196 |
+
fill: var(--text-color);
|
| 197 |
+
font-size: 12px;
|
| 198 |
+
font-weight: 700;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.d3-equation-editor .axes path,
|
| 202 |
+
.d3-equation-editor .axes line {
|
| 203 |
+
stroke: var(--axis-color);
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.d3-equation-editor .axes text {
|
| 207 |
+
fill: var(--tick-color);
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.d3-equation-editor .grid line {
|
| 211 |
+
stroke: var(--grid-color);
|
| 212 |
+
stroke-width: 1;
|
| 213 |
+
shape-rendering: crispEdges;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.d3-equation-editor .function-curve {
|
| 217 |
+
fill: none;
|
| 218 |
+
stroke-width: 2.5;
|
| 219 |
+
stroke-linejoin: round;
|
| 220 |
+
stroke-linecap: round;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.d3-equation-editor .d3-tooltip {
|
| 224 |
+
z-index: var(--z-tooltip);
|
| 225 |
+
backdrop-filter: saturate(1.12) blur(8px);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.d3-equation-editor .error-message {
|
| 229 |
+
color: var(--danger, #b00020);
|
| 230 |
+
font-size: 11px;
|
| 231 |
+
margin-top: 4px;
|
| 232 |
+
font-style: italic;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.d3-equation-editor .examples {
|
| 236 |
+
width: 100%;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.d3-equation-editor .examples .button {
|
| 240 |
+
margin: 0 10px 10px 0;
|
| 241 |
+
}
|
| 242 |
+
</style>
|
| 243 |
+
<script>
|
| 244 |
+
(() => {
|
| 245 |
+
const ensureD3 = (cb) => {
|
| 246 |
+
if (window.d3 && typeof window.d3.select === 'function') return cb();
|
| 247 |
+
let s = document.getElementById('d3-cdn-script');
|
| 248 |
+
if (!s) {
|
| 249 |
+
s = document.createElement('script');
|
| 250 |
+
s.id = 'd3-cdn-script';
|
| 251 |
+
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
|
| 252 |
+
document.head.appendChild(s);
|
| 253 |
+
}
|
| 254 |
+
const onReady = () => {
|
| 255 |
+
if (window.d3 && typeof window.d3.select === 'function') cb();
|
| 256 |
+
};
|
| 257 |
+
s.addEventListener('load', onReady, { once: true });
|
| 258 |
+
if (window.d3) onReady();
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
const bootstrap = () => {
|
| 262 |
+
const scriptEl = document.currentScript;
|
| 263 |
+
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 264 |
+
if (!(container && container.classList && container.classList.contains('d3-equation-editor'))) {
|
| 265 |
+
const candidates = Array.from(document.querySelectorAll('.d3-equation-editor'))
|
| 266 |
+
.filter(el => !(el.dataset && el.dataset.mounted === 'true'));
|
| 267 |
+
container = candidates[candidates.length - 1] || null;
|
| 268 |
+
}
|
| 269 |
+
if (!container) return;
|
| 270 |
+
if (container.dataset) {
|
| 271 |
+
if (container.dataset.mounted === 'true') return;
|
| 272 |
+
container.dataset.mounted = 'true';
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
// Controls
|
| 276 |
+
const controls = document.createElement('div');
|
| 277 |
+
controls.className = 'controls';
|
| 278 |
+
|
| 279 |
+
// Input row (equation + domain)
|
| 280 |
+
const inputRow = document.createElement('div');
|
| 281 |
+
inputRow.className = 'input-row';
|
| 282 |
+
|
| 283 |
+
// Equation input
|
| 284 |
+
const groupEquation = document.createElement('div');
|
| 285 |
+
groupEquation.className = 'control-group equation-group';
|
| 286 |
+
const labelEquation = document.createElement('label');
|
| 287 |
+
labelEquation.textContent = 'Equation f(x) =';
|
| 288 |
+
const inputEquation = document.createElement('input');
|
| 289 |
+
inputEquation.type = 'text';
|
| 290 |
+
inputEquation.value = 'sin(x) * exp(-x^2/8) + 0.3*sin(3*x)';
|
| 291 |
+
inputEquation.placeholder = 'e.g., sin(x)*exp(-x^2/8), x^3 - 3*x, sin(x) + cos(2*x)';
|
| 292 |
+
groupEquation.appendChild(labelEquation);
|
| 293 |
+
groupEquation.appendChild(inputEquation);
|
| 294 |
+
|
| 295 |
+
// Domain range
|
| 296 |
+
const groupRange = document.createElement('div');
|
| 297 |
+
groupRange.className = 'control-group domain-group';
|
| 298 |
+
const labelRange = document.createElement('label');
|
| 299 |
+
labelRange.textContent = 'Domain';
|
| 300 |
+
const inputRange = document.createElement('input');
|
| 301 |
+
inputRange.type = 'range';
|
| 302 |
+
inputRange.min = '1';
|
| 303 |
+
inputRange.max = '10';
|
| 304 |
+
inputRange.step = '0.5';
|
| 305 |
+
inputRange.value = '4';
|
| 306 |
+
const rangeValue = document.createElement('span');
|
| 307 |
+
rangeValue.style.fontSize = '13px';
|
| 308 |
+
rangeValue.style.color = 'var(--muted-color)';
|
| 309 |
+
rangeValue.style.fontWeight = '500';
|
| 310 |
+
rangeValue.style.marginTop = '4px';
|
| 311 |
+
rangeValue.textContent = '[-4Ο, 4Ο]';
|
| 312 |
+
groupRange.appendChild(labelRange);
|
| 313 |
+
groupRange.appendChild(inputRange);
|
| 314 |
+
groupRange.appendChild(rangeValue);
|
| 315 |
+
|
| 316 |
+
inputRow.appendChild(groupEquation);
|
| 317 |
+
inputRow.appendChild(groupRange);
|
| 318 |
+
|
| 319 |
+
// Examples (more focused and pertinent)
|
| 320 |
+
const examples = document.createElement('div');
|
| 321 |
+
examples.className = 'examples';
|
| 322 |
+
const exampleFunctions = [
|
| 323 |
+
'sin(x) * exp(-x^2/8)',
|
| 324 |
+
'sin(x) + 0.5*cos(2*x)',
|
| 325 |
+
'x^3 - 3*x',
|
| 326 |
+
'sin(x) * exp(-x^2/8) + 0.3*sin(3*x)',
|
| 327 |
+
'exp(-x^2/2) * cos(4*x)',
|
| 328 |
+
'sin(x) + sin(3*x)/3 + sin(5*x)/5'
|
| 329 |
+
];
|
| 330 |
+
exampleFunctions.forEach(func => {
|
| 331 |
+
const btn = document.createElement('button');
|
| 332 |
+
btn.className = 'button button--ghost';
|
| 333 |
+
btn.textContent = func;
|
| 334 |
+
btn.addEventListener('click', () => {
|
| 335 |
+
inputEquation.value = func;
|
| 336 |
+
updatePlot();
|
| 337 |
+
});
|
| 338 |
+
examples.appendChild(btn);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
controls.appendChild(inputRow);
|
| 342 |
+
controls.appendChild(examples);
|
| 343 |
+
|
| 344 |
+
// Error message
|
| 345 |
+
const errorMsg = document.createElement('div');
|
| 346 |
+
errorMsg.className = 'error-message';
|
| 347 |
+
errorMsg.style.display = 'none';
|
| 348 |
+
|
| 349 |
+
// Header (controls only) to be placed after chart
|
| 350 |
+
const header = document.createElement('div');
|
| 351 |
+
header.className = 'chart-header';
|
| 352 |
+
header.appendChild(controls);
|
| 353 |
+
|
| 354 |
+
// SVG scaffolding inside a card wrapper
|
| 355 |
+
const card = document.createElement('div');
|
| 356 |
+
card.className = 'chart-card';
|
| 357 |
+
container.appendChild(card);
|
| 358 |
+
container.appendChild(header);
|
| 359 |
+
container.appendChild(errorMsg);
|
| 360 |
+
|
| 361 |
+
const svg = d3.select(card).append('svg').attr('width', '100%').style('display', 'block');
|
| 362 |
+
const gRoot = svg.append('g');
|
| 363 |
+
const gGrid = gRoot.append('g').attr('class', 'grid');
|
| 364 |
+
const gAxes = gRoot.append('g').attr('class', 'axes');
|
| 365 |
+
const gCurve = gRoot.append('g').attr('class', 'curve');
|
| 366 |
+
|
| 367 |
+
// Tooltip
|
| 368 |
+
container.style.position = container.style.position || 'relative';
|
| 369 |
+
let tip = container.querySelector('.d3-tooltip');
|
| 370 |
+
let tipInner;
|
| 371 |
+
if (!tip) {
|
| 372 |
+
tip = document.createElement('div');
|
| 373 |
+
tip.className = 'd3-tooltip';
|
| 374 |
+
Object.assign(tip.style, {
|
| 375 |
+
position: 'absolute',
|
| 376 |
+
top: '0px',
|
| 377 |
+
left: '0px',
|
| 378 |
+
transform: 'translate(-9999px, -9999px)',
|
| 379 |
+
pointerEvents: 'none',
|
| 380 |
+
padding: '8px 10px',
|
| 381 |
+
borderRadius: 'var(--button-radius)',
|
| 382 |
+
fontSize: '12px',
|
| 383 |
+
lineHeight: '1.35',
|
| 384 |
+
border: '1px solid var(--border-color)',
|
| 385 |
+
background: 'var(--surface-bg)',
|
| 386 |
+
color: 'var(--text-color)',
|
| 387 |
+
boxShadow: '0 4px 24px rgba(0,0,0,.18)',
|
| 388 |
+
opacity: '0',
|
| 389 |
+
transition: 'opacity .12s ease'
|
| 390 |
+
});
|
| 391 |
+
tipInner = document.createElement('div');
|
| 392 |
+
tipInner.className = 'd3-tooltip__inner';
|
| 393 |
+
tipInner.style.textAlign = 'left';
|
| 394 |
+
tip.appendChild(tipInner);
|
| 395 |
+
container.appendChild(tip);
|
| 396 |
+
} else {
|
| 397 |
+
tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// State
|
| 401 |
+
let width = 800, height = 480;
|
| 402 |
+
const margin = { top: 16, right: 32, bottom: 44, left: 56 };
|
| 403 |
+
const xScale = d3.scaleLinear();
|
| 404 |
+
const yScale = d3.scaleLinear();
|
| 405 |
+
const line = d3.line()
|
| 406 |
+
.x(d => xScale(d.x))
|
| 407 |
+
.y(d => yScale(d.y))
|
| 408 |
+
.curve(d3.curveCardinal);
|
| 409 |
+
|
| 410 |
+
// Math parser - improved to handle complex expressions and exponents correctly
|
| 411 |
+
function safeEval(expr, x) {
|
| 412 |
+
try {
|
| 413 |
+
// First, replace x with the actual value in parentheses for safety
|
| 414 |
+
let cleanExpr = expr.replace(/\bx\b/g, `(${x})`);
|
| 415 |
+
|
| 416 |
+
// Replace math functions and constants
|
| 417 |
+
cleanExpr = cleanExpr
|
| 418 |
+
.replace(/\bsin\b/g, 'Math.sin')
|
| 419 |
+
.replace(/\bcos\b/g, 'Math.cos')
|
| 420 |
+
.replace(/\btan\b/g, 'Math.tan')
|
| 421 |
+
.replace(/\bexp\b/g, 'Math.exp')
|
| 422 |
+
.replace(/\blog\b/g, 'Math.log')
|
| 423 |
+
.replace(/\babs\b/g, 'Math.abs')
|
| 424 |
+
.replace(/\bsqrt\b/g, 'Math.sqrt')
|
| 425 |
+
.replace(/\bpi\b/g, 'Math.PI')
|
| 426 |
+
.replace(/\be\b/g, 'Math.E');
|
| 427 |
+
|
| 428 |
+
// Handle exponents more carefully - need to preserve operator precedence
|
| 429 |
+
// Convert x^n to Math.pow(x, n) for proper precedence
|
| 430 |
+
cleanExpr = cleanExpr.replace(/([^*+\-\/\s]+)\^([^*+\-\/\s]+)/g, 'Math.pow($1, $2)');
|
| 431 |
+
|
| 432 |
+
// Handle remaining ^ operators (fallback to **)
|
| 433 |
+
cleanExpr = cleanExpr.replace(/\^/g, '**');
|
| 434 |
+
|
| 435 |
+
// Handle implicit multiplication (e.g., 2x -> 2*x, sin(x)cos(x) -> sin(x)*cos(x))
|
| 436 |
+
cleanExpr = cleanExpr
|
| 437 |
+
.replace(/(\d)(\()/g, '$1*$2') // 2( -> 2*(
|
| 438 |
+
.replace(/(\))(\()/g, '$1*$2') // )( -> )*(
|
| 439 |
+
.replace(/(\))(\d)/g, '$1*$2') // )2 -> )*2
|
| 440 |
+
.replace(/(\d)([a-zA-Z])/g, '$1*$2'); // 2x -> 2*x
|
| 441 |
+
|
| 442 |
+
// Security check: only allow safe mathematical operations
|
| 443 |
+
const safePattern = /^[0-9+\-*/.()Math\w\s,]*$/;
|
| 444 |
+
const withoutMath = cleanExpr.replace(/Math\.\w+/g, '');
|
| 445 |
+
if (!safePattern.test(withoutMath)) {
|
| 446 |
+
throw new Error('Invalid expression');
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
const result = eval(cleanExpr);
|
| 450 |
+
return isFinite(result) ? result : NaN;
|
| 451 |
+
} catch (e) {
|
| 452 |
+
return NaN;
|
| 453 |
+
}
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
function getColor() {
|
| 457 |
+
try {
|
| 458 |
+
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 459 |
+
return window.ColorPalettes.getColors('categorical', 1)[0];
|
| 460 |
+
}
|
| 461 |
+
} catch (_) { }
|
| 462 |
+
return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4e79a7';
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
function updateScales() {
|
| 466 |
+
width = container.clientWidth || 800;
|
| 467 |
+
height = Math.max(280, Math.round(width / 3));
|
| 468 |
+
svg.attr('width', width).attr('height', height);
|
| 469 |
+
const innerWidth = width - margin.left - margin.right;
|
| 470 |
+
const innerHeight = height - margin.top - margin.bottom;
|
| 471 |
+
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 472 |
+
|
| 473 |
+
const domainSize = parseFloat(inputRange.value);
|
| 474 |
+
const xDomain = [-domainSize * Math.PI, domainSize * Math.PI];
|
| 475 |
+
xScale.domain(xDomain).range([0, innerWidth]);
|
| 476 |
+
|
| 477 |
+
// Calculate y domain based on current function
|
| 478 |
+
const equation = inputEquation.value.trim();
|
| 479 |
+
if (equation) {
|
| 480 |
+
const testPoints = d3.range(xDomain[0], xDomain[1], (xDomain[1] - xDomain[0]) / 100);
|
| 481 |
+
const yValues = testPoints.map(x => safeEval(equation, x)).filter(y => !isNaN(y) && isFinite(y));
|
| 482 |
+
if (yValues.length > 0) {
|
| 483 |
+
const yExtent = d3.extent(yValues);
|
| 484 |
+
const yPadding = (yExtent[1] - yExtent[0]) * 0.1 || 1;
|
| 485 |
+
yScale.domain([yExtent[0] - yPadding, yExtent[1] + yPadding]).range([innerHeight, 0]);
|
| 486 |
+
} else {
|
| 487 |
+
yScale.domain([-2, 2]).range([innerHeight, 0]);
|
| 488 |
+
}
|
| 489 |
+
} else {
|
| 490 |
+
yScale.domain([-2, 2]).range([innerHeight, 0]);
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
// Grid
|
| 494 |
+
gGrid.selectAll('*').remove();
|
| 495 |
+
gGrid.selectAll('line.grid-x').data(xScale.ticks(8)).join('line')
|
| 496 |
+
.attr('class', 'grid-x')
|
| 497 |
+
.attr('x1', d => xScale(d)).attr('x2', d => xScale(d))
|
| 498 |
+
.attr('y1', 0).attr('y2', innerHeight)
|
| 499 |
+
.attr('stroke', 'var(--grid-color)')
|
| 500 |
+
.attr('stroke-width', 1)
|
| 501 |
+
.attr('shape-rendering', 'crispEdges');
|
| 502 |
+
|
| 503 |
+
gGrid.selectAll('line.grid-y').data(yScale.ticks(6)).join('line')
|
| 504 |
+
.attr('class', 'grid-y')
|
| 505 |
+
.attr('x1', 0).attr('x2', innerWidth)
|
| 506 |
+
.attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
|
| 507 |
+
.attr('stroke', 'var(--grid-color)')
|
| 508 |
+
.attr('stroke-width', 1)
|
| 509 |
+
.attr('shape-rendering', 'crispEdges');
|
| 510 |
+
|
| 511 |
+
// Axes
|
| 512 |
+
gAxes.selectAll('*').remove();
|
| 513 |
+
gAxes.append('g')
|
| 514 |
+
.attr('transform', `translate(0,${innerHeight})`)
|
| 515 |
+
.call(d3.axisBottom(xScale).ticks(8).tickFormat(d => {
|
| 516 |
+
const val = d / Math.PI;
|
| 517 |
+
if (Math.abs(val) < 0.01) return '0';
|
| 518 |
+
if (Math.abs(val - 1) < 0.01) return 'Ο';
|
| 519 |
+
if (Math.abs(val + 1) < 0.01) return '-Ο';
|
| 520 |
+
if (Math.abs(val - 0.5) < 0.01) return 'Ο/2';
|
| 521 |
+
if (Math.abs(val + 0.5) < 0.01) return '-Ο/2';
|
| 522 |
+
if (Math.abs(val % 1) < 0.01) return `${Math.round(val)}Ο`;
|
| 523 |
+
return d3.format('.1f')(val) + 'Ο';
|
| 524 |
+
}))
|
| 525 |
+
.call(g => {
|
| 526 |
+
g.selectAll('path, line').attr('stroke', 'var(--axis-color)');
|
| 527 |
+
g.selectAll('text').attr('fill', 'var(--tick-color)');
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
gAxes.append('g')
|
| 531 |
+
.call(d3.axisLeft(yScale).ticks(6))
|
| 532 |
+
.call(g => {
|
| 533 |
+
g.selectAll('path, line').attr('stroke', 'var(--axis-color)');
|
| 534 |
+
g.selectAll('text').attr('fill', 'var(--tick-color)');
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
// Axis labels
|
| 538 |
+
gAxes.append('text')
|
| 539 |
+
.attr('class', 'axis-label axis-label--x')
|
| 540 |
+
.attr('x', innerWidth / 2)
|
| 541 |
+
.attr('y', innerHeight + 44)
|
| 542 |
+
.attr('text-anchor', 'middle')
|
| 543 |
+
.text('x');
|
| 544 |
+
|
| 545 |
+
gAxes.append('text')
|
| 546 |
+
.attr('class', 'axis-label axis-label--y')
|
| 547 |
+
.attr('text-anchor', 'middle')
|
| 548 |
+
.attr('transform', `translate(${-44},${innerHeight / 2}) rotate(-90)`)
|
| 549 |
+
.text('f(x)');
|
| 550 |
+
|
| 551 |
+
return { innerWidth, innerHeight };
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
function updatePlot() {
|
| 555 |
+
errorMsg.style.display = 'none';
|
| 556 |
+
const equation = inputEquation.value.trim();
|
| 557 |
+
|
| 558 |
+
if (!equation) {
|
| 559 |
+
gCurve.selectAll('*').remove();
|
| 560 |
+
return;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
updateScales();
|
| 564 |
+
|
| 565 |
+
// Generate data points
|
| 566 |
+
const domainSize = parseFloat(inputRange.value);
|
| 567 |
+
const xDomain = [-domainSize * Math.PI, domainSize * Math.PI];
|
| 568 |
+
const numPoints = Math.max(200, Math.min(1000, Math.round((xDomain[1] - xDomain[0]) * 50)));
|
| 569 |
+
const data = [];
|
| 570 |
+
let hasValidPoints = false;
|
| 571 |
+
let errorCount = 0;
|
| 572 |
+
|
| 573 |
+
for (let i = 0; i <= numPoints; i++) {
|
| 574 |
+
const x = xDomain[0] + (i / numPoints) * (xDomain[1] - xDomain[0]);
|
| 575 |
+
const y = safeEval(equation, x);
|
| 576 |
+
|
| 577 |
+
if (!isNaN(y) && isFinite(y)) {
|
| 578 |
+
data.push({ x, y });
|
| 579 |
+
hasValidPoints = true;
|
| 580 |
+
} else {
|
| 581 |
+
errorCount++;
|
| 582 |
+
}
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
if (!hasValidPoints) {
|
| 586 |
+
errorMsg.textContent = `Error: unable to evaluate equation "${equation}"`;
|
| 587 |
+
errorMsg.style.display = 'block';
|
| 588 |
+
gCurve.selectAll('*').remove();
|
| 589 |
+
return;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
if (errorCount > numPoints * 0.5) {
|
| 593 |
+
errorMsg.textContent = `Warning: ${errorCount} invalid points out of ${numPoints}`;
|
| 594 |
+
errorMsg.style.display = 'block';
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
// Draw the curve
|
| 598 |
+
const color = getColor();
|
| 599 |
+
const path = gCurve.selectAll('path.function-curve').data([data]);
|
| 600 |
+
|
| 601 |
+
path.enter()
|
| 602 |
+
.append('path')
|
| 603 |
+
.attr('class', 'function-curve')
|
| 604 |
+
.attr('stroke', color)
|
| 605 |
+
.merge(path)
|
| 606 |
+
.transition()
|
| 607 |
+
.duration(150)
|
| 608 |
+
.attr('d', line)
|
| 609 |
+
.attr('stroke', color);
|
| 610 |
+
|
| 611 |
+
path.exit().remove();
|
| 612 |
+
|
| 613 |
+
// Hover interaction
|
| 614 |
+
const overlay = gCurve.selectAll('rect.overlay').data([0]);
|
| 615 |
+
const { innerWidth, innerHeight } = updateScales();
|
| 616 |
+
|
| 617 |
+
overlay.enter()
|
| 618 |
+
.append('rect')
|
| 619 |
+
.attr('class', 'overlay')
|
| 620 |
+
.attr('fill', 'transparent')
|
| 621 |
+
.style('cursor', 'crosshair')
|
| 622 |
+
.merge(overlay)
|
| 623 |
+
.attr('width', innerWidth)
|
| 624 |
+
.attr('height', innerHeight)
|
| 625 |
+
.on('mousemove', function (event) {
|
| 626 |
+
const [mx] = d3.pointer(event, this);
|
| 627 |
+
const x = xScale.invert(mx);
|
| 628 |
+
const y = safeEval(equation, x);
|
| 629 |
+
|
| 630 |
+
if (!isNaN(y) && isFinite(y)) {
|
| 631 |
+
tipInner.innerHTML = `
|
| 632 |
+
<div><strong>f(${x.toFixed(3)}) = ${y.toFixed(3)}</strong></div>
|
| 633 |
+
<div style="font-size:11px;color:var(--muted-color);margin-top:2px;">${equation}</div>
|
| 634 |
+
`;
|
| 635 |
+
tip.style.opacity = '1';
|
| 636 |
+
const tx = Math.max(0, Math.min(mx + margin.left + 12, (container.clientWidth || 0) - (tip.offsetWidth + 6)));
|
| 637 |
+
const ty = Math.max(0, Math.min(yScale(y) + margin.top + 12, (container.clientHeight || 0) - (tip.offsetHeight + 6)));
|
| 638 |
+
tip.style.transform = `translate(${Math.round(tx)}px, ${Math.round(ty)}px)`;
|
| 639 |
+
}
|
| 640 |
+
})
|
| 641 |
+
.on('mouseleave', function () {
|
| 642 |
+
tip.style.opacity = '0';
|
| 643 |
+
tip.style.transform = 'translate(-9999px, -9999px)';
|
| 644 |
+
});
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
// Event listeners
|
| 649 |
+
inputEquation.addEventListener('input', updatePlot);
|
| 650 |
+
inputRange.addEventListener('input', () => {
|
| 651 |
+
const val = parseFloat(inputRange.value);
|
| 652 |
+
rangeValue.textContent = `[-${val}Ο, ${val}Ο]`;
|
| 653 |
+
updatePlot();
|
| 654 |
+
});
|
| 655 |
+
|
| 656 |
+
// Initial setup (already done above)
|
| 657 |
+
|
| 658 |
+
// Initial render
|
| 659 |
+
updatePlot();
|
| 660 |
+
|
| 661 |
+
// Resize handling
|
| 662 |
+
const rerender = () => updatePlot();
|
| 663 |
+
if (window.ResizeObserver) {
|
| 664 |
+
const ro = new ResizeObserver(() => rerender());
|
| 665 |
+
ro.observe(container);
|
| 666 |
+
} else {
|
| 667 |
+
window.addEventListener('resize', rerender);
|
| 668 |
+
}
|
| 669 |
+
};
|
| 670 |
+
|
| 671 |
+
if (document.readyState === 'loading') {
|
| 672 |
+
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
|
| 673 |
+
} else {
|
| 674 |
+
ensureD3(bootstrap);
|
| 675 |
+
}
|
| 676 |
+
})();
|
| 677 |
+
</script>
|
app/src/content/embeds/d3-line-quad.html
CHANGED
|
@@ -1,74 +1,190 @@
|
|
| 1 |
<div class="line-quad">
|
| 2 |
-
|
| 3 |
<div class="line-quad__grid">
|
| 4 |
<div class="quad-cell" data-title="Formatting Filter" data-csv="/data/formatting_filters.csv"></div>
|
| 5 |
<div class="quad-cell" data-title="Relevance Filter" data-csv="/data/relevance_filters.csv"></div>
|
| 6 |
<div class="quad-cell" data-title="Visual Dependency Filter" data-csv="/data/visual_dependency_filters.csv"></div>
|
| 7 |
-
<div class="quad-cell" data-title="Image Correspondence Filter" data-csv="/data/image_correspondence_filters.csv"
|
|
|
|
| 8 |
</div>
|
| 9 |
<noscript>JavaScript is required to render these charts.</noscript>
|
| 10 |
|
| 11 |
</div>
|
| 12 |
<style>
|
| 13 |
-
.line-quad {
|
|
|
|
|
|
|
|
|
|
| 14 |
/* Axis/tick/grid use global variables from _variables.css */
|
| 15 |
/* Apply axis/tick/grid purely via CSS */
|
| 16 |
.line-quad .axes path,
|
| 17 |
-
.line-quad .axes line {
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
/* Stacking order to ensure hover/tooltip overlays are not hidden by neighbors */
|
| 27 |
-
.line-quad__grid .quad-cell:nth-child(1) {
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
.
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
.quad-cell select {
|
| 36 |
-
font-size: 12px;
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 39 |
-
background-repeat: no-repeat;
|
| 40 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
|
|
|
| 42 |
[data-theme="dark"] .quad-cell select {
|
| 43 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 44 |
}
|
| 45 |
-
|
| 46 |
-
.quad-cell select:
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
/* Tooltip refined styling */
|
| 56 |
.line-quad .d3-tooltip {
|
| 57 |
z-index: 20;
|
| 58 |
backdrop-filter: saturate(1.12) blur(8px);
|
| 59 |
}
|
|
|
|
| 60 |
.line-quad .d3-tooltip__inner {
|
| 61 |
display: flex;
|
| 62 |
flex-direction: column;
|
| 63 |
gap: 6px;
|
| 64 |
min-width: 220px;
|
| 65 |
}
|
| 66 |
-
|
|
|
|
| 67 |
font-weight: 800;
|
| 68 |
letter-spacing: 0.1px;
|
| 69 |
margin-bottom: 0;
|
| 70 |
}
|
| 71 |
-
|
|
|
|
| 72 |
font-size: 11px;
|
| 73 |
color: var(--muted-color);
|
| 74 |
display: block;
|
|
@@ -76,18 +192,22 @@
|
|
| 76 |
margin-bottom: 2px;
|
| 77 |
letter-spacing: 0.1px;
|
| 78 |
}
|
| 79 |
-
|
|
|
|
| 80 |
padding-top: 6px;
|
| 81 |
border-top: 1px solid var(--border-color);
|
| 82 |
}
|
|
|
|
| 83 |
.line-quad .d3-tooltip__inner svg {
|
| 84 |
display: inline-block;
|
| 85 |
vertical-align: middle;
|
| 86 |
margin-right: 2px;
|
| 87 |
}
|
|
|
|
| 88 |
.line-quad .d3-tooltip__inner strong {
|
| 89 |
margin-right: 6px;
|
| 90 |
}
|
|
|
|
| 91 |
.line-quad .d3-tooltip__color-dot {
|
| 92 |
display: inline-block;
|
| 93 |
width: 12px;
|
|
@@ -95,6 +215,7 @@
|
|
| 95 |
border-radius: 3px;
|
| 96 |
border: 1px solid var(--border-color);
|
| 97 |
}
|
|
|
|
| 98 |
/* Header layout (like d3-line-simple) */
|
| 99 |
.line-quad__header {
|
| 100 |
display: flex;
|
|
@@ -104,6 +225,7 @@
|
|
| 104 |
margin: 8px 0 0 0;
|
| 105 |
flex-wrap: wrap;
|
| 106 |
}
|
|
|
|
| 107 |
.line-quad__header .legend-bottom {
|
| 108 |
display: flex;
|
| 109 |
flex-direction: column;
|
|
@@ -112,22 +234,26 @@
|
|
| 112 |
font-size: 12px;
|
| 113 |
color: var(--text-color);
|
| 114 |
}
|
|
|
|
| 115 |
.line-quad__header .legend-bottom .legend-title {
|
| 116 |
font-size: 12px;
|
| 117 |
font-weight: 700;
|
| 118 |
color: var(--text-color);
|
| 119 |
}
|
|
|
|
| 120 |
.line-quad__header .legend-bottom .items {
|
| 121 |
display: flex;
|
| 122 |
flex-wrap: wrap;
|
| 123 |
gap: 8px 14px;
|
| 124 |
}
|
|
|
|
| 125 |
.line-quad__header .legend-bottom .item {
|
| 126 |
display: inline-flex;
|
| 127 |
align-items: center;
|
| 128 |
gap: 6px;
|
| 129 |
white-space: nowrap;
|
| 130 |
}
|
|
|
|
| 131 |
.line-quad__header .legend-bottom .swatch {
|
| 132 |
width: 14px;
|
| 133 |
height: 14px;
|
|
@@ -135,6 +261,7 @@
|
|
| 135 |
border: 1px solid var(--border-color);
|
| 136 |
display: inline-block;
|
| 137 |
}
|
|
|
|
| 138 |
.line-quad .controls {
|
| 139 |
margin-top: 0;
|
| 140 |
display: flex;
|
|
@@ -144,12 +271,14 @@
|
|
| 144 |
width: auto;
|
| 145 |
flex-wrap: wrap;
|
| 146 |
}
|
|
|
|
| 147 |
.line-quad .controls .control-group {
|
| 148 |
display: flex;
|
| 149 |
flex-direction: column;
|
| 150 |
align-items: flex-start;
|
| 151 |
gap: 6px;
|
| 152 |
}
|
|
|
|
| 153 |
.line-quad .controls label {
|
| 154 |
font-size: 12px;
|
| 155 |
color: var(--text-color);
|
|
@@ -159,6 +288,7 @@
|
|
| 159 |
white-space: nowrap;
|
| 160 |
font-weight: 700;
|
| 161 |
}
|
|
|
|
| 162 |
.line-quad .controls select {
|
| 163 |
font-size: 12px;
|
| 164 |
padding: 8px 28px 8px 10px;
|
|
@@ -175,11 +305,20 @@
|
|
| 175 |
cursor: pointer;
|
| 176 |
transition: border-color .15s ease, box-shadow .15s ease;
|
| 177 |
}
|
|
|
|
| 178 |
[data-theme="dark"] .line-quad .controls select {
|
| 179 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 180 |
}
|
| 181 |
-
|
| 182 |
-
.line-quad .controls select:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
</style>
|
| 184 |
<script>
|
| 185 |
(() => {
|
|
@@ -208,7 +347,7 @@
|
|
| 208 |
s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
|
| 209 |
};
|
| 210 |
|
| 211 |
-
function initRunLine(cell){
|
| 212 |
const d3 = window.d3;
|
| 213 |
const csvPath = cell.getAttribute('data-csv');
|
| 214 |
const titleText = cell.getAttribute('data-title') || '';
|
|
@@ -221,14 +360,14 @@
|
|
| 221 |
|
| 222 |
// Body & SVG
|
| 223 |
const body = document.createElement('div'); body.className = 'cell-body'; cell.appendChild(body);
|
| 224 |
-
const svg = d3.select(body).append('svg').attr('width','100%').style('display','block');
|
| 225 |
const gRoot = svg.append('g');
|
| 226 |
-
const gGrid = gRoot.append('g').attr('class','grid');
|
| 227 |
-
const gAxes = gRoot.append('g').attr('class','axes');
|
| 228 |
-
const gAreas = gRoot.append('g').attr('class','areas');
|
| 229 |
-
const gLines = gRoot.append('g').attr('class','lines');
|
| 230 |
-
const gPoints = gRoot.append('g').attr('class','points');
|
| 231 |
-
const gHover = gRoot.append('g').attr('class','hover');
|
| 232 |
// Removed per-cell legend; using global footer legend
|
| 233 |
|
| 234 |
// Tooltip
|
|
@@ -238,26 +377,26 @@
|
|
| 238 |
tip = document.createElement('div');
|
| 239 |
tip.className = 'd3-tooltip';
|
| 240 |
Object.assign(tip.style, {
|
| 241 |
-
position:'absolute',
|
| 242 |
-
top:'0',
|
| 243 |
-
left:'0',
|
| 244 |
-
transform:'translate(-9999px,-9999px)',
|
| 245 |
-
pointerEvents:'none',
|
| 246 |
-
padding:'10px 12px',
|
| 247 |
-
borderRadius:'12px',
|
| 248 |
-
fontSize:'12px',
|
| 249 |
-
lineHeight:'1.35',
|
| 250 |
-
border:'1px solid var(--border-color)',
|
| 251 |
-
background:'var(--surface-bg)',
|
| 252 |
-
color:'var(--text-color)',
|
| 253 |
-
boxShadow:'0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
|
| 254 |
-
opacity:'0',
|
| 255 |
-
transition:'opacity .12s ease',
|
| 256 |
-
backdropFilter:'saturate(1.12) blur(8px)'
|
| 257 |
});
|
| 258 |
tipInner = document.createElement('div');
|
| 259 |
tipInner.className = 'd3-tooltip__inner';
|
| 260 |
-
tipInner.style.textAlign='left';
|
| 261 |
tip.appendChild(tipInner);
|
| 262 |
cell.appendChild(tip);
|
| 263 |
} else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
|
|
@@ -270,18 +409,18 @@
|
|
| 270 |
let isRankStrictFlag = false; let isRankMetricFlag = false; let rankTickMax = 1;
|
| 271 |
let sharedYConfig = null; // { type: 'rank_strict', maxRank } | { type: 'value', min, max }
|
| 272 |
let axisLabelY = 'Value';
|
| 273 |
-
|
| 274 |
// Colors and markers (match original embeds)
|
| 275 |
const getRunColors = (n) => {
|
| 276 |
-
try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch(_) {}
|
| 277 |
-
const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|
| 278 |
-
return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...(d3.schemeTableau10||[])].slice(0, n);
|
| 279 |
};
|
| 280 |
const pool = getRunColors(12);
|
| 281 |
-
// Shapes
|
| 282 |
// Ready signal for async load completion
|
| 283 |
let readyResolve = null;
|
| 284 |
-
const ready = new Promise((res)=> { readyResolve = res; });
|
| 285 |
|
| 286 |
// Shared formatter for thousands: 5000 -> 5k, 1500 -> 1.5k (trim .0)
|
| 287 |
const formatK = (v) => {
|
|
@@ -294,7 +433,7 @@
|
|
| 294 |
return d3.format('d')(v);
|
| 295 |
};
|
| 296 |
|
| 297 |
-
function updateScales(){
|
| 298 |
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 299 |
const axisColor = 'var(--axis-color)';
|
| 300 |
const tickColor = 'var(--tick-color)';
|
|
@@ -303,29 +442,29 @@
|
|
| 303 |
const rect = cell.getBoundingClientRect();
|
| 304 |
width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
|
| 305 |
height = Math.max(280, Math.round(width / 2.3));
|
| 306 |
-
svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio','xMidYMid meet');
|
| 307 |
const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
|
| 308 |
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 309 |
xScale.range([0, innerWidth]); yScale.range([innerHeight, 0]);
|
| 310 |
|
| 311 |
// Y ticks
|
| 312 |
let yTicks = [];
|
| 313 |
-
if (isRankStrictFlag) { const maxR = Math.max(1, Math.round(rankTickMax)); for (let v=1; v<=maxR; v+=1) yTicks.push(v); }
|
| 314 |
else { yTicks = yScale.ticks(6); }
|
| 315 |
|
| 316 |
// Grid
|
| 317 |
gGrid.selectAll('*').remove();
|
| 318 |
gGrid.selectAll('line').data(yTicks).join('line')
|
| 319 |
-
.attr('x1',0).attr('x2',innerWidth).attr('y1',d=>yScale(d)).attr('y2',d=>yScale(d))
|
| 320 |
-
.attr('stroke', gridColor).attr('stroke-width',1).attr('shape-rendering','crispEdges');
|
| 321 |
|
| 322 |
// Axes
|
| 323 |
gAxes.selectAll('*').remove();
|
| 324 |
let xAxis = d3.axisBottom(xScale).tickSizeOuter(0); xAxis = xAxis.ticks(8);
|
| 325 |
xAxis = xAxis.tickFormat(formatK);
|
| 326 |
const yAxis = d3.axisLeft(yScale).tickValues(yTicks).tickSizeOuter(0).tickFormat(isRankStrictFlag ? d3.format('d') : d3.format('.2f'));
|
| 327 |
-
gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(xAxis).call(g=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','11px'); });
|
| 328 |
-
gAxes.append('g').call(yAxis).call(g=>{ g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size','11px'); });
|
| 329 |
|
| 330 |
// Axis labels
|
| 331 |
gAxes.append('text')
|
|
@@ -352,16 +491,16 @@
|
|
| 352 |
return { innerWidth, innerHeight, tickColor };
|
| 353 |
}
|
| 354 |
|
| 355 |
-
function renderMetric(metricKey){
|
| 356 |
const map = dataByMetric.get(metricKey) || {};
|
| 357 |
const runs = runOrder;
|
| 358 |
let minStep = Infinity, maxStep = -Infinity, maxVal = 0, minVal = Infinity;
|
| 359 |
const isRank = /rank/i.test(metricKey); const isAverage = /average/i.test(metricKey); const isRankStrict = isRank && !isAverage;
|
| 360 |
-
runs.forEach(r => { (map[r]||[]).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep=Math.min(minStep,pt.step); maxStep=Math.max(maxStep,pt.step); maxVal=Math.max(maxVal,v); minVal=Math.min(minVal,v); }); });
|
| 361 |
if (!isFinite(minStep) || !isFinite(maxStep)) return;
|
| 362 |
xScale.domain([minStep, maxStep]);
|
| 363 |
if (sharedYConfig && sharedYConfig.type === 'rank_strict') {
|
| 364 |
-
rankTickMax = Math.max(1, Math.round(sharedYConfig.maxRank||1));
|
| 365 |
yScale.domain([rankTickMax, 1]);
|
| 366 |
isRankStrictFlag = true;
|
| 367 |
isRankMetricFlag = true;
|
|
@@ -385,7 +524,7 @@
|
|
| 385 |
const j = (typeof idx === 'number' ? idx : runs.indexOf(run));
|
| 386 |
return pool[(j >= 0 ? j : 0) % pool.length];
|
| 387 |
};
|
| 388 |
-
const series = runs.map((r, i) => ({ run:r, color: colorForRun(r, i), values:(map[r]||[]).slice().sort((a,b)=>a.step-b.step).map(pt => isRankStrict ? { step: pt.step, value: Math.round(pt.value), stderr: pt.stderr } : pt) }));
|
| 389 |
|
| 390 |
// zones Β± stderr (mΓ©triques non rank)
|
| 391 |
gAreas.selectAll('*').remove();
|
|
@@ -396,9 +535,9 @@
|
|
| 396 |
const upper = withErr.map(d => [xScale(d.step), yScale(d.value + d.stderr)]);
|
| 397 |
const lower = withErr.slice().reverse().map(d => [xScale(d.step), yScale(d.value - d.stderr)]);
|
| 398 |
const coords = upper.concat(lower);
|
| 399 |
-
const pathData = d3.line().x(d=>d[0]).y(d=>d[1]).curve(d3.curveLinearClosed)(coords);
|
| 400 |
gAreas.append('path')
|
| 401 |
-
.attr('class','area')
|
| 402 |
.attr('data-run', s.run)
|
| 403 |
.attr('d', pathData)
|
| 404 |
.attr('fill', s.color)
|
|
@@ -409,66 +548,68 @@
|
|
| 409 |
});
|
| 410 |
}
|
| 411 |
|
| 412 |
-
const paths = gLines.selectAll('path.run-line').data(series, d=>d.run);
|
| 413 |
paths.enter()
|
| 414 |
.append('path')
|
| 415 |
-
.attr('class','run-line')
|
| 416 |
-
.attr('data-run', d=>d.run)
|
| 417 |
-
.attr('fill','none')
|
| 418 |
.attr('stroke-width', 1)
|
| 419 |
-
.attr('opacity',0)
|
| 420 |
-
.attr('stroke', d=>d.color)
|
| 421 |
-
.attr('d', d=>lineGen(d.values))
|
| 422 |
.transition(tChange || undefined)
|
| 423 |
-
.attr('opacity',0.9);
|
| 424 |
paths
|
| 425 |
.transition(tChange || undefined)
|
| 426 |
-
.attr('stroke', d=>d.color)
|
| 427 |
-
.attr('opacity',0.9)
|
| 428 |
-
.attr('d', d=>lineGen(d.values));
|
| 429 |
paths.exit().remove();
|
| 430 |
|
| 431 |
// Draw light point markers at each data sample (subtle)
|
| 432 |
-
const allPoints = series.flatMap(s => s.values.map(v => ({ run:s.run, color:s.color, step:v.step, value:v.value })));
|
| 433 |
-
const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d=> `${d.run}-${d.step}`);
|
| 434 |
-
ptsSel.enter().append('circle').attr('class','pt')
|
| 435 |
-
.attr('data-run', d=>d.run)
|
| 436 |
.attr('r', 1.5)
|
| 437 |
-
.attr('fill', d=>d.color)
|
| 438 |
.attr('fill-opacity', 0.6)
|
| 439 |
.attr('stroke', 'none')
|
| 440 |
-
.attr('cx', d=>xScale(d.step))
|
| 441 |
-
.attr('cy', d=>yScale(d.value))
|
| 442 |
.merge(ptsSel)
|
| 443 |
-
.attr('fill', d=>d.color)
|
| 444 |
.transition(tChange || undefined)
|
| 445 |
.attr('r', 2)
|
| 446 |
-
.attr('cx', d=>xScale(d.step))
|
| 447 |
-
.attr('cy', d=>yScale(d.value));
|
| 448 |
ptsSel.exit().remove();
|
| 449 |
|
| 450 |
// No per-cell legend content (handled globally)
|
| 451 |
|
| 452 |
// Hover
|
| 453 |
gHover.selectAll('*').remove();
|
| 454 |
-
const overlay = gHover.append('rect').attr('fill','transparent').style('cursor','crosshair').attr('x',0).attr('y',0).attr('width', innerWidth).attr('height', innerHeight);
|
| 455 |
-
const hoverLine = gHover.append('line').style('stroke','var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width',1).attr('y1',0).attr('y2',innerHeight).style('display','none');
|
| 456 |
-
const stepSet = new Set(); series.forEach(s=>s.values.forEach(v=>stepSet.add(v.step))); const steps = Array.from(stepSet).sort((a,b)=>a-b);
|
| 457 |
-
function onMove(ev)
|
|
|
|
| 458 |
let html = `<div><strong>${titleText}</strong></div><div><strong>step</strong> ${formatK(nearest)}</div>`;
|
| 459 |
-
const entries = series.map(s=>{ const map = new Map(s.values.map(v=>[v.step, v])); const pt = map.get(nearest); return { run:s.run, color:s.color, pt }; }).filter(e => e.pt && e.pt.value!=null);
|
| 460 |
-
entries.sort((a,b)=> (a.pt.value - b.pt.value));
|
| 461 |
-
const fmt = (vv)=> (isRankStrictFlag? d3.format('d')(vv) : (+vv).toFixed(4));
|
| 462 |
entries.forEach(e => {
|
| 463 |
-
const err = (e.pt.stderr!=null && isFinite(e.pt.stderr) && e.pt.stderr>0) ? ` Β± ${fmt(e.pt.stderr)}` : '';
|
| 464 |
html += `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}${err}</span></div>`;
|
| 465 |
});
|
| 466 |
-
tipInner.innerHTML = html; const offsetX=12, offsetY=12; tip.style.opacity='1'; tip.style.transform
|
| 467 |
-
|
|
|
|
| 468 |
overlay.on('mousemove', onMove).on('mouseleave', onLeave);
|
| 469 |
}
|
| 470 |
|
| 471 |
-
async function load(){
|
| 472 |
try {
|
| 473 |
const file = (csvPath || '').split('/').pop();
|
| 474 |
const CANDIDATES = [
|
|
@@ -479,18 +620,18 @@
|
|
| 479 |
`../../assets/data/${file}`
|
| 480 |
].filter(Boolean);
|
| 481 |
let text = null;
|
| 482 |
-
for (const p of CANDIDATES){
|
| 483 |
-
try { const r = await fetch(p, { cache:'no-cache' }); if (r.ok) { text = await r.text(); break; } } catch(e){}
|
| 484 |
}
|
| 485 |
if (text == null) throw new Error(`CSV not found: ${file}`);
|
| 486 |
-
const rows = d3.csvParse(text, d => ({ run:(d.run||'').trim(), step
|
| 487 |
-
metricList = Array.from(new Set(rows.map(r=>r.metric))).sort();
|
| 488 |
-
runList = Array.from(new Set(rows.map(r=>r.run))).sort(); runOrder = runList;
|
| 489 |
-
metricList.forEach(m => { const map={}; runList.forEach(r=>map[r]=[]); rows.filter(r=>r.metric===m).forEach(r=>{ if(!isNaN(r.step)
|
| 490 |
const preferred = metricList.find(m => m === 'ai2d_exact_match') || metricList.find(m => /average_rank/i.test(m));
|
| 491 |
const def = preferred || metricList[0];
|
| 492 |
renderMetric(def);
|
| 493 |
-
const ro = window.ResizeObserver ? new ResizeObserver(()=>renderMetric(def)) : null; if (ro) ro.observe(cell);
|
| 494 |
if (typeof readyResolve === 'function') readyResolve();
|
| 495 |
} catch (e) {
|
| 496 |
const pre = document.createElement('pre'); pre.textContent = 'CSV load error: ' + (e && e.message ? e.message : e);
|
|
@@ -499,7 +640,7 @@
|
|
| 499 |
}
|
| 500 |
}
|
| 501 |
load();
|
| 502 |
-
|
| 503 |
return {
|
| 504 |
ready,
|
| 505 |
getMetrics: () => metricList.slice(),
|
|
@@ -508,7 +649,7 @@
|
|
| 508 |
const key = m; const map = dataByMetric.get(key) || {}; const runs = runOrder;
|
| 509 |
let maxVal = 0, minVal = Infinity; let minStep = Infinity, maxStep = -Infinity;
|
| 510 |
const isRank = /rank/i.test(key); const isAverage = /average/i.test(key); const isRankStrict = isRank && !isAverage;
|
| 511 |
-
runs.forEach(r => { (map[r]||[]).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep=Math.min(minStep,pt.step); maxStep=Math.max(maxStep,pt.step); maxVal=Math.max(maxVal,v); minVal=Math.min(minVal,v); }); });
|
| 512 |
const rankMax = isRank ? Math.max(1, Math.round(maxVal)) : null;
|
| 513 |
return { isRank, isRankStrict, min: maxVal === 0 && minVal === Infinity ? null : minVal, max: maxVal, rankMax };
|
| 514 |
},
|
|
@@ -560,7 +701,7 @@
|
|
| 560 |
const select = (headerEl || header).querySelector('.controls select');
|
| 561 |
if (select) {
|
| 562 |
select.innerHTML = '';
|
| 563 |
-
metrics.forEach(m => { const o=document.createElement('option'); o.value=m; o.textContent=prettyMetricLabel(m); select.appendChild(o); });
|
| 564 |
if (def) select.value = def;
|
| 565 |
}
|
| 566 |
|
|
@@ -577,7 +718,7 @@
|
|
| 577 |
const max = Math.max(...infos.map(info => info.max));
|
| 578 |
instances.forEach(i => i && typeof i.setSharedY === 'function' && i.setSharedY({ type: 'value', min, max, key: metric }));
|
| 579 |
}
|
| 580 |
-
} catch (_) {}
|
| 581 |
};
|
| 582 |
|
| 583 |
const applyAll = (v) => { computeAndApplySharedY(v); instances.forEach(i => i && typeof i.setMetric === 'function' && i.setMetric(v)); };
|
|
@@ -589,14 +730,14 @@
|
|
| 589 |
if (legendItemsHost) {
|
| 590 |
try {
|
| 591 |
const f = '/data/formatting_filters.csv';
|
| 592 |
-
const r = await fetch(f, { cache:'no-cache' });
|
| 593 |
if (r.ok && window.d3 && window.d3.csvParse) {
|
| 594 |
const txt = await r.text();
|
| 595 |
const rows = window.d3.csvParse(txt);
|
| 596 |
-
const runList = Array.from(new Set(rows.map(row => String(row.run||'').trim()).filter(Boolean))).sort();
|
| 597 |
-
const poolLegend = (window.ColorPalettes && typeof window.ColorPalettes.getColors==='function')
|
| 598 |
? window.ColorPalettes.getColors('categorical', runList.length)
|
| 599 |
-
: (()=>{ const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim()
|
| 600 |
// Build shared run->color map once
|
| 601 |
SHARED_RUN_COLOR = {};
|
| 602 |
runList.forEach((name, i) => { SHARED_RUN_COLOR[name] = poolLegend[i % poolLegend.length]; });
|
|
@@ -608,7 +749,7 @@
|
|
| 608 |
try {
|
| 609 |
const currentMetric = (select && select.value) || def;
|
| 610 |
if (currentMetric) applyAll(currentMetric);
|
| 611 |
-
} catch {}
|
| 612 |
// Legend hover ghosting across all cells
|
| 613 |
legendItemsHost.querySelectorAll('.item').forEach(el => {
|
| 614 |
el.addEventListener('mouseenter', () => {
|
|
@@ -632,14 +773,11 @@
|
|
| 632 |
});
|
| 633 |
});
|
| 634 |
}
|
| 635 |
-
} catch {}
|
| 636 |
}
|
| 637 |
})();
|
| 638 |
};
|
| 639 |
|
| 640 |
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
|
| 641 |
})();
|
| 642 |
-
</script>
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
|
|
|
| 1 |
<div class="line-quad">
|
| 2 |
+
|
| 3 |
<div class="line-quad__grid">
|
| 4 |
<div class="quad-cell" data-title="Formatting Filter" data-csv="/data/formatting_filters.csv"></div>
|
| 5 |
<div class="quad-cell" data-title="Relevance Filter" data-csv="/data/relevance_filters.csv"></div>
|
| 6 |
<div class="quad-cell" data-title="Visual Dependency Filter" data-csv="/data/visual_dependency_filters.csv"></div>
|
| 7 |
+
<div class="quad-cell" data-title="Image Correspondence Filter" data-csv="/data/image_correspondence_filters.csv">
|
| 8 |
+
</div>
|
| 9 |
</div>
|
| 10 |
<noscript>JavaScript is required to render these charts.</noscript>
|
| 11 |
|
| 12 |
</div>
|
| 13 |
<style>
|
| 14 |
+
.line-quad {
|
| 15 |
+
position: relative;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
/* Axis/tick/grid use global variables from _variables.css */
|
| 19 |
/* Apply axis/tick/grid purely via CSS */
|
| 20 |
.line-quad .axes path,
|
| 21 |
+
.line-quad .axes line {
|
| 22 |
+
stroke: var(--axis-color);
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.line-quad .axes text {
|
| 26 |
+
fill: var(--tick-color);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.line-quad .grid line {
|
| 30 |
+
stroke: var(--grid-color);
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.line-quad__grid {
|
| 34 |
+
display: grid;
|
| 35 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 36 |
+
gap: 12px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
@media (max-width: 980px) {
|
| 40 |
+
.line-quad__grid {
|
| 41 |
+
grid-template-columns: 1fr;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
|
|
|
|
| 45 |
|
| 46 |
+
|
| 47 |
+
.quad-cell {
|
| 48 |
+
border: 1px solid var(--border-color);
|
| 49 |
+
border-radius: 10px;
|
| 50 |
+
background: var(--surface-bg);
|
| 51 |
+
display: flex;
|
| 52 |
+
flex-direction: column;
|
| 53 |
+
position: relative;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
/* Stacking order to ensure hover/tooltip overlays are not hidden by neighbors */
|
| 57 |
+
.line-quad__grid .quad-cell:nth-child(1) {
|
| 58 |
+
z-index: 4;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
/* top-left */
|
| 62 |
+
.line-quad__grid .quad-cell:nth-child(3) {
|
| 63 |
+
z-index: 3;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* bottom-left */
|
| 67 |
+
.line-quad__grid .quad-cell:nth-child(2) {
|
| 68 |
+
z-index: 2;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/* top-right */
|
| 72 |
+
.line-quad__grid .quad-cell:nth-child(4) {
|
| 73 |
+
z-index: 1;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/* bottom-right */
|
| 77 |
+
.quad-cell .cell-header {
|
| 78 |
+
padding: 8px 10px;
|
| 79 |
+
border-bottom: 1px solid var(--border-color);
|
| 80 |
+
display: flex;
|
| 81 |
+
align-items: center;
|
| 82 |
+
justify-content: space-between;
|
| 83 |
+
gap: 8px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.quad-cell .cell-title {
|
| 87 |
+
font-size: 13px;
|
| 88 |
+
font-weight: 700;
|
| 89 |
+
color: var(--text-color);
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.quad-cell .cell-controls {
|
| 93 |
+
display: flex;
|
| 94 |
+
align-items: center;
|
| 95 |
+
gap: 12px;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.quad-cell .cell-controls label {
|
| 99 |
+
font-size: 12px;
|
| 100 |
+
color: var(--muted-color);
|
| 101 |
+
display: flex;
|
| 102 |
+
align-items: center;
|
| 103 |
+
gap: 6px;
|
| 104 |
+
white-space: nowrap;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
.quad-cell select {
|
| 108 |
+
font-size: 12px;
|
| 109 |
+
padding: 6px 28px 6px 10px;
|
| 110 |
+
border: 1px solid var(--border-color);
|
| 111 |
+
border-radius: 8px;
|
| 112 |
+
background-color: var(--surface-bg);
|
| 113 |
+
color: var(--text-color);
|
| 114 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%230f1115' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 115 |
+
background-repeat: no-repeat;
|
| 116 |
+
background-position: right 8px center;
|
| 117 |
+
background-size: 12px;
|
| 118 |
+
-webkit-appearance: none;
|
| 119 |
+
appearance: none;
|
| 120 |
+
cursor: pointer;
|
| 121 |
+
transition: border-color .15s ease, box-shadow .15s ease;
|
| 122 |
}
|
| 123 |
+
|
| 124 |
[data-theme="dark"] .quad-cell select {
|
| 125 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 126 |
}
|
| 127 |
+
|
| 128 |
+
.quad-cell select:hover {
|
| 129 |
+
border-color: var(--primary-color);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.quad-cell select:focus {
|
| 133 |
+
border-color: var(--primary-color);
|
| 134 |
+
box-shadow: 0 0 0 3px rgba(232, 137, 171, .25);
|
| 135 |
+
outline: none;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
.quad-cell .cell-body {
|
| 139 |
+
position: relative;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.quad-cell .cell-body {
|
| 143 |
+
width: 100%;
|
| 144 |
+
overflow: hidden;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.quad-cell .cell-body svg {
|
| 148 |
+
max-width: 100%;
|
| 149 |
+
height: auto;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.line-quad.hovering .lines path.ghost {
|
| 153 |
+
opacity: .25;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.line-quad.hovering .points circle.ghost {
|
| 157 |
+
opacity: .25;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.line-quad.hovering .areas path.ghost {
|
| 161 |
+
opacity: .08;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.line-quad.hovering .legend-bottom .item.ghost {
|
| 165 |
+
opacity: .35;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
/* Tooltip refined styling */
|
| 169 |
.line-quad .d3-tooltip {
|
| 170 |
z-index: 20;
|
| 171 |
backdrop-filter: saturate(1.12) blur(8px);
|
| 172 |
}
|
| 173 |
+
|
| 174 |
.line-quad .d3-tooltip__inner {
|
| 175 |
display: flex;
|
| 176 |
flex-direction: column;
|
| 177 |
gap: 6px;
|
| 178 |
min-width: 220px;
|
| 179 |
}
|
| 180 |
+
|
| 181 |
+
.line-quad .d3-tooltip__inner>div:first-child {
|
| 182 |
font-weight: 800;
|
| 183 |
letter-spacing: 0.1px;
|
| 184 |
margin-bottom: 0;
|
| 185 |
}
|
| 186 |
+
|
| 187 |
+
.line-quad .d3-tooltip__inner>div:nth-child(2) {
|
| 188 |
font-size: 11px;
|
| 189 |
color: var(--muted-color);
|
| 190 |
display: block;
|
|
|
|
| 192 |
margin-bottom: 2px;
|
| 193 |
letter-spacing: 0.1px;
|
| 194 |
}
|
| 195 |
+
|
| 196 |
+
.line-quad .d3-tooltip__inner>div:nth-child(n+3) {
|
| 197 |
padding-top: 6px;
|
| 198 |
border-top: 1px solid var(--border-color);
|
| 199 |
}
|
| 200 |
+
|
| 201 |
.line-quad .d3-tooltip__inner svg {
|
| 202 |
display: inline-block;
|
| 203 |
vertical-align: middle;
|
| 204 |
margin-right: 2px;
|
| 205 |
}
|
| 206 |
+
|
| 207 |
.line-quad .d3-tooltip__inner strong {
|
| 208 |
margin-right: 6px;
|
| 209 |
}
|
| 210 |
+
|
| 211 |
.line-quad .d3-tooltip__color-dot {
|
| 212 |
display: inline-block;
|
| 213 |
width: 12px;
|
|
|
|
| 215 |
border-radius: 3px;
|
| 216 |
border: 1px solid var(--border-color);
|
| 217 |
}
|
| 218 |
+
|
| 219 |
/* Header layout (like d3-line-simple) */
|
| 220 |
.line-quad__header {
|
| 221 |
display: flex;
|
|
|
|
| 225 |
margin: 8px 0 0 0;
|
| 226 |
flex-wrap: wrap;
|
| 227 |
}
|
| 228 |
+
|
| 229 |
.line-quad__header .legend-bottom {
|
| 230 |
display: flex;
|
| 231 |
flex-direction: column;
|
|
|
|
| 234 |
font-size: 12px;
|
| 235 |
color: var(--text-color);
|
| 236 |
}
|
| 237 |
+
|
| 238 |
.line-quad__header .legend-bottom .legend-title {
|
| 239 |
font-size: 12px;
|
| 240 |
font-weight: 700;
|
| 241 |
color: var(--text-color);
|
| 242 |
}
|
| 243 |
+
|
| 244 |
.line-quad__header .legend-bottom .items {
|
| 245 |
display: flex;
|
| 246 |
flex-wrap: wrap;
|
| 247 |
gap: 8px 14px;
|
| 248 |
}
|
| 249 |
+
|
| 250 |
.line-quad__header .legend-bottom .item {
|
| 251 |
display: inline-flex;
|
| 252 |
align-items: center;
|
| 253 |
gap: 6px;
|
| 254 |
white-space: nowrap;
|
| 255 |
}
|
| 256 |
+
|
| 257 |
.line-quad__header .legend-bottom .swatch {
|
| 258 |
width: 14px;
|
| 259 |
height: 14px;
|
|
|
|
| 261 |
border: 1px solid var(--border-color);
|
| 262 |
display: inline-block;
|
| 263 |
}
|
| 264 |
+
|
| 265 |
.line-quad .controls {
|
| 266 |
margin-top: 0;
|
| 267 |
display: flex;
|
|
|
|
| 271 |
width: auto;
|
| 272 |
flex-wrap: wrap;
|
| 273 |
}
|
| 274 |
+
|
| 275 |
.line-quad .controls .control-group {
|
| 276 |
display: flex;
|
| 277 |
flex-direction: column;
|
| 278 |
align-items: flex-start;
|
| 279 |
gap: 6px;
|
| 280 |
}
|
| 281 |
+
|
| 282 |
.line-quad .controls label {
|
| 283 |
font-size: 12px;
|
| 284 |
color: var(--text-color);
|
|
|
|
| 288 |
white-space: nowrap;
|
| 289 |
font-weight: 700;
|
| 290 |
}
|
| 291 |
+
|
| 292 |
.line-quad .controls select {
|
| 293 |
font-size: 12px;
|
| 294 |
padding: 8px 28px 8px 10px;
|
|
|
|
| 305 |
cursor: pointer;
|
| 306 |
transition: border-color .15s ease, box-shadow .15s ease;
|
| 307 |
}
|
| 308 |
+
|
| 309 |
[data-theme="dark"] .line-quad .controls select {
|
| 310 |
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 311 |
}
|
| 312 |
+
|
| 313 |
+
.line-quad .controls select:hover {
|
| 314 |
+
border-color: var(--primary-color);
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.line-quad .controls select:focus {
|
| 318 |
+
border-color: var(--primary-color);
|
| 319 |
+
box-shadow: 0 0 0 3px rgba(232, 137, 171, .25);
|
| 320 |
+
outline: none;
|
| 321 |
+
}
|
| 322 |
</style>
|
| 323 |
<script>
|
| 324 |
(() => {
|
|
|
|
| 347 |
s.addEventListener('load', onReady, { once: true }); if (window.d3) onReady();
|
| 348 |
};
|
| 349 |
|
| 350 |
+
function initRunLine(cell) {
|
| 351 |
const d3 = window.d3;
|
| 352 |
const csvPath = cell.getAttribute('data-csv');
|
| 353 |
const titleText = cell.getAttribute('data-title') || '';
|
|
|
|
| 360 |
|
| 361 |
// Body & SVG
|
| 362 |
const body = document.createElement('div'); body.className = 'cell-body'; cell.appendChild(body);
|
| 363 |
+
const svg = d3.select(body).append('svg').attr('width', '100%').style('display', 'block');
|
| 364 |
const gRoot = svg.append('g');
|
| 365 |
+
const gGrid = gRoot.append('g').attr('class', 'grid');
|
| 366 |
+
const gAxes = gRoot.append('g').attr('class', 'axes');
|
| 367 |
+
const gAreas = gRoot.append('g').attr('class', 'areas');
|
| 368 |
+
const gLines = gRoot.append('g').attr('class', 'lines');
|
| 369 |
+
const gPoints = gRoot.append('g').attr('class', 'points');
|
| 370 |
+
const gHover = gRoot.append('g').attr('class', 'hover');
|
| 371 |
// Removed per-cell legend; using global footer legend
|
| 372 |
|
| 373 |
// Tooltip
|
|
|
|
| 377 |
tip = document.createElement('div');
|
| 378 |
tip.className = 'd3-tooltip';
|
| 379 |
Object.assign(tip.style, {
|
| 380 |
+
position: 'absolute',
|
| 381 |
+
top: '0',
|
| 382 |
+
left: '0',
|
| 383 |
+
transform: 'translate(-9999px,-9999px)',
|
| 384 |
+
pointerEvents: 'none',
|
| 385 |
+
padding: '10px 12px',
|
| 386 |
+
borderRadius: '12px',
|
| 387 |
+
fontSize: '12px',
|
| 388 |
+
lineHeight: '1.35',
|
| 389 |
+
border: '1px solid var(--border-color)',
|
| 390 |
+
background: 'var(--surface-bg)',
|
| 391 |
+
color: 'var(--text-color)',
|
| 392 |
+
boxShadow: '0 8px 32px rgba(0,0,0,.28), 0 2px 8px rgba(0,0,0,.12)',
|
| 393 |
+
opacity: '0',
|
| 394 |
+
transition: 'opacity .12s ease',
|
| 395 |
+
backdropFilter: 'saturate(1.12) blur(8px)'
|
| 396 |
});
|
| 397 |
tipInner = document.createElement('div');
|
| 398 |
tipInner.className = 'd3-tooltip__inner';
|
| 399 |
+
tipInner.style.textAlign = 'left';
|
| 400 |
tip.appendChild(tipInner);
|
| 401 |
cell.appendChild(tip);
|
| 402 |
} else { tipInner = tip.querySelector('.d3-tooltip__inner') || tip; }
|
|
|
|
| 409 |
let isRankStrictFlag = false; let isRankMetricFlag = false; let rankTickMax = 1;
|
| 410 |
let sharedYConfig = null; // { type: 'rank_strict', maxRank } | { type: 'value', min, max }
|
| 411 |
let axisLabelY = 'Value';
|
| 412 |
+
|
| 413 |
// Colors and markers (match original embeds)
|
| 414 |
const getRunColors = (n) => {
|
| 415 |
+
try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch (_) { }
|
| 416 |
+
const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
|
| 417 |
+
return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...(d3.schemeTableau10 || [])].slice(0, n);
|
| 418 |
};
|
| 419 |
const pool = getRunColors(12);
|
| 420 |
+
// Shapes removed: we only use color
|
| 421 |
// Ready signal for async load completion
|
| 422 |
let readyResolve = null;
|
| 423 |
+
const ready = new Promise((res) => { readyResolve = res; });
|
| 424 |
|
| 425 |
// Shared formatter for thousands: 5000 -> 5k, 1500 -> 1.5k (trim .0)
|
| 426 |
const formatK = (v) => {
|
|
|
|
| 433 |
return d3.format('d')(v);
|
| 434 |
};
|
| 435 |
|
| 436 |
+
function updateScales() {
|
| 437 |
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
| 438 |
const axisColor = 'var(--axis-color)';
|
| 439 |
const tickColor = 'var(--tick-color)';
|
|
|
|
| 442 |
const rect = cell.getBoundingClientRect();
|
| 443 |
width = Math.max(1, Math.round(rect && rect.width ? rect.width : (cell.clientWidth || 800)));
|
| 444 |
height = Math.max(280, Math.round(width / 2.3));
|
| 445 |
+
svg.attr('width', width).attr('height', height).attr('viewBox', `0 0 ${width} ${height}`).attr('preserveAspectRatio', 'xMidYMid meet');
|
| 446 |
const innerWidth = width - margin.left - margin.right; const innerHeight = height - margin.top - margin.bottom;
|
| 447 |
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 448 |
xScale.range([0, innerWidth]); yScale.range([innerHeight, 0]);
|
| 449 |
|
| 450 |
// Y ticks
|
| 451 |
let yTicks = [];
|
| 452 |
+
if (isRankStrictFlag) { const maxR = Math.max(1, Math.round(rankTickMax)); for (let v = 1; v <= maxR; v += 1) yTicks.push(v); }
|
| 453 |
else { yTicks = yScale.ticks(6); }
|
| 454 |
|
| 455 |
// Grid
|
| 456 |
gGrid.selectAll('*').remove();
|
| 457 |
gGrid.selectAll('line').data(yTicks).join('line')
|
| 458 |
+
.attr('x1', 0).attr('x2', innerWidth).attr('y1', d => yScale(d)).attr('y2', d => yScale(d))
|
| 459 |
+
.attr('stroke', gridColor).attr('stroke-width', 1).attr('shape-rendering', 'crispEdges');
|
| 460 |
|
| 461 |
// Axes
|
| 462 |
gAxes.selectAll('*').remove();
|
| 463 |
let xAxis = d3.axisBottom(xScale).tickSizeOuter(0); xAxis = xAxis.ticks(8);
|
| 464 |
xAxis = xAxis.tickFormat(formatK);
|
| 465 |
const yAxis = d3.axisLeft(yScale).tickValues(yTicks).tickSizeOuter(0).tickFormat(isRankStrictFlag ? d3.format('d') : d3.format('.2f'));
|
| 466 |
+
gAxes.append('g').attr('transform', `translate(0,${innerHeight})`).call(xAxis).call(g => { g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size', '11px'); });
|
| 467 |
+
gAxes.append('g').call(yAxis).call(g => { g.selectAll('path, line').attr('stroke', axisColor); g.selectAll('text').attr('fill', tickColor).style('font-size', '11px'); });
|
| 468 |
|
| 469 |
// Axis labels
|
| 470 |
gAxes.append('text')
|
|
|
|
| 491 |
return { innerWidth, innerHeight, tickColor };
|
| 492 |
}
|
| 493 |
|
| 494 |
+
function renderMetric(metricKey) {
|
| 495 |
const map = dataByMetric.get(metricKey) || {};
|
| 496 |
const runs = runOrder;
|
| 497 |
let minStep = Infinity, maxStep = -Infinity, maxVal = 0, minVal = Infinity;
|
| 498 |
const isRank = /rank/i.test(metricKey); const isAverage = /average/i.test(metricKey); const isRankStrict = isRank && !isAverage;
|
| 499 |
+
runs.forEach(r => { (map[r] || []).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); maxVal = Math.max(maxVal, v); minVal = Math.min(minVal, v); }); });
|
| 500 |
if (!isFinite(minStep) || !isFinite(maxStep)) return;
|
| 501 |
xScale.domain([minStep, maxStep]);
|
| 502 |
if (sharedYConfig && sharedYConfig.type === 'rank_strict') {
|
| 503 |
+
rankTickMax = Math.max(1, Math.round(sharedYConfig.maxRank || 1));
|
| 504 |
yScale.domain([rankTickMax, 1]);
|
| 505 |
isRankStrictFlag = true;
|
| 506 |
isRankMetricFlag = true;
|
|
|
|
| 524 |
const j = (typeof idx === 'number' ? idx : runs.indexOf(run));
|
| 525 |
return pool[(j >= 0 ? j : 0) % pool.length];
|
| 526 |
};
|
| 527 |
+
const series = runs.map((r, i) => ({ run: r, color: colorForRun(r, i), values: (map[r] || []).slice().sort((a, b) => a.step - b.step).map(pt => isRankStrict ? { step: pt.step, value: Math.round(pt.value), stderr: pt.stderr } : pt) }));
|
| 528 |
|
| 529 |
// zones Β± stderr (mΓ©triques non rank)
|
| 530 |
gAreas.selectAll('*').remove();
|
|
|
|
| 535 |
const upper = withErr.map(d => [xScale(d.step), yScale(d.value + d.stderr)]);
|
| 536 |
const lower = withErr.slice().reverse().map(d => [xScale(d.step), yScale(d.value - d.stderr)]);
|
| 537 |
const coords = upper.concat(lower);
|
| 538 |
+
const pathData = d3.line().x(d => d[0]).y(d => d[1]).curve(d3.curveLinearClosed)(coords);
|
| 539 |
gAreas.append('path')
|
| 540 |
+
.attr('class', 'area')
|
| 541 |
.attr('data-run', s.run)
|
| 542 |
.attr('d', pathData)
|
| 543 |
.attr('fill', s.color)
|
|
|
|
| 548 |
});
|
| 549 |
}
|
| 550 |
|
| 551 |
+
const paths = gLines.selectAll('path.run-line').data(series, d => d.run);
|
| 552 |
paths.enter()
|
| 553 |
.append('path')
|
| 554 |
+
.attr('class', 'run-line')
|
| 555 |
+
.attr('data-run', d => d.run)
|
| 556 |
+
.attr('fill', 'none')
|
| 557 |
.attr('stroke-width', 1)
|
| 558 |
+
.attr('opacity', 0)
|
| 559 |
+
.attr('stroke', d => d.color)
|
| 560 |
+
.attr('d', d => lineGen(d.values))
|
| 561 |
.transition(tChange || undefined)
|
| 562 |
+
.attr('opacity', 0.9);
|
| 563 |
paths
|
| 564 |
.transition(tChange || undefined)
|
| 565 |
+
.attr('stroke', d => d.color)
|
| 566 |
+
.attr('opacity', 0.9)
|
| 567 |
+
.attr('d', d => lineGen(d.values));
|
| 568 |
paths.exit().remove();
|
| 569 |
|
| 570 |
// Draw light point markers at each data sample (subtle)
|
| 571 |
+
const allPoints = series.flatMap(s => s.values.map(v => ({ run: s.run, color: s.color, step: v.step, value: v.value })));
|
| 572 |
+
const ptsSel = gPoints.selectAll('circle.pt').data(allPoints, d => `${d.run}-${d.step}`);
|
| 573 |
+
ptsSel.enter().append('circle').attr('class', 'pt')
|
| 574 |
+
.attr('data-run', d => d.run)
|
| 575 |
.attr('r', 1.5)
|
| 576 |
+
.attr('fill', d => d.color)
|
| 577 |
.attr('fill-opacity', 0.6)
|
| 578 |
.attr('stroke', 'none')
|
| 579 |
+
.attr('cx', d => xScale(d.step))
|
| 580 |
+
.attr('cy', d => yScale(d.value))
|
| 581 |
.merge(ptsSel)
|
| 582 |
+
.attr('fill', d => d.color)
|
| 583 |
.transition(tChange || undefined)
|
| 584 |
.attr('r', 2)
|
| 585 |
+
.attr('cx', d => xScale(d.step))
|
| 586 |
+
.attr('cy', d => yScale(d.value));
|
| 587 |
ptsSel.exit().remove();
|
| 588 |
|
| 589 |
// No per-cell legend content (handled globally)
|
| 590 |
|
| 591 |
// Hover
|
| 592 |
gHover.selectAll('*').remove();
|
| 593 |
+
const overlay = gHover.append('rect').attr('fill', 'transparent').style('cursor', 'crosshair').attr('x', 0).attr('y', 0).attr('width', innerWidth).attr('height', innerHeight);
|
| 594 |
+
const hoverLine = gHover.append('line').style('stroke', 'var(--text-color)').attr('stroke-opacity', 0.25).attr('stroke-width', 1).attr('y1', 0).attr('y2', innerHeight).style('display', 'none');
|
| 595 |
+
const stepSet = new Set(); series.forEach(s => s.values.forEach(v => stepSet.add(v.step))); const steps = Array.from(stepSet).sort((a, b) => a - b);
|
| 596 |
+
function onMove(ev) {
|
| 597 |
+
if (hideTipTimer) { clearTimeout(hideTipTimer); hideTipTimer = null; } const [mx, my] = d3.pointer(ev, overlay.node()); const nearest = steps.reduce((best, s) => Math.abs(s - xScale.invert(mx)) < Math.abs(best - xScale.invert(mx)) ? s : best, steps[0]); const xpx = xScale(nearest); hoverLine.attr('x1', xpx).attr('x2', xpx).style('display', null);
|
| 598 |
let html = `<div><strong>${titleText}</strong></div><div><strong>step</strong> ${formatK(nearest)}</div>`;
|
| 599 |
+
const entries = series.map(s => { const map = new Map(s.values.map(v => [v.step, v])); const pt = map.get(nearest); return { run: s.run, color: s.color, pt }; }).filter(e => e.pt && e.pt.value != null);
|
| 600 |
+
entries.sort((a, b) => (a.pt.value - b.pt.value));
|
| 601 |
+
const fmt = (vv) => (isRankStrictFlag ? d3.format('d')(vv) : (+vv).toFixed(4));
|
| 602 |
entries.forEach(e => {
|
| 603 |
+
const err = (e.pt.stderr != null && isFinite(e.pt.stderr) && e.pt.stderr > 0) ? ` Β± ${fmt(e.pt.stderr)}` : '';
|
| 604 |
html += `<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;"><span class=\"d3-tooltip__color-dot\" style=\"background:${e.color}\"></span><strong>${e.run}</strong><span style=\"margin-left:auto;text-align:right;\">${fmt(e.pt.value)}${err}</span></div>`;
|
| 605 |
});
|
| 606 |
+
tipInner.innerHTML = html; const offsetX = 12, offsetY = 12; tip.style.opacity = '1'; tip.style.transform = `translate(${Math.round(mx + offsetX + margin.left)}px, ${Math.round(my + offsetY + margin.top)}px)`;
|
| 607 |
+
}
|
| 608 |
+
function onLeave() { hideTipTimer = setTimeout(() => { tip.style.opacity = '0'; tip.style.transform = 'translate(-9999px, -9999px)'; hoverLine.style('display', 'none'); }, 100); }
|
| 609 |
overlay.on('mousemove', onMove).on('mouseleave', onLeave);
|
| 610 |
}
|
| 611 |
|
| 612 |
+
async function load() {
|
| 613 |
try {
|
| 614 |
const file = (csvPath || '').split('/').pop();
|
| 615 |
const CANDIDATES = [
|
|
|
|
| 620 |
`../../assets/data/${file}`
|
| 621 |
].filter(Boolean);
|
| 622 |
let text = null;
|
| 623 |
+
for (const p of CANDIDATES) {
|
| 624 |
+
try { const r = await fetch(p, { cache: 'no-cache' }); if (r.ok) { text = await r.text(); break; } } catch (e) { }
|
| 625 |
}
|
| 626 |
if (text == null) throw new Error(`CSV not found: ${file}`);
|
| 627 |
+
const rows = d3.csvParse(text, d => ({ run: (d.run || '').trim(), step: +d.step, metric: (d.metric || '').trim(), value: +d.value, stderr: (d.stderr != null && d.stderr !== '') ? +d.stderr : null }));
|
| 628 |
+
metricList = Array.from(new Set(rows.map(r => r.metric))).sort();
|
| 629 |
+
runList = Array.from(new Set(rows.map(r => r.run))).sort(); runOrder = runList;
|
| 630 |
+
metricList.forEach(m => { const map = {}; runList.forEach(r => map[r] = []); rows.filter(r => r.metric === m).forEach(r => { if (!isNaN(r.step) && !isNaN(r.value)) map[r.run].push({ step: r.step, value: r.value, stderr: r.stderr }); }); dataByMetric.set(m, map); });
|
| 631 |
const preferred = metricList.find(m => m === 'ai2d_exact_match') || metricList.find(m => /average_rank/i.test(m));
|
| 632 |
const def = preferred || metricList[0];
|
| 633 |
renderMetric(def);
|
| 634 |
+
const ro = window.ResizeObserver ? new ResizeObserver(() => renderMetric(def)) : null; if (ro) ro.observe(cell);
|
| 635 |
if (typeof readyResolve === 'function') readyResolve();
|
| 636 |
} catch (e) {
|
| 637 |
const pre = document.createElement('pre'); pre.textContent = 'CSV load error: ' + (e && e.message ? e.message : e);
|
|
|
|
| 640 |
}
|
| 641 |
}
|
| 642 |
load();
|
| 643 |
+
|
| 644 |
return {
|
| 645 |
ready,
|
| 646 |
getMetrics: () => metricList.slice(),
|
|
|
|
| 649 |
const key = m; const map = dataByMetric.get(key) || {}; const runs = runOrder;
|
| 650 |
let maxVal = 0, minVal = Infinity; let minStep = Infinity, maxStep = -Infinity;
|
| 651 |
const isRank = /rank/i.test(key); const isAverage = /average/i.test(key); const isRankStrict = isRank && !isAverage;
|
| 652 |
+
runs.forEach(r => { (map[r] || []).forEach(pt => { const v = isRankStrict ? Math.round(pt.value) : pt.value; minStep = Math.min(minStep, pt.step); maxStep = Math.max(maxStep, pt.step); maxVal = Math.max(maxVal, v); minVal = Math.min(minVal, v); }); });
|
| 653 |
const rankMax = isRank ? Math.max(1, Math.round(maxVal)) : null;
|
| 654 |
return { isRank, isRankStrict, min: maxVal === 0 && minVal === Infinity ? null : minVal, max: maxVal, rankMax };
|
| 655 |
},
|
|
|
|
| 701 |
const select = (headerEl || header).querySelector('.controls select');
|
| 702 |
if (select) {
|
| 703 |
select.innerHTML = '';
|
| 704 |
+
metrics.forEach(m => { const o = document.createElement('option'); o.value = m; o.textContent = prettyMetricLabel(m); select.appendChild(o); });
|
| 705 |
if (def) select.value = def;
|
| 706 |
}
|
| 707 |
|
|
|
|
| 718 |
const max = Math.max(...infos.map(info => info.max));
|
| 719 |
instances.forEach(i => i && typeof i.setSharedY === 'function' && i.setSharedY({ type: 'value', min, max, key: metric }));
|
| 720 |
}
|
| 721 |
+
} catch (_) { }
|
| 722 |
};
|
| 723 |
|
| 724 |
const applyAll = (v) => { computeAndApplySharedY(v); instances.forEach(i => i && typeof i.setMetric === 'function' && i.setMetric(v)); };
|
|
|
|
| 730 |
if (legendItemsHost) {
|
| 731 |
try {
|
| 732 |
const f = '/data/formatting_filters.csv';
|
| 733 |
+
const r = await fetch(f, { cache: 'no-cache' });
|
| 734 |
if (r.ok && window.d3 && window.d3.csvParse) {
|
| 735 |
const txt = await r.text();
|
| 736 |
const rows = window.d3.csvParse(txt);
|
| 737 |
+
const runList = Array.from(new Set(rows.map(row => String(row.run || '').trim()).filter(Boolean))).sort();
|
| 738 |
+
const poolLegend = (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function')
|
| 739 |
? window.ColorPalettes.getColors('categorical', runList.length)
|
| 740 |
+
: (() => { const primary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim(); return [primary, '#4EA5B7', '#E38A42', '#CEC0FA', ...((window.d3 && window.d3.schemeTableau10) ? window.d3.schemeTableau10 : ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ab'])]; })();
|
| 741 |
// Build shared run->color map once
|
| 742 |
SHARED_RUN_COLOR = {};
|
| 743 |
runList.forEach((name, i) => { SHARED_RUN_COLOR[name] = poolLegend[i % poolLegend.length]; });
|
|
|
|
| 749 |
try {
|
| 750 |
const currentMetric = (select && select.value) || def;
|
| 751 |
if (currentMetric) applyAll(currentMetric);
|
| 752 |
+
} catch { }
|
| 753 |
// Legend hover ghosting across all cells
|
| 754 |
legendItemsHost.querySelectorAll('.item').forEach(el => {
|
| 755 |
el.addEventListener('mouseenter', () => {
|
|
|
|
| 773 |
});
|
| 774 |
});
|
| 775 |
}
|
| 776 |
+
} catch { }
|
| 777 |
}
|
| 778 |
})();
|
| 779 |
};
|
| 780 |
|
| 781 |
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
|
| 782 |
})();
|
| 783 |
+
</script>
|
|
|
|
|
|
|
|
|
app/src/content/embeds/d3-matrix.html
CHANGED
|
@@ -1,18 +1,21 @@
|
|
| 1 |
-
<div class="d3-matrix"
|
| 2 |
<style>
|
| 3 |
.d3-matrix {
|
| 4 |
position: relative;
|
| 5 |
}
|
|
|
|
| 6 |
.d3-matrix .panels {
|
| 7 |
display: flex;
|
| 8 |
flex-wrap: wrap;
|
| 9 |
gap: 16px;
|
| 10 |
margin-bottom: 4px;
|
| 11 |
}
|
|
|
|
| 12 |
.d3-matrix .panel {
|
| 13 |
flex: 1 1 320px;
|
| 14 |
min-width: 280px;
|
| 15 |
}
|
|
|
|
| 16 |
.d3-matrix .panel__title {
|
| 17 |
color: var(--text-color);
|
| 18 |
font-size: 12px;
|
|
@@ -20,22 +23,31 @@
|
|
| 20 |
margin: 0 0 6px 0;
|
| 21 |
font-weight: 600;
|
| 22 |
}
|
|
|
|
| 23 |
.d3-matrix .axis-label {
|
| 24 |
fill: var(--text-color);
|
| 25 |
font-size: 11px;
|
| 26 |
font-weight: 700;
|
| 27 |
}
|
|
|
|
| 28 |
.d3-matrix .cell-border {
|
| 29 |
stroke: var(--border-color);
|
| 30 |
stroke-width: 1px;
|
| 31 |
fill: none;
|
| 32 |
}
|
|
|
|
| 33 |
.d3-matrix .cell-text {
|
| 34 |
fill: var(--muted-color);
|
| 35 |
font-size: 11px;
|
| 36 |
pointer-events: none;
|
| 37 |
}
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
</style>
|
| 40 |
<script>
|
| 41 |
(() => {
|
|
@@ -57,7 +69,7 @@
|
|
| 57 |
const bootstrap = () => {
|
| 58 |
const scriptEl = document.currentScript;
|
| 59 |
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 60 |
-
if (!(container && container.classList && container.classList.contains('d3-matrix'))){
|
| 61 |
const cs = Array.from(document.querySelectorAll('.d3-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
|
| 62 |
container = cs[cs.length - 1] || null;
|
| 63 |
}
|
|
@@ -131,30 +143,30 @@
|
|
| 131 |
|
| 132 |
// Demo data (two distinct 10x10 matrices: Baseline vs Improved)
|
| 133 |
// Rows / Columns are generic class labels
|
| 134 |
-
const classes = ['0','1','2','3','4','5','6','7','8','9'];
|
| 135 |
const matrixA = [
|
| 136 |
-
[90,
|
| 137 |
-
[
|
| 138 |
-
[
|
| 139 |
-
[
|
| 140 |
-
[
|
| 141 |
-
[
|
| 142 |
-
[
|
| 143 |
-
[
|
| 144 |
-
[
|
| 145 |
-
[
|
| 146 |
];
|
| 147 |
const matrixB = [
|
| 148 |
-
[94,
|
| 149 |
-
[
|
| 150 |
-
[
|
| 151 |
-
[
|
| 152 |
-
[
|
| 153 |
-
[
|
| 154 |
-
[
|
| 155 |
-
[
|
| 156 |
-
[
|
| 157 |
-
[
|
| 158 |
];
|
| 159 |
|
| 160 |
// Colors: sequential palette via window.ColorPalettes with graceful fallback
|
|
@@ -163,7 +175,7 @@
|
|
| 163 |
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 164 |
return window.ColorPalettes.getColors('sequential', count);
|
| 165 |
}
|
| 166 |
-
} catch (_) {}
|
| 167 |
// Fallback: generate a monochrome scale using the primary color with varying opacity
|
| 168 |
const arr = [];
|
| 169 |
for (let i = 0; i < count; i++) arr.push('var(--primary-color)');
|
|
@@ -176,13 +188,13 @@
|
|
| 176 |
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 177 |
return window.ColorPalettes.getColors('diverging', count);
|
| 178 |
}
|
| 179 |
-
} catch (_) {}
|
| 180 |
-
const steps = Math.max(3, count|0);
|
| 181 |
const arr = [];
|
| 182 |
for (let i = 0; i < steps; i++) {
|
| 183 |
const t = i / (steps - 1);
|
| 184 |
const pct = Math.round(t * 100);
|
| 185 |
-
arr.push(`color-mix(in srgb, #D64545 ${100-pct}%, #3A7BD5 ${pct}%)`);
|
| 186 |
}
|
| 187 |
return arr;
|
| 188 |
};
|
|
@@ -248,19 +260,19 @@
|
|
| 248 |
}
|
| 249 |
|
| 250 |
// Compute a fixed readable text color from a CSS rgb()/rgba() string
|
| 251 |
-
function chooseFixedReadableTextOnBg(bgCss){
|
| 252 |
try {
|
| 253 |
-
const m = String(bgCss||'').match(/rgba?\(([^)]+)\)/);
|
| 254 |
if (!m) return '#0e1116';
|
| 255 |
const parts = m[1].split(',').map(s => parseFloat(s.trim()));
|
| 256 |
const [r, g, b] = parts;
|
| 257 |
// sRGB β relative luminance
|
| 258 |
const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255);
|
| 259 |
-
const linear = srgb.map(c => (c <= 0.03928 ? c/12.92 : Math.pow((c + 0.055)/1.055, 2.4)));
|
| 260 |
-
const L = 0.2126*linear[0] + 0.7152*linear[1] + 0.0722*linear[2];
|
| 261 |
// Threshold ~ 0.5 for readability; darker BG β white text, else near-black
|
| 262 |
return L < 0.5 ? '#ffffff' : '#0e1116';
|
| 263 |
-
} catch(_) { return '#0e1116'; }
|
| 264 |
}
|
| 265 |
|
| 266 |
function render() {
|
|
@@ -319,7 +331,7 @@
|
|
| 319 |
.attr('x', d => x(d.c) + x.bandwidth() / 2)
|
| 320 |
.attr('y', d => y(d.r) + y.bandwidth() / 2)
|
| 321 |
.text(d => `${Math.round(d.value * 100)}`)
|
| 322 |
-
.style('fill', function(d){
|
| 323 |
try {
|
| 324 |
const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
|
| 325 |
const bg = rect ? getComputedStyle(rect).fill : colorA(d.value);
|
|
@@ -382,7 +394,7 @@
|
|
| 382 |
const delta = dataB.data.map(d => ({ r: d.r, c: d.c, count: d.count, value: (d.value - (mapA.get(d.r + '-' + d.c) || 0)) }));
|
| 383 |
// Symmetric domain around 0 (in proportions), express later as pp in labels
|
| 384 |
const maxAbsDelta = Math.max(0.01, d3.max(delta, d => Math.abs(d.value)) || 0.01);
|
| 385 |
-
const colorB = d3.scaleQuantize().domain([-maxAbsDelta, maxAbsDelta]).range(diverging);
|
| 386 |
|
| 387 |
gCellsB.selectAll('rect.cell-bg')
|
| 388 |
.data([0])
|
|
@@ -408,12 +420,12 @@
|
|
| 408 |
.attr('ry', 2)
|
| 409 |
.on('mousemove', (event, d) => {
|
| 410 |
const [px, py] = d3.pointer(event, container);
|
| 411 |
-
const a = dataA.data.find(x => x.r===d.r && x.c===d.c);
|
| 412 |
-
const b = dataB.data.find(x => x.r===d.r && x.c===d.c);
|
| 413 |
const dv = ((b ? b.value : 0) - (a ? a.value : 0)) * 100;
|
| 414 |
tipInner.innerHTML = `<strong>${classes[d.r]}</strong> β <strong>${classes[d.c]}</strong>` +
|
| 415 |
-
`<br/>baseline ${(a ? a.value*100 : 0).toFixed(1)}%` +
|
| 416 |
-
`<br/>improved ${(b ? b.value*100 : 0).toFixed(1)}%` +
|
| 417 |
`<br/>delta ${dv.toFixed(1)} pp`;
|
| 418 |
tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;
|
| 419 |
tip.style.opacity = '1';
|
|
@@ -434,18 +446,18 @@
|
|
| 434 |
.attr('y', d => y(d.r))
|
| 435 |
.attr('width', Math.max(1, x.bandwidth()))
|
| 436 |
.attr('height', Math.max(1, y.bandwidth()))
|
| 437 |
-
.attr('fill', d => colorB(delta.find(x => x.r===d.r && x.c===d.c).value));
|
| 438 |
|
| 439 |
cellsMergedB.select('text')
|
| 440 |
.attr('x', d => x(d.c) + x.bandwidth() / 2)
|
| 441 |
.attr('y', d => y(d.r) + y.bandwidth() / 2)
|
| 442 |
.text(d => {
|
| 443 |
-
const dv = delta.find(x => x.r===d.r && x.c===d.c).value; return `${Math.round(dv * 100)}`;
|
| 444 |
})
|
| 445 |
-
.style('fill', function(d){
|
| 446 |
try {
|
| 447 |
const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
|
| 448 |
-
const dv = delta.find(x => x.r===d.r && x.c===d.c).value;
|
| 449 |
const bg = rect ? getComputedStyle(rect).fill : colorB(dv);
|
| 450 |
return chooseFixedReadableTextOnBg(bg);
|
| 451 |
} catch (_) {
|
|
@@ -509,7 +521,4 @@
|
|
| 509 |
ensureD3(bootstrap);
|
| 510 |
}
|
| 511 |
})();
|
| 512 |
-
</script>
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
|
|
|
| 1 |
+
<div class="d3-matrix"></div>
|
| 2 |
<style>
|
| 3 |
.d3-matrix {
|
| 4 |
position: relative;
|
| 5 |
}
|
| 6 |
+
|
| 7 |
.d3-matrix .panels {
|
| 8 |
display: flex;
|
| 9 |
flex-wrap: wrap;
|
| 10 |
gap: 16px;
|
| 11 |
margin-bottom: 4px;
|
| 12 |
}
|
| 13 |
+
|
| 14 |
.d3-matrix .panel {
|
| 15 |
flex: 1 1 320px;
|
| 16 |
min-width: 280px;
|
| 17 |
}
|
| 18 |
+
|
| 19 |
.d3-matrix .panel__title {
|
| 20 |
color: var(--text-color);
|
| 21 |
font-size: 12px;
|
|
|
|
| 23 |
margin: 0 0 6px 0;
|
| 24 |
font-weight: 600;
|
| 25 |
}
|
| 26 |
+
|
| 27 |
.d3-matrix .axis-label {
|
| 28 |
fill: var(--text-color);
|
| 29 |
font-size: 11px;
|
| 30 |
font-weight: 700;
|
| 31 |
}
|
| 32 |
+
|
| 33 |
.d3-matrix .cell-border {
|
| 34 |
stroke: var(--border-color);
|
| 35 |
stroke-width: 1px;
|
| 36 |
fill: none;
|
| 37 |
}
|
| 38 |
+
|
| 39 |
.d3-matrix .cell-text {
|
| 40 |
fill: var(--muted-color);
|
| 41 |
font-size: 11px;
|
| 42 |
pointer-events: none;
|
| 43 |
}
|
| 44 |
+
|
| 45 |
+
.d3-matrix .chart-card {
|
| 46 |
+
background: var(--surface-bg);
|
| 47 |
+
border: 1px solid var(--border-color);
|
| 48 |
+
border-radius: 10px;
|
| 49 |
+
padding: 8px;
|
| 50 |
+
}
|
| 51 |
</style>
|
| 52 |
<script>
|
| 53 |
(() => {
|
|
|
|
| 69 |
const bootstrap = () => {
|
| 70 |
const scriptEl = document.currentScript;
|
| 71 |
let container = scriptEl ? scriptEl.previousElementSibling : null;
|
| 72 |
+
if (!(container && container.classList && container.classList.contains('d3-matrix'))) {
|
| 73 |
const cs = Array.from(document.querySelectorAll('.d3-matrix')).filter(el => !(el.dataset && el.dataset.mounted === 'true'));
|
| 74 |
container = cs[cs.length - 1] || null;
|
| 75 |
}
|
|
|
|
| 143 |
|
| 144 |
// Demo data (two distinct 10x10 matrices: Baseline vs Improved)
|
| 145 |
// Rows / Columns are generic class labels
|
| 146 |
+
const classes = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
| 147 |
const matrixA = [
|
| 148 |
+
[90, 2, 1, 0, 0, 0, 1, 0, 5, 1],
|
| 149 |
+
[3, 85, 5, 1, 0, 1, 2, 1, 1, 1],
|
| 150 |
+
[1, 6, 70, 10, 4, 4, 1, 1, 1, 2],
|
| 151 |
+
[0, 1, 8, 65, 10, 10, 2, 1, 1, 2],
|
| 152 |
+
[0, 0, 2, 6, 83, 3, 1, 1, 3, 1],
|
| 153 |
+
[0, 1, 2, 12, 4, 70, 5, 2, 2, 2],
|
| 154 |
+
[1, 2, 1, 0, 1, 2, 88, 1, 3, 1],
|
| 155 |
+
[0, 1, 1, 1, 1, 1, 2, 90, 1, 2],
|
| 156 |
+
[6, 2, 2, 4, 6, 3, 3, 2, 70, 2],
|
| 157 |
+
[1, 1, 1, 1, 2, 1, 1, 2, 1, 89]
|
| 158 |
];
|
| 159 |
const matrixB = [
|
| 160 |
+
[94, 1, 0, 0, 0, 0, 1, 0, 3, 1],
|
| 161 |
+
[2, 90, 3, 1, 0, 0, 1, 1, 1, 1],
|
| 162 |
+
[1, 4, 78, 7, 3, 3, 1, 1, 1, 1],
|
| 163 |
+
[0, 1, 5, 74, 7, 8, 1, 1, 1, 2],
|
| 164 |
+
[0, 0, 1, 4, 88, 2, 1, 1, 2, 1],
|
| 165 |
+
[0, 1, 1, 9, 3, 78, 3, 1, 2, 2],
|
| 166 |
+
[1, 1, 1, 0, 1, 1, 91, 1, 2, 1],
|
| 167 |
+
[0, 1, 1, 1, 1, 1, 1, 92, 1, 1],
|
| 168 |
+
[4, 1, 1, 3, 4, 2, 2, 2, 79, 2],
|
| 169 |
+
[1, 1, 1, 1, 2, 1, 1, 1, 1, 90]
|
| 170 |
];
|
| 171 |
|
| 172 |
// Colors: sequential palette via window.ColorPalettes with graceful fallback
|
|
|
|
| 175 |
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 176 |
return window.ColorPalettes.getColors('sequential', count);
|
| 177 |
}
|
| 178 |
+
} catch (_) { }
|
| 179 |
// Fallback: generate a monochrome scale using the primary color with varying opacity
|
| 180 |
const arr = [];
|
| 181 |
for (let i = 0; i < count; i++) arr.push('var(--primary-color)');
|
|
|
|
| 188 |
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 189 |
return window.ColorPalettes.getColors('diverging', count);
|
| 190 |
}
|
| 191 |
+
} catch (_) { }
|
| 192 |
+
const steps = Math.max(3, count | 0);
|
| 193 |
const arr = [];
|
| 194 |
for (let i = 0; i < steps; i++) {
|
| 195 |
const t = i / (steps - 1);
|
| 196 |
const pct = Math.round(t * 100);
|
| 197 |
+
arr.push(`color-mix(in srgb, #D64545 ${100 - pct}%, #3A7BD5 ${pct}%)`);
|
| 198 |
}
|
| 199 |
return arr;
|
| 200 |
};
|
|
|
|
| 260 |
}
|
| 261 |
|
| 262 |
// Compute a fixed readable text color from a CSS rgb()/rgba() string
|
| 263 |
+
function chooseFixedReadableTextOnBg(bgCss) {
|
| 264 |
try {
|
| 265 |
+
const m = String(bgCss || '').match(/rgba?\(([^)]+)\)/);
|
| 266 |
if (!m) return '#0e1116';
|
| 267 |
const parts = m[1].split(',').map(s => parseFloat(s.trim()));
|
| 268 |
const [r, g, b] = parts;
|
| 269 |
// sRGB β relative luminance
|
| 270 |
const srgb = [r, g, b].map(v => Math.max(0, Math.min(255, v)) / 255);
|
| 271 |
+
const linear = srgb.map(c => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
|
| 272 |
+
const L = 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
| 273 |
// Threshold ~ 0.5 for readability; darker BG β white text, else near-black
|
| 274 |
return L < 0.5 ? '#ffffff' : '#0e1116';
|
| 275 |
+
} catch (_) { return '#0e1116'; }
|
| 276 |
}
|
| 277 |
|
| 278 |
function render() {
|
|
|
|
| 331 |
.attr('x', d => x(d.c) + x.bandwidth() / 2)
|
| 332 |
.attr('y', d => y(d.r) + y.bandwidth() / 2)
|
| 333 |
.text(d => `${Math.round(d.value * 100)}`)
|
| 334 |
+
.style('fill', function (d) {
|
| 335 |
try {
|
| 336 |
const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
|
| 337 |
const bg = rect ? getComputedStyle(rect).fill : colorA(d.value);
|
|
|
|
| 394 |
const delta = dataB.data.map(d => ({ r: d.r, c: d.c, count: d.count, value: (d.value - (mapA.get(d.r + '-' + d.c) || 0)) }));
|
| 395 |
// Symmetric domain around 0 (in proportions), express later as pp in labels
|
| 396 |
const maxAbsDelta = Math.max(0.01, d3.max(delta, d => Math.abs(d.value)) || 0.01);
|
| 397 |
+
const colorB = d3.scaleQuantize().domain([-maxAbsDelta / 2, maxAbsDelta]).range(diverging);
|
| 398 |
|
| 399 |
gCellsB.selectAll('rect.cell-bg')
|
| 400 |
.data([0])
|
|
|
|
| 420 |
.attr('ry', 2)
|
| 421 |
.on('mousemove', (event, d) => {
|
| 422 |
const [px, py] = d3.pointer(event, container);
|
| 423 |
+
const a = dataA.data.find(x => x.r === d.r && x.c === d.c);
|
| 424 |
+
const b = dataB.data.find(x => x.r === d.r && x.c === d.c);
|
| 425 |
const dv = ((b ? b.value : 0) - (a ? a.value : 0)) * 100;
|
| 426 |
tipInner.innerHTML = `<strong>${classes[d.r]}</strong> β <strong>${classes[d.c]}</strong>` +
|
| 427 |
+
`<br/>baseline ${(a ? a.value * 100 : 0).toFixed(1)}%` +
|
| 428 |
+
`<br/>improved ${(b ? b.value * 100 : 0).toFixed(1)}%` +
|
| 429 |
`<br/>delta ${dv.toFixed(1)} pp`;
|
| 430 |
tip.style.transform = `translate(${px + 10}px, ${py + 10}px)`;
|
| 431 |
tip.style.opacity = '1';
|
|
|
|
| 446 |
.attr('y', d => y(d.r))
|
| 447 |
.attr('width', Math.max(1, x.bandwidth()))
|
| 448 |
.attr('height', Math.max(1, y.bandwidth()))
|
| 449 |
+
.attr('fill', d => colorB(delta.find(x => x.r === d.r && x.c === d.c).value));
|
| 450 |
|
| 451 |
cellsMergedB.select('text')
|
| 452 |
.attr('x', d => x(d.c) + x.bandwidth() / 2)
|
| 453 |
.attr('y', d => y(d.r) + y.bandwidth() / 2)
|
| 454 |
.text(d => {
|
| 455 |
+
const dv = delta.find(x => x.r === d.r && x.c === d.c).value; return `${Math.round(dv * 100)}`;
|
| 456 |
})
|
| 457 |
+
.style('fill', function (d) {
|
| 458 |
try {
|
| 459 |
const rect = this && this.parentNode ? this.parentNode.querySelector('rect') : null;
|
| 460 |
+
const dv = delta.find(x => x.r === d.r && x.c === d.c).value;
|
| 461 |
const bg = rect ? getComputedStyle(rect).fill : colorB(dv);
|
| 462 |
return chooseFixedReadableTextOnBg(bg);
|
| 463 |
} catch (_) {
|
|
|
|
| 521 |
ensureD3(bootstrap);
|
| 522 |
}
|
| 523 |
})();
|
| 524 |
+
</script>
|
|
|
|
|
|
|
|
|
app/src/content/embeds/d3-neural-network.html
CHANGED
|
@@ -1,33 +1,172 @@
|
|
| 1 |
<div class="d3-neural"></div>
|
| 2 |
<style>
|
| 3 |
-
.d3-neural {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
.d3-neural .
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
@media (max-width: 800px) {
|
| 14 |
-
.d3-neural .panel {
|
|
|
|
|
|
|
|
|
|
| 15 |
.d3-neural .left,
|
| 16 |
-
.d3-neural .right {
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
-
.d3-neural canvas { width: 100%; height: auto; border-radius: 8px; border: 1px solid var(--border-color); background: var(--surface-bg); display:block; }
|
| 20 |
-
.d3-neural .preview28 { display:grid; grid-template-columns: repeat(28, 1fr); gap: 1px; width: 100%; }
|
| 21 |
-
.d3-neural .preview28 span { display:block; aspect-ratio:1/1; border-radius:2px; }
|
| 22 |
-
.d3-neural .legend { font-size: 12px; color: var(--text-color); line-height:1.35; }
|
| 23 |
-
.d3-neural .probs { display:flex; gap:6px; align-items:flex-end; height: 64px; }
|
| 24 |
-
.d3-neural .probs .bar { width: 10px; border-radius:2px 2px 0 0; background: var(--border-color); transition: height .15s ease, background-color .15s ease; }
|
| 25 |
-
.d3-neural .probs .bar.active { background: var(--primary-color); }
|
| 26 |
-
.d3-neural .probs .tick { font-size: 10px; color: var(--muted-color); text-align:center; margin-top: 2px; }
|
| 27 |
-
.d3-neural .canvas-wrap { position: relative; }
|
| 28 |
-
.d3-neural .erase-btn { position: absolute; top: 8px; right: 8px; width: 32px; height: 32px; display:flex; align-items:center; justify-content:center; border: 1px solid var(--border-color); }
|
| 29 |
-
.d3-neural .canvas-hint { position: absolute; top: 8px; left: 12px; font-size: 12px; font-weight: 700; color: rgba(0,0,0,.9); pointer-events: none; transition: opacity .12s ease; }
|
| 30 |
-
|
| 31 |
</style>
|
| 32 |
<script>
|
| 33 |
(() => {
|
|
@@ -40,14 +179,7 @@
|
|
| 40 |
if (window.d3) onReady();
|
| 41 |
};
|
| 42 |
|
| 43 |
-
|
| 44 |
-
if (window.tf && typeof window.tf.tensor === 'function') return cb();
|
| 45 |
-
let s = document.getElementById('tfjs-cdn-script');
|
| 46 |
-
if (!s) { s = document.createElement('script'); s.id = 'tfjs-cdn-script'; s.src = 'https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.20.0/dist/tf.min.js'; document.head.appendChild(s); }
|
| 47 |
-
const onReady = () => { if (window.tf && typeof window.tf.tensor === 'function') cb(); };
|
| 48 |
-
s.addEventListener('load', onReady, { once: true });
|
| 49 |
-
if (window.tf) onReady();
|
| 50 |
-
};
|
| 51 |
|
| 52 |
const bootstrap = () => {
|
| 53 |
const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
|
|
@@ -72,18 +204,18 @@
|
|
| 72 |
const canvas = document.createElement('canvas'); canvas.width = CANVAS_PX; canvas.height = CANVAS_PX;
|
| 73 |
const ctx = canvas.getContext('2d');
|
| 74 |
// init white bg
|
| 75 |
-
ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX);
|
| 76 |
const canvasWrap = document.createElement('div'); canvasWrap.className = 'canvas-wrap';
|
| 77 |
canvasWrap.appendChild(canvas);
|
| 78 |
// Erase icon button (top-right)
|
| 79 |
-
const eraseBtn = document.createElement('button'); eraseBtn.className='erase-btn button--ghost'; eraseBtn.type='button'; eraseBtn.setAttribute('aria-label','Clear');
|
| 80 |
// Hidden until the user interacts with the canvas
|
| 81 |
eraseBtn.style.display = 'none';
|
| 82 |
eraseBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>';
|
| 83 |
eraseBtn.addEventListener('click', () => clearCanvas());
|
| 84 |
canvasWrap.appendChild(eraseBtn);
|
| 85 |
// Hint (top-left)
|
| 86 |
-
const hint = document.createElement('div'); hint.className='canvas-hint'; hint.textContent='Draw a digit here';
|
| 87 |
canvasWrap.appendChild(hint);
|
| 88 |
left.appendChild(canvasWrap);
|
| 89 |
|
|
@@ -94,133 +226,133 @@
|
|
| 94 |
// (prediction panel removed; predictions rendered next to output nodes)
|
| 95 |
|
| 96 |
// SVG network on right
|
| 97 |
-
const svg = d3.select(right).append('svg').attr('width','100%').style('display','block');
|
| 98 |
const defs = svg.append('defs');
|
| 99 |
const gRoot = svg.append('g');
|
| 100 |
-
const gInput = gRoot.append('g').attr('class','input');
|
| 101 |
-
const gInputLinks = gRoot.append('g').attr('class','input-links');
|
| 102 |
-
const gLinks = gRoot.append('g').attr('class','links');
|
| 103 |
-
const gNodes = gRoot.append('g').attr('class','nodes');
|
| 104 |
-
const gLabels = gRoot.append('g').attr('class','labels');
|
| 105 |
-
const gOutText = gRoot.append('g').attr('class','out-probs');
|
| 106 |
|
| 107 |
// Network structure (compact: 8 -> 8 -> 10)
|
| 108 |
const layerSizes = [8, 8, 10];
|
| 109 |
-
const layers = layerSizes.map((n, li)=> Array.from({length:n}, (_, i)=>({ id
|
| 110 |
// Links only between hidden->hidden and hidden->output
|
| 111 |
const links = [];
|
| 112 |
-
for (let i=0;i<layerSizes[0];i++){
|
| 113 |
-
for (let j=0;j<layerSizes[1];j++) links.push({ s:{l:0,i}, t:{l:1,j}, w: (Math.sin(i*17+j*31)+1)/2 });
|
| 114 |
}
|
| 115 |
-
for (let i=0;i<layerSizes[1];i++){
|
| 116 |
-
for (let j=0;j<layerSizes[2];j++) links.push({ s:{l:1,i}, t:{l:2,j}, w: (Math.cos(i*7+j*13)+1)/2 });
|
| 117 |
}
|
| 118 |
|
| 119 |
// Linear classifier: logits = W * feats + b, feats in [0,1]
|
| 120 |
// features: [total, cx, cy, lr, tb, htrans, vtrans, loopiness]
|
| 121 |
const W = [
|
| 122 |
// 0 1 2 3 4 5 6 7
|
| 123 |
-
[
|
| 124 |
-
[-0.2, 0.9, 0.2, 0.8, 0.1, -0.2,
|
| 125 |
-
[
|
| 126 |
-
[
|
| 127 |
-
[
|
| 128 |
-
[
|
| 129 |
-
[
|
| 130 |
-
[
|
| 131 |
-
[
|
| 132 |
-
[
|
| 133 |
];
|
| 134 |
const b = [-0.2, -0.1, -0.05, -0.05, -0.05, -0.05, -0.05, -0.1, -0.15, -0.1];
|
| 135 |
|
| 136 |
-
function computeFeatures(x28){
|
| 137 |
// x28: Float32Array length 784, values in [0,1] (1 = black/ink)
|
| 138 |
-
let sum=0, cx=0, cy=0; const w=28, h=28;
|
| 139 |
const rowSum = new Array(h).fill(0); const colSum = new Array(w).fill(0);
|
| 140 |
-
let hTransitions=0, vTransitions=0;
|
| 141 |
-
for (let y=0;y<h;y++){
|
| 142 |
-
for (let x=0;x<w;x++){
|
| 143 |
-
const v = x28[y*w+x]; sum += v; cx += x*v; cy += y*v; rowSum[y]+=v; colSum[x]+=v;
|
| 144 |
-
if (x>0){ const v0=x28[y*w+(x-1)], v1=v; if ((v0>0.25)!==(v1>0.25)) hTransitions+=1; }
|
| 145 |
-
if (y>0){ const v0=x28[(y-1)*w+x], v1=v; if ((v0>0.25)!==(v1>0.25)) vTransitions+=1; }
|
| 146 |
}
|
| 147 |
}
|
| 148 |
-
const total = sum/(w*h); // [0,1]
|
| 149 |
-
const cxn = sum>1e-6 ? (cx/sum)/(w-1) : 0.5; // [0,1]
|
| 150 |
-
const cyn = sum>1e-6 ? (cy/sum)/(h-1) : 0.5; // [0,1]
|
| 151 |
-
let left=0,right=0,top=0,bottom=0;
|
| 152 |
-
for (let y=0;y<h;y++){ for (let x=0;x<w;x++){ const v=x28[y*w+x]; if (x<w/2) left+=v; else right+=v; if (y<h/2) top+=v; else bottom+=v; }}
|
| 153 |
-
const lr = (right/(right+left+1e-6));
|
| 154 |
-
const tb = (bottom/(bottom+top+1e-6));
|
| 155 |
-
const htn = Math.min(1, hTransitions/(w*h*0.35));
|
| 156 |
-
const vtn = Math.min(1, vTransitions/(w*h*0.35));
|
| 157 |
// Loopiness proxy: ink near perimeter low vs center high
|
| 158 |
-
let perimeter=0, center=0; const m=5;
|
| 159 |
-
for (let y=0;y<h;y++){
|
| 160 |
-
for (let x=0;x<w;x++){
|
| 161 |
-
const v=x28[y*w+x];
|
| 162 |
-
const isBorder = (x<m||x>=w-m||y<m||y>=h-m);
|
| 163 |
-
if (isBorder) perimeter+=v; else center+=v;
|
| 164 |
}
|
| 165 |
}
|
| 166 |
-
const loopiness = Math.min(1, center/(perimeter+center+1e-6)*1.8);
|
| 167 |
return [total, cxn, cyn, lr, tb, htn, vtn, loopiness];
|
| 168 |
}
|
| 169 |
|
| 170 |
-
function softmax(arr){ const m=Math.max(...arr); const ex=arr.map(v=>Math.exp(v-m)); const s=ex.reduce((a,b)=>a+b,0)+1e-12; return ex.map(v=>v/s); }
|
| 171 |
-
function l2norm(a){ return Math.hypot(...a) || 0; }
|
| 172 |
-
function normalize(a){ const n=l2norm(a); return n>0 ? a.map(v=>v/n) : a.slice(); }
|
| 173 |
-
function cosine(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; const na=l2norm(a), nb=l2norm(b)||1; return na>0 ? s/(na*nb) : 0; }
|
| 174 |
|
| 175 |
// MNIST-like normalization: crop to tight bbox, scale into 20x20, center in 28x28
|
| 176 |
-
function normalize28(x28){
|
| 177 |
-
const w=28,h=28, thr=0.2;
|
| 178 |
-
let minX=29,minY=29,maxX
|
| 179 |
-
for (let y=0;y<h;y++){
|
| 180 |
-
for (let x=0;x<w;x++){
|
| 181 |
-
const v = x28[y*w+x];
|
| 182 |
-
if (v>thr){ if (x<minX) minX=x; if (x>maxX) maxX=x; if (y<minY) minY=y; if (y>maxY) maxY=y; }
|
| 183 |
-
sum += v; cx += x*v; cy += y*v;
|
| 184 |
}
|
| 185 |
}
|
| 186 |
-
if (sum < 1e-3 || maxX<0){ return x28; }
|
| 187 |
-
const comX = cx/sum, comY = cy/sum;
|
| 188 |
-
const bw = Math.max(1, maxX-minX+1), bh = Math.max(1, maxY-minY+1);
|
| 189 |
-
const scale = 20/Math.max(bw, bh);
|
| 190 |
-
const out = new Float32Array(w*h);
|
| 191 |
// center of canvas
|
| 192 |
-
const cxOut = (w-1)/2, cyOut = (h-1)/2;
|
| 193 |
-
for (let y=0;y<h;y++){
|
| 194 |
-
for (let x=0;x<w;x++){
|
| 195 |
// map output pixel to source space around COM
|
| 196 |
-
const sx = (x - cxOut)/scale + comX;
|
| 197 |
-
const sy = (y - cyOut)/scale + comY;
|
| 198 |
-
out[y*w+x] = bilinearSample(x28, w, h, sx, sy);
|
| 199 |
}
|
| 200 |
}
|
| 201 |
return out;
|
| 202 |
}
|
| 203 |
-
function bilinearSample(img, w, h, x, y){
|
| 204 |
const x0 = Math.floor(x), y0 = Math.floor(y);
|
| 205 |
-
const x1 = x0+1, y1 = y0+1;
|
| 206 |
const tx = x - x0, ty = y - y0;
|
| 207 |
-
function at(ix,iy){ if (ix<0||iy<0||ix>=w||iy>=h) return 0; return img[iy*w+ix]; }
|
| 208 |
-
const v00 = at(x0,y0), v10 = at(x1,y0), v01 = at(x0,y1), v11 = at(x1,y1);
|
| 209 |
-
const a = v00*(1-tx)+v10*tx; const b = v01*(1-tx)+v11*tx; return a*(1-ty)+b*ty;
|
| 210 |
}
|
| 211 |
// Simple dilation (max-pooling 3x3) to thicken strokes
|
| 212 |
-
function dilate28(x){
|
| 213 |
-
const w=28,h=28; const out=new Float32Array(w*h);
|
| 214 |
-
for (let y=0;y<h;y++){
|
| 215 |
-
for (let x0=0;x0<w;x0++){
|
| 216 |
-
let m=0;
|
| 217 |
-
for (let dy
|
| 218 |
-
for (let dx
|
| 219 |
-
const xx=x0+dx, yy=y+dy; if (xx<0||yy<0||xx>=w||yy>=h) continue;
|
| 220 |
-
const v = x[yy*w+xx]; if (v>m) m=v;
|
| 221 |
}
|
| 222 |
}
|
| 223 |
-
out[y*w+x0]=m;
|
| 224 |
}
|
| 225 |
}
|
| 226 |
return out;
|
|
@@ -228,44 +360,44 @@
|
|
| 228 |
|
| 229 |
// Glyph-based 28x28 prototypes for digits 0-9 (normalized)
|
| 230 |
const protoGlyphs28 = [];
|
| 231 |
-
(function buildGlyphProtos(){
|
| 232 |
const off = document.createElement('canvas'); off.width = CANVAS_PX; off.height = CANVAS_PX;
|
| 233 |
const c = off.getContext('2d');
|
| 234 |
-
for (let d=0; d<10; d++){
|
| 235 |
-
c.fillStyle = '#ffffff'; c.fillRect(0,0,off.width,off.height);
|
| 236 |
-
c.fillStyle = '#000000'; c.textAlign='center'; c.textBaseline='middle';
|
| 237 |
c.font = 'bold 180px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
|
| 238 |
-
c.fillText(String(d), off.width/2, off.height*0.56);
|
| 239 |
-
const src = c.getImageData(0,0,off.width,off.height).data; const block = off.width/28;
|
| 240 |
-
const vec = new Float32Array(28*28);
|
| 241 |
-
for (let gy=0; gy<28; gy++){
|
| 242 |
-
for (let gx=0; gx<28; gx++){
|
| 243 |
-
let acc=0, cnt=0; const x0=Math.floor(gx*block), y0=Math.floor(gy*block);
|
| 244 |
-
for (let yy=y0; yy<y0+block; yy++){
|
| 245 |
-
for (let xx=x0; xx<x0+block; xx++){
|
| 246 |
-
const idx=(yy*off.width+xx)*4; const r=src[idx], g=src[idx+1], b=src[idx+2];
|
| 247 |
-
const gray=(r+g+b)/3/255; acc += (1-gray); cnt++;
|
| 248 |
}
|
| 249 |
}
|
| 250 |
-
vec[gy*28+gx] = acc/(cnt||1);
|
| 251 |
}
|
| 252 |
}
|
| 253 |
const normed = normalize28(vec);
|
| 254 |
-
const n = l2norm(normed)||1; protoGlyphs28.push(normed.map(v=>v/n));
|
| 255 |
}
|
| 256 |
})();
|
| 257 |
-
function dot(a,b){ let s=0; for (let i=0;i<a.length;i++) s+=a[i]*b[i]; return s; }
|
| 258 |
|
| 259 |
// Resize handling and node layout
|
| 260 |
-
let width=640, height=360; const margin = { top: 16, right: 8, bottom: 24, left: 8 };
|
| 261 |
let inputGrid = { cell: 0, x: 0, y: 0, width: 0, height: 0 };
|
| 262 |
-
function layoutNodes(){
|
| 263 |
// Right panel width, and a non-square aspect ratio for clarity
|
| 264 |
width = Math.max(280, Math.round(right.clientWidth || 640));
|
| 265 |
height = Math.max(260, Math.round(width * 0.56));
|
| 266 |
svg.attr('width', width).attr('height', height);
|
| 267 |
// Match canvas height to SVG height so both columns align vertically
|
| 268 |
-
try { canvas.style.height = '100%'; canvasWrap.style.height = height + 'px'; } catch(_) {}
|
| 269 |
const innerW = width - margin.left - margin.right; const innerH = height - margin.top - margin.bottom;
|
| 270 |
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 271 |
// Input grid layout (28x28) at left β cap width to a fraction of innerW
|
|
@@ -273,8 +405,8 @@
|
|
| 273 |
const cellByHeight = Math.floor(innerH / 28);
|
| 274 |
const cellByWidth = Math.floor((innerW * maxGridFrac) / 28);
|
| 275 |
let cell = Math.max(3, Math.min(cellByHeight, cellByWidth));
|
| 276 |
-
let gridH = cell * 28; let gridY = Math.floor((innerH - gridH)/2);
|
| 277 |
-
inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };
|
| 278 |
// Equal horizontal gaps: grid -> L0 -> L1 -> L2
|
| 279 |
const nLayers = layerSizes.length; // 3
|
| 280 |
const rightLabelPad = 36; // smaller pad; use more width for spreading layers
|
|
@@ -283,30 +415,30 @@
|
|
| 283 |
const desiredMinFree = rightLabelPad + nLayers * minGap; // 3 equal gaps
|
| 284 |
if (inputGrid.width + desiredMinFree > innerW) {
|
| 285 |
cell = Math.max(3, Math.floor((innerW - desiredMinFree) / 28));
|
| 286 |
-
gridH = cell * 28; gridY = Math.floor((innerH - gridH)/2);
|
| 287 |
-
inputGrid = { cell, x: 0, y: gridY, width: cell*28, height: gridH };
|
| 288 |
}
|
| 289 |
const gridRight = inputGrid.x + inputGrid.width;
|
| 290 |
const freeW = Math.max(nLayers * minGap, innerW - gridRight - rightLabelPad);
|
| 291 |
const gapX = Math.min(maxGap, Math.max(minGap, Math.floor(freeW / nLayers)));
|
| 292 |
const xs = Array.from({ length: nLayers }, (_, li) => gridRight + gapX * (li + 1));
|
| 293 |
// Y positions evenly spaced per layer
|
| 294 |
-
layers.forEach((nodes, li)=>{
|
| 295 |
const n = nodes.length;
|
| 296 |
if (n <= 1) {
|
| 297 |
-
nodes.forEach((nd)=>{ nd.x = xs[li]; nd.y = innerH/2; });
|
| 298 |
} else {
|
| 299 |
const occupancy = 0.9; // use 90% of vertical space
|
| 300 |
const span = innerH * occupancy;
|
| 301 |
const topPad = (innerH - span) / 2;
|
| 302 |
const spacing = span / (n - 1);
|
| 303 |
-
nodes.forEach((nd, i)=>{ nd.x = xs[li]; nd.y = topPad + i*spacing; });
|
| 304 |
}
|
| 305 |
});
|
| 306 |
}
|
| 307 |
|
| 308 |
-
let lastX28 = new Float32Array(28*28);
|
| 309 |
-
function nodeRadiusForNode(n){
|
| 310 |
const a = Math.max(0, Math.min(1, (n && typeof n.a === 'number') ? n.a : 0));
|
| 311 |
if (n && n.layer === 2) {
|
| 312 |
// Output nodes: variable radius based on activation
|
|
@@ -315,14 +447,14 @@
|
|
| 315 |
// Hidden/feature nodes: variable radius based on activation
|
| 316 |
return 5 + 5 * a; // ~5β10
|
| 317 |
}
|
| 318 |
-
function renderInputGrid(){
|
| 319 |
if (!inputGrid || inputGrid.cell <= 0) return;
|
| 320 |
-
const data = Array.from({ length: 28*28 }, (_, i) => ({ i, v: lastX28[i] || 0 }));
|
| 321 |
-
const sel = gInput.selectAll('rect.input-px').data(data, d=>d.i);
|
| 322 |
const gap = Math.max(1, Math.floor(inputGrid.cell * 0.10));
|
| 323 |
const inner = Math.max(1, inputGrid.cell - gap);
|
| 324 |
const offset = Math.floor(gap / 2);
|
| 325 |
-
sel.enter().append('rect').attr('class','input-px')
|
| 326 |
.attr('width', inner).attr('height', inner)
|
| 327 |
.merge(sel)
|
| 328 |
.attr('x', d => inputGrid.x + (d.i % 28) * inputGrid.cell + offset)
|
|
@@ -338,26 +470,26 @@
|
|
| 338 |
|
| 339 |
// Border around the input grid area
|
| 340 |
const borderSel = gInput.selectAll('rect.input-border').data([0]);
|
| 341 |
-
borderSel.enter().append('rect').attr('class','input-border')
|
| 342 |
-
.attr('fill','none')
|
| 343 |
.attr('rx', 0).attr('ry', 0)
|
| 344 |
-
.attr('stroke','var(--text-color)')
|
| 345 |
.attr('stroke-opacity', 0.25)
|
| 346 |
.attr('stroke-width', 1)
|
| 347 |
.lower()
|
| 348 |
.merge(borderSel)
|
| 349 |
-
.attr('x', inputGrid.x-1)
|
| 350 |
-
.attr('y', inputGrid.y-1)
|
| 351 |
-
.attr('width', inputGrid.width+1)
|
| 352 |
-
.attr('height', inputGrid.height+1);
|
| 353 |
|
| 354 |
// Centered label above the input grid
|
| 355 |
const labelSel = gInput.selectAll('text.input-label').data([0]);
|
| 356 |
-
labelSel.enter().append('text').attr('class','input-label')
|
| 357 |
-
.attr('text-anchor','middle')
|
| 358 |
-
.style('font-size','12px')
|
| 359 |
-
.style('font-weight','700')
|
| 360 |
-
.style('fill','var(--muted-color)')
|
| 361 |
.merge(labelSel)
|
| 362 |
.attr('x', inputGrid.x + inputGrid.width / 2)
|
| 363 |
.attr('y', Math.max(12, inputGrid.y - 10))
|
|
@@ -365,7 +497,7 @@
|
|
| 365 |
}
|
| 366 |
|
| 367 |
// Compute link path between two layered nodes using their current radii
|
| 368 |
-
function computeLinkD(d){
|
| 369 |
const s = layers[d.s.l][d.s.i];
|
| 370 |
const t = layers[d.t.l][d.t.j];
|
| 371 |
if (!s || !t) return '';
|
|
@@ -375,10 +507,10 @@
|
|
| 375 |
const x1 = s.x + rs, y1 = s.y; // right edge of source circle
|
| 376 |
const x2 = t.x - rt, y2 = t.y; // left edge of target circle
|
| 377 |
const dx = (x2 - x1) * 0.45;
|
| 378 |
-
return `M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`;
|
| 379 |
}
|
| 380 |
|
| 381 |
-
function renderInputLinks(){
|
| 382 |
// Draw bundle-like links from input grid right edge to first layer nodes (features)
|
| 383 |
const firstLayer = layers[0];
|
| 384 |
if (!firstLayer || !inputGrid || inputGrid.cell <= 0) { gInputLinks.selectAll('path').remove(); return; }
|
|
@@ -400,20 +532,20 @@
|
|
| 400 |
return { x0, y0, x1, y1, c1x: x0 + dx, c1y: y0, c2x: x1 - dx, c2y: y1, idx };
|
| 401 |
});
|
| 402 |
const sel = gInputLinks.selectAll('path.input-link').data(paths);
|
| 403 |
-
sel.enter().append('path').attr('class','input-link')
|
| 404 |
-
.attr('fill','none')
|
| 405 |
-
.attr('stroke','var(--text-color)')
|
| 406 |
.attr('stroke-opacity', 0.25)
|
| 407 |
.attr('stroke-width', 1)
|
| 408 |
-
.attr('stroke-linecap','round')
|
| 409 |
.merge(sel)
|
| 410 |
.attr('d', d => `M${d.x0},${d.y0} C${d.c1x},${d.c1y} ${d.c2x},${d.c2y} ${d.x1},${d.y1}`)
|
| 411 |
-
.attr('stroke','var(--text-color)');
|
| 412 |
sel.exit().remove();
|
| 413 |
}
|
| 414 |
|
| 415 |
// Recompute input link path on the fly (used when node radii change)
|
| 416 |
-
function computeInputLinkD(idx){
|
| 417 |
const firstLayer = layers[0];
|
| 418 |
const n = firstLayer[idx]; if (!n) return '';
|
| 419 |
const x0 = inputGrid.x + inputGrid.width;
|
|
@@ -433,66 +565,66 @@
|
|
| 433 |
return `M${x0},${y0} C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1}`;
|
| 434 |
}
|
| 435 |
|
| 436 |
-
function renderGraph(showEdges){
|
| 437 |
layoutNodes();
|
| 438 |
renderInputGrid();
|
| 439 |
renderInputLinks();
|
| 440 |
// Nodes
|
| 441 |
const allNodes = layers.flat();
|
| 442 |
-
const nodeSel = gNodes.selectAll('circle.node').data(allNodes, d=>d.id);
|
| 443 |
-
nodeSel.enter().append('circle').attr('class','node')
|
| 444 |
.attr('r', 10)
|
| 445 |
-
.attr('cx', d=>d.x).attr('cy', d=>d.y)
|
| 446 |
-
.attr('fill', d=> d.layer===2 ? 'var(--page-bg)' : 'var(--primary-color)')
|
| 447 |
-
.attr('fill-opacity', d=> d.layer===2 ? 1 : 0.12)
|
| 448 |
-
.attr('stroke', d=> d.layer===2 ? 'var(--border-color)' : 'var(--border-color)')
|
| 449 |
-
.attr('stroke-width',1)
|
| 450 |
-
.attr('stroke-linejoin','round')
|
| 451 |
.merge(nodeSel)
|
| 452 |
-
.attr('cx', d=>d.x).attr('cy', d=>d.y)
|
| 453 |
.attr('opacity', 1);
|
| 454 |
nodeSel.exit().remove();
|
| 455 |
|
| 456 |
// Labels for first hidden layer only (avoid stacking with output probs)
|
| 457 |
const labels = [];
|
| 458 |
-
layers[0].forEach((n,i)=> labels.push({ x:n.x-30, y:n.y+4, txt
|
| 459 |
const labSel = gLabels.selectAll('text').data(labels);
|
| 460 |
labSel.enter().append('text')
|
| 461 |
-
.style('font-size','10px')
|
| 462 |
-
.style('fill','var(--muted-color)')
|
| 463 |
-
.style('paint-order','stroke')
|
| 464 |
-
.style('stroke','var(--page-bg)')
|
| 465 |
-
.style('stroke-width','3px')
|
| 466 |
-
.attr('x', d=>d.x)
|
| 467 |
-
.attr('y', d=>d.y)
|
| 468 |
-
.text(d=>d.txt)
|
| 469 |
.merge(labSel)
|
| 470 |
-
.style('paint-order','stroke')
|
| 471 |
-
.style('stroke','var(--page-bg)')
|
| 472 |
-
.style('stroke-width','5px')
|
| 473 |
-
.attr('x', d=>d.x)
|
| 474 |
-
.attr('y', d=>d.y)
|
| 475 |
-
.text(d=>d.txt);
|
| 476 |
labSel.exit().remove();
|
| 477 |
|
| 478 |
// Links as smooth curves
|
| 479 |
-
const linkSel = gLinks.selectAll('path.link').data(links, d=> `${d.s.l}-${d.s.i}-${d.t.l}-${d.t.j}`);
|
| 480 |
-
linkSel.enter().append('path').attr('class','link')
|
| 481 |
.attr('d', computeLinkD)
|
| 482 |
-
.attr('fill','none')
|
| 483 |
-
.attr('stroke','var(--text-color)')
|
| 484 |
.attr('stroke-opacity', 0.25)
|
| 485 |
-
.attr('stroke-width', d=> 0.5 + d.w*1.2)
|
| 486 |
-
.attr('stroke-linecap','round')
|
| 487 |
.merge(linkSel)
|
| 488 |
.attr('d', computeLinkD)
|
| 489 |
-
.attr('stroke','var(--text-color)')
|
| 490 |
-
.attr('stroke-width', d=> 0.5 + d.w*1.2);
|
| 491 |
linkSel.exit().remove();
|
| 492 |
|
| 493 |
// Ensure output labels remain aligned with the last layer on resize
|
| 494 |
gOutText.selectAll('g.out-label')
|
| 495 |
-
.attr('transform', function(d){
|
| 496 |
if (!d || typeof d.digit !== 'number') return d3.select(this).attr('transform');
|
| 497 |
const n = layers[2][d.digit];
|
| 498 |
if (!n) return d3.select(this).attr('transform');
|
|
@@ -501,73 +633,73 @@
|
|
| 501 |
});
|
| 502 |
// Ensure clip-path circles are updated on resize
|
| 503 |
if (defs) {
|
| 504 |
-
const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d=>d.id);
|
| 505 |
-
const ce = clips.enter().append('clipPath').attr('class','clip-node').attr('clipPathUnits','userSpaceOnUse').attr('id', d
|
| 506 |
ce.append('circle');
|
| 507 |
-
clips.merge(ce).select('circle').attr('cx', d=>d.x).attr('cy', d=>d.y).attr('r', d=>nodeRadiusForNode(d));
|
| 508 |
clips.exit().remove();
|
| 509 |
}
|
| 510 |
}
|
| 511 |
|
| 512 |
-
function setNodeActivations(h1, h2, out){
|
| 513 |
-
layers[0].forEach((n,i)=> n.a = h1[i] || 0);
|
| 514 |
-
layers[1].forEach((n,i)=> n.a = h2[i] || 0);
|
| 515 |
-
layers[2].forEach((n,i)=> n.a = out[i] || 0);
|
| 516 |
// Determine top prediction (for ghosting others)
|
| 517 |
let argmaxIdx = 0; let bestProb = -1;
|
| 518 |
if (Array.isArray(out)) {
|
| 519 |
-
for (let i=0;i<out.length;i++){ if (out[i] > bestProb){ bestProb = out[i]; argmaxIdx = i; } }
|
| 520 |
}
|
| 521 |
// Color/size by activation with smooth transitions
|
| 522 |
gNodes.selectAll('circle.node')
|
| 523 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 524 |
-
.attr('fill', d=> d.layer===2 ? 'var(--page-bg)' : 'var(--primary-color)')
|
| 525 |
-
.attr('fill-opacity', d=> d.layer===2 ? 1 : (0.12 + 0.58*Math.min(1, d.a||0)))
|
| 526 |
.attr('stroke', 'var(--primary-color)')
|
| 527 |
-
.attr('stroke-opacity', d=> (d.layer===2 ? 0.9 : (0.45 + 0.45*Math.min(1, d.a||0))))
|
| 528 |
-
.attr('opacity', d=> 0.55 + 0.45*Math.min(1, d.a||0))
|
| 529 |
-
.attr('r', d=> nodeRadiusForNode(d));
|
| 530 |
// Link opacity by activation flow
|
| 531 |
gLinks.selectAll('path.link')
|
| 532 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 533 |
.attr('d', computeLinkD)
|
| 534 |
-
.attr('stroke','var(--text-color)')
|
| 535 |
-
.attr('stroke-opacity', d=>{
|
| 536 |
const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
|
| 537 |
return Math.min(1, 0.15 + 0.85 * (aS * aT));
|
| 538 |
})
|
| 539 |
-
.attr('stroke-width', d=>{
|
| 540 |
const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
|
| 541 |
-
return 0.6 + 2.2*(aS*aT);
|
| 542 |
});
|
| 543 |
// Theme-aware and activation-aware input links
|
| 544 |
gInputLinks.selectAll('path.input-link')
|
| 545 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 546 |
-
.attr('d', (d)=> computeInputLinkD(d.idx))
|
| 547 |
-
.attr('stroke','var(--text-color)')
|
| 548 |
.attr('stroke-opacity', 0.25)
|
| 549 |
-
.attr('stroke-width', d=> 0.6 + 2.0*(layers[0][d.idx] ? (layers[0][d.idx].a||0) : 0));
|
| 550 |
// Update clip-path circles to match new radii/positions of output nodes
|
| 551 |
if (defs) {
|
| 552 |
-
const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d=>d.id);
|
| 553 |
clips.select('circle')
|
| 554 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 555 |
-
.attr('cx', d=>d.x)
|
| 556 |
-
.attr('cy', d=>d.y)
|
| 557 |
-
.attr('r', d=> nodeRadiusForNode(d));
|
| 558 |
}
|
| 559 |
// Theme-aware input links on updates handled above via transition
|
| 560 |
// Output labels: digit placed to the right of the node
|
| 561 |
-
const outs = layers[2].map((n,i)=>({ x:n.x + nodeRadiusForNode(n) + 8, y:n.y, digit: i, prob: (out[i]||0), isTop: i===argmaxIdx }));
|
| 562 |
-
const gSel = gOutText.selectAll('g.out-label').data(outs, d=>d.digit);
|
| 563 |
-
const gEnter = gSel.enter().append('g').attr('class','out-label');
|
| 564 |
-
gEnter.append('text').attr('class','out-digit')
|
| 565 |
-
.style('font-size','12px').style('font-weight','800').style('fill','var(--text-color)')
|
| 566 |
-
.attr('text-anchor','start').attr('dominant-baseline','middle')
|
| 567 |
-
.style('paint-order','stroke').style('stroke','var(--transparent-page-contrast)').style('stroke-width','3px');
|
| 568 |
const merged = gEnter.merge(gSel)
|
| 569 |
-
.attr('transform', d
|
| 570 |
-
.each(function(d){
|
| 571 |
const sel = d3.select(this);
|
| 572 |
sel.select('text.out-digit')
|
| 573 |
.attr('x', 0).attr('y', 0)
|
|
@@ -581,28 +713,28 @@
|
|
| 581 |
gSel.exit().remove();
|
| 582 |
|
| 583 |
// Output liquid fill using clipPath + rect from bottom
|
| 584 |
-
const rects = gNodes.selectAll('rect.out-liquid').data(layers[2], d=>d.id);
|
| 585 |
-
const rectEnter = rects.enter().append('rect').attr('class','out-liquid')
|
| 586 |
-
.attr('fill','var(--primary-color)')
|
| 587 |
.attr('fill-opacity', 0.55)
|
| 588 |
.attr('clip-path', d => `url(#clip-${d.id})`);
|
| 589 |
rectEnter.merge(rects)
|
| 590 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 591 |
-
.attr('x', d=> d.x - nodeRadiusForNode(d))
|
| 592 |
-
.attr('width', d=> 2 * nodeRadiusForNode(d))
|
| 593 |
-
.attr('y', d=> {
|
| 594 |
const r = nodeRadiusForNode(d);
|
| 595 |
-
const h = 2 * r * Math.max(0, Math.min(1, d.a||0));
|
| 596 |
return d.y + r - h;
|
| 597 |
})
|
| 598 |
-
.attr('height', d=> 2 * nodeRadiusForNode(d) * Math.max(0, Math.min(1, d.a||0)))
|
| 599 |
.attr('fill-opacity', 0.55);
|
| 600 |
rects.exit().remove();
|
| 601 |
}
|
| 602 |
|
| 603 |
// (no separate updateBars; bars are rendered next to nodes)
|
| 604 |
|
| 605 |
-
function runPipeline(){
|
| 606 |
const x28raw = downsample28();
|
| 607 |
const x28 = dilate28(normalize28(x28raw));
|
| 608 |
// Update input grid data
|
|
@@ -615,14 +747,14 @@
|
|
| 615 |
// Hidden 1 = raw features
|
| 616 |
const h1 = feats;
|
| 617 |
// Hidden 2 = simple non-linear mix for visualization only
|
| 618 |
-
const h2 = layers[1].map((_, j)=>{
|
| 619 |
-
let s=0; for (let i=0;i<layers[0].length;i++){ const w = (Math.sin(i*17+j*31)+1)/2 * 0.8 + 0.1; s += w*h1[i]; }
|
| 620 |
-
return Math.tanh(s*0.8);
|
| 621 |
});
|
| 622 |
let prob;
|
| 623 |
-
if (inkMass < 0.03){
|
| 624 |
// Too little ink: return near-uniform distribution
|
| 625 |
-
prob = Array.from({length:10}, ()=> 1/10);
|
| 626 |
} else {
|
| 627 |
// Prefer TFJS model if available
|
| 628 |
const tfProbs = predictTfjs(x28);
|
|
@@ -632,115 +764,85 @@
|
|
| 632 |
// Fallback: rely mostly on glyph similarity
|
| 633 |
const x28n = normalize(x28);
|
| 634 |
const logitsGlyph = protoGlyphs28.map(p => 8.0 * cosine(x28n, p));
|
| 635 |
-
const logitsLinear = W.map((row, k)=> dot(row, h1) + b[k]);
|
| 636 |
-
const logits = logitsGlyph.map((v,k)=> v + 0.2*logitsLinear[k]);
|
| 637 |
prob = softmax(logits);
|
| 638 |
}
|
| 639 |
}
|
| 640 |
-
setNodeActivations(h1, h2.map(v => (v+1)/2), prob);
|
| 641 |
}
|
| 642 |
|
| 643 |
-
function downsample28(){
|
| 644 |
// From canvas (224x224) to 28x28 by average pooling in 8x8 blocks
|
| 645 |
-
const block = CANVAS_PX/28; // 8
|
| 646 |
-
const src = ctx.getImageData(0,0,CANVAS_PX,CANVAS_PX).data;
|
| 647 |
-
const out = new Float32Array(28*28);
|
| 648 |
-
for (let gy=0; gy<28; gy++){
|
| 649 |
-
for (let gx=0; gx<28; gx++){
|
| 650 |
-
let acc=0; let cnt=0;
|
| 651 |
-
const x0 = Math.floor(gx*block), y0 = Math.floor(gy*block);
|
| 652 |
-
for (let y=y0; y<y0+block; y++){
|
| 653 |
-
for (let x=x0; x<x0+block; x++){
|
| 654 |
-
const idx = (y*CANVAS_PX + x)*4; // RGBA
|
| 655 |
-
const r=src[idx], g=src[idx+1], b=src[idx+2];
|
| 656 |
-
const gray = (r+g+b)/3/255; // 1: white, 0: black
|
| 657 |
-
const ink = 1-gray; // 1: ink/black
|
| 658 |
acc += ink; cnt++;
|
| 659 |
}
|
| 660 |
}
|
| 661 |
-
out[gy*28+gx] = acc/(cnt||1);
|
| 662 |
}
|
| 663 |
}
|
| 664 |
return out;
|
| 665 |
}
|
| 666 |
|
| 667 |
-
function clearCanvas(){ ctx.fillStyle = '#ffffff'; ctx.fillRect(0,0,CANVAS_PX,CANVAS_PX); runPipeline(); }
|
| 668 |
|
| 669 |
// Drawing interactions
|
| 670 |
-
let drawing=false; let last=null;
|
| 671 |
-
let hasInteracted=false;
|
| 672 |
const getPos = (ev) => {
|
| 673 |
const rect = canvas.getBoundingClientRect();
|
| 674 |
-
const sx = CANVAS_PX/rect.width; const sy = CANVAS_PX/rect.height;
|
| 675 |
-
const x = (('touches' in ev)? ev.touches[0].clientX : ev.clientX) - rect.left;
|
| 676 |
-
const y = (('touches' in ev)? ev.touches[0].clientY : ev.clientY) - rect.top;
|
| 677 |
-
return { x: x*sx, y: y*sy };
|
| 678 |
};
|
| 679 |
-
function drawTo(p){
|
| 680 |
const size = 24;
|
| 681 |
-
ctx.lineCap='round'; ctx.lineJoin='round'; ctx.strokeStyle='#000000'; ctx.lineWidth=size;
|
| 682 |
if (!last) last = p;
|
| 683 |
ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
| 684 |
last = p; runPipeline();
|
| 685 |
}
|
| 686 |
-
function onDown(ev){
|
| 687 |
-
drawing=true; last=null;
|
| 688 |
-
if (!hasInteracted){ hasInteracted=true; try { eraseBtn.style.display = 'flex'; } catch(_) {} }
|
| 689 |
drawTo(getPos(ev)); ev.preventDefault();
|
| 690 |
}
|
| 691 |
-
function onMove(ev){ if (!drawing) return; drawTo(getPos(ev)); ev.preventDefault(); }
|
| 692 |
-
function onUp(){ drawing=false; last=null; }
|
| 693 |
canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
|
| 694 |
-
canvas.addEventListener('touchstart', onDown, { passive:false }); canvas.addEventListener('touchmove', onMove, { passive:false }); window.addEventListener('touchend', onUp);
|
| 695 |
|
| 696 |
// (erase button handled as overlay)
|
| 697 |
const rerender = () => { renderGraph(true); };
|
| 698 |
if (window.ResizeObserver) {
|
| 699 |
-
const ro = new ResizeObserver(()=>rerender());
|
| 700 |
ro.observe(right);
|
| 701 |
ro.observe(canvas);
|
| 702 |
} else { window.addEventListener('resize', rerender); }
|
| 703 |
|
| 704 |
-
// TFJS model
|
| 705 |
let tfModel = null;
|
| 706 |
const tryLoadModel = async () => {
|
| 707 |
-
|
| 708 |
-
const candidates = [
|
| 709 |
-
// Prefer public path via symlink to assets/data
|
| 710 |
-
'/data/mnist-variant-model.json',
|
| 711 |
-
// Fallbacks to relative copies under content assets (shards must be colocated)
|
| 712 |
-
'./assets/data/mnist-variant-model.json',
|
| 713 |
-
'../assets/data/mnist-variant-model.json',
|
| 714 |
-
'/assets/data/mnist-variant-model.json',
|
| 715 |
-
// Fallback to public TFJS MNIST
|
| 716 |
-
'https://storage.googleapis.com/tfjs-models/tfjs/mnist/model.json'
|
| 717 |
-
];
|
| 718 |
-
for (const u of candidates){
|
| 719 |
-
try { tfModel = await tf.loadLayersModel(u); return; } catch(_) { /* try next */ }
|
| 720 |
-
}
|
| 721 |
tfModel = null;
|
| 722 |
};
|
| 723 |
|
| 724 |
-
function predictTfjs(x28){
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
const t = tf.tidy(()=> tf.tensor(arr, [28,28,1]).expandDims(0));
|
| 728 |
-
try { const y = tfModel.predict(t); const p = y.softmax(); const out = Array.from(p.dataSync()); tf.dispose([y,p,t]); return out; } catch(e){ tf.dispose(t); return null; }
|
| 729 |
-
};
|
| 730 |
-
// Try both orientations and keep the one with higher confidence
|
| 731 |
-
const p1 = run(x28);
|
| 732 |
-
const inv = x28.map(v=>1-v);
|
| 733 |
-
const p2 = run(inv);
|
| 734 |
-
let probs = p1 || p2;
|
| 735 |
-
if (p1 && p2){
|
| 736 |
-
const m1 = Math.max(...p1), m2 = Math.max(...p2);
|
| 737 |
-
probs = m2>m1 ? p2 : p1;
|
| 738 |
-
}
|
| 739 |
-
if (!probs) return null;
|
| 740 |
-
// Normalize output size to 10 classes (pad or slice)
|
| 741 |
-
if (probs.length < 10){ probs = probs.concat(Array(10 - probs.length).fill(0)); }
|
| 742 |
-
if (probs.length > 10){ probs = probs.slice(0,10); }
|
| 743 |
-
return probs;
|
| 744 |
}
|
| 745 |
|
| 746 |
// Initial render
|
|
@@ -751,8 +853,4 @@
|
|
| 751 |
|
| 752 |
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
|
| 753 |
})();
|
| 754 |
-
</script>
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
|
|
|
| 1 |
<div class="d3-neural"></div>
|
| 2 |
<style>
|
| 3 |
+
.d3-neural {
|
| 4 |
+
position: relative;
|
| 5 |
+
width: 100%;
|
| 6 |
+
margin: 0;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
.d3-neural .controls {
|
| 10 |
+
margin-top: 12px;
|
| 11 |
+
display: flex;
|
| 12 |
+
gap: 12px;
|
| 13 |
+
align-items: center;
|
| 14 |
+
flex-wrap: wrap;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.d3-neural .controls label {
|
| 18 |
+
font-size: 12px;
|
| 19 |
+
color: var(--muted-color);
|
| 20 |
+
display: flex;
|
| 21 |
+
align-items: center;
|
| 22 |
+
gap: 8px;
|
| 23 |
+
white-space: nowrap;
|
| 24 |
+
padding: 6px 10px;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.d3-neural .controls input[type="range"] {
|
| 28 |
+
width: 160px;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.d3-neural .panel {
|
| 32 |
+
display: flex;
|
| 33 |
+
gap: 8px;
|
| 34 |
+
align-items: stretch;
|
| 35 |
+
flex-wrap: nowrap;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.d3-neural .left {
|
| 39 |
+
flex: 0 0 33.333%;
|
| 40 |
+
max-width: 33.333%;
|
| 41 |
+
min-width: 160px;
|
| 42 |
+
display: flex;
|
| 43 |
+
flex-direction: column;
|
| 44 |
+
gap: 8px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.d3-neural .right {
|
| 48 |
+
flex: 1 1 66.666%;
|
| 49 |
+
max-width: 66.666%;
|
| 50 |
+
min-width: 280px;
|
| 51 |
+
display: flex;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.d3-neural .right>svg {
|
| 55 |
+
flex: 1 1 auto;
|
| 56 |
+
height: 100%;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.d3-neural .arrow-sep {
|
| 60 |
+
flex: 0 0 18px;
|
| 61 |
+
max-width: 18px;
|
| 62 |
+
display: flex;
|
| 63 |
+
align-items: center;
|
| 64 |
+
justify-content: center;
|
| 65 |
+
color: var(--muted-color);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.d3-neural .arrow-sep svg {
|
| 69 |
+
display: block;
|
| 70 |
+
width: 16px;
|
| 71 |
+
height: 16px;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
@media (max-width: 800px) {
|
| 75 |
+
.d3-neural .panel {
|
| 76 |
+
flex-direction: column;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
.d3-neural .left,
|
| 80 |
+
.d3-neural .right {
|
| 81 |
+
flex: 0 0 100%;
|
| 82 |
+
max-width: 100%;
|
| 83 |
+
min-width: 0;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.d3-neural .arrow-sep {
|
| 87 |
+
display: none;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.d3-neural canvas {
|
| 92 |
+
width: 100%;
|
| 93 |
+
height: auto;
|
| 94 |
+
border-radius: 8px;
|
| 95 |
+
border: 1px solid var(--border-color);
|
| 96 |
+
background: var(--surface-bg);
|
| 97 |
+
display: block;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.d3-neural .preview28 {
|
| 101 |
+
display: grid;
|
| 102 |
+
grid-template-columns: repeat(28, 1fr);
|
| 103 |
+
gap: 1px;
|
| 104 |
+
width: 100%;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.d3-neural .preview28 span {
|
| 108 |
+
display: block;
|
| 109 |
+
aspect-ratio: 1/1;
|
| 110 |
+
border-radius: 2px;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
.d3-neural .legend {
|
| 114 |
+
font-size: 12px;
|
| 115 |
+
color: var(--text-color);
|
| 116 |
+
line-height: 1.35;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.d3-neural .probs {
|
| 120 |
+
display: flex;
|
| 121 |
+
gap: 6px;
|
| 122 |
+
align-items: flex-end;
|
| 123 |
+
height: 64px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.d3-neural .probs .bar {
|
| 127 |
+
width: 10px;
|
| 128 |
+
border-radius: 2px 2px 0 0;
|
| 129 |
+
background: var(--border-color);
|
| 130 |
+
transition: height .15s ease, background-color .15s ease;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.d3-neural .probs .bar.active {
|
| 134 |
+
background: var(--primary-color);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.d3-neural .probs .tick {
|
| 138 |
+
font-size: 10px;
|
| 139 |
+
color: var(--muted-color);
|
| 140 |
+
text-align: center;
|
| 141 |
+
margin-top: 2px;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.d3-neural .canvas-wrap {
|
| 145 |
+
position: relative;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.d3-neural .erase-btn {
|
| 149 |
+
position: absolute;
|
| 150 |
+
top: 8px;
|
| 151 |
+
right: 8px;
|
| 152 |
+
width: 32px;
|
| 153 |
+
height: 32px;
|
| 154 |
+
display: flex;
|
| 155 |
+
align-items: center;
|
| 156 |
+
justify-content: center;
|
| 157 |
+
border: 1px solid var(--border-color);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
.d3-neural .canvas-hint {
|
| 161 |
+
position: absolute;
|
| 162 |
+
top: 8px;
|
| 163 |
+
left: 12px;
|
| 164 |
+
font-size: 12px;
|
| 165 |
+
font-weight: 700;
|
| 166 |
+
color: rgba(0, 0, 0, .9);
|
| 167 |
+
pointer-events: none;
|
| 168 |
+
transition: opacity .12s ease;
|
| 169 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
</style>
|
| 171 |
<script>
|
| 172 |
(() => {
|
|
|
|
| 179 |
if (window.d3) onReady();
|
| 180 |
};
|
| 181 |
|
| 182 |
+
// TensorFlow.js loading removed - not needed for glyph-based fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
const bootstrap = () => {
|
| 185 |
const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
|
|
|
|
| 204 |
const canvas = document.createElement('canvas'); canvas.width = CANVAS_PX; canvas.height = CANVAS_PX;
|
| 205 |
const ctx = canvas.getContext('2d');
|
| 206 |
// init white bg
|
| 207 |
+
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_PX, CANVAS_PX);
|
| 208 |
const canvasWrap = document.createElement('div'); canvasWrap.className = 'canvas-wrap';
|
| 209 |
canvasWrap.appendChild(canvas);
|
| 210 |
// Erase icon button (top-right)
|
| 211 |
+
const eraseBtn = document.createElement('button'); eraseBtn.className = 'erase-btn button--ghost'; eraseBtn.type = 'button'; eraseBtn.setAttribute('aria-label', 'Clear');
|
| 212 |
// Hidden until the user interacts with the canvas
|
| 213 |
eraseBtn.style.display = 'none';
|
| 214 |
eraseBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"></path></svg>';
|
| 215 |
eraseBtn.addEventListener('click', () => clearCanvas());
|
| 216 |
canvasWrap.appendChild(eraseBtn);
|
| 217 |
// Hint (top-left)
|
| 218 |
+
const hint = document.createElement('div'); hint.className = 'canvas-hint'; hint.textContent = 'Draw a digit here';
|
| 219 |
canvasWrap.appendChild(hint);
|
| 220 |
left.appendChild(canvasWrap);
|
| 221 |
|
|
|
|
| 226 |
// (prediction panel removed; predictions rendered next to output nodes)
|
| 227 |
|
| 228 |
// SVG network on right
|
| 229 |
+
const svg = d3.select(right).append('svg').attr('width', '100%').style('display', 'block');
|
| 230 |
const defs = svg.append('defs');
|
| 231 |
const gRoot = svg.append('g');
|
| 232 |
+
const gInput = gRoot.append('g').attr('class', 'input');
|
| 233 |
+
const gInputLinks = gRoot.append('g').attr('class', 'input-links');
|
| 234 |
+
const gLinks = gRoot.append('g').attr('class', 'links');
|
| 235 |
+
const gNodes = gRoot.append('g').attr('class', 'nodes');
|
| 236 |
+
const gLabels = gRoot.append('g').attr('class', 'labels');
|
| 237 |
+
const gOutText = gRoot.append('g').attr('class', 'out-probs');
|
| 238 |
|
| 239 |
// Network structure (compact: 8 -> 8 -> 10)
|
| 240 |
const layerSizes = [8, 8, 10];
|
| 241 |
+
const layers = layerSizes.map((n, li) => Array.from({ length: n }, (_, i) => ({ id: `L${li}N${i}`, layer: li, index: i, a: 0 })));
|
| 242 |
// Links only between hidden->hidden and hidden->output
|
| 243 |
const links = [];
|
| 244 |
+
for (let i = 0; i < layerSizes[0]; i++) {
|
| 245 |
+
for (let j = 0; j < layerSizes[1]; j++) links.push({ s: { l: 0, i }, t: { l: 1, j }, w: (Math.sin(i * 17 + j * 31) + 1) / 2 });
|
| 246 |
}
|
| 247 |
+
for (let i = 0; i < layerSizes[1]; i++) {
|
| 248 |
+
for (let j = 0; j < layerSizes[2]; j++) links.push({ s: { l: 1, i }, t: { l: 2, j }, w: (Math.cos(i * 7 + j * 13) + 1) / 2 });
|
| 249 |
}
|
| 250 |
|
| 251 |
// Linear classifier: logits = W * feats + b, feats in [0,1]
|
| 252 |
// features: [total, cx, cy, lr, tb, htrans, vtrans, loopiness]
|
| 253 |
const W = [
|
| 254 |
// 0 1 2 3 4 5 6 7
|
| 255 |
+
[0.3, 0.0, 0.0, 0.0, 0.0, -0.8, -0.6, 1.2], // 0
|
| 256 |
+
[-0.2, 0.9, 0.2, 0.8, 0.1, -0.2, 0.2, -1.1], // 1
|
| 257 |
+
[0.1, 0.4, 0.2, 0.5, 0.2, 0.9, 0.1, -0.6], // 2
|
| 258 |
+
[0.2, 0.3, 0.2, 0.2, 0.2, 0.9, 0.0, -0.2], // 3
|
| 259 |
+
[0.0, -0.3, 0.2, -0.6, 0.4, 0.2, 0.8, -0.6], // 4
|
| 260 |
+
[0.1, -0.4, 0.2, -0.5, 0.5, 0.9, 0.1, -0.6], // 5
|
| 261 |
+
[0.2, -0.2, 0.6, -0.2, 0.8, -0.3, 0.2, 0.6], // 6
|
| 262 |
+
[0.0, 0.6, -0.2, 0.6, -0.8, 0.6, 0.0, -0.8], // 7
|
| 263 |
+
[0.4, 0.0, 0.0, 0.1, 0.1, 0.6, 0.6, 1.0], // 8
|
| 264 |
+
[0.2, 0.2, -0.6, 0.2, -0.8, 0.2, 0.6, 0.5], // 9
|
| 265 |
];
|
| 266 |
const b = [-0.2, -0.1, -0.05, -0.05, -0.05, -0.05, -0.05, -0.1, -0.15, -0.1];
|
| 267 |
|
| 268 |
+
function computeFeatures(x28) {
|
| 269 |
// x28: Float32Array length 784, values in [0,1] (1 = black/ink)
|
| 270 |
+
let sum = 0, cx = 0, cy = 0; const w = 28, h = 28;
|
| 271 |
const rowSum = new Array(h).fill(0); const colSum = new Array(w).fill(0);
|
| 272 |
+
let hTransitions = 0, vTransitions = 0;
|
| 273 |
+
for (let y = 0; y < h; y++) {
|
| 274 |
+
for (let x = 0; x < w; x++) {
|
| 275 |
+
const v = x28[y * w + x]; sum += v; cx += x * v; cy += y * v; rowSum[y] += v; colSum[x] += v;
|
| 276 |
+
if (x > 0) { const v0 = x28[y * w + (x - 1)], v1 = v; if ((v0 > 0.25) !== (v1 > 0.25)) hTransitions += 1; }
|
| 277 |
+
if (y > 0) { const v0 = x28[(y - 1) * w + x], v1 = v; if ((v0 > 0.25) !== (v1 > 0.25)) vTransitions += 1; }
|
| 278 |
}
|
| 279 |
}
|
| 280 |
+
const total = sum / (w * h); // [0,1]
|
| 281 |
+
const cxn = sum > 1e-6 ? (cx / sum) / (w - 1) : 0.5; // [0,1]
|
| 282 |
+
const cyn = sum > 1e-6 ? (cy / sum) / (h - 1) : 0.5; // [0,1]
|
| 283 |
+
let left = 0, right = 0, top = 0, bottom = 0;
|
| 284 |
+
for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const v = x28[y * w + x]; if (x < w / 2) left += v; else right += v; if (y < h / 2) top += v; else bottom += v; } }
|
| 285 |
+
const lr = (right / (right + left + 1e-6));
|
| 286 |
+
const tb = (bottom / (bottom + top + 1e-6));
|
| 287 |
+
const htn = Math.min(1, hTransitions / (w * h * 0.35));
|
| 288 |
+
const vtn = Math.min(1, vTransitions / (w * h * 0.35));
|
| 289 |
// Loopiness proxy: ink near perimeter low vs center high
|
| 290 |
+
let perimeter = 0, center = 0; const m = 5;
|
| 291 |
+
for (let y = 0; y < h; y++) {
|
| 292 |
+
for (let x = 0; x < w; x++) {
|
| 293 |
+
const v = x28[y * w + x];
|
| 294 |
+
const isBorder = (x < m || x >= w - m || y < m || y >= h - m);
|
| 295 |
+
if (isBorder) perimeter += v; else center += v;
|
| 296 |
}
|
| 297 |
}
|
| 298 |
+
const loopiness = Math.min(1, center / (perimeter + center + 1e-6) * 1.8);
|
| 299 |
return [total, cxn, cyn, lr, tb, htn, vtn, loopiness];
|
| 300 |
}
|
| 301 |
|
| 302 |
+
function softmax(arr) { const m = Math.max(...arr); const ex = arr.map(v => Math.exp(v - m)); const s = ex.reduce((a, b) => a + b, 0) + 1e-12; return ex.map(v => v / s); }
|
| 303 |
+
function l2norm(a) { return Math.hypot(...a) || 0; }
|
| 304 |
+
function normalize(a) { const n = l2norm(a); return n > 0 ? a.map(v => v / n) : a.slice(); }
|
| 305 |
+
function cosine(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; const na = l2norm(a), nb = l2norm(b) || 1; return na > 0 ? s / (na * nb) : 0; }
|
| 306 |
|
| 307 |
// MNIST-like normalization: crop to tight bbox, scale into 20x20, center in 28x28
|
| 308 |
+
function normalize28(x28) {
|
| 309 |
+
const w = 28, h = 28, thr = 0.2;
|
| 310 |
+
let minX = 29, minY = 29, maxX = -1, maxY = -1, sum = 0, cx = 0, cy = 0;
|
| 311 |
+
for (let y = 0; y < h; y++) {
|
| 312 |
+
for (let x = 0; x < w; x++) {
|
| 313 |
+
const v = x28[y * w + x];
|
| 314 |
+
if (v > thr) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; }
|
| 315 |
+
sum += v; cx += x * v; cy += y * v;
|
| 316 |
}
|
| 317 |
}
|
| 318 |
+
if (sum < 1e-3 || maxX < 0) { return x28; }
|
| 319 |
+
const comX = cx / sum, comY = cy / sum;
|
| 320 |
+
const bw = Math.max(1, maxX - minX + 1), bh = Math.max(1, maxY - minY + 1);
|
| 321 |
+
const scale = 20 / Math.max(bw, bh);
|
| 322 |
+
const out = new Float32Array(w * h);
|
| 323 |
// center of canvas
|
| 324 |
+
const cxOut = (w - 1) / 2, cyOut = (h - 1) / 2;
|
| 325 |
+
for (let y = 0; y < h; y++) {
|
| 326 |
+
for (let x = 0; x < w; x++) {
|
| 327 |
// map output pixel to source space around COM
|
| 328 |
+
const sx = (x - cxOut) / scale + comX;
|
| 329 |
+
const sy = (y - cyOut) / scale + comY;
|
| 330 |
+
out[y * w + x] = bilinearSample(x28, w, h, sx, sy);
|
| 331 |
}
|
| 332 |
}
|
| 333 |
return out;
|
| 334 |
}
|
| 335 |
+
function bilinearSample(img, w, h, x, y) {
|
| 336 |
const x0 = Math.floor(x), y0 = Math.floor(y);
|
| 337 |
+
const x1 = x0 + 1, y1 = y0 + 1;
|
| 338 |
const tx = x - x0, ty = y - y0;
|
| 339 |
+
function at(ix, iy) { if (ix < 0 || iy < 0 || ix >= w || iy >= h) return 0; return img[iy * w + ix]; }
|
| 340 |
+
const v00 = at(x0, y0), v10 = at(x1, y0), v01 = at(x0, y1), v11 = at(x1, y1);
|
| 341 |
+
const a = v00 * (1 - tx) + v10 * tx; const b = v01 * (1 - tx) + v11 * tx; return a * (1 - ty) + b * ty;
|
| 342 |
}
|
| 343 |
// Simple dilation (max-pooling 3x3) to thicken strokes
|
| 344 |
+
function dilate28(x) {
|
| 345 |
+
const w = 28, h = 28; const out = new Float32Array(w * h);
|
| 346 |
+
for (let y = 0; y < h; y++) {
|
| 347 |
+
for (let x0 = 0; x0 < w; x0++) {
|
| 348 |
+
let m = 0;
|
| 349 |
+
for (let dy = -1; dy <= 1; dy++) {
|
| 350 |
+
for (let dx = -1; dx <= 1; dx++) {
|
| 351 |
+
const xx = x0 + dx, yy = y + dy; if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
|
| 352 |
+
const v = x[yy * w + xx]; if (v > m) m = v;
|
| 353 |
}
|
| 354 |
}
|
| 355 |
+
out[y * w + x0] = m;
|
| 356 |
}
|
| 357 |
}
|
| 358 |
return out;
|
|
|
|
| 360 |
|
| 361 |
// Glyph-based 28x28 prototypes for digits 0-9 (normalized)
|
| 362 |
const protoGlyphs28 = [];
|
| 363 |
+
(function buildGlyphProtos() {
|
| 364 |
const off = document.createElement('canvas'); off.width = CANVAS_PX; off.height = CANVAS_PX;
|
| 365 |
const c = off.getContext('2d');
|
| 366 |
+
for (let d = 0; d < 10; d++) {
|
| 367 |
+
c.fillStyle = '#ffffff'; c.fillRect(0, 0, off.width, off.height);
|
| 368 |
+
c.fillStyle = '#000000'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
| 369 |
c.font = 'bold 180px system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif';
|
| 370 |
+
c.fillText(String(d), off.width / 2, off.height * 0.56);
|
| 371 |
+
const src = c.getImageData(0, 0, off.width, off.height).data; const block = off.width / 28;
|
| 372 |
+
const vec = new Float32Array(28 * 28);
|
| 373 |
+
for (let gy = 0; gy < 28; gy++) {
|
| 374 |
+
for (let gx = 0; gx < 28; gx++) {
|
| 375 |
+
let acc = 0, cnt = 0; const x0 = Math.floor(gx * block), y0 = Math.floor(gy * block);
|
| 376 |
+
for (let yy = y0; yy < y0 + block; yy++) {
|
| 377 |
+
for (let xx = x0; xx < x0 + block; xx++) {
|
| 378 |
+
const idx = (yy * off.width + xx) * 4; const r = src[idx], g = src[idx + 1], b = src[idx + 2];
|
| 379 |
+
const gray = (r + g + b) / 3 / 255; acc += (1 - gray); cnt++;
|
| 380 |
}
|
| 381 |
}
|
| 382 |
+
vec[gy * 28 + gx] = acc / (cnt || 1);
|
| 383 |
}
|
| 384 |
}
|
| 385 |
const normed = normalize28(vec);
|
| 386 |
+
const n = l2norm(normed) || 1; protoGlyphs28.push(normed.map(v => v / n));
|
| 387 |
}
|
| 388 |
})();
|
| 389 |
+
function dot(a, b) { let s = 0; for (let i = 0; i < a.length; i++) s += a[i] * b[i]; return s; }
|
| 390 |
|
| 391 |
// Resize handling and node layout
|
| 392 |
+
let width = 640, height = 360; const margin = { top: 16, right: 8, bottom: 24, left: 8 };
|
| 393 |
let inputGrid = { cell: 0, x: 0, y: 0, width: 0, height: 0 };
|
| 394 |
+
function layoutNodes() {
|
| 395 |
// Right panel width, and a non-square aspect ratio for clarity
|
| 396 |
width = Math.max(280, Math.round(right.clientWidth || 640));
|
| 397 |
height = Math.max(260, Math.round(width * 0.56));
|
| 398 |
svg.attr('width', width).attr('height', height);
|
| 399 |
// Match canvas height to SVG height so both columns align vertically
|
| 400 |
+
try { canvas.style.height = '100%'; canvasWrap.style.height = height + 'px'; } catch (_) { }
|
| 401 |
const innerW = width - margin.left - margin.right; const innerH = height - margin.top - margin.bottom;
|
| 402 |
gRoot.attr('transform', `translate(${margin.left},${margin.top})`);
|
| 403 |
// Input grid layout (28x28) at left β cap width to a fraction of innerW
|
|
|
|
| 405 |
const cellByHeight = Math.floor(innerH / 28);
|
| 406 |
const cellByWidth = Math.floor((innerW * maxGridFrac) / 28);
|
| 407 |
let cell = Math.max(3, Math.min(cellByHeight, cellByWidth));
|
| 408 |
+
let gridH = cell * 28; let gridY = Math.floor((innerH - gridH) / 2);
|
| 409 |
+
inputGrid = { cell, x: 0, y: gridY, width: cell * 28, height: gridH };
|
| 410 |
// Equal horizontal gaps: grid -> L0 -> L1 -> L2
|
| 411 |
const nLayers = layerSizes.length; // 3
|
| 412 |
const rightLabelPad = 36; // smaller pad; use more width for spreading layers
|
|
|
|
| 415 |
const desiredMinFree = rightLabelPad + nLayers * minGap; // 3 equal gaps
|
| 416 |
if (inputGrid.width + desiredMinFree > innerW) {
|
| 417 |
cell = Math.max(3, Math.floor((innerW - desiredMinFree) / 28));
|
| 418 |
+
gridH = cell * 28; gridY = Math.floor((innerH - gridH) / 2);
|
| 419 |
+
inputGrid = { cell, x: 0, y: gridY, width: cell * 28, height: gridH };
|
| 420 |
}
|
| 421 |
const gridRight = inputGrid.x + inputGrid.width;
|
| 422 |
const freeW = Math.max(nLayers * minGap, innerW - gridRight - rightLabelPad);
|
| 423 |
const gapX = Math.min(maxGap, Math.max(minGap, Math.floor(freeW / nLayers)));
|
| 424 |
const xs = Array.from({ length: nLayers }, (_, li) => gridRight + gapX * (li + 1));
|
| 425 |
// Y positions evenly spaced per layer
|
| 426 |
+
layers.forEach((nodes, li) => {
|
| 427 |
const n = nodes.length;
|
| 428 |
if (n <= 1) {
|
| 429 |
+
nodes.forEach((nd) => { nd.x = xs[li]; nd.y = innerH / 2; });
|
| 430 |
} else {
|
| 431 |
const occupancy = 0.9; // use 90% of vertical space
|
| 432 |
const span = innerH * occupancy;
|
| 433 |
const topPad = (innerH - span) / 2;
|
| 434 |
const spacing = span / (n - 1);
|
| 435 |
+
nodes.forEach((nd, i) => { nd.x = xs[li]; nd.y = topPad + i * spacing; });
|
| 436 |
}
|
| 437 |
});
|
| 438 |
}
|
| 439 |
|
| 440 |
+
let lastX28 = new Float32Array(28 * 28);
|
| 441 |
+
function nodeRadiusForNode(n) {
|
| 442 |
const a = Math.max(0, Math.min(1, (n && typeof n.a === 'number') ? n.a : 0));
|
| 443 |
if (n && n.layer === 2) {
|
| 444 |
// Output nodes: variable radius based on activation
|
|
|
|
| 447 |
// Hidden/feature nodes: variable radius based on activation
|
| 448 |
return 5 + 5 * a; // ~5β10
|
| 449 |
}
|
| 450 |
+
function renderInputGrid() {
|
| 451 |
if (!inputGrid || inputGrid.cell <= 0) return;
|
| 452 |
+
const data = Array.from({ length: 28 * 28 }, (_, i) => ({ i, v: lastX28[i] || 0 }));
|
| 453 |
+
const sel = gInput.selectAll('rect.input-px').data(data, d => d.i);
|
| 454 |
const gap = Math.max(1, Math.floor(inputGrid.cell * 0.10));
|
| 455 |
const inner = Math.max(1, inputGrid.cell - gap);
|
| 456 |
const offset = Math.floor(gap / 2);
|
| 457 |
+
sel.enter().append('rect').attr('class', 'input-px')
|
| 458 |
.attr('width', inner).attr('height', inner)
|
| 459 |
.merge(sel)
|
| 460 |
.attr('x', d => inputGrid.x + (d.i % 28) * inputGrid.cell + offset)
|
|
|
|
| 470 |
|
| 471 |
// Border around the input grid area
|
| 472 |
const borderSel = gInput.selectAll('rect.input-border').data([0]);
|
| 473 |
+
borderSel.enter().append('rect').attr('class', 'input-border')
|
| 474 |
+
.attr('fill', 'none')
|
| 475 |
.attr('rx', 0).attr('ry', 0)
|
| 476 |
+
.attr('stroke', 'var(--text-color)')
|
| 477 |
.attr('stroke-opacity', 0.25)
|
| 478 |
.attr('stroke-width', 1)
|
| 479 |
.lower()
|
| 480 |
.merge(borderSel)
|
| 481 |
+
.attr('x', inputGrid.x - 1)
|
| 482 |
+
.attr('y', inputGrid.y - 1)
|
| 483 |
+
.attr('width', inputGrid.width + 1)
|
| 484 |
+
.attr('height', inputGrid.height + 1);
|
| 485 |
|
| 486 |
// Centered label above the input grid
|
| 487 |
const labelSel = gInput.selectAll('text.input-label').data([0]);
|
| 488 |
+
labelSel.enter().append('text').attr('class', 'input-label')
|
| 489 |
+
.attr('text-anchor', 'middle')
|
| 490 |
+
.style('font-size', '12px')
|
| 491 |
+
.style('font-weight', '700')
|
| 492 |
+
.style('fill', 'var(--muted-color)')
|
| 493 |
.merge(labelSel)
|
| 494 |
.attr('x', inputGrid.x + inputGrid.width / 2)
|
| 495 |
.attr('y', Math.max(12, inputGrid.y - 10))
|
|
|
|
| 497 |
}
|
| 498 |
|
| 499 |
// Compute link path between two layered nodes using their current radii
|
| 500 |
+
function computeLinkD(d) {
|
| 501 |
const s = layers[d.s.l][d.s.i];
|
| 502 |
const t = layers[d.t.l][d.t.j];
|
| 503 |
if (!s || !t) return '';
|
|
|
|
| 507 |
const x1 = s.x + rs, y1 = s.y; // right edge of source circle
|
| 508 |
const x2 = t.x - rt, y2 = t.y; // left edge of target circle
|
| 509 |
const dx = (x2 - x1) * 0.45;
|
| 510 |
+
return `M${x1},${y1} C${x1 + dx},${y1} ${x2 - dx},${y2} ${x2},${y2}`;
|
| 511 |
}
|
| 512 |
|
| 513 |
+
function renderInputLinks() {
|
| 514 |
// Draw bundle-like links from input grid right edge to first layer nodes (features)
|
| 515 |
const firstLayer = layers[0];
|
| 516 |
if (!firstLayer || !inputGrid || inputGrid.cell <= 0) { gInputLinks.selectAll('path').remove(); return; }
|
|
|
|
| 532 |
return { x0, y0, x1, y1, c1x: x0 + dx, c1y: y0, c2x: x1 - dx, c2y: y1, idx };
|
| 533 |
});
|
| 534 |
const sel = gInputLinks.selectAll('path.input-link').data(paths);
|
| 535 |
+
sel.enter().append('path').attr('class', 'input-link')
|
| 536 |
+
.attr('fill', 'none')
|
| 537 |
+
.attr('stroke', 'var(--text-color)')
|
| 538 |
.attr('stroke-opacity', 0.25)
|
| 539 |
.attr('stroke-width', 1)
|
| 540 |
+
.attr('stroke-linecap', 'round')
|
| 541 |
.merge(sel)
|
| 542 |
.attr('d', d => `M${d.x0},${d.y0} C${d.c1x},${d.c1y} ${d.c2x},${d.c2y} ${d.x1},${d.y1}`)
|
| 543 |
+
.attr('stroke', 'var(--text-color)');
|
| 544 |
sel.exit().remove();
|
| 545 |
}
|
| 546 |
|
| 547 |
// Recompute input link path on the fly (used when node radii change)
|
| 548 |
+
function computeInputLinkD(idx) {
|
| 549 |
const firstLayer = layers[0];
|
| 550 |
const n = firstLayer[idx]; if (!n) return '';
|
| 551 |
const x0 = inputGrid.x + inputGrid.width;
|
|
|
|
| 565 |
return `M${x0},${y0} C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1}`;
|
| 566 |
}
|
| 567 |
|
| 568 |
+
function renderGraph(showEdges) {
|
| 569 |
layoutNodes();
|
| 570 |
renderInputGrid();
|
| 571 |
renderInputLinks();
|
| 572 |
// Nodes
|
| 573 |
const allNodes = layers.flat();
|
| 574 |
+
const nodeSel = gNodes.selectAll('circle.node').data(allNodes, d => d.id);
|
| 575 |
+
nodeSel.enter().append('circle').attr('class', 'node')
|
| 576 |
.attr('r', 10)
|
| 577 |
+
.attr('cx', d => d.x).attr('cy', d => d.y)
|
| 578 |
+
.attr('fill', d => d.layer === 2 ? 'var(--page-bg)' : 'var(--primary-color)')
|
| 579 |
+
.attr('fill-opacity', d => d.layer === 2 ? 1 : 0.12)
|
| 580 |
+
.attr('stroke', d => d.layer === 2 ? 'var(--border-color)' : 'var(--border-color)')
|
| 581 |
+
.attr('stroke-width', 1)
|
| 582 |
+
.attr('stroke-linejoin', 'round')
|
| 583 |
.merge(nodeSel)
|
| 584 |
+
.attr('cx', d => d.x).attr('cy', d => d.y)
|
| 585 |
.attr('opacity', 1);
|
| 586 |
nodeSel.exit().remove();
|
| 587 |
|
| 588 |
// Labels for first hidden layer only (avoid stacking with output probs)
|
| 589 |
const labels = [];
|
| 590 |
+
layers[0].forEach((n, i) => labels.push({ x: n.x - 30, y: n.y + 4, txt: `f${i + 1}` }));
|
| 591 |
const labSel = gLabels.selectAll('text').data(labels);
|
| 592 |
labSel.enter().append('text')
|
| 593 |
+
.style('font-size', '10px')
|
| 594 |
+
.style('fill', 'var(--muted-color)')
|
| 595 |
+
.style('paint-order', 'stroke')
|
| 596 |
+
.style('stroke', 'var(--page-bg)')
|
| 597 |
+
.style('stroke-width', '3px')
|
| 598 |
+
.attr('x', d => d.x)
|
| 599 |
+
.attr('y', d => d.y)
|
| 600 |
+
.text(d => d.txt)
|
| 601 |
.merge(labSel)
|
| 602 |
+
.style('paint-order', 'stroke')
|
| 603 |
+
.style('stroke', 'var(--page-bg)')
|
| 604 |
+
.style('stroke-width', '5px')
|
| 605 |
+
.attr('x', d => d.x)
|
| 606 |
+
.attr('y', d => d.y)
|
| 607 |
+
.text(d => d.txt);
|
| 608 |
labSel.exit().remove();
|
| 609 |
|
| 610 |
// Links as smooth curves
|
| 611 |
+
const linkSel = gLinks.selectAll('path.link').data(links, d => `${d.s.l}-${d.s.i}-${d.t.l}-${d.t.j}`);
|
| 612 |
+
linkSel.enter().append('path').attr('class', 'link')
|
| 613 |
.attr('d', computeLinkD)
|
| 614 |
+
.attr('fill', 'none')
|
| 615 |
+
.attr('stroke', 'var(--text-color)')
|
| 616 |
.attr('stroke-opacity', 0.25)
|
| 617 |
+
.attr('stroke-width', d => 0.5 + d.w * 1.2)
|
| 618 |
+
.attr('stroke-linecap', 'round')
|
| 619 |
.merge(linkSel)
|
| 620 |
.attr('d', computeLinkD)
|
| 621 |
+
.attr('stroke', 'var(--text-color)')
|
| 622 |
+
.attr('stroke-width', d => 0.5 + d.w * 1.2);
|
| 623 |
linkSel.exit().remove();
|
| 624 |
|
| 625 |
// Ensure output labels remain aligned with the last layer on resize
|
| 626 |
gOutText.selectAll('g.out-label')
|
| 627 |
+
.attr('transform', function (d) {
|
| 628 |
if (!d || typeof d.digit !== 'number') return d3.select(this).attr('transform');
|
| 629 |
const n = layers[2][d.digit];
|
| 630 |
if (!n) return d3.select(this).attr('transform');
|
|
|
|
| 633 |
});
|
| 634 |
// Ensure clip-path circles are updated on resize
|
| 635 |
if (defs) {
|
| 636 |
+
const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d => d.id);
|
| 637 |
+
const ce = clips.enter().append('clipPath').attr('class', 'clip-node').attr('clipPathUnits', 'userSpaceOnUse').attr('id', d => `clip-${d.id}`);
|
| 638 |
ce.append('circle');
|
| 639 |
+
clips.merge(ce).select('circle').attr('cx', d => d.x).attr('cy', d => d.y).attr('r', d => nodeRadiusForNode(d));
|
| 640 |
clips.exit().remove();
|
| 641 |
}
|
| 642 |
}
|
| 643 |
|
| 644 |
+
function setNodeActivations(h1, h2, out) {
|
| 645 |
+
layers[0].forEach((n, i) => n.a = h1[i] || 0);
|
| 646 |
+
layers[1].forEach((n, i) => n.a = h2[i] || 0);
|
| 647 |
+
layers[2].forEach((n, i) => n.a = out[i] || 0);
|
| 648 |
// Determine top prediction (for ghosting others)
|
| 649 |
let argmaxIdx = 0; let bestProb = -1;
|
| 650 |
if (Array.isArray(out)) {
|
| 651 |
+
for (let i = 0; i < out.length; i++) { if (out[i] > bestProb) { bestProb = out[i]; argmaxIdx = i; } }
|
| 652 |
}
|
| 653 |
// Color/size by activation with smooth transitions
|
| 654 |
gNodes.selectAll('circle.node')
|
| 655 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 656 |
+
.attr('fill', d => d.layer === 2 ? 'var(--page-bg)' : 'var(--primary-color)')
|
| 657 |
+
.attr('fill-opacity', d => d.layer === 2 ? 1 : (0.12 + 0.58 * Math.min(1, d.a || 0)))
|
| 658 |
.attr('stroke', 'var(--primary-color)')
|
| 659 |
+
.attr('stroke-opacity', d => (d.layer === 2 ? 0.9 : (0.45 + 0.45 * Math.min(1, d.a || 0))))
|
| 660 |
+
.attr('opacity', d => 0.55 + 0.45 * Math.min(1, d.a || 0))
|
| 661 |
+
.attr('r', d => nodeRadiusForNode(d));
|
| 662 |
// Link opacity by activation flow
|
| 663 |
gLinks.selectAll('path.link')
|
| 664 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 665 |
.attr('d', computeLinkD)
|
| 666 |
+
.attr('stroke', 'var(--text-color)')
|
| 667 |
+
.attr('stroke-opacity', d => {
|
| 668 |
const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
|
| 669 |
return Math.min(1, 0.15 + 0.85 * (aS * aT));
|
| 670 |
})
|
| 671 |
+
.attr('stroke-width', d => {
|
| 672 |
const aS = layers[d.s.l][d.s.i].a || 0; const aT = layers[d.t.l][d.t.j].a || 0;
|
| 673 |
+
return 0.6 + 2.2 * (aS * aT);
|
| 674 |
});
|
| 675 |
// Theme-aware and activation-aware input links
|
| 676 |
gInputLinks.selectAll('path.input-link')
|
| 677 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 678 |
+
.attr('d', (d) => computeInputLinkD(d.idx))
|
| 679 |
+
.attr('stroke', 'var(--text-color)')
|
| 680 |
.attr('stroke-opacity', 0.25)
|
| 681 |
+
.attr('stroke-width', d => 0.6 + 2.0 * (layers[0][d.idx] ? (layers[0][d.idx].a || 0) : 0));
|
| 682 |
// Update clip-path circles to match new radii/positions of output nodes
|
| 683 |
if (defs) {
|
| 684 |
+
const clips = defs.selectAll('clipPath.clip-node').data(layers[2], d => d.id);
|
| 685 |
clips.select('circle')
|
| 686 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 687 |
+
.attr('cx', d => d.x)
|
| 688 |
+
.attr('cy', d => d.y)
|
| 689 |
+
.attr('r', d => nodeRadiusForNode(d));
|
| 690 |
}
|
| 691 |
// Theme-aware input links on updates handled above via transition
|
| 692 |
// Output labels: digit placed to the right of the node
|
| 693 |
+
const outs = layers[2].map((n, i) => ({ x: n.x + nodeRadiusForNode(n) + 8, y: n.y, digit: i, prob: (out[i] || 0), isTop: i === argmaxIdx }));
|
| 694 |
+
const gSel = gOutText.selectAll('g.out-label').data(outs, d => d.digit);
|
| 695 |
+
const gEnter = gSel.enter().append('g').attr('class', 'out-label');
|
| 696 |
+
gEnter.append('text').attr('class', 'out-digit')
|
| 697 |
+
.style('font-size', '12px').style('font-weight', '800').style('fill', 'var(--text-color)')
|
| 698 |
+
.attr('text-anchor', 'start').attr('dominant-baseline', 'middle')
|
| 699 |
+
.style('paint-order', 'stroke').style('stroke', 'var(--transparent-page-contrast)').style('stroke-width', '3px');
|
| 700 |
const merged = gEnter.merge(gSel)
|
| 701 |
+
.attr('transform', d => `translate(${d.x},${d.y})`)
|
| 702 |
+
.each(function (d) {
|
| 703 |
const sel = d3.select(this);
|
| 704 |
sel.select('text.out-digit')
|
| 705 |
.attr('x', 0).attr('y', 0)
|
|
|
|
| 713 |
gSel.exit().remove();
|
| 714 |
|
| 715 |
// Output liquid fill using clipPath + rect from bottom
|
| 716 |
+
const rects = gNodes.selectAll('rect.out-liquid').data(layers[2], d => d.id);
|
| 717 |
+
const rectEnter = rects.enter().append('rect').attr('class', 'out-liquid')
|
| 718 |
+
.attr('fill', 'var(--primary-color)')
|
| 719 |
.attr('fill-opacity', 0.55)
|
| 720 |
.attr('clip-path', d => `url(#clip-${d.id})`);
|
| 721 |
rectEnter.merge(rects)
|
| 722 |
.transition().duration(180).ease(d3.easeCubicOut)
|
| 723 |
+
.attr('x', d => d.x - nodeRadiusForNode(d))
|
| 724 |
+
.attr('width', d => 2 * nodeRadiusForNode(d))
|
| 725 |
+
.attr('y', d => {
|
| 726 |
const r = nodeRadiusForNode(d);
|
| 727 |
+
const h = 2 * r * Math.max(0, Math.min(1, d.a || 0));
|
| 728 |
return d.y + r - h;
|
| 729 |
})
|
| 730 |
+
.attr('height', d => 2 * nodeRadiusForNode(d) * Math.max(0, Math.min(1, d.a || 0)))
|
| 731 |
.attr('fill-opacity', 0.55);
|
| 732 |
rects.exit().remove();
|
| 733 |
}
|
| 734 |
|
| 735 |
// (no separate updateBars; bars are rendered next to nodes)
|
| 736 |
|
| 737 |
+
function runPipeline() {
|
| 738 |
const x28raw = downsample28();
|
| 739 |
const x28 = dilate28(normalize28(x28raw));
|
| 740 |
// Update input grid data
|
|
|
|
| 747 |
// Hidden 1 = raw features
|
| 748 |
const h1 = feats;
|
| 749 |
// Hidden 2 = simple non-linear mix for visualization only
|
| 750 |
+
const h2 = layers[1].map((_, j) => {
|
| 751 |
+
let s = 0; for (let i = 0; i < layers[0].length; i++) { const w = (Math.sin(i * 17 + j * 31) + 1) / 2 * 0.8 + 0.1; s += w * h1[i]; }
|
| 752 |
+
return Math.tanh(s * 0.8);
|
| 753 |
});
|
| 754 |
let prob;
|
| 755 |
+
if (inkMass < 0.03) {
|
| 756 |
// Too little ink: return near-uniform distribution
|
| 757 |
+
prob = Array.from({ length: 10 }, () => 1 / 10);
|
| 758 |
} else {
|
| 759 |
// Prefer TFJS model if available
|
| 760 |
const tfProbs = predictTfjs(x28);
|
|
|
|
| 764 |
// Fallback: rely mostly on glyph similarity
|
| 765 |
const x28n = normalize(x28);
|
| 766 |
const logitsGlyph = protoGlyphs28.map(p => 8.0 * cosine(x28n, p));
|
| 767 |
+
const logitsLinear = W.map((row, k) => dot(row, h1) + b[k]);
|
| 768 |
+
const logits = logitsGlyph.map((v, k) => v + 0.2 * logitsLinear[k]);
|
| 769 |
prob = softmax(logits);
|
| 770 |
}
|
| 771 |
}
|
| 772 |
+
setNodeActivations(h1, h2.map(v => (v + 1) / 2), prob);
|
| 773 |
}
|
| 774 |
|
| 775 |
+
function downsample28() {
|
| 776 |
// From canvas (224x224) to 28x28 by average pooling in 8x8 blocks
|
| 777 |
+
const block = CANVAS_PX / 28; // 8
|
| 778 |
+
const src = ctx.getImageData(0, 0, CANVAS_PX, CANVAS_PX).data;
|
| 779 |
+
const out = new Float32Array(28 * 28);
|
| 780 |
+
for (let gy = 0; gy < 28; gy++) {
|
| 781 |
+
for (let gx = 0; gx < 28; gx++) {
|
| 782 |
+
let acc = 0; let cnt = 0;
|
| 783 |
+
const x0 = Math.floor(gx * block), y0 = Math.floor(gy * block);
|
| 784 |
+
for (let y = y0; y < y0 + block; y++) {
|
| 785 |
+
for (let x = x0; x < x0 + block; x++) {
|
| 786 |
+
const idx = (y * CANVAS_PX + x) * 4; // RGBA
|
| 787 |
+
const r = src[idx], g = src[idx + 1], b = src[idx + 2];
|
| 788 |
+
const gray = (r + g + b) / 3 / 255; // 1: white, 0: black
|
| 789 |
+
const ink = 1 - gray; // 1: ink/black
|
| 790 |
acc += ink; cnt++;
|
| 791 |
}
|
| 792 |
}
|
| 793 |
+
out[gy * 28 + gx] = acc / (cnt || 1);
|
| 794 |
}
|
| 795 |
}
|
| 796 |
return out;
|
| 797 |
}
|
| 798 |
|
| 799 |
+
function clearCanvas() { ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, CANVAS_PX, CANVAS_PX); runPipeline(); }
|
| 800 |
|
| 801 |
// Drawing interactions
|
| 802 |
+
let drawing = false; let last = null;
|
| 803 |
+
let hasInteracted = false;
|
| 804 |
const getPos = (ev) => {
|
| 805 |
const rect = canvas.getBoundingClientRect();
|
| 806 |
+
const sx = CANVAS_PX / rect.width; const sy = CANVAS_PX / rect.height;
|
| 807 |
+
const x = (('touches' in ev) ? ev.touches[0].clientX : ev.clientX) - rect.left;
|
| 808 |
+
const y = (('touches' in ev) ? ev.touches[0].clientY : ev.clientY) - rect.top;
|
| 809 |
+
return { x: x * sx, y: y * sy };
|
| 810 |
};
|
| 811 |
+
function drawTo(p) {
|
| 812 |
const size = 24;
|
| 813 |
+
ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#000000'; ctx.lineWidth = size;
|
| 814 |
if (!last) last = p;
|
| 815 |
ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
|
| 816 |
last = p; runPipeline();
|
| 817 |
}
|
| 818 |
+
function onDown(ev) {
|
| 819 |
+
drawing = true; last = null;
|
| 820 |
+
if (!hasInteracted) { hasInteracted = true; try { eraseBtn.style.display = 'flex'; } catch (_) { } }
|
| 821 |
drawTo(getPos(ev)); ev.preventDefault();
|
| 822 |
}
|
| 823 |
+
function onMove(ev) { if (!drawing) return; drawTo(getPos(ev)); ev.preventDefault(); }
|
| 824 |
+
function onUp() { drawing = false; last = null; }
|
| 825 |
canvas.addEventListener('mousedown', onDown); canvas.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp);
|
| 826 |
+
canvas.addEventListener('touchstart', onDown, { passive: false }); canvas.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onUp);
|
| 827 |
|
| 828 |
// (erase button handled as overlay)
|
| 829 |
const rerender = () => { renderGraph(true); };
|
| 830 |
if (window.ResizeObserver) {
|
| 831 |
+
const ro = new ResizeObserver(() => rerender());
|
| 832 |
ro.observe(right);
|
| 833 |
ro.observe(canvas);
|
| 834 |
} else { window.addEventListener('resize', rerender); }
|
| 835 |
|
| 836 |
+
// TFJS model disabled - using glyph-based fallback only
|
| 837 |
let tfModel = null;
|
| 838 |
const tryLoadModel = async () => {
|
| 839 |
+
// Model loading disabled to avoid 404 errors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
tfModel = null;
|
| 841 |
};
|
| 842 |
|
| 843 |
+
function predictTfjs(x28) {
|
| 844 |
+
// Always return null to use glyph-based fallback
|
| 845 |
+
return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
}
|
| 847 |
|
| 848 |
// Initial render
|
|
|
|
| 853 |
|
| 854 |
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); } else { ensureD3(bootstrap); }
|
| 855 |
})();
|
| 856 |
+
</script>
|
|
|
|
|
|
|
|
|
|
|
|
app/src/content/embeds/d3-pie-quad.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
| 21 |
.d3-pie-quad.hovering .slice.ghost {
|
| 22 |
opacity: .25;
|
| 23 |
}
|
| 24 |
-
/*
|
| 25 |
.d3-pie-quad .plots-grid {
|
| 26 |
display: flex;
|
| 27 |
flex-wrap: wrap;
|
|
@@ -33,21 +33,21 @@
|
|
| 33 |
margin-right: auto;
|
| 34 |
width: 100%;
|
| 35 |
}
|
| 36 |
-
/*
|
| 37 |
.content-grid .d3-pie-quad .plots-grid { width: 100%; }
|
| 38 |
.content-grid .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 20px)/2); }
|
| 39 |
-
/*
|
| 40 |
.wide .d3-pie-quad .plots-grid,
|
| 41 |
.full-width .d3-pie-quad .plots-grid { width: 100%; }
|
| 42 |
.wide .d3-pie-quad .pie-cell,
|
| 43 |
.full-width .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 60px)/4); }
|
| 44 |
-
/*
|
| 45 |
.content-grid .d3-pie-quad .plots-grid { width: min(740px, 100%); }
|
| 46 |
.d3-pie-quad .pie-cell {
|
| 47 |
display: flex;
|
| 48 |
flex-direction: column;
|
| 49 |
align-items: center;
|
| 50 |
-
flex: 0 0 360px; /* 2
|
| 51 |
}
|
| 52 |
/* 4/2/1 colonnes en fonction de la largeur du parent */
|
| 53 |
/* @container (min-width: 740px) {
|
|
@@ -179,10 +179,10 @@
|
|
| 179 |
const CAPTION_GAP = 36; // espace entre titre et donut
|
| 180 |
const GAP_X = 20; // espace entre colonnes
|
| 181 |
const GAP_Y = 12; // espace entre lignes
|
| 182 |
-
const TOP_OFFSET = 4; //
|
| 183 |
const DONUT_INNER_RATIO = 0.58; // ratio du trou central (0 = pie plein, 0.5 = moitiΓ©)
|
| 184 |
// LEGEND_GAP supprimΓ©: l'espacement est dΓ©sormais gΓ©rΓ© en CSS via .d3-pie-quad .legend { margin-bottom }
|
| 185 |
-
const SVG_VPAD = 16; //
|
| 186 |
|
| 187 |
const updateSize = () => {
|
| 188 |
width = container.clientWidth || 800;
|
|
@@ -199,7 +199,7 @@
|
|
| 199 |
function drawPies(rows){
|
| 200 |
const { innerWidth } = updateSize();
|
| 201 |
|
| 202 |
-
//
|
| 203 |
const categories = Array.from(new Set(rows.map(r => r.eagle_cathegory || 'Unknown'))).sort();
|
| 204 |
const getCatColors = (n) => {
|
| 205 |
try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch(_) {}
|
|
|
|
| 21 |
.d3-pie-quad.hovering .slice.ghost {
|
| 22 |
opacity: .25;
|
| 23 |
}
|
| 24 |
+
/* HTML layout (not JS) for grid and cells */
|
| 25 |
.d3-pie-quad .plots-grid {
|
| 26 |
display: flex;
|
| 27 |
flex-wrap: wrap;
|
|
|
|
| 33 |
margin-right: auto;
|
| 34 |
width: 100%;
|
| 35 |
}
|
| 36 |
+
/* Default (flow ~1280): 2 centered columns */
|
| 37 |
.content-grid .d3-pie-quad .plots-grid { width: 100%; }
|
| 38 |
.content-grid .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 20px)/2); }
|
| 39 |
+
/* In wide wrappers: aim for 4 columns if space allows */
|
| 40 |
.wide .d3-pie-quad .plots-grid,
|
| 41 |
.full-width .d3-pie-quad .plots-grid { width: 100%; }
|
| 42 |
.wide .d3-pie-quad .pie-cell,
|
| 43 |
.full-width .d3-pie-quad .pie-cell { flex: 0 0 calc((100% - 60px)/4); }
|
| 44 |
+
/* Force 2 columns in flow when parent ~1280px */
|
| 45 |
.content-grid .d3-pie-quad .plots-grid { width: min(740px, 100%); }
|
| 46 |
.d3-pie-quad .pie-cell {
|
| 47 |
display: flex;
|
| 48 |
flex-direction: column;
|
| 49 |
align-items: center;
|
| 50 |
+
flex: 0 0 360px; /* 2 fixed columns in flow at 1280px */
|
| 51 |
}
|
| 52 |
/* 4/2/1 colonnes en fonction de la largeur du parent */
|
| 53 |
/* @container (min-width: 740px) {
|
|
|
|
| 179 |
const CAPTION_GAP = 36; // espace entre titre et donut
|
| 180 |
const GAP_X = 20; // espace entre colonnes
|
| 181 |
const GAP_Y = 12; // espace entre lignes
|
| 182 |
+
const TOP_OFFSET = 4; // additional vertical offset to air out the top
|
| 183 |
const DONUT_INNER_RATIO = 0.58; // ratio du trou central (0 = pie plein, 0.5 = moitiΓ©)
|
| 184 |
// LEGEND_GAP supprimΓ©: l'espacement est dΓ©sormais gΓ©rΓ© en CSS via .d3-pie-quad .legend { margin-bottom }
|
| 185 |
+
const SVG_VPAD = 16; // additional vertical padding inside SVGs to avoid cropping
|
| 186 |
|
| 187 |
const updateSize = () => {
|
| 188 |
width = container.clientWidth || 800;
|
|
|
|
| 199 |
function drawPies(rows){
|
| 200 |
const { innerWidth } = updateSize();
|
| 201 |
|
| 202 |
+
// Categories (sorted) + color scale harmonized with banner.html
|
| 203 |
const categories = Array.from(new Set(rows.map(r => r.eagle_cathegory || 'Unknown'))).sort();
|
| 204 |
const getCatColors = (n) => {
|
| 205 |
try { if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') return window.ColorPalettes.getColors('categorical', n); } catch(_) {}
|
app/src/content/embeds/d3-scatter.html
CHANGED
|
@@ -80,7 +80,7 @@
|
|
| 80 |
function getDotStrokeColor(fillColor = null){
|
| 81 |
if (!fillColor) return 'var(--muted-color)';
|
| 82 |
|
| 83 |
-
//
|
| 84 |
let resolvedColor = fillColor;
|
| 85 |
if (fillColor.startsWith('var(')) {
|
| 86 |
const tempEl = document.createElement('div');
|
|
|
|
| 80 |
function getDotStrokeColor(fillColor = null){
|
| 81 |
if (!fillColor) return 'var(--muted-color)';
|
| 82 |
|
| 83 |
+
// Resolve CSS variables to actual colors
|
| 84 |
let resolvedColor = fillColor;
|
| 85 |
if (fillColor.startsWith('var(')) {
|
| 86 |
const tempEl = document.createElement('div');
|
app/src/content/embeds/d3-umap-typography.html
ADDED
|
@@ -0,0 +1,804 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<div class="typography-umap-2d">
|
| 2 |
+
<svg class="main-svg" xmlns="http://www.w3.org/2000/svg">
|
| 3 |
+
<defs id="font-defs"></defs>
|
| 4 |
+
<g class="viewport-group"></g>
|
| 5 |
+
<g class="ui-group"></g>
|
| 6 |
+
</svg>
|
| 7 |
+
<div class="ui-layer"></div>
|
| 8 |
+
</div>
|
| 9 |
+
<style>
|
| 10 |
+
.typography-umap-2d {
|
| 11 |
+
position: relative;
|
| 12 |
+
min-height: 280px;
|
| 13 |
+
max-height: 450px;
|
| 14 |
+
width: 100%;
|
| 15 |
+
overflow: hidden;
|
| 16 |
+
cursor: grab;
|
| 17 |
+
contain: layout style paint;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.main-svg {
|
| 21 |
+
width: 100%;
|
| 22 |
+
height: 100%;
|
| 23 |
+
display: block;
|
| 24 |
+
cursor: grab;
|
| 25 |
+
/* Rendering optimizations */
|
| 26 |
+
shape-rendering: optimizeLegibility;
|
| 27 |
+
text-rendering: optimizeLegibility;
|
| 28 |
+
image-rendering: optimizeLegibility;
|
| 29 |
+
/* GPU layer */
|
| 30 |
+
will-change: transform;
|
| 31 |
+
transform: translateZ(0);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.typography-umap-2d:active {
|
| 35 |
+
cursor: grabbing;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/* Viewport - THE only element that receives transforms */
|
| 39 |
+
.viewport {
|
| 40 |
+
position: absolute;
|
| 41 |
+
top: 0;
|
| 42 |
+
left: 0;
|
| 43 |
+
width: 100%;
|
| 44 |
+
height: 100%;
|
| 45 |
+
will-change: transform;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
/* Font glyphs with expanded hit area */
|
| 49 |
+
.font-glyph-group {
|
| 50 |
+
cursor: pointer;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.font-hit-area {
|
| 54 |
+
cursor: pointer;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
.font-glyph {
|
| 58 |
+
pointer-events: none;
|
| 59 |
+
/* The glyph itself no longer intercepts events */
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/* Native SVG centroid labels */
|
| 63 |
+
.centroid-label {
|
| 64 |
+
font-weight: 700;
|
| 65 |
+
font-size: 14px;
|
| 66 |
+
pointer-events: none;
|
| 67 |
+
paint-order: stroke fill;
|
| 68 |
+
stroke: var(--page-bg);
|
| 69 |
+
stroke-width: 3px;
|
| 70 |
+
stroke-linejoin: round;
|
| 71 |
+
stroke-linecap: round;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
/* UI Layer - static */
|
| 75 |
+
.ui-layer {
|
| 76 |
+
position: absolute;
|
| 77 |
+
top: 0;
|
| 78 |
+
left: 0;
|
| 79 |
+
width: 100%;
|
| 80 |
+
height: 100%;
|
| 81 |
+
pointer-events: none;
|
| 82 |
+
z-index: 100;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* HTML tooltip in foreignObject */
|
| 86 |
+
.svg-tooltip {
|
| 87 |
+
pointer-events: none;
|
| 88 |
+
opacity: 0;
|
| 89 |
+
transition: opacity 0.2s ease;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.tooltip-html {
|
| 93 |
+
background: var(--surface-bg);
|
| 94 |
+
border: 1px solid var(--border-color);
|
| 95 |
+
border-radius: 8px;
|
| 96 |
+
padding: 12px;
|
| 97 |
+
font-size: 12px;
|
| 98 |
+
line-height: 1.4;
|
| 99 |
+
color: var(--text-color);
|
| 100 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 101 |
+
white-space: nowrap;
|
| 102 |
+
font-family: var(--font-sans);
|
| 103 |
+
min-width: 140px;
|
| 104 |
+
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.08));
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.tooltip-title {
|
| 108 |
+
font-weight: 600;
|
| 109 |
+
font-size: 13px;
|
| 110 |
+
margin-bottom: 4px;
|
| 111 |
+
color: var(--text-color);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.tooltip-category {
|
| 115 |
+
font-size: 11px;
|
| 116 |
+
color: var(--muted-color);
|
| 117 |
+
margin-bottom: 2px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Zoom controls */
|
| 121 |
+
.zoom-controls {
|
| 122 |
+
position: absolute;
|
| 123 |
+
top: 10px;
|
| 124 |
+
right: 10px;
|
| 125 |
+
display: flex;
|
| 126 |
+
flex-direction: column;
|
| 127 |
+
gap: 4px;
|
| 128 |
+
pointer-events: all;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.zoom-controls button {
|
| 132 |
+
background: var(--surface-bg);
|
| 133 |
+
border: 1px solid var(--border-color);
|
| 134 |
+
border-radius: 6px;
|
| 135 |
+
width: 32px;
|
| 136 |
+
height: 32px;
|
| 137 |
+
display: flex;
|
| 138 |
+
align-items: center;
|
| 139 |
+
justify-content: center;
|
| 140 |
+
cursor: pointer;
|
| 141 |
+
font-size: 16px;
|
| 142 |
+
font-weight: bold;
|
| 143 |
+
color: var(--text-color);
|
| 144 |
+
transition: all 0.2s ease;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.zoom-controls button:hover {
|
| 148 |
+
background: var(--primary-color);
|
| 149 |
+
color: white;
|
| 150 |
+
transform: scale(1.1);
|
| 151 |
+
}
|
| 152 |
+
</style>
|
| 153 |
+
|
| 154 |
+
<script>
|
| 155 |
+
(() => {
|
| 156 |
+
// Ensure D3 is loaded
|
| 157 |
+
const ensureD3 = (cb) => {
|
| 158 |
+
if (window.d3) return cb();
|
| 159 |
+
const script = document.createElement('script');
|
| 160 |
+
script.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
|
| 161 |
+
script.onload = cb;
|
| 162 |
+
document.head.appendChild(script);
|
| 163 |
+
};
|
| 164 |
+
|
| 165 |
+
const bootstrap = () => {
|
| 166 |
+
// Get container
|
| 167 |
+
const containers = document.querySelectorAll('.typography-umap-2d');
|
| 168 |
+
const container = containers[containers.length - 1];
|
| 169 |
+
if (!container || container.dataset.mounted) return;
|
| 170 |
+
container.dataset.mounted = 'true';
|
| 171 |
+
|
| 172 |
+
// SVG elements - Native architecture
|
| 173 |
+
const mainSvg = container.querySelector('.main-svg');
|
| 174 |
+
let fontDefs = container.querySelector('#font-defs');
|
| 175 |
+
let viewportGroup = container.querySelector('.viewport-group');
|
| 176 |
+
const uiGroup = container.querySelector('.ui-group');
|
| 177 |
+
const uiLayer = container.querySelector('.ui-layer');
|
| 178 |
+
|
| 179 |
+
// Create viewportGroup if necessary (will be used as zoomGroup)
|
| 180 |
+
if (!viewportGroup) {
|
| 181 |
+
viewportGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
| 182 |
+
viewportGroup.className = 'viewport-group';
|
| 183 |
+
mainSvg.appendChild(viewportGroup);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// Check and create fontDefs if necessary
|
| 187 |
+
if (!fontDefs) {
|
| 188 |
+
fontDefs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
| 189 |
+
fontDefs.id = 'font-defs';
|
| 190 |
+
mainSvg.insertBefore(fontDefs, mainSvg.firstChild);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
// State
|
| 194 |
+
let width = 800, height = 500;
|
| 195 |
+
const margin = 20;
|
| 196 |
+
let data = [];
|
| 197 |
+
let visibleGlyphs = new Map();
|
| 198 |
+
let centroids = new Map();
|
| 199 |
+
let transform = d3.zoomIdentity;
|
| 200 |
+
let isZooming = false;
|
| 201 |
+
|
| 202 |
+
// Native SVG tooltip
|
| 203 |
+
let tooltipGroup = null;
|
| 204 |
+
let currentTooltipFont = null;
|
| 205 |
+
let hoverTimeout = null;
|
| 206 |
+
|
| 207 |
+
// Scales
|
| 208 |
+
const x = d3.scaleLinear();
|
| 209 |
+
const y = d3.scaleLinear();
|
| 210 |
+
const color = d3.scaleOrdinal(d3.schemeTableau10);
|
| 211 |
+
|
| 212 |
+
// Typography families
|
| 213 |
+
const families = {
|
| 214 |
+
'serif': 'Serif',
|
| 215 |
+
'sans-serif': 'Sans Serif',
|
| 216 |
+
'monospace': 'Monospace',
|
| 217 |
+
'display': 'Display',
|
| 218 |
+
'handwriting': 'Handwriting',
|
| 219 |
+
'geometric': 'Geometric'
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
// Mapping fonts to sprite IDs
|
| 223 |
+
let fontMapping = {};
|
| 224 |
+
|
| 225 |
+
// Load mapping and inject sprite into defs - SIMPLIFIED APPROACH
|
| 226 |
+
const initSprite = async () => {
|
| 227 |
+
try {
|
| 228 |
+
// Load mapping
|
| 229 |
+
const mappingPaths = ['/data/font-sprite-mapping.json', './assets/data/font-sprite-mapping.json', '../assets/data/font-sprite-mapping.json'];
|
| 230 |
+
let mappingResponse;
|
| 231 |
+
for (const path of mappingPaths) {
|
| 232 |
+
try {
|
| 233 |
+
mappingResponse = await fetch(path);
|
| 234 |
+
if (mappingResponse.ok) break;
|
| 235 |
+
} catch (e) { }
|
| 236 |
+
}
|
| 237 |
+
if (!mappingResponse?.ok) throw new Error('Mapping not found');
|
| 238 |
+
fontMapping = await mappingResponse.json();
|
| 239 |
+
|
| 240 |
+
// Load SVG sprite
|
| 241 |
+
const spritePaths = ['/data/font-sprite.svg', './assets/sprites/font-sprite.svg', '../assets/sprites/font-sprite.svg'];
|
| 242 |
+
let spriteResponse;
|
| 243 |
+
for (const path of spritePaths) {
|
| 244 |
+
try {
|
| 245 |
+
spriteResponse = await fetch(path);
|
| 246 |
+
if (spriteResponse.ok) break;
|
| 247 |
+
} catch (e) { }
|
| 248 |
+
}
|
| 249 |
+
if (!spriteResponse?.ok) throw new Error('Sprite not found');
|
| 250 |
+
const spriteContent = await spriteResponse.text();
|
| 251 |
+
|
| 252 |
+
// SIMPLIFIED APPROACH: Inject complete sprite at beginning of document
|
| 253 |
+
// This makes symbols available globally via <use>
|
| 254 |
+
if (!document.getElementById('global-font-sprite')) {
|
| 255 |
+
const spriteContainer = document.createElement('div');
|
| 256 |
+
spriteContainer.id = 'global-font-sprite';
|
| 257 |
+
spriteContainer.innerHTML = spriteContent;
|
| 258 |
+
spriteContainer.style.display = 'none';
|
| 259 |
+
spriteContainer.style.position = 'absolute';
|
| 260 |
+
spriteContainer.style.width = '0';
|
| 261 |
+
spriteContainer.style.height = '0';
|
| 262 |
+
document.body.insertBefore(spriteContainer, document.body.firstChild);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
} catch (error) {
|
| 266 |
+
console.error('Sprite loading error:', error);
|
| 267 |
+
}
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
const getFontSymbolId = (fontName) => {
|
| 271 |
+
const mapped = fontMapping[fontName];
|
| 272 |
+
if (mapped) return mapped;
|
| 273 |
+
|
| 274 |
+
// Fallback: generate ID from name
|
| 275 |
+
return fontName.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_]/g, '').toLowerCase() + '_a';
|
| 276 |
+
};
|
| 277 |
+
|
| 278 |
+
const createFontUse = (fontName, x, y) => {
|
| 279 |
+
const symbolId = getFontSymbolId(fontName);
|
| 280 |
+
|
| 281 |
+
// Group containing glyph and its hit area
|
| 282 |
+
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
| 283 |
+
group.setAttribute('class', 'font-glyph-group');
|
| 284 |
+
group.setAttribute('data-font', fontName);
|
| 285 |
+
|
| 286 |
+
// Invisible hit area (larger square)
|
| 287 |
+
const hitArea = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
| 288 |
+
hitArea.setAttribute('x', x - 12);
|
| 289 |
+
hitArea.setAttribute('y', y - 12);
|
| 290 |
+
hitArea.setAttribute('width', '24');
|
| 291 |
+
hitArea.setAttribute('height', '24');
|
| 292 |
+
hitArea.setAttribute('fill', 'transparent');
|
| 293 |
+
hitArea.setAttribute('class', 'font-hit-area');
|
| 294 |
+
hitArea.setAttribute('data-font', fontName);
|
| 295 |
+
|
| 296 |
+
// The visible glyph
|
| 297 |
+
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
| 298 |
+
use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', `#${symbolId}`);
|
| 299 |
+
use.setAttribute('x', x - 8);
|
| 300 |
+
use.setAttribute('y', y - 8);
|
| 301 |
+
use.setAttribute('width', '16');
|
| 302 |
+
use.setAttribute('height', '16');
|
| 303 |
+
use.setAttribute('class', 'font-glyph');
|
| 304 |
+
use.setAttribute('data-font', fontName);
|
| 305 |
+
|
| 306 |
+
// Simple click handler with delay to avoid conflicts with D3
|
| 307 |
+
group.addEventListener('click', (e) => {
|
| 308 |
+
if (!isZooming) {
|
| 309 |
+
// Small delay to ensure it's not a drag
|
| 310 |
+
setTimeout(() => {
|
| 311 |
+
if (!isZooming) {
|
| 312 |
+
e.preventDefault();
|
| 313 |
+
e.stopPropagation();
|
| 314 |
+
|
| 315 |
+
const fontData = data.find(d => d.label === fontName);
|
| 316 |
+
if (fontData && fontData.googleFontsUrl) {
|
| 317 |
+
window.open(fontData.googleFontsUrl, '_blank');
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
}, 10);
|
| 321 |
+
}
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
group.appendChild(hitArea);
|
| 325 |
+
group.appendChild(use);
|
| 326 |
+
|
| 327 |
+
return group;
|
| 328 |
+
};
|
| 329 |
+
|
| 330 |
+
// TOOLTIP SYSTEM with foreignObject + HTML
|
| 331 |
+
const createTooltipGroup = () => {
|
| 332 |
+
if (tooltipGroup) return tooltipGroup;
|
| 333 |
+
|
| 334 |
+
tooltipGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
| 335 |
+
tooltipGroup.setAttribute('class', 'svg-tooltip');
|
| 336 |
+
|
| 337 |
+
// foreignObject to contain HTML
|
| 338 |
+
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
|
| 339 |
+
foreignObject.setAttribute('width', '160');
|
| 340 |
+
foreignObject.setAttribute('height', '60');
|
| 341 |
+
|
| 342 |
+
// HTML content in the foreignObject
|
| 343 |
+
const htmlDiv = document.createElement('div');
|
| 344 |
+
htmlDiv.className = 'tooltip-html';
|
| 345 |
+
htmlDiv.innerHTML = `
|
| 346 |
+
<div class="tooltip-title"></div>
|
| 347 |
+
<div class="tooltip-category">
|
| 348 |
+
<span class="tooltip-category-text"></span>
|
| 349 |
+
</div>
|
| 350 |
+
`;
|
| 351 |
+
|
| 352 |
+
foreignObject.appendChild(htmlDiv);
|
| 353 |
+
tooltipGroup.appendChild(foreignObject);
|
| 354 |
+
|
| 355 |
+
uiGroup.appendChild(tooltipGroup);
|
| 356 |
+
return tooltipGroup;
|
| 357 |
+
};
|
| 358 |
+
|
| 359 |
+
const showTooltip = (fontData, mouseX, mouseY) => {
|
| 360 |
+
if (isZooming) return;
|
| 361 |
+
|
| 362 |
+
// Clear pending hide
|
| 363 |
+
if (hoverTimeout) {
|
| 364 |
+
clearTimeout(hoverTimeout);
|
| 365 |
+
hoverTimeout = null;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// Same font, just update position
|
| 369 |
+
if (currentTooltipFont === fontData.label) {
|
| 370 |
+
updateTooltipPosition(mouseX, mouseY);
|
| 371 |
+
return;
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
currentTooltipFont = fontData.label;
|
| 375 |
+
const tooltip = createTooltipGroup();
|
| 376 |
+
const clr = color(fontData.group);
|
| 377 |
+
|
| 378 |
+
// Update HTML content
|
| 379 |
+
const titleEl = tooltip.querySelector('.tooltip-title');
|
| 380 |
+
const categoryTextEl = tooltip.querySelector('.tooltip-category-text');
|
| 381 |
+
|
| 382 |
+
titleEl.textContent = fontData.label;
|
| 383 |
+
categoryTextEl.textContent = families[fontData.group] || fontData.group;
|
| 384 |
+
|
| 385 |
+
// Position tooltip in screen coordinates (SVG viewport coordinates)
|
| 386 |
+
const { x: svgX, y: svgY } = screenToSvg(mouseX, mouseY);
|
| 387 |
+
|
| 388 |
+
// Fixed size for foreignObject (smaller without preview)
|
| 389 |
+
const tooltipWidth = 160;
|
| 390 |
+
const tooltipHeight = 60;
|
| 391 |
+
|
| 392 |
+
let tooltipX = svgX + 15;
|
| 393 |
+
let tooltipY = svgY - tooltipHeight - 10;
|
| 394 |
+
|
| 395 |
+
// Keep tooltip in bounds
|
| 396 |
+
if (tooltipX + tooltipWidth > width) tooltipX = svgX - tooltipWidth - 15;
|
| 397 |
+
if (tooltipY < 0) tooltipY = svgY + 15;
|
| 398 |
+
|
| 399 |
+
tooltip.setAttribute('transform', `translate(${tooltipX}, ${tooltipY})`);
|
| 400 |
+
tooltip.style.opacity = '1';
|
| 401 |
+
};
|
| 402 |
+
|
| 403 |
+
const hideTooltip = () => {
|
| 404 |
+
if (!tooltipGroup || hoverTimeout) return;
|
| 405 |
+
|
| 406 |
+
hoverTimeout = setTimeout(() => {
|
| 407 |
+
if (tooltipGroup) {
|
| 408 |
+
tooltipGroup.style.opacity = '0';
|
| 409 |
+
}
|
| 410 |
+
currentTooltipFont = null;
|
| 411 |
+
hoverTimeout = null;
|
| 412 |
+
}, 100);
|
| 413 |
+
};
|
| 414 |
+
|
| 415 |
+
const updateTooltipPosition = (mouseX, mouseY) => {
|
| 416 |
+
if (!tooltipGroup || !currentTooltipFont) return;
|
| 417 |
+
|
| 418 |
+
const { x: svgX, y: svgY } = screenToSvg(mouseX, mouseY);
|
| 419 |
+
|
| 420 |
+
const tooltipWidth = 160;
|
| 421 |
+
const tooltipHeight = 60;
|
| 422 |
+
|
| 423 |
+
let tooltipX = svgX + 15;
|
| 424 |
+
let tooltipY = svgY - tooltipHeight - 10;
|
| 425 |
+
|
| 426 |
+
if (tooltipX + tooltipWidth > width) tooltipX = svgX - tooltipWidth - 15;
|
| 427 |
+
if (tooltipY < 0) tooltipY = svgY + 15;
|
| 428 |
+
|
| 429 |
+
tooltipGroup.setAttribute('transform', `translate(${tooltipX}, ${tooltipY})`);
|
| 430 |
+
};
|
| 431 |
+
|
| 432 |
+
const createFallbackSvg = () => {
|
| 433 |
+
return `
|
| 434 |
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" width="16" height="16">
|
| 435 |
+
<rect width="80" height="80" fill="var(--surface-bg)" stroke="var(--border-color)" stroke-width="1"/>
|
| 436 |
+
<text x="40" y="50" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="32" fill="currentColor">A</text>
|
| 437 |
+
</svg>
|
| 438 |
+
`;
|
| 439 |
+
};
|
| 440 |
+
|
| 441 |
+
// CORE: Update viewport transform - SVG NATIVE
|
| 442 |
+
const updateTransform = () => {
|
| 443 |
+
const { k, x: tx, y: ty } = transform;
|
| 444 |
+
viewportGroup.setAttribute('transform', `translate(${tx}, ${ty}) scale(${k})`);
|
| 445 |
+
|
| 446 |
+
// Adjust centroid label size to remain constant on screen
|
| 447 |
+
// centroids.forEach((labelElement, family) => {
|
| 448 |
+
// const inverseScale = 1 / k;
|
| 449 |
+
// // Get original centroid position
|
| 450 |
+
// const originalX = parseFloat(labelElement.getAttribute('data-x'));
|
| 451 |
+
// const originalY = parseFloat(labelElement.getAttribute('data-y'));
|
| 452 |
+
|
| 453 |
+
// // Apply inverse scale centered on label position
|
| 454 |
+
// labelElement.setAttribute('transform', `translate(${originalX}, ${originalY}) scale(${inverseScale}) translate(${-originalX}, ${-originalY})`);
|
| 455 |
+
// });
|
| 456 |
+
};
|
| 457 |
+
|
| 458 |
+
// Native SVG render - ULTRA PERFORMANCE
|
| 459 |
+
const render = () => {
|
| 460 |
+
if (!data.length) return;
|
| 461 |
+
|
| 462 |
+
// Create all glyphs as <use> elements in SVG
|
| 463 |
+
data.forEach((d, i) => {
|
| 464 |
+
if (visibleGlyphs.has(i)) return; // Already created
|
| 465 |
+
|
| 466 |
+
const useElement = createFontUse(d.label, x(d.x), y(d.y));
|
| 467 |
+
viewportGroup.appendChild(useElement);
|
| 468 |
+
visibleGlyphs.set(i, useElement);
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
// Native SVG centroids with density-weighted position
|
| 472 |
+
// if (centroids.size === 0) {
|
| 473 |
+
// const groups = d3.rollup(data, v => {
|
| 474 |
+
// // Calculate centroid weighted by local density
|
| 475 |
+
// const positions = v.map(d => ({ x: d.x, y: d.y }));
|
| 476 |
+
|
| 477 |
+
// // For each point, calculate its local density (number of neighbors within radius)
|
| 478 |
+
// const radius = 2.0; // Radius to calculate local density
|
| 479 |
+
// const densityWeights = positions.map(pos => {
|
| 480 |
+
// const neighbors = positions.filter(other => {
|
| 481 |
+
// const dist = Math.sqrt(Math.pow(pos.x - other.x, 2) + Math.pow(pos.y - other.y, 2));
|
| 482 |
+
// return dist <= radius;
|
| 483 |
+
// });
|
| 484 |
+
// return neighbors.length; // Weight = number of neighbors
|
| 485 |
+
// });
|
| 486 |
+
|
| 487 |
+
// // Calculate weighted centroid
|
| 488 |
+
// const totalWeight = d3.sum(densityWeights);
|
| 489 |
+
// const weightedX = d3.sum(positions, (d, i) => d.x * densityWeights[i]) / totalWeight;
|
| 490 |
+
// const weightedY = d3.sum(positions, (d, i) => d.y * densityWeights[i]) / totalWeight;
|
| 491 |
+
|
| 492 |
+
// return {
|
| 493 |
+
// x: weightedX,
|
| 494 |
+
// y: weightedY,
|
| 495 |
+
// count: v.length
|
| 496 |
+
// };
|
| 497 |
+
// }, d => d.group);
|
| 498 |
+
|
| 499 |
+
// groups.forEach((info, family) => {
|
| 500 |
+
// if (info.count < 3) return; // Show groups with 3+ fonts
|
| 501 |
+
|
| 502 |
+
// const posX = x(info.x);
|
| 503 |
+
// const posY = y(info.y);
|
| 504 |
+
|
| 505 |
+
// const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
| 506 |
+
// text.setAttribute('x', posX);
|
| 507 |
+
// text.setAttribute('y', posY);
|
| 508 |
+
// text.setAttribute('data-x', posX); // Store original position
|
| 509 |
+
// text.setAttribute('data-y', posY); // Store original position
|
| 510 |
+
// text.setAttribute('class', 'centroid-label');
|
| 511 |
+
// text.setAttribute('text-anchor', 'middle');
|
| 512 |
+
// text.setAttribute('dominant-baseline', 'middle');
|
| 513 |
+
// text.setAttribute('fill', color(family));
|
| 514 |
+
// text.textContent = families[family] || family;
|
| 515 |
+
|
| 516 |
+
// viewportGroup.appendChild(text);
|
| 517 |
+
// centroids.set(family, text);
|
| 518 |
+
// });
|
| 519 |
+
// }
|
| 520 |
+
};
|
| 521 |
+
|
| 522 |
+
// Zoom handler - ONLY transform viewport
|
| 523 |
+
const handleZoom = (event) => {
|
| 524 |
+
transform = event.transform;
|
| 525 |
+
updateTransform();
|
| 526 |
+
};
|
| 527 |
+
|
| 528 |
+
// Professional approach for zoom/pan constraints
|
| 529 |
+
const getConstraints = () => {
|
| 530 |
+
// No data = generic default constraints
|
| 531 |
+
if (!data.length) {
|
| 532 |
+
return {
|
| 533 |
+
scaleExtent: [0.25, 10],
|
| 534 |
+
translateExtent: [[-width * 0.5, -height * 0.5], [width * 1.5, height * 1.5]]
|
| 535 |
+
};
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
// Calculate actual data bounds in screen space
|
| 539 |
+
const xExtent = d3.extent(data, d => d.x);
|
| 540 |
+
const yExtent = d3.extent(data, d => d.y);
|
| 541 |
+
|
| 542 |
+
// Convert to screen coordinates
|
| 543 |
+
const screenLeft = x(xExtent[0]);
|
| 544 |
+
const screenRight = x(xExtent[1]);
|
| 545 |
+
const screenTop = y(yExtent[1]); // y inverted
|
| 546 |
+
const screenBottom = y(yExtent[0]); // y inverted
|
| 547 |
+
|
| 548 |
+
const contentWidth = screenRight - screenLeft;
|
| 549 |
+
const contentHeight = screenBottom - screenTop;
|
| 550 |
+
|
| 551 |
+
// Minimum scale: see all content with comfortable padding
|
| 552 |
+
const paddingFactor = 1.1; // 10% padding
|
| 553 |
+
const minScaleX = width / (contentWidth * paddingFactor);
|
| 554 |
+
const minScaleY = height / (contentHeight * paddingFactor);
|
| 555 |
+
const minScale = Math.min(minScaleX, minScaleY) * 0.9; // Small safety margin
|
| 556 |
+
|
| 557 |
+
// Maximum scale: fine details visible
|
| 558 |
+
const maxScale = 12;
|
| 559 |
+
|
| 560 |
+
// Translation bounds: "rubber band" strategy - can go out but not too much
|
| 561 |
+
// Allow centering any part of content in view
|
| 562 |
+
const buffer = Math.min(width, height) * 0.3; // 30% flexible buffer
|
| 563 |
+
|
| 564 |
+
const translateExtent = [
|
| 565 |
+
// Top-left bounds: can go quite far left/up
|
| 566 |
+
[screenLeft - width + buffer, screenTop - height + buffer],
|
| 567 |
+
// Bottom-right bounds: can go quite far right/down
|
| 568 |
+
[screenRight - buffer, screenBottom - buffer]
|
| 569 |
+
];
|
| 570 |
+
|
| 571 |
+
const constraints = {
|
| 572 |
+
scaleExtent: [Math.max(0.1, minScale), maxScale],
|
| 573 |
+
translateExtent
|
| 574 |
+
};
|
| 575 |
+
|
| 576 |
+
return constraints;
|
| 577 |
+
};
|
| 578 |
+
|
| 579 |
+
// Setup zoom with D3 best practices
|
| 580 |
+
// 1. Element that receives the behavior (mainSvg)
|
| 581 |
+
// 2. Element that will be transformed (zoomGroup in the SVG)
|
| 582 |
+
// 3. Visual content (in zoomGroup)
|
| 583 |
+
|
| 584 |
+
const zoom = d3.zoom()
|
| 585 |
+
.scaleExtent([1, 4])
|
| 586 |
+
// Constraints will be applied after data loading
|
| 587 |
+
.on('start', () => {
|
| 588 |
+
isZooming = true;
|
| 589 |
+
if (tooltipGroup) {
|
| 590 |
+
tooltipGroup.style.opacity = '0';
|
| 591 |
+
}
|
| 592 |
+
currentTooltipFont = null;
|
| 593 |
+
})
|
| 594 |
+
.on('zoom', (event) => {
|
| 595 |
+
const { transform } = event;
|
| 596 |
+
|
| 597 |
+
// Apply transformation directly (WITHOUT updateTransform which interferes)
|
| 598 |
+
d3.select(viewportGroup).attr('transform', transform.toString());
|
| 599 |
+
})
|
| 600 |
+
.on('end', () => {
|
| 601 |
+
setTimeout(() => { isZooming = false; }, 100);
|
| 602 |
+
});
|
| 603 |
+
d3.select(mainSvg).call(zoom);
|
| 604 |
+
|
| 605 |
+
// Convert screen coordinates to SVG
|
| 606 |
+
const screenToSvg = (clientX, clientY) => {
|
| 607 |
+
const rect = mainSvg.getBoundingClientRect();
|
| 608 |
+
const svgX = ((clientX - rect.left) / rect.width) * width;
|
| 609 |
+
const svgY = ((clientY - rect.top) / rect.height) * height;
|
| 610 |
+
return { x: svgX, y: svgY };
|
| 611 |
+
};
|
| 612 |
+
|
| 613 |
+
// TOOLTIP EVENTS - Screen coordinates
|
| 614 |
+
mainSvg.addEventListener('mousemove', (e) => {
|
| 615 |
+
if (isZooming) return;
|
| 616 |
+
|
| 617 |
+
// Find element under mouse
|
| 618 |
+
const element = document.elementFromPoint(e.clientX, e.clientY);
|
| 619 |
+
|
| 620 |
+
// Check if it's a glyph, hit area, or in a glyph group
|
| 621 |
+
let fontName = null;
|
| 622 |
+
if (element && (element.classList.contains('font-glyph') || element.classList.contains('font-hit-area'))) {
|
| 623 |
+
fontName = element.getAttribute('data-font');
|
| 624 |
+
} else if (element && element.closest('.font-glyph-group')) {
|
| 625 |
+
fontName = element.closest('.font-glyph-group').getAttribute('data-font');
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
if (fontName) {
|
| 629 |
+
const fontData = data.find(d => d.label === fontName);
|
| 630 |
+
if (fontData) {
|
| 631 |
+
showTooltip(fontData, e.clientX, e.clientY);
|
| 632 |
+
}
|
| 633 |
+
} else {
|
| 634 |
+
hideTooltip();
|
| 635 |
+
}
|
| 636 |
+
});
|
| 637 |
+
|
| 638 |
+
mainSvg.addEventListener('mouseleave', () => {
|
| 639 |
+
hideTooltip();
|
| 640 |
+
});
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
// Zoom controls
|
| 644 |
+
const controls = document.createElement('div');
|
| 645 |
+
controls.className = 'zoom-controls';
|
| 646 |
+
controls.innerHTML = `
|
| 647 |
+
<button title="Zoom In">+</button>
|
| 648 |
+
<button title="Zoom Out">β</button>
|
| 649 |
+
<button title="Reset">β</button>
|
| 650 |
+
`;
|
| 651 |
+
|
| 652 |
+
const [zoomIn, zoomOut, reset] = controls.querySelectorAll('button');
|
| 653 |
+
zoomIn.onclick = () => d3.select(mainSvg).transition().call(zoom.scaleBy, 1.5);
|
| 654 |
+
zoomOut.onclick = () => d3.select(mainSvg).transition().call(zoom.scaleBy, 1 / 1.5);
|
| 655 |
+
|
| 656 |
+
// Smart reset: return to optimal data view
|
| 657 |
+
reset.onclick = () => {
|
| 658 |
+
if (!data.length) {
|
| 659 |
+
d3.select(mainSvg).transition().call(zoom.transform, d3.zoomIdentity);
|
| 660 |
+
return;
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
const constraints = getConstraints();
|
| 664 |
+
const optimalScale = constraints.scaleExtent[0] * 1.05; // Slightly above minimum
|
| 665 |
+
|
| 666 |
+
// Calculate translation to center content
|
| 667 |
+
const xExtent = d3.extent(data, d => d.x);
|
| 668 |
+
const yExtent = d3.extent(data, d => d.y);
|
| 669 |
+
const centerX = (x(xExtent[0]) + x(xExtent[1])) / 2;
|
| 670 |
+
const centerY = (y(yExtent[0]) + y(yExtent[1])) / 2;
|
| 671 |
+
|
| 672 |
+
const targetX = width / 2 - centerX * optimalScale;
|
| 673 |
+
const targetY = height / 2 - centerY * optimalScale;
|
| 674 |
+
|
| 675 |
+
const resetTransform = d3.zoomIdentity.translate(targetX, targetY).scale(optimalScale);
|
| 676 |
+
|
| 677 |
+
d3.select(mainSvg).transition().duration(750).call(zoom.transform, resetTransform);
|
| 678 |
+
};
|
| 679 |
+
|
| 680 |
+
uiLayer.appendChild(controls);
|
| 681 |
+
|
| 682 |
+
// Setup scales - Compact size inspired by scatter
|
| 683 |
+
const updateScales = () => {
|
| 684 |
+
width = container.clientWidth || 800;
|
| 685 |
+
height = Math.max(280, Math.min(450, Math.round(width * 0.4))); // More compact: 2.5:1 max ratio
|
| 686 |
+
|
| 687 |
+
// Update main SVG viewBox
|
| 688 |
+
mainSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
|
| 689 |
+
|
| 690 |
+
const xExtent = d3.extent(data, d => d.x);
|
| 691 |
+
const yExtent = d3.extent(data, d => d.y);
|
| 692 |
+
|
| 693 |
+
x.domain(xExtent).range([margin, width - margin]);
|
| 694 |
+
y.domain(yExtent).range([height - margin, margin]);
|
| 695 |
+
};
|
| 696 |
+
|
| 697 |
+
// Color palette
|
| 698 |
+
const updateColors = () => {
|
| 699 |
+
try {
|
| 700 |
+
if (window.ColorPalettes?.getColors) {
|
| 701 |
+
const colors = window.ColorPalettes.getColors('categorical', 6);
|
| 702 |
+
if (colors?.length) color.range(colors);
|
| 703 |
+
}
|
| 704 |
+
} catch (e) { }
|
| 705 |
+
};
|
| 706 |
+
|
| 707 |
+
// Load data
|
| 708 |
+
const loadData = async () => {
|
| 709 |
+
try {
|
| 710 |
+
// Load SVG sprite first
|
| 711 |
+
await initSprite();
|
| 712 |
+
|
| 713 |
+
const paths = ['/data/typography_data.json', './assets/data/typography_data.json', '../assets/data/typography_data.json'];
|
| 714 |
+
let response;
|
| 715 |
+
for (const path of paths) {
|
| 716 |
+
try {
|
| 717 |
+
response = await fetch(path);
|
| 718 |
+
if (response.ok) break;
|
| 719 |
+
} catch (e) { }
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
if (!response?.ok) throw new Error('Data not found');
|
| 723 |
+
|
| 724 |
+
const raw = await response.json();
|
| 725 |
+
const fontsData = raw.fonts || raw; // Support both formats
|
| 726 |
+
data = fontsData.map((d, i) => ({
|
| 727 |
+
id: i,
|
| 728 |
+
originalId: d.id,
|
| 729 |
+
googleFontsUrl: d.google_fonts_url, // Pre-generated URL
|
| 730 |
+
x: d.x,
|
| 731 |
+
y: d.y,
|
| 732 |
+
group: d.family || 'sans-serif',
|
| 733 |
+
label: d.name
|
| 734 |
+
})).filter(d => Number.isFinite(d.x) && Number.isFinite(d.y));
|
| 735 |
+
|
| 736 |
+
color.domain([...new Set(data.map(d => d.group))]);
|
| 737 |
+
updateColors();
|
| 738 |
+
updateScales();
|
| 739 |
+
|
| 740 |
+
// Apply professional constraints based on data
|
| 741 |
+
const xExtent = d3.extent(data, d => d.x);
|
| 742 |
+
const yExtent = d3.extent(data, d => d.y);
|
| 743 |
+
|
| 744 |
+
// Convert to screen coordinates
|
| 745 |
+
const contentLeft = x(xExtent[0]);
|
| 746 |
+
const contentRight = x(xExtent[1]);
|
| 747 |
+
const contentTop = y(yExtent[1]);
|
| 748 |
+
const contentBottom = y(yExtent[0]);
|
| 749 |
+
|
| 750 |
+
const contentWidth = contentRight - contentLeft;
|
| 751 |
+
const contentHeight = contentBottom - contentTop;
|
| 752 |
+
|
| 753 |
+
// Professional pan area: slightly larger than content
|
| 754 |
+
const padding = 1; // padding in pixels
|
| 755 |
+
const panLeft = contentLeft - padding;
|
| 756 |
+
const panRight = contentRight + padding;
|
| 757 |
+
const panTop = contentTop - padding;
|
| 758 |
+
const panBottom = contentBottom + padding;
|
| 759 |
+
|
| 760 |
+
// Apply constraints to zoom
|
| 761 |
+
zoom.translateExtent([[panLeft, panTop], [panRight, panBottom]]);
|
| 762 |
+
|
| 763 |
+
render();
|
| 764 |
+
} catch (e) {
|
| 765 |
+
console.error('Failed to load data:', e);
|
| 766 |
+
container.innerHTML = '<div style="color:red;padding:20px;">Failed to load typography data</div>';
|
| 767 |
+
}
|
| 768 |
+
};
|
| 769 |
+
|
| 770 |
+
// Initialize
|
| 771 |
+
updateColors();
|
| 772 |
+
document.addEventListener('palettes:updated', updateColors);
|
| 773 |
+
|
| 774 |
+
// Resize handler
|
| 775 |
+
let resizeTimer;
|
| 776 |
+
const handleResize = () => {
|
| 777 |
+
clearTimeout(resizeTimer);
|
| 778 |
+
resizeTimer = setTimeout(() => {
|
| 779 |
+
updateScales();
|
| 780 |
+
|
| 781 |
+
// TEMPORARILY DISABLED: Recalculate constraints after resize
|
| 782 |
+
// if (data.length) {
|
| 783 |
+
// const constraints = getConstraints();
|
| 784 |
+
// zoom.scaleExtent(constraints.scaleExtent)
|
| 785 |
+
// .translateExtent(constraints.translateExtent);
|
| 786 |
+
// }
|
| 787 |
+
|
| 788 |
+
render();
|
| 789 |
+
}, 100);
|
| 790 |
+
};
|
| 791 |
+
|
| 792 |
+
new ResizeObserver(handleResize).observe(container);
|
| 793 |
+
|
| 794 |
+
loadData();
|
| 795 |
+
};
|
| 796 |
+
|
| 797 |
+
// Start when ready
|
| 798 |
+
if (document.readyState === 'loading') {
|
| 799 |
+
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap));
|
| 800 |
+
} else {
|
| 801 |
+
ensureD3(bootstrap);
|
| 802 |
+
}
|
| 803 |
+
})();
|
| 804 |
+
</script>
|
app/src/content/embeds/demo/color-picker.html
DELETED
|
@@ -1,226 +0,0 @@
|
|
| 1 |
-
<div class="color-picker" style="width:100%; margin: 10px 0;">
|
| 2 |
-
<style>
|
| 3 |
-
.color-picker .picker__stack { display:flex; flex-direction:column; gap:12px; }
|
| 4 |
-
.color-picker .current-card { display:grid; grid-template-columns: 30% 70%; align-items: center; gap:14px; padding:14px 32px 14px 16px; border:1px solid var(--border-color); background: var(--surface-bg); border-radius: 12px; }
|
| 5 |
-
.color-picker .current-left { display:flex; flex-direction: column; gap:8px; min-width: 0; }
|
| 6 |
-
.color-picker .current-right { display:flex; flex-direction: column; gap:8px; padding-left: 14px; border-left: 1px solid var(--border-color); }
|
| 7 |
-
.color-picker .current-main { display:flex; align-items:center; gap:12px; min-width: 0; }
|
| 8 |
-
.color-picker .current-swatch { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); }
|
| 9 |
-
.color-picker .current-text { display:flex; flex-direction: column; line-height: 1.2; min-width: 0; }
|
| 10 |
-
.color-picker .current-name { font-size: 14px; font-weight: 800; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
|
| 11 |
-
.color-picker .current-hex, .color-picker .current-extra { font-size: 11px; color: var(--muted-color); letter-spacing: .02em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: clamp(140px, 28vw, 260px); }
|
| 12 |
-
/* theme preview styles removed */
|
| 13 |
-
.color-picker .picker__label { font-weight:700; font-size: 12px; color: var(--muted-color); text-transform: uppercase; letter-spacing: .02em; }
|
| 14 |
-
.color-picker .hue-slider { position:relative; height:16px; border-radius:10px; border:1px solid var(--border-color); background: linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%); cursor: ew-resize; touch-action: none; flex: 1 1 auto; min-width: 200px; }
|
| 15 |
-
.color-picker .hue-knob { position:absolute; top:50%; left:93.6%; width:14px; height:14px; border-radius:50%; border:2px solid #fff; transform:translate(-50%, -50%); background: var(--surface-bg); z-index: 2; box-shadow: 0 0 0 1px rgba(0,0,0,.05); }
|
| 16 |
-
.color-picker .hue-slider:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
| 17 |
-
.color-picker .hue-value { font-variant-numeric: tabular-nums; color: var(--muted-color); font-size: 12px; }
|
| 18 |
-
@media (max-width: 720px) { .color-picker .current-card { grid-template-columns: 1fr; } .color-picker .current-right { padding-left: 0; border-left: none; } }
|
| 19 |
-
</style>
|
| 20 |
-
<div class="picker__stack">
|
| 21 |
-
<div class="current-card">
|
| 22 |
-
<div class="current-left">
|
| 23 |
-
<div class="current-main">
|
| 24 |
-
<div class="current-swatch" aria-label="Current color" title="Current color"></div>
|
| 25 |
-
<div class="current-text">
|
| 26 |
-
<div class="current-name">β</div>
|
| 27 |
-
<div class="current-hex">β</div>
|
| 28 |
-
<div class="current-extra current-lch">β</div>
|
| 29 |
-
<div class="current-extra current-rgb">β</div>
|
| 30 |
-
</div>
|
| 31 |
-
</div>
|
| 32 |
-
</div>
|
| 33 |
-
<div class="current-right">
|
| 34 |
-
<div class="picker__label">Hue</div>
|
| 35 |
-
<div class="hue-slider" role="slider" aria-label="Hue" aria-valuemin="0" aria-valuemax="360" aria-valuenow="337" tabindex="0">
|
| 36 |
-
<div class="hue-knob"></div>
|
| 37 |
-
</div>
|
| 38 |
-
<div class="hue-value">337Β°</div>
|
| 39 |
-
</div>
|
| 40 |
-
</div>
|
| 41 |
-
</div>
|
| 42 |
-
</div>
|
| 43 |
-
<script>
|
| 44 |
-
(() => {
|
| 45 |
-
// Ensure chroma.js is loaded once
|
| 46 |
-
const ensureChroma = (next) => {
|
| 47 |
-
if (window.chroma) return next();
|
| 48 |
-
const loadScript = (id, src, onload, onerror) => {
|
| 49 |
-
let s = document.getElementById(id);
|
| 50 |
-
if (s) { return onload && onload(); }
|
| 51 |
-
s = document.createElement('script');
|
| 52 |
-
s.id = id; s.src = src; s.async = true;
|
| 53 |
-
if (onload) s.addEventListener('load', onload, { once: true });
|
| 54 |
-
if (onerror) s.addEventListener('error', onerror, { once: true });
|
| 55 |
-
document.head.appendChild(s);
|
| 56 |
-
};
|
| 57 |
-
loadScript('chroma-cdn', 'https://unpkg.com/chroma-js@2.4.2/dist/chroma.min.js', next, () => {
|
| 58 |
-
loadScript('chroma-cdn-fallback', 'https://cdnjs.cloudflare.com/ajax/libs/chroma-js/2.4.2/chroma.min.js', next);
|
| 59 |
-
});
|
| 60 |
-
};
|
| 61 |
-
|
| 62 |
-
// Minimal embedded color-name list (same as palettes)
|
| 63 |
-
const COLOR_NAMES = [{"name":"Candy Apple Red","hex":"#ff0800"},{"name":"Boiling Magma","hex":"#ff3300"},{"name":"Aerospace Orange","hex":"#ff4f00"},{"name":"Burtuqali Orange","hex":"#ff6700"},{"name":"American Orange","hex":"#ff8b00"},{"name":"Cheese","hex":"#ffa600"},{"name":"Amber","hex":"#ffbf00"},{"name":"Demonic Yellow","hex":"#ffe700"},{"name":"Bat-Signal","hex":"#feff00"},{"name":"Bitter Lime","hex":"#cfff00"},{"name":"Electric Lime","hex":"#ccff00"},{"name":"Bright Yellow Green","hex":"#9dff00"},{"name":"Lasting Lime","hex":"#88ff00"},{"name":"Bright Green","hex":"#66ff00"},{"name":"Chlorophyll Green","hex":"#4aff00"},{"name":"Green Screen","hex":"#22ff00"},{"name":"Electric Pickle","hex":"#00ff04"},{"name":"Acid","hex":"#00ff22"},{"name":"Lucent Lime","hex":"#00ff33"},{"name":"Cathode Green","hex":"#00ff55"},{"name":"Booger Buster","hex":"#00ff77"},{"name":"Green Gas","hex":"#00ff99"},{"name":"Enthusiasm","hex":"#00ffaa"},{"name":"Ice Ice Baby","hex":"#00ffdd"},{"name":"Master Sword Blue","hex":"#00ffee"},{"name":"Agressive Aqua","hex":"#00fbff"},{"name":"Vivid Sky Blue","hex":"#00ccff"},{"name":"Capri","hex":"#00bfff"},{"name":"Sky of Magritte","hex":"#0099ff"},{"name":"Azure","hex":"#007fff"},{"name":"Blue Ribbon","hex":"#0066ff"},{"name":"Blinking Blue","hex":"#0033ff"},{"name":"Icelandic Water","hex":"#0011ff"},{"name":"Blue","hex":"#0000ff"},{"name":"Blue Pencil","hex":"#2200ff"},{"name":"Electric Ultramarine","hex":"#3f00ff"},{"name":"Aladdin's Feather","hex":"#5500ff"},{"name":"Purple Climax","hex":"#8800ff"},{"name":"Amethyst Ganzstar","hex":"#8f00ff"},{"name":"Electric Purple","hex":"#bf00ff"},{"name":"Phlox","hex":"#df00ff"},{"name":"Brusque Pink","hex":"#ee00ff"},{"name":"Bright Magenta","hex":"#ff08e8"},{"name":"Big bang Pink","hex":"#ff00bb"},{"name":"Mean Girls Lipstick","hex":"#ff00ae"},{"name":"Pink","hex":"#ff0099"},{"name":"Hot Flamingoes","hex":"#ff005d"},{"name":"Blazing Dragonfruit","hex":"#ff0054"},{"name":"Carmine Red","hex":"#ff0038"},{"name":"Bright Red","hex":"#ff000d"}];
|
| 64 |
-
if (!window.__colorNames) window.__colorNames = COLOR_NAMES;
|
| 65 |
-
|
| 66 |
-
// Shared event bus so multiple instances stay in sync
|
| 67 |
-
if (!window.__colorPickerBus) {
|
| 68 |
-
window.__colorPickerBus = (() => {
|
| 69 |
-
let hue = 337; // shared initial hue
|
| 70 |
-
let adjusting = false;
|
| 71 |
-
const listeners = new Set();
|
| 72 |
-
return {
|
| 73 |
-
get: () => ({ hue, adjusting }),
|
| 74 |
-
publish: (sourceId, nextHue, isAdjusting) => {
|
| 75 |
-
hue = ((nextHue % 360) + 360) % 360;
|
| 76 |
-
adjusting = !!isAdjusting;
|
| 77 |
-
listeners.forEach((fn) => { try { fn({ sourceId, hue, adjusting }); } catch {} });
|
| 78 |
-
},
|
| 79 |
-
subscribe: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }
|
| 80 |
-
};
|
| 81 |
-
})();
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
const bootstrap = () => {
|
| 85 |
-
const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
|
| 86 |
-
const root = mount && mount.closest('.color-picker') ? mount.closest('.color-picker') : document.querySelector('.color-picker');
|
| 87 |
-
if (!root || root.dataset.mounted) return; root.dataset.mounted = 'true';
|
| 88 |
-
|
| 89 |
-
const slider = root.querySelector('.hue-slider');
|
| 90 |
-
const knob = root.querySelector('.hue-knob');
|
| 91 |
-
const hueValue = root.querySelector('.hue-value');
|
| 92 |
-
const currentSwatch = root.querySelector('.current-swatch');
|
| 93 |
-
const currentName = root.querySelector('.current-name');
|
| 94 |
-
const currentHex = root.querySelector('.current-hex');
|
| 95 |
-
const currentLch = root.querySelector('.current-lch');
|
| 96 |
-
const currentRgb = root.querySelector('.current-rgb');
|
| 97 |
-
|
| 98 |
-
const bus = window.__colorPickerBus;
|
| 99 |
-
const instanceId = Math.random().toString(36).slice(2);
|
| 100 |
-
|
| 101 |
-
const getKnobRadius = () => {
|
| 102 |
-
try { const w = knob ? knob.getBoundingClientRect().width : 0; return w ? w / 2 : 8; } catch { return 8; }
|
| 103 |
-
};
|
| 104 |
-
|
| 105 |
-
const getName = (hex) => {
|
| 106 |
-
const list = (window.__colorNames && window.__colorNames.length) ? window.__colorNames : COLOR_NAMES;
|
| 107 |
-
if (list && window.chroma) {
|
| 108 |
-
let bestName = null; let best = Infinity;
|
| 109 |
-
for (let i = 0; i < list.length; i++) {
|
| 110 |
-
const item = list[i];
|
| 111 |
-
const d = (chroma.deltaE ? chroma.deltaE(hex, item.hex) : chroma.distance(hex, item.hex, 'lab'));
|
| 112 |
-
if (d < best) { best = d; bestName = item.name; }
|
| 113 |
-
}
|
| 114 |
-
if (bestName) return bestName;
|
| 115 |
-
}
|
| 116 |
-
const hh = chroma(hex).get('hsl.h') || 0;
|
| 117 |
-
const labels = ['Red','Orange','Yellow','Lime','Green','Cyan','Blue','Indigo','Violet','Magenta'];
|
| 118 |
-
const idx = Math.round(((hh % 360) / 360) * (labels.length - 1));
|
| 119 |
-
return labels[idx];
|
| 120 |
-
};
|
| 121 |
-
|
| 122 |
-
const updateUI = (h, adjusting) => {
|
| 123 |
-
const rect = slider.getBoundingClientRect();
|
| 124 |
-
const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
|
| 125 |
-
const t = Math.max(0, Math.min(1, (h / 360)));
|
| 126 |
-
const leftPx = r + t * Math.max(0, (rect.width - 2 * r));
|
| 127 |
-
if (knob) knob.style.left = (leftPx / rect.width * 100) + '%';
|
| 128 |
-
if (hueValue) hueValue.textContent = `${Math.round(h)}Β°`;
|
| 129 |
-
if (slider) slider.setAttribute('aria-valuenow', String(Math.round(h)));
|
| 130 |
-
// Use LCH for consistent chroma across hues
|
| 131 |
-
const L = 70; // lightness
|
| 132 |
-
const C = 60; // chroma kept within sRGB-friendly range
|
| 133 |
-
const base = chroma.lch(L, C, h);
|
| 134 |
-
const baseHex = base.hex();
|
| 135 |
-
if (currentSwatch) currentSwatch.style.background = baseHex;
|
| 136 |
-
if (currentName) currentName.textContent = getName(baseHex.toUpperCase());
|
| 137 |
-
if (currentHex) currentHex.textContent = baseHex.toUpperCase();
|
| 138 |
-
if (currentLch) {
|
| 139 |
-
const lc = base.lch();
|
| 140 |
-
const L = Math.round((lc[0] || 0));
|
| 141 |
-
const C = Math.round((lc[1] || 0));
|
| 142 |
-
const H = Math.round(((lc[2] || 0) % 360 + 360) % 360);
|
| 143 |
-
currentLch.textContent = `LCH ${L}, ${C}, ${H}Β°`;
|
| 144 |
-
}
|
| 145 |
-
if (currentRgb) {
|
| 146 |
-
const rgb = base.rgb().map(v => Math.round(v));
|
| 147 |
-
currentRgb.textContent = `RGB ${rgb[0]}, ${rgb[1]}, ${rgb[2]}`;
|
| 148 |
-
}
|
| 149 |
-
// Apply to theme (always, to reflect the selection)
|
| 150 |
-
const hoverL = Math.max(0, Math.min(100, L - 8));
|
| 151 |
-
const hoverHex = chroma.lch(hoverL, C, h).hex();
|
| 152 |
-
const rootEl = document.documentElement;
|
| 153 |
-
rootEl.style.setProperty('--primary-color', baseHex);
|
| 154 |
-
rootEl.style.setProperty('--primary-color-hover', hoverHex);
|
| 155 |
-
};
|
| 156 |
-
|
| 157 |
-
const getHueFromEvent = (ev) => {
|
| 158 |
-
const rect = slider.getBoundingClientRect();
|
| 159 |
-
const clientX = ev.touches ? ev.touches[0].clientX : ev.clientX;
|
| 160 |
-
const x = clientX - rect.left;
|
| 161 |
-
const r = Math.min(getKnobRadius(), Math.max(0, rect.width / 2 - 1));
|
| 162 |
-
const effX = Math.max(r, Math.min(rect.width - r, x));
|
| 163 |
-
const denom = Math.max(1, rect.width - 2 * r);
|
| 164 |
-
const t = (effX - r) / denom;
|
| 165 |
-
return t * 360;
|
| 166 |
-
};
|
| 167 |
-
|
| 168 |
-
// Subscribe to bus to sync multiple instances
|
| 169 |
-
const unsubscribe = bus.subscribe(({ sourceId, hue, adjusting }) => {
|
| 170 |
-
if (sourceId === instanceId) return; // avoid feedback
|
| 171 |
-
updateUI(hue, adjusting);
|
| 172 |
-
});
|
| 173 |
-
|
| 174 |
-
// Init from theme color if available
|
| 175 |
-
try {
|
| 176 |
-
const cssPrimary = getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim();
|
| 177 |
-
if (cssPrimary) {
|
| 178 |
-
const initH = chroma(cssPrimary).get('hsl.h') || 0;
|
| 179 |
-
updateUI(initH, false);
|
| 180 |
-
bus.publish(instanceId, initH, false);
|
| 181 |
-
} else {
|
| 182 |
-
const { hue: sharedHue } = bus.get();
|
| 183 |
-
updateUI(sharedHue, false);
|
| 184 |
-
}
|
| 185 |
-
} catch {
|
| 186 |
-
const { hue: sharedHue } = bus.get();
|
| 187 |
-
updateUI(sharedHue, false);
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
const onDown = (ev) => {
|
| 191 |
-
ev.preventDefault();
|
| 192 |
-
const h = getHueFromEvent(ev);
|
| 193 |
-
updateUI(h, true);
|
| 194 |
-
bus.publish(instanceId, h, true);
|
| 195 |
-
const move = (e) => { e.preventDefault && e.preventDefault(); const hh = getHueFromEvent(e); updateUI(hh, true); bus.publish(instanceId, hh, true); };
|
| 196 |
-
const up = () => { bus.publish(instanceId, getHueFromEvent(ev), false); window.removeEventListener('mousemove', move); window.removeEventListener('touchmove', move); window.removeEventListener('mouseup', up); window.removeEventListener('touchend', up); };
|
| 197 |
-
window.addEventListener('mousemove', move, { passive: false });
|
| 198 |
-
window.addEventListener('touchmove', move, { passive: false });
|
| 199 |
-
window.addEventListener('mouseup', up, { once: true });
|
| 200 |
-
window.addEventListener('touchend', up, { once: true });
|
| 201 |
-
};
|
| 202 |
-
|
| 203 |
-
if (slider) {
|
| 204 |
-
slider.addEventListener('mousedown', onDown);
|
| 205 |
-
slider.addEventListener('touchstart', onDown, { passive: false });
|
| 206 |
-
// Minimal keyboard support (β/β, Shift for larger steps)
|
| 207 |
-
slider.addEventListener('keydown', (e) => {
|
| 208 |
-
const step = e.shiftKey ? 10 : 2;
|
| 209 |
-
if (e.key === 'ArrowLeft') { e.preventDefault(); const { hue } = bus.get(); const h = hue - step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); }
|
| 210 |
-
if (e.key === 'ArrowRight') { e.preventDefault(); const { hue } = bus.get(); const h = hue + step; updateUI(h, true); bus.publish(instanceId, h, true); bus.publish(instanceId, h, false); }
|
| 211 |
-
});
|
| 212 |
-
}
|
| 213 |
-
|
| 214 |
-
// Clean up on detach (best-effort)
|
| 215 |
-
const ro = new MutationObserver(() => {
|
| 216 |
-
if (!document.body.contains(root)) { unsubscribe && unsubscribe(); ro.disconnect(); }
|
| 217 |
-
});
|
| 218 |
-
ro.observe(document.body, { childList: true, subtree: true });
|
| 219 |
-
};
|
| 220 |
-
|
| 221 |
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => ensureChroma(bootstrap), { once: true });
|
| 222 |
-
else ensureChroma(bootstrap);
|
| 223 |
-
})();
|
| 224 |
-
</script>
|
| 225 |
-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/src/content/embeds/demo/palettes.html
DELETED
|
@@ -1,219 +0,0 @@
|
|
| 1 |
-
<div class="palettes" style="width:100%; margin: 10px 0;">
|
| 2 |
-
<style>
|
| 3 |
-
.palettes { box-sizing: border-box; overflow-x: hidden; }
|
| 4 |
-
.palettes .palettes__grid { display: grid; grid-template-columns: 1fr; gap: 12px; max-width: 100%; }
|
| 5 |
-
.palettes .palette-card { position: relative; display: grid; grid-template-columns: auto 1fr minmax(0, 220px); align-items: stretch; gap: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--surface-bg); padding: 12px; transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease; }
|
| 6 |
-
/* removed circular badge */
|
| 7 |
-
.palettes .palette-card__swatches { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); grid-auto-rows: 1fr; gap: 2px; margin: 0; min-height: 20px; }
|
| 8 |
-
.palettes .palette-card__swatches .sw { width: 100%; min-width: 0; min-height: 0; border-radius: 0; border: 1px solid var(--border-color); }
|
| 9 |
-
.palettes .palette-card__swatches .sw:first-child { border-top-left-radius: 8px; border-bottom-left-radius: 8px; }
|
| 10 |
-
.palettes .palette-card__swatches .sw:last-child { border-top-right-radius: 8px; border-bottom-right-radius: 8px; }
|
| 11 |
-
.palettes .palette-card__content { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: 6px; min-width: 0; padding-right: 0; }
|
| 12 |
-
.palettes .palette-card__content__info { display: flex; flex-direction: column; }
|
| 13 |
-
.palettes .palette-card__title { text-align: left; font-weight: 800; font-size: 15px; }
|
| 14 |
-
.palettes .palette-card__desc { text-align: left; color: var(--muted-color); line-height: 1.5; font-size: 12px; }
|
| 15 |
-
.palettes .palette-card__actions { display: flex; align-items: center; justify-content: flex-start; justify-self: start; align-self: stretch; }
|
| 16 |
-
/* .palettes .copy-btn { margin: 0; padding: 0 10px; height: 100%; border-radius: 8px; } */
|
| 17 |
-
/* .palettes .copy-btn:hover { background: var(--primary-color); color: var(--on-primary)!important; border-color: transparent; }
|
| 18 |
-
.palettes .copy-btn:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; } */
|
| 19 |
-
.palettes .copy-btn svg { width: 18px; height: 18px; fill: currentColor; display: block; }
|
| 20 |
-
/* Simulation UI */
|
| 21 |
-
.palettes .palettes__select { width: 100%; max-width: 100%; border: 1px solid var(--border-color); background: var(--surface-bg); color: var(--text-color); padding: 8px 10px; border-radius: 8px; }
|
| 22 |
-
.palettes .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 1px, 1px); white-space: nowrap; border: 0; }
|
| 23 |
-
.palettes .palettes__controls { display: flex; flex-wrap: wrap; gap: 16px; align-items: center; margin: 8px 0 14px; }
|
| 24 |
-
.palettes .palettes__field { display: flex; flex-direction: column; gap: 6px; min-width: 0; flex: 1 1 280px; max-width: 100%; }
|
| 25 |
-
.palettes .palettes__label { font-size: 12px; color: var(--muted-color); font-weight: 800; }
|
| 26 |
-
.palettes .palettes__label-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
| 27 |
-
.palettes .ghost-badge { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border-color); color: var(--muted-color); background: transparent; font-variant-numeric: tabular-nums; }
|
| 28 |
-
.palettes .palettes__count { display: flex; align-items: center; gap: 8px; max-width: 100%; }
|
| 29 |
-
.palettes .palettes__count input[type="range"] { width: 100%; }
|
| 30 |
-
.palettes .palettes__count output { min-width: 28px; text-align: center; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--muted-color); }
|
| 31 |
-
/* Slider styling */
|
| 32 |
-
.palettes input[type="range"] { -webkit-appearance: none; appearance: none; height: 24px; background: transparent; cursor: pointer; accent-color: var(--primary-color); }
|
| 33 |
-
.palettes input[type="range"]:focus { outline: none; }
|
| 34 |
-
/* WebKit */
|
| 35 |
-
.palettes input[type="range"]::-webkit-slider-runnable-track { height: 6px; background: var(--border-color); border-radius: 999px; }
|
| 36 |
-
.palettes input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; margin-top: -6px; width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
|
| 37 |
-
/* Firefox */
|
| 38 |
-
.palettes input[type="range"]::-moz-range-track { height: 6px; background: var(--border-color); border: none; border-radius: 999px; }
|
| 39 |
-
.palettes input[type="range"]::-moz-range-progress { height: 6px; background: var(--primary-color); border-radius: 999px; }
|
| 40 |
-
.palettes input[type="range"]::-moz-range-thumb { width: 18px; height: 18px; background: var(--primary-color); border: 2px solid var(--surface-bg); border-radius: 50%; }
|
| 41 |
-
/* Page-wide color vision simulation classes */
|
| 42 |
-
html.cb-grayscale, body.cb-grayscale { filter: grayscale(1) !important; }
|
| 43 |
-
html.cb-protanopia, body.cb-protanopia { filter: url(#cb-protanopia) !important; }
|
| 44 |
-
html.cb-deuteranopia, body.cb-deuteranopia { filter: url(#cb-deuteranopia) !important; }
|
| 45 |
-
html.cb-tritanopia, body.cb-tritanopia { filter: url(#cb-tritanopia) !important; }
|
| 46 |
-
html.cb-achromatopsia, body.cb-achromatopsia { filter: url(#cb-achromatopsia) !important; }
|
| 47 |
-
@media (max-width: 640px) {
|
| 48 |
-
.palettes .palette-card { grid-template-columns: 1fr; align-items: stretch; gap: 10px; }
|
| 49 |
-
.palettes .palette-card__swatches { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
| 50 |
-
.palettes .palette-card__content { border-right: none; padding-right: 0; }
|
| 51 |
-
.palettes .palette-card__actions { justify-self: start; }
|
| 52 |
-
|
| 53 |
-
}
|
| 54 |
-
</style>
|
| 55 |
-
<div class="palettes__controls">
|
| 56 |
-
<div class="palettes__field">
|
| 57 |
-
<label class="palettes__label" for="cb-select">Color vision simulation</label>
|
| 58 |
-
<select id="cb-select" class="palettes__select">
|
| 59 |
-
<option value="none">Normal color vision β typical for most people</option>
|
| 60 |
-
<option value="achromatopsia">Achromatopsia β no color at all</option>
|
| 61 |
-
<option value="protanopia">Protanopia β reduced/absent reds</option>
|
| 62 |
-
<option value="deuteranopia">Deuteranopia β reduced/absent greens</option>
|
| 63 |
-
<option value="tritanopia">Tritanopia β reduced/absent blues</option>
|
| 64 |
-
</select>
|
| 65 |
-
</div>
|
| 66 |
-
<div class="palettes__field">
|
| 67 |
-
<div class="palettes__label-row">
|
| 68 |
-
<label class="palettes__label" for="color-count">Number of colors</label>
|
| 69 |
-
<output id="color-count-out" for="color-count" class="ghost-badge">8</output>
|
| 70 |
-
</div>
|
| 71 |
-
<div class="palettes__count">
|
| 72 |
-
<input id="color-count" type="range" min="6" max="10" step="1" value="8" aria-label="Number of colors" />
|
| 73 |
-
</div>
|
| 74 |
-
</div>
|
| 75 |
-
</div>
|
| 76 |
-
<div class="palettes__grid"></div>
|
| 77 |
-
<div class="palettes__simu" role="group" aria-labelledby="cb-sim-title">
|
| 78 |
-
<!-- Hidden SVG filters used by the page-wide simulation classes -->
|
| 79 |
-
<svg aria-hidden="true" focusable="false" width="0" height="0" style="position:absolute; left:-9999px; overflow:hidden;">
|
| 80 |
-
<defs>
|
| 81 |
-
<!-- Matrices from common color vision deficiency simulations -->
|
| 82 |
-
<filter id="cb-protanopia">
|
| 83 |
-
<feColorMatrix type="matrix" values="0.567 0.433 0 0 0 0.558 0.442 0 0 0 0 0.242 0.758 0 0 0 0 0 1 0"/>
|
| 84 |
-
</filter>
|
| 85 |
-
<filter id="cb-deuteranopia">
|
| 86 |
-
<feColorMatrix type="matrix" values="0.625 0.375 0 0 0 0.7 0.3 0 0 0 0 0.3 0.7 0 0 0 0 0 1 0"/>
|
| 87 |
-
</filter>
|
| 88 |
-
<filter id="cb-tritanopia">
|
| 89 |
-
<feColorMatrix type="matrix" values="0.95 0.05 0 0 0 0 0.433 0.567 0 0 0 0.475 0.525 0 0 0 0 0 1 0"/>
|
| 90 |
-
</filter>
|
| 91 |
-
<filter id="cb-achromatopsia">
|
| 92 |
-
<feColorMatrix type="matrix" values="0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0.299 0.587 0.114 0 0 0 0 0 1 0"/>
|
| 93 |
-
</filter>
|
| 94 |
-
</defs>
|
| 95 |
-
</svg>
|
| 96 |
-
</div>
|
| 97 |
-
</div>
|
| 98 |
-
<script>
|
| 99 |
-
(() => {
|
| 100 |
-
const cards = [
|
| 101 |
-
{ key: 'categorical', title: 'Categorical', desc: 'For <strong>nonβnumeric categories</strong>; <strong>visually distinct</strong> colors. The more you have the more likely they are to look similar.' },
|
| 102 |
-
{ key: 'sequential', title: 'Sequential', desc: 'For <strong>numeric scales</strong>; gradient from <strong>dark to light</strong>. Ideal for <strong>heatmaps</strong>.' },
|
| 103 |
-
{ key: 'diverging', title: 'Diverging', desc: 'Opposing extremes via <strong>base β white β complement</strong>; smooth contrast around a neutral midpoint.' }
|
| 104 |
-
];
|
| 105 |
-
|
| 106 |
-
const getPaletteColors = (key, count) => {
|
| 107 |
-
const total = Number(count) || 6;
|
| 108 |
-
if (window.ColorPalettes && typeof window.ColorPalettes.getColors === 'function') {
|
| 109 |
-
return window.ColorPalettes.getColors(key, total) || [];
|
| 110 |
-
}
|
| 111 |
-
return [];
|
| 112 |
-
};
|
| 113 |
-
|
| 114 |
-
const render = () => {
|
| 115 |
-
const mount = document.currentScript ? document.currentScript.previousElementSibling : null;
|
| 116 |
-
const root = mount && mount.closest('.palettes') ? mount.closest('.palettes') : document.querySelector('.palettes');
|
| 117 |
-
if (!root) return;
|
| 118 |
-
const grid = root.querySelector('.palettes__grid');
|
| 119 |
-
if (!grid) return;
|
| 120 |
-
const input = document.getElementById('color-count');
|
| 121 |
-
const total = input ? Number(input.value) || 6 : 6;
|
| 122 |
-
const html = cards.map((c) => {
|
| 123 |
-
const colors = getPaletteColors(c.key, total);
|
| 124 |
-
const swatches = colors.map(col => `<div class="sw" style="background:${col}"></div>`).join('');
|
| 125 |
-
return `
|
| 126 |
-
<div class="palette-card" data-colors="${colors.join(',')}">
|
| 127 |
-
<div class="palette-card__content">
|
| 128 |
-
<div class="palette-card__content__info">
|
| 129 |
-
<div class="palette-card__title">${c.title}</div>
|
| 130 |
-
<div class="palette-card__desc">${c.desc}</div>
|
| 131 |
-
</div>
|
| 132 |
-
<button class="copy-btn button--ghost" type="button" aria-label="Copy palette">
|
| 133 |
-
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M16 1H4c-1.1 0-2 .9-2 2v12h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>
|
| 134 |
-
</button>
|
| 135 |
-
</div>
|
| 136 |
-
<div class="palette-card__actions"></div>
|
| 137 |
-
<div class="palette-card__swatches" style="grid-template-columns: repeat(${colors.length}, minmax(0, 1fr));">${swatches}</div>
|
| 138 |
-
</div>
|
| 139 |
-
`;
|
| 140 |
-
}).join('');
|
| 141 |
-
grid.innerHTML = html;
|
| 142 |
-
};
|
| 143 |
-
|
| 144 |
-
const MODE_TO_CLASS = { protanopia: 'cb-protanopia', deuteranopia: 'cb-deuteranopia', tritanopia: 'cb-tritanopia', achromatopsia: 'cb-achromatopsia' };
|
| 145 |
-
const CLEAR_CLASSES = Object.values(MODE_TO_CLASS);
|
| 146 |
-
const clearCbClasses = () => {
|
| 147 |
-
const rootEl = document.documentElement;
|
| 148 |
-
CLEAR_CLASSES.forEach(cls => rootEl.classList.remove(cls));
|
| 149 |
-
};
|
| 150 |
-
const applyCbClass = (mode) => {
|
| 151 |
-
clearCbClasses();
|
| 152 |
-
const cls = MODE_TO_CLASS[mode];
|
| 153 |
-
if (cls) document.documentElement.classList.add(cls);
|
| 154 |
-
};
|
| 155 |
-
const currentCbMode = () => {
|
| 156 |
-
const rootEl = document.documentElement;
|
| 157 |
-
for (const [mode, cls] of Object.entries(MODE_TO_CLASS)) { if (rootEl.classList.contains(cls)) return mode; }
|
| 158 |
-
return 'none';
|
| 159 |
-
};
|
| 160 |
-
const setupCbSim = () => {
|
| 161 |
-
const select = document.getElementById('cb-select');
|
| 162 |
-
if (!select) return;
|
| 163 |
-
try { select.value = currentCbMode(); } catch {}
|
| 164 |
-
select.addEventListener('change', () => applyCbClass(select.value));
|
| 165 |
-
};
|
| 166 |
-
|
| 167 |
-
const setupCountControl = () => {
|
| 168 |
-
const input = document.getElementById('color-count');
|
| 169 |
-
const out = document.getElementById('color-count-out');
|
| 170 |
-
if (!input) return;
|
| 171 |
-
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
| 172 |
-
const read = () => clamp(Number(input.value) || 6, 6, 10);
|
| 173 |
-
const syncOut = () => { if (out) out.textContent = String(read()); };
|
| 174 |
-
const onChange = () => { syncOut(); render(); };
|
| 175 |
-
syncOut();
|
| 176 |
-
input.addEventListener('input', onChange);
|
| 177 |
-
document.addEventListener('palettes:updated', () => { syncOut(); render(); });
|
| 178 |
-
};
|
| 179 |
-
|
| 180 |
-
let copyDelegationSetup = false;
|
| 181 |
-
const setupCopyDelegation = () => {
|
| 182 |
-
if (copyDelegationSetup) return;
|
| 183 |
-
const root = document.querySelector('.palettes');
|
| 184 |
-
if (!root) return;
|
| 185 |
-
const grid = root.querySelector('.palettes__grid');
|
| 186 |
-
if (!grid) return;
|
| 187 |
-
grid.addEventListener('click', async (e) => {
|
| 188 |
-
const target = e.target.closest ? e.target.closest('.copy-btn') : null;
|
| 189 |
-
if (!target) return;
|
| 190 |
-
const card = target.closest('.palette-card');
|
| 191 |
-
if (!card) return;
|
| 192 |
-
const colors = (card.dataset.colors || '').split(',').filter(Boolean);
|
| 193 |
-
const json = JSON.stringify(colors, null, 2);
|
| 194 |
-
try {
|
| 195 |
-
await navigator.clipboard.writeText(json);
|
| 196 |
-
const old = target.innerHTML;
|
| 197 |
-
target.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M9 16.2l-3.5-3.5-1.4 1.4L9 19 20.3 7.7l-1.4-1.4z"/></svg>';
|
| 198 |
-
setTimeout(() => target.innerHTML = old, 900);
|
| 199 |
-
} catch {
|
| 200 |
-
window.prompt('Copy palette', json);
|
| 201 |
-
}
|
| 202 |
-
});
|
| 203 |
-
copyDelegationSetup = true;
|
| 204 |
-
};
|
| 205 |
-
|
| 206 |
-
const bootstrap = () => {
|
| 207 |
-
setupCbSim();
|
| 208 |
-
setupCountControl();
|
| 209 |
-
render();
|
| 210 |
-
setupCopyDelegation();
|
| 211 |
-
// Re-render when primary color changes
|
| 212 |
-
document.addEventListener('palettes:updated', render);
|
| 213 |
-
};
|
| 214 |
-
|
| 215 |
-
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
|
| 216 |
-
else bootstrap();
|
| 217 |
-
})();
|
| 218 |
-
</script>
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|