aaurelions commited on
Commit
c558623
·
verified ·
1 Parent(s): 2da99ab

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +422 -19
index.html CHANGED
@@ -1,19 +1,422 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
6
+ <title>Futuristic Dataset Visualizer v4</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js"></script>
10
+ <style>
11
+ @keyframes aurora {
12
+ 0% { background-position: 0% 50%; }
13
+ 50% { background-position: 100% 50%; }
14
+ 100% { background-position: 0% 50%; }
15
+ }
16
+ html { scroll-behavior: smooth; }
17
+ body {
18
+ font-family: 'SF Mono', 'Courier New', Courier, monospace;
19
+ background-color: #0D1117;
20
+ color: #E6EDF3;
21
+ background-image: radial-gradient(at 27% 37%, hsla(215, 98%, 61%, 0.1) 0px, transparent 50%),
22
+ radial-gradient(at 97% 21%, hsla(125, 98%, 72%, 0.1) 0px, transparent 50%);
23
+ background-size: 250% 250%;
24
+ animation: aurora 20s ease infinite;
25
+ }
26
+ .glass-card {
27
+ background: rgba(17, 25, 40, 0.7);
28
+ backdrop-filter: blur(16px) saturate(180%);
29
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
30
+ border: 1px solid rgba(255, 255, 255, 0.1);
31
+ }
32
+ .btn-glow:hover {
33
+ box-shadow: 0 0 20px rgba(59, 130, 246, 0.5), 0 0 8px rgba(59, 130, 246, 0.4);
34
+ }
35
+ .card-glow:hover {
36
+ transform: translateY(-4px);
37
+ box-shadow: 0 0 25px rgba(59, 130, 246, 0.3), 0 0 10px rgba(59, 130, 246, 0.2);
38
+ }
39
+ .modal { background: rgba(13, 17, 23, 0.85); backdrop-filter: blur(16px); }
40
+ #modal-canvas { cursor: grab; touch-action: none; }
41
+ #modal-canvas:active { cursor: grabbing; }
42
+ </style>
43
+ </head>
44
+ <body class="overscroll-none">
45
+
46
+ <div class="container mx-auto p-4 lg:p-8">
47
+
48
+ <header class="text-center mb-10">
49
+ <h1 class="text-3xl md:text-5xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-teal-300 mb-2">Dataset Visualizer v4</h1>
50
+ <p class="text-gray-400">Inspect, pan, and zoom your vision datasets with precision.</p>
51
+ </header>
52
+
53
+ <!-- Control Panel -->
54
+ <div class="glass-card p-4 sm:p-6 rounded-xl mb-10 max-w-3xl mx-auto">
55
+ <div id="control-panel-content">
56
+ <h2 class="text-2xl font-semibold text-blue-300 mb-4 text-center">Get Started</h2>
57
+ <label for="zip-file" class="btn-glow bg-blue-600 text-white flex items-center justify-center w-full p-4 rounded-lg cursor-pointer text-xl transition-all duration-300">
58
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 mr-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
59
+ Upload dataset.zip
60
+ </label>
61
+ <input type="file" id="zip-file" accept=".zip" class="hidden">
62
+ <p id="zip-file-name" class="text-center text-gray-500 mt-2 text-sm h-5"></p>
63
+ <div id="status-container" class="h-6 text-center mt-2"></div>
64
+ </div>
65
+ <div id="labels-panel" class="hidden mt-4 pt-4 border-t border-gray-700">
66
+ <h3 class="text-lg font-semibold text-blue-300 mb-2">Class Labels (Optional)</h3>
67
+ <textarea id="yaml-paste" rows="4" class="w-full bg-gray-900 border border-gray-600 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Paste COCO .yaml content here..."></textarea>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Image Grid -->
72
+ <div id="image-container-wrapper">
73
+ <div id="empty-state" class="text-center glass-card p-10 rounded-xl">
74
+ <h3 class="text-2xl text-gray-400">Your visualizer is ready.</h3>
75
+ <p class="text-gray-500">Upload a dataset to begin.</p>
76
+ </div>
77
+ <div id="image-container" class="hidden grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-6"></div>
78
+ </div>
79
+
80
+ <div id="pagination" class="flex justify-center items-center space-x-2 mt-10"></div>
81
+ </div>
82
+
83
+ <!-- Modal -->
84
+ <div id="modal-backdrop" class="fixed inset-0 z-50 flex items-center justify-center hidden">
85
+ <div class="modal w-full h-full relative flex items-center justify-center overflow-hidden">
86
+ <!-- Loading Spinner for Modal -->
87
+ <div id="modal-spinner" class="absolute z-10">
88
+ <svg class="animate-spin h-10 w-10 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
89
+ </div>
90
+
91
+ <!-- THIS IS THE HTML FIX: Added w-full and h-full classes -->
92
+ <canvas id="modal-canvas" class="absolute top-0 left-0 w-full h-full"></canvas>
93
+
94
+ <div id="modal-legend" class="absolute bottom-4 left-4 glass-card p-3 rounded-lg max-h-48 overflow-y-auto text-sm z-20 hidden"></div>
95
+
96
+ <!-- Modal Controls -->
97
+ <div class="absolute top-4 right-4 z-20 flex flex-col gap-2">
98
+ <button id="modal-close" class="bg-red-600/80 text-white rounded-full w-10 h-10 flex items-center justify-center text-3xl font-bold">×</button>
99
+ </div>
100
+ <div class="absolute bottom-4 right-4 z-20 glass-card rounded-lg flex items-center">
101
+ <button id="zoom-out-btn" class="p-3 text-2xl">-</button>
102
+ <button id="reset-view-btn" class="p-3">
103
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd" /></svg>
104
+ </button>
105
+ <button id="zoom-in-btn" class="p-3 text-2xl">+</button>
106
+ <button id="fullscreen-btn" class="p-3 ml-2 border-l border-gray-600">
107
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 4a1 1 0 011-1h4a1 1 0 110 2H5v2a1 1 0 11-2 0V4zm12 0a1 1 0 011 1v2a1 1 0 11-2 0V5h-2a1 1 0 110-2h4zM4 17a1 1 0 01-1-1v-2a1 1 0 112 0v2h2a1 1 0 110 2H4zm12 0a1 1 0 01-1 1h-4a1 1 0 110-2h2v-2a1 1 0 112 0v2z" clip-rule="evenodd" /></svg>
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ <script>
114
+ const dom = {
115
+ zipFileInput: document.getElementById('zip-file'), zipFileNameDisplay: document.getElementById('zip-file-name'),
116
+ yamlPasteArea: document.getElementById('yaml-paste'),
117
+ imageContainer: document.getElementById('image-container'), emptyState: document.getElementById('empty-state'),
118
+ paginationContainer: document.getElementById('pagination'), statusContainer: document.getElementById('status-container'),
119
+ labelsPanel: document.getElementById('labels-panel'),
120
+ modal: {
121
+ backdrop: document.getElementById('modal-backdrop'), canvas: document.getElementById('modal-canvas'),
122
+ closeBtn: document.getElementById('modal-close'), legend: document.getElementById('modal-legend'),
123
+ spinner: document.getElementById('modal-spinner'),
124
+ zoomInBtn: document.getElementById('zoom-in-btn'), zoomOutBtn: document.getElementById('zoom-out-btn'),
125
+ resetViewBtn: document.getElementById('reset-view-btn'), fullscreenBtn: document.getElementById('fullscreen-btn'),
126
+ }
127
+ };
128
+
129
+ let state = {
130
+ allImageData: [], classNames: {}, classColors: {}, currentPage: 1, imagesPerPage: 20,
131
+ modal: { transform: new DOMMatrix(), initialTransform: new DOMMatrix(), isPanning: false, lastPos: { x: 0, y: 0 }, img: null, labelText: ''}
132
+ };
133
+
134
+ // --- Event Listeners ---
135
+ dom.zipFileInput.addEventListener('change', handleZipUpload);
136
+ dom.yamlPasteArea.addEventListener('input', handleYamlPaste);
137
+ dom.modal.backdrop.addEventListener('click', (e) => e.target === dom.modal.backdrop && closeModal());
138
+ dom.modal.closeBtn.addEventListener('click', closeModal);
139
+ dom.modal.canvas.addEventListener('mousedown', panStart);
140
+ dom.modal.canvas.addEventListener('mousemove', panMove);
141
+ dom.modal.canvas.addEventListener('mouseup', panEnd);
142
+ dom.modal.canvas.addEventListener('mouseout', panEnd);
143
+ dom.modal.canvas.addEventListener('wheel', handleZoom);
144
+ dom.modal.canvas.addEventListener('touchstart', touchStart);
145
+ dom.modal.canvas.addEventListener('touchmove', touchMove);
146
+ dom.modal.canvas.addEventListener('touchend', panEnd);
147
+ dom.modal.zoomInBtn.addEventListener('click', () => applyZoom(1.2));
148
+ dom.modal.zoomOutBtn.addEventListener('click', () => applyZoom(0.8));
149
+ dom.modal.resetViewBtn.addEventListener('click', () => { state.modal.transform = state.modal.initialTransform.translate(0,0); redrawModalCanvas(); });
150
+ dom.modal.fullscreenBtn.addEventListener('click', toggleFullscreen);
151
+ window.addEventListener('resize', () => { if(state.modal.img) showModal(state.modal.currentItem); });
152
+
153
+ // --- Core Logic ---
154
+ async function handleZipUpload(event) {
155
+ const file = event.target.files[0];
156
+ if (!file) return;
157
+
158
+ showStatus('Processing Zip...', 'loading');
159
+ dom.zipFileNameDisplay.textContent = file.name;
160
+ dom.labelsPanel.classList.remove('hidden');
161
+
162
+ try {
163
+ const zip = await JSZip.loadAsync(file);
164
+ const imageFiles = {}, labelFiles = {};
165
+ for (const filename in zip.files) {
166
+ if (zip.files[filename].dir) continue;
167
+ const baseName = filename.split('/').pop().split('.')[0];
168
+ if (/\.(jpe?g|png)$/i.test(filename)) imageFiles[baseName] = zip.files[filename];
169
+ if (/\.txt$/i.test(filename)) labelFiles[baseName] = zip.files[filename];
170
+ }
171
+
172
+ state.allImageData = Object.keys(imageFiles)
173
+ .filter(baseName => labelFiles[baseName])
174
+ .map(baseName => ({ id: baseName, image: imageFiles[baseName], label: labelFiles[baseName] }));
175
+
176
+ if (state.allImageData.length > 0) {
177
+ dom.imageContainer.classList.remove('hidden');
178
+ dom.emptyState.classList.add('hidden');
179
+ renderPage(1);
180
+ setupPagination();
181
+ showStatus(`${state.allImageData.length} images loaded.`, 'success');
182
+ } else {
183
+ showStatus('No matching image/label pairs found.', 'error');
184
+ }
185
+ } catch (e) { showStatus('Error reading zip file.', 'error'); console.error(e); }
186
+ }
187
+
188
+ function handleYamlPaste(event) {
189
+ const yamlString = event.target.value;
190
+ if (!yamlString) { state.classNames = {}; if(state.allImageData.length > 0) renderPage(state.currentPage); return; }
191
+ try {
192
+ const data = jsyaml.load(yamlString);
193
+ state.classNames = (data && data.names) ? data.names : {};
194
+ showStatus('YAML class names updated.', 'success');
195
+ if(state.allImageData.length > 0) renderPage(state.currentPage);
196
+ } catch (e) { showStatus('Invalid YAML format.', 'warning'); }
197
+ }
198
+
199
+ // --- UI & Drawing ---
200
+ async function renderPage(page) {
201
+ state.currentPage = page;
202
+ dom.imageContainer.innerHTML = '';
203
+ const paginatedItems = state.allImageData.slice((page - 1) * state.imagesPerPage, page * state.imagesPerPage);
204
+
205
+ for (const item of paginatedItems) {
206
+ const card = document.createElement('div');
207
+ card.className = 'card-glow glass-card rounded-xl overflow-hidden transition-all duration-300 cursor-pointer';
208
+ card.innerHTML = `<canvas class="w-full h-40 md:h-48 object-cover bg-gray-900/50"></canvas><h3 class="p-3 font-semibold text-sm text-blue-300 truncate">${item.image.name.split('/').pop()}</h3>`;
209
+ dom.imageContainer.appendChild(card);
210
+ card.addEventListener('click', () => showModal(item));
211
+
212
+ const canvas = card.querySelector('canvas');
213
+ const ctx = canvas.getContext('2d');
214
+ const img = new Image();
215
+ img.src = URL.createObjectURL(await item.image.async('blob'));
216
+ img.onload = async () => {
217
+ canvas.width = img.width; canvas.height = img.height;
218
+ ctx.drawImage(img, 0, 0);
219
+ drawAnnotations(ctx, await item.label.async('string'), img.width, img.height);
220
+ URL.revokeObjectURL(img.src);
221
+ };
222
+ }
223
+ updateActivePagination();
224
+ }
225
+
226
+ function drawAnnotations(ctx, labelText, w, h, isModal = false) {
227
+ const lines = labelText.trim().split('\n').filter(line => line.trim() !== '');
228
+ if (lines.length === 0) return;
229
+ const isDetection = lines[0].trim().split(' ').length === 5;
230
+ const uniqueClasses = new Set();
231
+
232
+ lines.forEach(line => {
233
+ const parts = line.split(' ').map(parseFloat);
234
+ const classId = parts[0];
235
+ uniqueClasses.add(classId);
236
+ const color = generateColor(classId);
237
+ ctx.strokeStyle = color;
238
+ ctx.lineWidth = (isModal ? 3 : 2) / (isModal ? state.modal.transform.a : 1);
239
+ ctx.beginPath();
240
+ if (isDetection) {
241
+ const [, xc, yc, width, height] = parts;
242
+ ctx.rect((xc - width / 2) * w, (yc - height / 2) * h, width * w, height * h);
243
+ } else {
244
+ const points = parts.slice(1);
245
+ ctx.moveTo(points[0] * w, points[1] * h);
246
+ for (let i = 2; i < points.length; i += 2) ctx.lineTo(points[i] * w, points[i+1] * h);
247
+ ctx.closePath();
248
+ }
249
+ ctx.stroke();
250
+ });
251
+ if (isModal) buildLegend(uniqueClasses);
252
+ }
253
+
254
+ // --- Modal & Pan/Zoom (IMPROVED & FIXED) ---
255
+ async function showModal(item) {
256
+ dom.modal.backdrop.classList.remove('hidden');
257
+ dom.modal.spinner.classList.remove('hidden');
258
+ dom.modal.legend.classList.add('hidden');
259
+
260
+ const canvas = dom.modal.canvas;
261
+ const ctx = canvas.getContext('2d');
262
+ // Thanks to the HTML fix, canvas.clientWidth/Height are now correct.
263
+ // We clear any previous drawing immediately.
264
+ canvas.width = canvas.clientWidth;
265
+ canvas.height = canvas.clientHeight;
266
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
267
+
268
+ state.modal.currentItem = item;
269
+ state.modal.img = new Image();
270
+ state.modal.labelText = await item.label.async('string');
271
+
272
+ state.modal.img.onerror = () => {
273
+ console.error("Modal image failed to load.");
274
+ showStatus('Error: Could not load the full-size image.', 'error');
275
+ closeModal();
276
+ };
277
+
278
+ state.modal.img.onload = () => {
279
+ if (!state.modal.img) return; // Modal was closed before image loaded.
280
+
281
+ // Set canvas resolution to its displayed size. This is crucial.
282
+ canvas.width = canvas.clientWidth;
283
+ canvas.height = canvas.clientHeight;
284
+
285
+ const hRatio = canvas.width / state.modal.img.width;
286
+ const vRatio = canvas.height / state.modal.img.height;
287
+ const initialScale = Math.min(hRatio, vRatio) * 0.9;
288
+
289
+ // Calculate the transformation to center and scale the image.
290
+ state.modal.transform = new DOMMatrix()
291
+ .translate(canvas.width / 2, canvas.height / 2)
292
+ .scale(initialScale, initialScale)
293
+ .translate(-state.modal.img.width / 2, -state.modal.img.height / 2);
294
+
295
+ state.modal.initialTransform = state.modal.transform.translate(0,0);
296
+
297
+ dom.modal.spinner.classList.add('hidden');
298
+ dom.modal.legend.classList.remove('hidden');
299
+
300
+ redrawModalCanvas();
301
+ };
302
+
303
+ state.modal.img.src = URL.createObjectURL(await item.image.async('blob'));
304
+ }
305
+
306
+ function redrawModalCanvas() {
307
+ if (!state.modal.img) return;
308
+ const canvas = dom.modal.canvas, ctx = canvas.getContext('2d');
309
+ ctx.save();
310
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
311
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
312
+ ctx.setTransform(state.modal.transform);
313
+ ctx.drawImage(state.modal.img, 0, 0);
314
+ drawAnnotations(ctx, state.modal.labelText, state.modal.img.width, state.modal.img.height, true);
315
+ ctx.restore();
316
+ }
317
+
318
+ function panStart(e) { e.preventDefault(); state.modal.isPanning = true; state.modal.lastPos = { x: e.clientX, y: e.clientY }; }
319
+ function panMove(e) {
320
+ e.preventDefault();
321
+ if (!state.modal.isPanning) return;
322
+ state.modal.transform.translateSelf((e.clientX - state.modal.lastPos.x) / state.modal.transform.a, (e.clientY - state.modal.lastPos.y) / state.modal.transform.d);
323
+ redrawModalCanvas();
324
+ state.modal.lastPos = { x: e.clientX, y: e.clientY };
325
+ }
326
+ function panEnd() { state.modal.isPanning = false; }
327
+
328
+ function applyZoom(scaleAmount, clientX, clientY) {
329
+ const canvas = dom.modal.canvas;
330
+ const pt = new DOMPoint(clientX ?? canvas.clientWidth / 2, clientY ?? canvas.clientHeight / 2);
331
+ const transformedPt = pt.matrixTransform(state.modal.transform.inverse());
332
+ state.modal.transform.translateSelf(transformedPt.x, transformedPt.y)
333
+ .scaleSelf(scaleAmount, scaleAmount).translateSelf(-transformedPt.x, -transformedPt.y);
334
+ redrawModalCanvas();
335
+ }
336
+
337
+ function handleZoom(e) { e.preventDefault(); applyZoom(e.deltaY > 0 ? 0.9 : 1.1, e.clientX, e.clientY); }
338
+
339
+ function touchStart(e) {
340
+ if (e.touches.length === 1) { e.preventDefault(); panStart(e.touches[0]); }
341
+ if (e.touches.length === 2) { e.preventDefault(); state.modal.lastPos = { dist: Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY) };}
342
+ }
343
+
344
+ function touchMove(e) {
345
+ e.preventDefault();
346
+ if (e.touches.length === 1) panMove(e.touches[0]);
347
+ if (e.touches.length === 2) {
348
+ const newDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
349
+ applyZoom(newDist / state.modal.lastPos.dist, (e.touches[0].clientX + e.touches[1].clientX) / 2, (e.touches[0].clientY + e.touches[1].clientY) / 2);
350
+ state.modal.lastPos.dist = newDist;
351
+ }
352
+ }
353
+
354
+ function closeModal() {
355
+ dom.modal.backdrop.classList.add('hidden');
356
+ if (state.modal.img && state.modal.img.src.startsWith('blob:')) {
357
+ URL.revokeObjectURL(state.modal.img.src);
358
+ }
359
+ state.modal.img = null;
360
+ if (document.fullscreenElement) document.exitFullscreen();
361
+ }
362
+
363
+ function toggleFullscreen() {
364
+ if (!document.fullscreenElement) { dom.modal.backdrop.requestFullscreen().catch(err => alert(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`));
365
+ } else { document.exitFullscreen(); }
366
+ }
367
+
368
+ // --- Helpers ---
369
+ function generateColor(classId) {
370
+ if (!state.classColors[classId]) {
371
+ const hue = (classId * 137.508) % 360;
372
+ state.classColors[classId] = `hsl(${hue}, 95%, 65%)`;
373
+ }
374
+ return state.classColors[classId];
375
+ }
376
+
377
+ function buildLegend(classIds) {
378
+ dom.modal.legend.innerHTML = '';
379
+ if (classIds.size > 0) {
380
+ const list = document.createElement('ul');
381
+ classIds.forEach(id => {
382
+ const name = state.classNames[id] || `Class ${id}`;
383
+ list.innerHTML += `<li class="flex items-center gap-2"><span class="w-4 h-4 rounded-full border border-white/50" style="background-color:${generateColor(id)};"></span>${name}</li>`;
384
+ });
385
+ dom.modal.legend.appendChild(list);
386
+ }
387
+ }
388
+
389
+ function setupPagination() {
390
+ dom.paginationContainer.innerHTML = '';
391
+ const pageCount = Math.ceil(state.allImageData.length / state.imagesPerPage);
392
+ if (pageCount > 1) {
393
+ for (let i = 1; i <= pageCount; i++) {
394
+ const button = document.createElement('button');
395
+ button.innerText = i;
396
+ button.dataset.page = i;
397
+ button.className = "px-4 py-2 rounded-md bg-gray-800 text-gray-300 hover:bg-blue-600 transition-colors";
398
+ button.addEventListener('click', (e) => renderPage(parseInt(e.target.dataset.page)));
399
+ dom.paginationContainer.appendChild(button);
400
+ }
401
+ }
402
+ updateActivePagination();
403
+ }
404
+
405
+ function updateActivePagination() {
406
+ dom.paginationContainer.querySelectorAll('button').forEach(btn => {
407
+ const isActive = parseInt(btn.dataset.page) === state.currentPage;
408
+ btn.classList.toggle('bg-blue-600', isActive);
409
+ btn.classList.toggle('text-white', isActive);
410
+ });
411
+ }
412
+
413
+ function showStatus(message, type = 'info') {
414
+ const styles = {
415
+ info: 'text-gray-400', success: 'text-green-400',
416
+ warning: 'text-yellow-400', error: 'text-red-500', loading: 'text-blue-400'
417
+ };
418
+ dom.statusContainer.innerHTML = `<p class="${styles[type]}">${message}</p>`;
419
+ }
420
+ </script>
421
+ </body>
422
+ </html>