dylanebert HF staff commited on
Commit
62c50cd
·
1 Parent(s): d898c0f

topology toggle, brighter models, more layout improvements

Browse files
src/routes/ModelDetails.svelte CHANGED
@@ -67,41 +67,35 @@
67
 
68
  <div class="model-details">
69
  {#if config && (config.Model || config.Space || config.Paper)}
70
- <table class="config-table">
71
  {#if config.Model}
72
- <tr>
73
- <td>Model:</td>
74
- <td
75
- ><a class="muted" href={config.Model} target="_blank"
76
- >{config.Model.replace("https://huggingface.co/", "")}</a
77
- ></td
78
- >
79
- </tr>
80
  {/if}
81
  {#if config.Space}
82
- <tr>
83
- <td>Space:</td>
84
- <td
85
- ><a class="muted" href={config.Space} target="_blank"
86
- >{config.Space.replace("https://huggingface.co/spaces/", "")}</a
87
- ></td
88
- >
89
- </tr>
90
  {/if}
91
  {#if config.Paper}
92
- <tr>
93
- <td>Paper:</td>
94
- <td
95
- ><a class="muted" href={config.Paper} target="_blank"
96
- >{config.Paper.replace("https://huggingface.co/papers/", "").replace(
97
- "https://arxiv.org/abs/",
98
- ""
99
- )}</a
100
- ></td
101
- >
102
- </tr>
103
  {/if}
104
- </table>
105
  {/if}
106
 
107
  {#if scenes.length > 0}
 
67
 
68
  <div class="model-details">
69
  {#if config && (config.Model || config.Space || config.Paper)}
70
+ <div class="config-container">
71
  {#if config.Model}
72
+ <div class="config-item">
73
+ <span class="config-label">Model:</span>
74
+ <a class="muted" href={config.Model} target="_blank">
75
+ {config.Model.replace("https://huggingface.co/", "")}
76
+ </a>
77
+ </div>
 
 
78
  {/if}
79
  {#if config.Space}
80
+ <div class="config-item">
81
+ <span class="config-label">Space:</span>
82
+ <a class="muted" href={config.Space} target="_blank">
83
+ {config.Space.replace("https://huggingface.co/spaces/", "")}
84
+ </a>
85
+ </div>
 
 
86
  {/if}
87
  {#if config.Paper}
88
+ <div class="config-item">
89
+ <span class="config-label">Paper:</span>
90
+ <a class="muted" href={config.Paper} target="_blank">
91
+ {config.Paper.replace("https://huggingface.co/papers/", "").replace(
92
+ "https://arxiv.org/abs/",
93
+ ""
94
+ )}
95
+ </a>
96
+ </div>
 
 
97
  {/if}
98
+ </div>
99
  {/if}
100
 
101
  {#if scenes.length > 0}
src/routes/Viewer.svelte CHANGED
@@ -2,7 +2,7 @@
2
  import { onMount, onDestroy } from "svelte";
3
  import type { IViewer } from "./viewers/IViewer";
4
  import { createViewer } from "./viewers/ViewerFactory";
5
- import ArrowLeft from "carbon-icons-svelte/lib/ArrowLeft.svelte";
6
 
7
  interface Scene {
8
  name: string;
@@ -35,10 +35,8 @@
35
  function handleResize() {
36
  if (!canvas || !container) return;
37
  requestAnimationFrame(() => {
38
- const maxWidth = container.clientHeight * 16 / 9;
39
- const maxHeight = container.clientWidth * 9 / 16;
40
- canvas.width = Math.min(container.clientWidth, maxWidth);
41
- canvas.height = Math.min(container.clientHeight, maxHeight);
42
  });
43
  }
44
 
@@ -89,5 +87,21 @@
89
  <div bind:this={loadingBarFill} class="loading-bar-fill" />
90
  </div>
91
  </div>
92
- <canvas class="viewer-canvas" bind:this={canvas} width={800} height={600} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  </div>
 
2
  import { onMount, onDestroy } from "svelte";
3
  import type { IViewer } from "./viewers/IViewer";
4
  import { createViewer } from "./viewers/ViewerFactory";
5
+ import { ArrowLeft, Cube, WatsonHealth3DPrintMesh } from "carbon-icons-svelte";
6
 
7
  interface Scene {
8
  name: string;
 
35
  function handleResize() {
36
  if (!canvas || !container) return;
37
  requestAnimationFrame(() => {
38
+ canvas.width = container.clientWidth;
39
+ canvas.height = container.clientHeight;
 
 
40
  });
41
  }
42
 
 
87
  <div bind:this={loadingBarFill} class="loading-bar-fill" />
88
  </div>
89
  </div>
90
+ <canvas class="viewer-canvas" bind:this={canvas} width={800} height={600}> </canvas>
91
+ <div class="mode-toggle">
92
+ <label>
93
+ <input
94
+ type="radio"
95
+ name="modeB"
96
+ value="default"
97
+ checked
98
+ on:change={() => viewer.setRenderMode("default")}
99
+ />
100
+ <Cube class="mode-toggle-icon" />
101
+ </label>
102
+ <label>
103
+ <input type="radio" name="modeB" value="wireframe" on:change={() => viewer.setRenderMode("wireframe")} />
104
+ <WatsonHealth3DPrintMesh class="mode-toggle-icon" />
105
+ </label>
106
+ </div>
107
  </div>
src/routes/Vote.svelte CHANGED
@@ -3,6 +3,7 @@
3
  import { v4 as uuidv4 } from "uuid";
4
  import type { IViewer } from "./viewers/IViewer";
5
  import { createViewer } from "./viewers/ViewerFactory";
 
6
 
7
  interface Data {
8
  input: string;
@@ -79,15 +80,14 @@
79
  const model2_path = `${baseUrl}${data.model2_path}`;
80
 
81
  try {
82
- const promises = [
83
  createViewer(model1_path, canvasA, (progress) => {
84
  loadingBarFillA.style.width = `${progress * 100}%`;
85
  }),
86
  createViewer(model2_path, canvasB, (progress) => {
87
  loadingBarFillB.style.width = `${progress * 100}%`;
88
  }),
89
- ];
90
- await Promise.all(promises);
91
 
92
  window.addEventListener("resize", handleResize);
93
  handleResize();
@@ -138,20 +138,20 @@
138
  function handleResize() {
139
  requestAnimationFrame(() => {
140
  if (canvasA && containerA) {
141
- const maxWidth = (containerA.clientHeight * 16) / 9;
142
- const maxHeight = (containerA.clientWidth * 9) / 16;
143
- canvasA.width = Math.min(containerA.clientWidth, maxWidth);
144
- canvasA.height = Math.min(containerA.clientHeight, maxHeight);
145
  }
146
  if (canvasB && containerB) {
147
- const maxWidth = (containerB.clientHeight * 16) / 9;
148
- const maxHeight = (containerB.clientWidth * 9) / 16;
149
- canvasB.width = Math.min(containerB.clientWidth, maxWidth);
150
- canvasB.height = Math.min(containerB.clientHeight, maxHeight);
151
  }
152
  });
153
  }
154
 
 
 
 
 
155
  onMount(loadScenes);
156
 
157
  onDestroy(() => {
@@ -169,26 +169,71 @@
169
  <p class="center-title muted">{statusMessage}</p>
170
  {:else}
171
  <h2 class="center-title">Which is better?</h2>
 
172
  <div class="voting-container">
173
- <div bind:this={containerA} class="voting-canvas-wrapper">
174
  <div bind:this={overlayA} class="loading-overlay">
175
  <div class="loading-bar">
176
  <div bind:this={loadingBarFillA} class="loading-bar-fill" />
177
  </div>
178
  </div>
179
- <canvas bind:this={canvasA} class="voting-canvas" id="canvas1"></canvas>
180
- <button class="vote-button" on:click={() => vote("A")}>A is Better</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  </div>
182
- <div bind:this={containerB} class="voting-canvas-wrapper">
183
  <div bind:this={overlayB} class="loading-overlay">
184
  <div class="loading-bar">
185
  <div bind:this={loadingBarFillB} class="loading-bar-fill" />
186
  </div>
187
  </div>
188
- <canvas bind:this={canvasB} class="voting-canvas" id="canvas2"></canvas>
189
- <button class="vote-button" on:click={() => vote("B")}>B is Better</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  </div>
191
  </div>
 
 
 
 
192
  <div class="skip-container">
193
  <button class="vote-button" on:click={() => loadScenes()}>Skip</button>
194
  </div>
 
3
  import { v4 as uuidv4 } from "uuid";
4
  import type { IViewer } from "./viewers/IViewer";
5
  import { createViewer } from "./viewers/ViewerFactory";
6
+ import { Cube, WatsonHealth3DPrintMesh } from "carbon-icons-svelte";
7
 
8
  interface Data {
9
  input: string;
 
80
  const model2_path = `${baseUrl}${data.model2_path}`;
81
 
82
  try {
83
+ [viewerA, viewerB] = await Promise.all([
84
  createViewer(model1_path, canvasA, (progress) => {
85
  loadingBarFillA.style.width = `${progress * 100}%`;
86
  }),
87
  createViewer(model2_path, canvasB, (progress) => {
88
  loadingBarFillB.style.width = `${progress * 100}%`;
89
  }),
90
+ ]);
 
91
 
92
  window.addEventListener("resize", handleResize);
93
  handleResize();
 
138
  function handleResize() {
139
  requestAnimationFrame(() => {
140
  if (canvasA && containerA) {
141
+ canvasA.width = containerA.clientWidth;
142
+ canvasA.height = containerA.clientHeight;
 
 
143
  }
144
  if (canvasB && containerB) {
145
+ canvasB.width = containerB.clientWidth;
146
+ canvasB.height = containerB.clientHeight;
 
 
147
  }
148
  });
149
  }
150
 
151
+ function setRenderMode(viewer: IViewer, mode: string) {
152
+ viewer.setRenderMode(mode);
153
+ }
154
+
155
  onMount(loadScenes);
156
 
157
  onDestroy(() => {
 
169
  <p class="center-title muted">{statusMessage}</p>
170
  {:else}
171
  <h2 class="center-title">Which is better?</h2>
172
+ <p class="center-subtitle">Use mouse/touch to change the view.</p>
173
  <div class="voting-container">
174
+ <div bind:this={containerA} class="canvas-container">
175
  <div bind:this={overlayA} class="loading-overlay">
176
  <div class="loading-bar">
177
  <div bind:this={loadingBarFillA} class="loading-bar-fill" />
178
  </div>
179
  </div>
180
+ <canvas bind:this={canvasA} class="viewer-canvas" id="canvas1"> </canvas>
181
+ <div class="mode-toggle">
182
+ <label>
183
+ <input
184
+ type="radio"
185
+ name="modeA"
186
+ value="default"
187
+ checked
188
+ on:change={() => setRenderMode(viewerA, "default")}
189
+ />
190
+ <Cube class="mode-toggle-icon" />
191
+ </label>
192
+ <label>
193
+ <input
194
+ type="radio"
195
+ name="modeA"
196
+ value="wireframe"
197
+ on:change={() => setRenderMode(viewerA, "wireframe")}
198
+ />
199
+ <WatsonHealth3DPrintMesh class="mode-toggle-icon" />
200
+ </label>
201
+ </div>
202
  </div>
203
+ <div bind:this={containerB} class="canvas-container">
204
  <div bind:this={overlayB} class="loading-overlay">
205
  <div class="loading-bar">
206
  <div bind:this={loadingBarFillB} class="loading-bar-fill" />
207
  </div>
208
  </div>
209
+ <canvas bind:this={canvasB} class="viewer-canvas" id="canvas2"></canvas>
210
+ <div class="mode-toggle">
211
+ <label>
212
+ <input
213
+ type="radio"
214
+ name="modeB"
215
+ value="default"
216
+ checked
217
+ on:change={() => setRenderMode(viewerB, "default")}
218
+ />
219
+ <Cube class="mode-toggle-icon" />
220
+ </label>
221
+ <label>
222
+ <input
223
+ type="radio"
224
+ name="modeB"
225
+ value="wireframe"
226
+ on:change={() => setRenderMode(viewerB, "wireframe")}
227
+ />
228
+ <WatsonHealth3DPrintMesh class="mode-toggle-icon" />
229
+ </label>
230
+ </div>
231
  </div>
232
  </div>
233
+ <div class="vote-buttons-container">
234
+ <button class="vote-button" on:click={() => vote("A")}>A is Better</button>
235
+ <button class="vote-button" on:click={() => vote("B")}>B is Better</button>
236
+ </div>
237
  <div class="skip-container">
238
  <button class="vote-button" on:click={() => loadScenes()}>Skip</button>
239
  </div>
src/routes/viewers/BabylonViewer.ts CHANGED
@@ -12,6 +12,14 @@ export class BabylonViewer implements IViewer {
12
 
13
  triangleCount: number = 0;
14
 
 
 
 
 
 
 
 
 
15
  constructor(canvas: HTMLCanvasElement) {
16
  this.canvas = canvas;
17
 
@@ -19,6 +27,7 @@ export class BabylonViewer implements IViewer {
19
 
20
  this.scene = new BABYLON.Scene(this.engine);
21
  this.scene.clearColor = BABYLON.Color4.FromHexString("#000000FF");
 
22
 
23
  this.camera = new BABYLON.ArcRotateCamera(
24
  "camera",
@@ -68,13 +77,13 @@ export class BabylonViewer implements IViewer {
68
  });
69
 
70
  // Add lights
71
- const light = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), this.scene);
72
- light.intensity = 1;
73
- light.diffuse = new BABYLON.Color3(1, 1, 1);
74
- light.groundColor = new BABYLON.Color3(0.3, 0.3, 0.3);
75
 
76
  const sun = new BABYLON.DirectionalLight("sun", new BABYLON.Vector3(-0.5, -1, -0.5), this.scene);
77
- sun.intensity = 2;
78
  sun.diffuse = new BABYLON.Color3(1, 1, 1);
79
 
80
  // Center and scale model
@@ -123,6 +132,7 @@ export class BabylonViewer implements IViewer {
123
  if (this.engine) {
124
  this.engine.dispose();
125
  }
 
126
  window.removeEventListener("resize", this.handleResize);
127
  }
128
 
@@ -141,6 +151,30 @@ export class BabylonViewer implements IViewer {
141
 
142
  setRenderMode(mode: string) {
143
  this.scene.forceWireframe = mode === "wireframe";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  }
145
 
146
  getStats(): { name: string; value: any }[] {
 
12
 
13
  triangleCount: number = 0;
14
 
15
+ private _originalColors: Map<
16
+ BABYLON.AbstractMesh,
17
+ {
18
+ albedoColor: BABYLON.Color3;
19
+ emissiveColor: BABYLON.Color3;
20
+ }
21
+ > = new Map();
22
+
23
  constructor(canvas: HTMLCanvasElement) {
24
  this.canvas = canvas;
25
 
 
27
 
28
  this.scene = new BABYLON.Scene(this.engine);
29
  this.scene.clearColor = BABYLON.Color4.FromHexString("#000000FF");
30
+ this.scene.imageProcessingConfiguration.exposure = 3;
31
 
32
  this.camera = new BABYLON.ArcRotateCamera(
33
  "camera",
 
77
  });
78
 
79
  // Add lights
80
+ const hemi = new BABYLON.HemisphericLight("hemi", new BABYLON.Vector3(0, 1, 0), this.scene);
81
+ hemi.intensity = 0.5;
82
+ hemi.diffuse = new BABYLON.Color3(1, 1, 1);
83
+ hemi.groundColor = new BABYLON.Color3(1, 1, 1);
84
 
85
  const sun = new BABYLON.DirectionalLight("sun", new BABYLON.Vector3(-0.5, -1, -0.5), this.scene);
86
+ sun.intensity = 1;
87
  sun.diffuse = new BABYLON.Color3(1, 1, 1);
88
 
89
  // Center and scale model
 
132
  if (this.engine) {
133
  this.engine.dispose();
134
  }
135
+ this._originalColors.clear();
136
  window.removeEventListener("resize", this.handleResize);
137
  }
138
 
 
151
 
152
  setRenderMode(mode: string) {
153
  this.scene.forceWireframe = mode === "wireframe";
154
+ if (mode === "wireframe") {
155
+ this.scene.meshes.forEach((mesh) => {
156
+ const material = mesh.material as BABYLON.PBRMaterial;
157
+ if (material) {
158
+ this._originalColors.set(mesh, {
159
+ albedoColor: material.albedoColor,
160
+ emissiveColor: material.emissiveColor,
161
+ });
162
+ material.albedoColor = new BABYLON.Color3(0, 0, 0);
163
+ material.emissiveColor = new BABYLON.Color3(1, 0.65, 0);
164
+ }
165
+ });
166
+ } else {
167
+ this.scene.meshes.forEach((mesh) => {
168
+ const material = mesh.material as BABYLON.PBRMaterial;
169
+ if (material) {
170
+ const originalColors = this._originalColors.get(mesh);
171
+ if (originalColors) {
172
+ material.albedoColor = originalColors.albedoColor;
173
+ material.emissiveColor = originalColors.emissiveColor;
174
+ }
175
+ }
176
+ });
177
+ }
178
  }
179
 
180
  getStats(): { name: string; value: any }[] {
src/routes/viewers/IViewer.ts CHANGED
@@ -2,5 +2,6 @@ export interface IViewer {
2
  loadScene(url: string, onProgress?: (progress: number) => void): Promise<void>;
3
  dispose(): void;
4
  capture(): Promise<string | null>;
 
5
  getStats(): { name: string; value: any }[];
6
  }
 
2
  loadScene(url: string, onProgress?: (progress: number) => void): Promise<void>;
3
  dispose(): void;
4
  capture(): Promise<string | null>;
5
+ setRenderMode(mode: string): void;
6
  getStats(): { name: string; value: any }[];
7
  }
src/routes/viewers/SplatViewer.ts CHANGED
@@ -8,6 +8,7 @@ export class SplatViewer implements IViewer {
8
  scene: SPLAT.Scene;
9
  camera: SPLAT.Camera;
10
  controls: SPLAT.OrbitControls;
 
11
 
12
  disposed: boolean = false;
13
 
@@ -19,17 +20,18 @@ export class SplatViewer implements IViewer {
19
  this.camera = new SPLAT.Camera();
20
  this.controls = new SPLAT.OrbitControls(this.camera, canvas);
21
  this.controls.orbitSpeed = 3.0;
 
22
 
23
  this.handleResize = this.handleResize.bind(this);
24
  }
25
 
26
  async loadScene(url: string, loadingBarCallback?: (progress: number) => void) {
27
  if (url.endsWith(".splat")) {
28
- await SPLAT.Loader.LoadAsync(url, this.scene, (progress) => {
29
  loadingBarCallback?.(progress);
30
  });
31
  } else if (url.endsWith(".ply")) {
32
- await SPLAT.PLYLoader.LoadAsync(url, this.scene, (progress) => {
33
  loadingBarCallback?.(progress);
34
  });
35
  } else {
@@ -96,6 +98,16 @@ export class SplatViewer implements IViewer {
96
  });
97
  }
98
 
 
 
 
 
 
 
 
 
 
 
99
  getStats(): { name: string; value: any }[] {
100
  return [];
101
  }
 
8
  scene: SPLAT.Scene;
9
  camera: SPLAT.Camera;
10
  controls: SPLAT.OrbitControls;
11
+ splat: SPLAT.Splat | null;
12
 
13
  disposed: boolean = false;
14
 
 
20
  this.camera = new SPLAT.Camera();
21
  this.controls = new SPLAT.OrbitControls(this.camera, canvas);
22
  this.controls.orbitSpeed = 3.0;
23
+ this.splat = null;
24
 
25
  this.handleResize = this.handleResize.bind(this);
26
  }
27
 
28
  async loadScene(url: string, loadingBarCallback?: (progress: number) => void) {
29
  if (url.endsWith(".splat")) {
30
+ this.splat = await SPLAT.Loader.LoadAsync(url, this.scene, (progress) => {
31
  loadingBarCallback?.(progress);
32
  });
33
  } else if (url.endsWith(".ply")) {
34
+ this.splat = await SPLAT.PLYLoader.LoadAsync(url, this.scene, (progress) => {
35
  loadingBarCallback?.(progress);
36
  });
37
  } else {
 
98
  });
99
  }
100
 
101
+ setRenderMode(mode: string): void {
102
+ if (!this.splat) return;
103
+
104
+ if (mode === "wireframe") {
105
+ this.splat.selected = true;
106
+ } else {
107
+ this.splat.selected = false;
108
+ }
109
+ }
110
+
111
  getStats(): { name: string; value: any }[] {
112
  return [];
113
  }
static/global.css CHANGED
@@ -66,52 +66,37 @@ body {
66
  }
67
  }
68
 
69
- .config-table {
70
- margin: 1rem auto;
71
- }
72
-
73
- @media (min-width: 768px) {
74
- .config-table tr {
75
- display: inline-block;
76
- margin-right: 1rem;
77
- }
78
- }
79
-
80
  .container {
81
- padding: 10px 15px 80px 15px;
 
82
  margin-left: auto;
83
  margin-right: auto;
84
  height: 100vh;
85
- overflow-y: auto;
86
  position: relative;
87
  box-sizing: border-box;
88
 
89
  @media (min-width: 576px) {
90
- max-width: 540px;
91
  }
92
 
93
  @media (min-width: 768px) {
94
- max-width: 720px;
95
  }
96
 
97
  @media (min-width: 992px) {
98
- max-width: 960px;
99
  }
100
 
101
  @media (min-width: 1200px) {
102
- max-width: 1140px;
103
  }
104
  }
105
 
106
  .canvas-container {
107
  position: relative;
108
- box-sizing: border-box;
109
- display: flex;
110
- flex-direction: column;
111
- justify-content: center;
112
- align-items: center;
113
  width: 100%;
114
- max-height: 50%;
115
  overflow: hidden;
116
  }
117
 
@@ -174,6 +159,16 @@ body {
174
  transform: scale(1.2);
175
  }
176
 
 
 
 
 
 
 
 
 
 
 
177
  .spacer {
178
  flex: 1;
179
  }
@@ -214,19 +209,11 @@ body {
214
  width: 100%;
215
 
216
  @media (min-width: 576px) {
217
- width: calc(33.333% - 10px);
218
- }
219
-
220
- @media (min-width: 768px) {
221
- width: calc(25% - 10px);
222
  }
223
 
224
  @media (min-width: 992px) {
225
- width: calc(20% - 10px);
226
- }
227
-
228
- @media (min-width: 1200px) {
229
- width: calc(16.666% - 10px);
230
  }
231
  }
232
 
@@ -329,33 +316,35 @@ body {
329
  text-align: center;
330
  display: block;
331
  margin: 0 auto;
332
- padding: 10px;
333
  }
334
 
335
- .voting-container {
336
- display: flex;
337
- justify-content: space-around;
 
 
 
338
  }
339
 
340
- .voting-canvas-wrapper {
341
- position: relative;
342
  display: flex;
343
- flex-direction: column;
344
- align-items: center;
345
- width: 100%;
346
- max-width: 50%;
347
- box-sizing: border-box;
348
- padding: 10px;
349
  }
350
 
351
- .voting-canvas {
352
- width: 100%;
353
- height: auto;
354
- aspect-ratio: 16 / 9;
355
  }
356
 
357
  .viewer-canvas {
358
- aspect-ratio: 16 / 9;
 
 
 
 
359
  }
360
 
361
  .vote-button {
@@ -363,7 +352,7 @@ body {
363
  color: #fff;
364
  border: 1px solid #444;
365
  outline: none;
366
- padding: 10px 10px 10px 10px;
367
  font-family: "Roboto", sans-serif;
368
  font-size: 14px;
369
  font-weight: 600;
@@ -378,8 +367,40 @@ body {
378
  }
379
 
380
  .skip-container {
381
- max-width: 50%;
382
- margin: 0 auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  }
384
 
385
  .tabs {
 
66
  }
67
  }
68
 
 
 
 
 
 
 
 
 
 
 
 
69
  .container {
70
+ display: flex;
71
+ flex-direction: column;
72
  margin-left: auto;
73
  margin-right: auto;
74
  height: 100vh;
75
+ overflow: hidden;
76
  position: relative;
77
  box-sizing: border-box;
78
 
79
  @media (min-width: 576px) {
80
+ max-width: 320px;
81
  }
82
 
83
  @media (min-width: 768px) {
84
+ max-width: 480px;
85
  }
86
 
87
  @media (min-width: 992px) {
88
+ max-width: 600px;
89
  }
90
 
91
  @media (min-width: 1200px) {
92
+ max-width: 800px;
93
  }
94
  }
95
 
96
  .canvas-container {
97
  position: relative;
 
 
 
 
 
98
  width: 100%;
99
+ padding-top: 56.25%;
100
  overflow: hidden;
101
  }
102
 
 
159
  transform: scale(1.2);
160
  }
161
 
162
+ .config-container {
163
+ display: flex;
164
+ flex-direction: row;
165
+ gap: 10px;
166
+ justify-content: center;
167
+ align-items: center;
168
+ padding: 10px;
169
+ flex-wrap: wrap;
170
+ }
171
+
172
  .spacer {
173
  flex: 1;
174
  }
 
209
  width: 100%;
210
 
211
  @media (min-width: 576px) {
212
+ width: calc(50% - 10px);
 
 
 
 
213
  }
214
 
215
  @media (min-width: 992px) {
216
+ width: calc(33.333% - 10px);
 
 
 
 
217
  }
218
  }
219
 
 
316
  text-align: center;
317
  display: block;
318
  margin: 0 auto;
319
+ padding-top: 10px 0 0 0;
320
  }
321
 
322
+ .center-subtitle {
323
+ text-align: center;
324
+ display: block;
325
+ margin: 0 auto;
326
+ padding: 10px 10px 20px 10px;
327
+ color: #aaa;
328
  }
329
 
330
+ .vote-buttons-container {
 
331
  display: flex;
332
+ flex-direction: row;
333
+ gap: 10px;
 
 
 
 
334
  }
335
 
336
+ .voting-container {
337
+ display: flex;
338
+ justify-content: space-around;
339
+ gap: 10px;
340
  }
341
 
342
  .viewer-canvas {
343
+ position: absolute;
344
+ top: 0;
345
+ left: 0;
346
+ width: 100%;
347
+ height: 100%;
348
  }
349
 
350
  .vote-button {
 
352
  color: #fff;
353
  border: 1px solid #444;
354
  outline: none;
355
+ padding: 10px;
356
  font-family: "Roboto", sans-serif;
357
  font-size: 14px;
358
  font-weight: 600;
 
367
  }
368
 
369
  .skip-container {
370
+ width: 50%;
371
+ margin: 10px auto;
372
+ }
373
+
374
+ .mode-toggle {
375
+ position: absolute;
376
+ top: 0;
377
+ right: 0;
378
+ display: flex;
379
+ flex-direction: column;
380
+ padding: 10px;
381
+ box-sizing: border-box;
382
+ }
383
+
384
+ .mode-toggle input[type="radio"] {
385
+ display: none;
386
+ }
387
+
388
+ .mode-toggle-icon {
389
+ padding: 10px;
390
+ color: #aaa;
391
+ background-color: #1a1b1e;
392
+ width: 14px;
393
+ height: 14px;
394
+ transition: background-color 0.2s ease;
395
+ }
396
+
397
+ .mode-toggle-icon:hover {
398
+ cursor: pointer;
399
+ background-color: #333;
400
+ }
401
+
402
+ .mode-toggle input[type="radio"]:checked + .mode-toggle-icon {
403
+ background-color: #444;
404
  }
405
 
406
  .tabs {