Skip to content

Commit 7c96073

Browse files
committed
Add GIF generation
1 parent d0e5c05 commit 7c96073

File tree

3 files changed

+246
-4
lines changed

3 files changed

+246
-4
lines changed

webapp/index.html

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<!-- jsPDF for PDF generation -->
1919
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
2020

21+
<!-- gif.js for GIF generation -->
22+
<script src="https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js"></script>
23+
2124
<link rel="stylesheet" href="styles.css" />
2225
</head>
2326
<body>
@@ -31,7 +34,7 @@
3134
<input type="text" id="dataDir" placeholder="Enter directory name (e.g., 'output')" />
3235
<button id="loadDataBtn">Load Data</button>
3336
<div class="data-info">
34-
<small>Place your edges and densities CSV files in: webapp/{directory_name}/</small>
37+
<small>Place your edges and densities CSV files under the main directory: {directory_name}</small>
3538
</div>
3639
</div>
3740

@@ -65,6 +68,17 @@
6568
<span id="timeLabel">N/A</span>
6669
</div>
6770

71+
<!-- Legend container -->
72+
<div class="legend-container">
73+
<div class="legend-title">Density</div>
74+
<div class="legend-bar"></div>
75+
<div class="legend-labels">
76+
<span>0%</span>
77+
<span>50%</span>
78+
<span>100%</span>
79+
</div>
80+
</div>
81+
6882
<script src="script.js"></script>
6983
</body>
7084
</html>

webapp/script.js

Lines changed: 196 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,196 @@ L.Control.Screenshot = L.Control.extend({
237237
// Add screenshot control to map
238238
map.addControl(new L.Control.Screenshot());
239239

240+
// Add GIF recording control
241+
L.Control.GIFRecorder = L.Control.extend({
242+
options: {
243+
position: 'topleft'
244+
},
245+
246+
onAdd: function(map) {
247+
const container = L.DomUtil.create('div', 'leaflet-control-gif');
248+
const button = L.DomUtil.create('a', 'leaflet-control-gif-button', container);
249+
250+
button.innerHTML = '🎥';
251+
button.href = '#';
252+
button.title = 'Record GIF';
253+
button.style.cssText = `
254+
width: 26px;
255+
height: 26px;
256+
line-height: 26px;
257+
display: block;
258+
text-align: center;
259+
text-decoration: none;
260+
color: black;
261+
background: white;
262+
border: 2px solid rgba(0,0,0,0.2);
263+
border-radius: 4px;
264+
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
265+
font-size: 14px;
266+
margin-bottom: 5px;
267+
`;
268+
269+
L.DomEvent.on(button, 'click', L.DomEvent.stopPropagation)
270+
.on(button, 'click', L.DomEvent.preventDefault)
271+
.on(button, 'click', this._startRecording, this);
272+
273+
this.button = button;
274+
return container;
275+
},
276+
277+
_startRecording: async function() {
278+
if (!densities || densities.length === 0) {
279+
alert('Please load data first.');
280+
return;
281+
}
282+
283+
// Pause playback if active
284+
const playBtn = document.getElementById('playBtn');
285+
if (playBtn && playBtn.textContent === '⏸') {
286+
playBtn.click();
287+
}
288+
289+
const fpsInput = document.getElementById('fpsInput');
290+
const fps = parseFloat(fpsInput.value) || 10;
291+
292+
if (!confirm(`Start recording GIF from current time to end?\nFPS: ${fps}\nNote: This process may take a while.`)) {
293+
return;
294+
}
295+
296+
let isRecording = true;
297+
298+
// Show loading/progress indicator
299+
const loadingDiv = document.createElement('div');
300+
loadingDiv.id = 'gif-progress';
301+
loadingDiv.style.cssText = `
302+
position: fixed;
303+
top: 50%;
304+
left: 50%;
305+
transform: translate(-50%, -50%);
306+
background: rgba(0,0,0,0.8);
307+
color: white;
308+
padding: 20px;
309+
border-radius: 10px;
310+
z-index: 10000;
311+
font-size: 16px;
312+
text-align: center;
313+
`;
314+
loadingDiv.innerHTML = 'Initializing GIF recorder...<br>';
315+
316+
// Add Stop button
317+
const stopBtn = document.createElement('button');
318+
stopBtn.textContent = 'Stop & Save';
319+
stopBtn.style.cssText = 'margin-top: 10px; padding: 5px 10px; cursor: pointer;';
320+
stopBtn.onclick = () => {
321+
isRecording = false;
322+
stopBtn.disabled = true;
323+
stopBtn.textContent = 'Stopping...';
324+
};
325+
loadingDiv.appendChild(stopBtn);
326+
327+
document.body.appendChild(loadingDiv);
328+
329+
// Initialize GIF
330+
// Create a blob for the worker to avoid cross-origin issues
331+
const workerBlob = new Blob([`importScripts('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js');`], { type: 'application/javascript' });
332+
const workerUrl = URL.createObjectURL(workerBlob);
333+
334+
const gif = new GIF({
335+
workers: 2,
336+
quality: 10,
337+
width: map.getSize().x,
338+
height: map.getSize().y,
339+
workerScript: workerUrl
340+
});
341+
342+
const timeSlider = document.getElementById('timeSlider');
343+
const startVal = parseInt(timeSlider.value);
344+
const maxVal = parseInt(timeSlider.max);
345+
const step = parseInt(timeSlider.step);
346+
347+
let currentVal = startVal;
348+
349+
gif.on('finished', function(blob) {
350+
if (document.getElementById('gif-progress')) {
351+
document.body.removeChild(loadingDiv);
352+
}
353+
window.URL.revokeObjectURL(workerUrl);
354+
355+
const url = URL.createObjectURL(blob);
356+
const a = document.createElement('a');
357+
a.href = url;
358+
a.download = `simulation_${new Date().toISOString().slice(0,19).replace(/:/g, '-')}.gif`;
359+
document.body.appendChild(a);
360+
a.click();
361+
document.body.removeChild(a);
362+
});
363+
364+
gif.on('progress', function(p) {
365+
if (document.getElementById('gif-progress')) {
366+
// Keep the stop button if rendering hasn't finished, but usually rendering is blocking or fast enough.
367+
// Actually gif.js renders in workers.
368+
// We can just update the text part.
369+
loadingDiv.firstChild.textContent = `Rendering GIF: ${Math.round(p * 100)}%`;
370+
}
371+
});
372+
373+
// Capture loop
374+
const captureFrame = async () => {
375+
if (!isRecording || currentVal > maxVal) {
376+
loadingDiv.innerHTML = 'Rendering GIF...';
377+
gif.render();
378+
return;
379+
}
380+
381+
// Update slider and map
382+
timeSlider.value = currentVal;
383+
timeSlider.dispatchEvent(new Event('input'));
384+
385+
// Update progress text
386+
const progress = Math.round(((currentVal - startVal) / (maxVal - startVal)) * 100);
387+
388+
// Clear previous content but keep the stop button
389+
loadingDiv.innerHTML = '';
390+
loadingDiv.appendChild(document.createTextNode(`Capturing frames: ${progress}%`));
391+
loadingDiv.appendChild(document.createElement('br'));
392+
loadingDiv.appendChild(document.createTextNode(`Time: ${document.getElementById('timeLabel').textContent}`));
393+
loadingDiv.appendChild(document.createElement('br'));
394+
loadingDiv.appendChild(stopBtn); // Re-append button to keep it at bottom
395+
396+
// Wait a bit for render (though input event is sync, leaflet/canvas might need a tick)
397+
await new Promise(resolve => setTimeout(resolve, 200));
398+
399+
try {
400+
const canvas = await html2canvas(document.getElementById('map'), {
401+
useCORS: true,
402+
allowTaint: false,
403+
logging: false,
404+
scale: 1 // Use 1 for GIF to keep size reasonable
405+
});
406+
407+
const currentFps = parseFloat(document.getElementById('fpsInput').value) || 10;
408+
const currentDelay = 1000 / currentFps;
409+
gif.addFrame(canvas, {delay: currentDelay});
410+
411+
currentVal += step;
412+
// Schedule next frame
413+
setTimeout(captureFrame, 0);
414+
} catch (err) {
415+
console.error(err);
416+
alert('Error capturing frame');
417+
if (document.getElementById('gif-progress')) {
418+
document.body.removeChild(loadingDiv);
419+
}
420+
}
421+
};
422+
423+
captureFrame();
424+
}
425+
});
426+
427+
// Add GIF recorder control to map
428+
map.addControl(new L.Control.GIFRecorder());
429+
240430
// Custom Canvas layer for edges
241431
L.CanvasEdges = L.Layer.extend({
242432
initialize: function(edges, options) {
@@ -578,8 +768,8 @@ loadDataBtn.addEventListener('click', async function() {
578768
loadDataBtn.disabled = true;
579769

580770
// Fetch CSV files from the data subdirectory
581-
const edgesUrl = `${dirName}/edges.csv`;
582-
const densitiesUrl = `${dirName}/densities.csv`;
771+
const edgesUrl = `../${dirName}/edges.csv`;
772+
const densitiesUrl = `../${dirName}/densities.csv`;
583773

584774
// Load CSV data
585775
Promise.all([
@@ -679,7 +869,7 @@ loadDataBtn.addEventListener('click', async function() {
679869
playBtn.textContent = isPlaying ? '⏸' : '▶';
680870

681871
if (isPlaying) {
682-
const fps = parseInt(fpsInput.value) || 10;
872+
const fps = parseFloat(fpsInput.value) || 10;
683873
const interval = 1000 / fps;
684874

685875
playInterval = setInterval(() => {
@@ -850,6 +1040,9 @@ loadDataBtn.addEventListener('click', async function() {
8501040
// Hide data selector and show slider and search
8511041
document.querySelector('.data-selector').style.display = 'none';
8521042
document.querySelector('.slider-container').style.display = 'block';
1043+
1044+
const legendContainer = document.querySelector('.legend-container');
1045+
legendContainer.style.display = 'block';
8531046
}).catch(error => {
8541047
console.error("Error loading CSV files:", error);
8551048
alert('Error loading data files. Please check the console for details.');

webapp/styles.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,38 @@ body, html {
220220
margin-left: 5px;
221221
padding: 2px;
222222
}
223+
224+
/* Legend container */
225+
.legend-container {
226+
position: absolute;
227+
bottom: 120px; /* Above the slider */
228+
left: 20px;
229+
z-index: 1000;
230+
padding: 10px;
231+
background: rgba(255, 255, 255, 0.8);
232+
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
233+
border-radius: 5px;
234+
display: none; /* Initially hidden */
235+
font-family: sans-serif;
236+
font-size: 12px;
237+
}
238+
239+
.legend-title {
240+
font-weight: bold;
241+
margin-bottom: 5px;
242+
text-align: center;
243+
}
244+
245+
.legend-bar {
246+
width: 150px;
247+
height: 15px;
248+
background: linear-gradient(to right, green, yellow, red);
249+
border: 1px solid #ccc;
250+
margin-bottom: 2px;
251+
}
252+
253+
.legend-labels {
254+
display: flex;
255+
justify-content: space-between;
256+
width: 150px;
257+
}

0 commit comments

Comments
 (0)