-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathheatmapSonificationDemo.html
530 lines (424 loc) · 15.9 KB
/
heatmapSonificationDemo.html
1
2
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<title>Heatmap Sonification Demo</title>
<style>
* {
font-family:"Roboto";
}
canvas {
border: 1px solid black;
display: block;
margin-top: 10px;
}
.large-text {
font-size: 72pt;
}
.display{
display:grid;
grid-template-columns: 2fr 1fr;
}
.heatmapCanvas{
max-width: 60vw;
max-height: 60vh;
width: auto;
height: auto;
}
.upload{
font-size: 65px;
}
</style>
</head>
<body>
<div class="large-text">
<a href=https://accessiblegragh.github.io/about_HSD.html>How to use</a><br>
</div>
<h2>Upload Heatmap Image</h2>
<input type="file" id="fileInput" accept="image/*" class="upload">
<div class="display">
<canvas id="heatmapCanvas"></canvas>
<canvas id="magnifier"></canvas>
</div>
<div class="color-option">
<label>
Low Pitch Color:
<input type="text" id="lowColorInput" value="#000080">
<input type="color" id="lowColorPicker" value="#000080" aria-label="Low pitch color picker">
</label>
<label>
High Pitch Color:
<input type="text" id="highColorInput" value="#ff0000">
<input type="color" id="highColorPicker" value="#ff0000" aria-label="High pitch color picker">
</label>
</div>
<button id="toggleVisibilityButton">Toggle Image Visibility (P)</button>
<script>
const canvas = document.getElementById('heatmapCanvas');
const magnifier = document.getElementById('magnifier');
const ctx = canvas.getContext('2d');
const magCtx = magnifier.getContext('2d');
const toggleVisibilityButton = document.getElementById('toggleVisibilityButton');
let cursorSize = 72; // Updated to 72px for visually impaired users
const cursorStep = cursorSize / 4; // Move 1/4 block for finer control
let cursorX = 0;
let cursorY = 0;
let image = new Image();
image.style.width = "70vw";
image.style.height= "70vh";
// image.style.objectFit= cover;
let magImage = new Image(); //image for magnifier
let imageVisible = true;
let soundEnabled = true; // Track whether sound is enabled
let magnify = 6;
magnifier.width = magnify * cursorSize;
magnifier.height = magnify * cursorSize;
let audioCtx = new (window.AudioContext || window.webkitAudioContext)();
let currentOscillator = null; // Store the currently playing oscillator
// Store image data separately so we can sonify even if the image is hidden
let cachedImageData = null;
// Define global RGB values
const low = [0, 0, 128];
const high = [255, 0, 0];
function resizeImageIfTooLarge(originalImage, maxBrowserWidthRatio = 0.6, maxHeight = 512) {
const canvas = document.createElement('canvas');
const browserLimitWidth = Math.floor(window.innerWidth * maxBrowserWidthRatio);
// Only scale down if image exceeds target dimensions
const shouldResize = originalImage.width > browserLimitWidth || originalImage.height > maxHeight;
if (!shouldResize) {
// Return the original image as-is
return Promise.resolve(originalImage);
}
// Resize logic
const scaleRatio = Math.min(browserLimitWidth / originalImage.width, maxHeight / originalImage.height);
const newWidth = Math.round(originalImage.width * scaleRatio);
const newHeight = Math.round(originalImage.height * scaleRatio);
canvas.width = newWidth;
canvas.height = newHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(originalImage, 0, 0, newWidth, newHeight);
const resizedImage = new Image();
resizedImage.src = canvas.toDataURL();
return new Promise(resolve => {
resizedImage.onload = () => resolve(resizedImage);
});
}
// Simulate image upload from URL (default image)
function simulateImageUploadFromURL(url) {
image = new Image();
image.crossOrigin = 'Anonymous'; // Required if the image is hosted on another domain
image.src = url;
image.onload = () => {
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
cachedImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
drawCanvas();
triggerInitialSound();
};
}
// Trigger initial sound after loading default image
function triggerInitialSound() {
const closeness = getAverageColorCloseness(cursorX, cursorY, cursorSize);
playContinuousSoundForValue(closeness);
}
// Call the simulation on page load
window.onload = () => {
simulateImageUploadFromURL('https://accessiblegragh.github.io/example_small.png');
};
// File upload handler
document.getElementById('fileInput').addEventListener('change', handleFileUpload);
function handleFileUpload(event) {
cursorX = 0;
cursorY = 0;
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const rawImage = new Image();
rawImage.src = e.target.result;
rawImage.onload = async () => {
// Resize if needed before using it
image = await resizeImageIfTooLarge(rawImage);
canvas.width = image.width;
canvas.height = image.height;
ctx.drawImage(image, 0, 0);
cachedImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
triggerInitialSound();
drawCanvas();
};
};
reader.readAsDataURL(file);
}
// Draw canvas (optional image + cursor)
function drawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (imageVisible && cachedImageData) {
ctx.drawImage(image, 0, 0);
}
drawMagnifier();
drawCursor();
}
// Draw filled cursor block with thick black border
function drawCursor() {
ctx.fillStyle = 'rgba(0, 0, 0, 1)';
ctx.fillRect(cursorX, cursorY, cursorSize, cursorSize);
ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
ctx.lineWidth = 4;
ctx.strokeRect(cursorX, cursorY, cursorSize, cursorSize);
}
function drawMagnifier() {
if (!cachedImageData) return;
const imageData = magCtx.createImageData(cursorSize, cursorSize);
for (let row = 0; row < cursorSize; row++) {
for (let col = 0; col < cursorSize; col++) {
const srcX = cursorX + col;
const srcY = cursorY + row;
if (srcX >= canvas.width || srcY >= canvas.height) continue;
const srcIndex = (srcY * canvas.width + srcX) * 4;
const dstIndex = (row * cursorSize + col) * 4;
imageData.data[dstIndex] = cachedImageData.data[srcIndex];
imageData.data[dstIndex + 1] = cachedImageData.data[srcIndex + 1];
imageData.data[dstIndex + 2] = cachedImageData.data[srcIndex + 2];
imageData.data[dstIndex + 3] = cachedImageData.data[srcIndex + 3];
}
}
// Step 1: Draw raw data into a temporary canvas
const tempCanvas = document.createElement('canvas');
tempCanvas.width = cursorSize;
tempCanvas.height = cursorSize;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.putImageData(imageData, 0, 0);
// Step 2: Draw magnified version to main magnifier canvas
magCtx.clearRect(0, 0, magnifier.width, magnifier.height);
magCtx.imageSmoothingEnabled = false; // Keep pixel edges sharp
magCtx.drawImage(
tempCanvas,
0, 0, cursorSize, cursorSize, // source: original pixel block
0, 0, magnifier.width, magnifier.height // destination: magnified canvas
);
}
// Move cursor, redraw canvas, and play sound if enabled
function moveCursor(dx, dy) {
cursorX = Math.max(0, Math.min(canvas.width - cursorSize, cursorX + dx * cursorStep));
cursorY = Math.max(0, Math.min(canvas.height - cursorSize, cursorY + dy * cursorStep));
drawCanvas();
const closeness = getAverageColorCloseness(cursorX, cursorY, cursorSize);
if (soundEnabled) {
playContinuousSoundForValue(closeness);
} else {
stopCurrentSound();
}
}
// Calculate average color closeness to low and high (rescaled to 0-1)
function getAverageColorCloseness(x, y, size) {
if (!cachedImageData) return 0;
let totalCloseness = 0;
let pixelCount = 0;
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
const pixelIndex = ((y + row) * canvas.width + (x + col)) * 4;
const r = cachedImageData.data[pixelIndex];
const g = cachedImageData.data[pixelIndex + 1];
const b = cachedImageData.data[pixelIndex + 2];
const distanceLow = Math.sqrt(Math.pow(r - low[0], 2) + Math.pow(g - low[1], 2) + Math.pow(b - low[2], 2));
const distanceHigh = Math.sqrt(Math.pow(r - high[0], 2) + Math.pow(g - high[1], 2) + Math.pow(b - high[2], 2));
const closeness = distanceLow / (distanceLow + distanceHigh); // Rescale to 0-1
totalCloseness += closeness;
pixelCount++;
}
}
return totalCloseness / pixelCount;
}
// Play continuous sound based on closeness, stopping previous sound
function playContinuousSoundForValue(value) {
stopCurrentSound();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
const minFrequency = 100;
const maxFrequency = 1000;
const pitch = minFrequency + value * (maxFrequency - minFrequency);
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(pitch, audioCtx.currentTime);
gainNode.gain.setValueAtTime(0.2, audioCtx.currentTime);
oscillator.connect(gainNode).connect(audioCtx.destination);
oscillator.start();
currentOscillator = oscillator;
}
// Stop current oscillator if playing
function stopCurrentSound() {
if (currentOscillator) {
currentOscillator.stop();
currentOscillator.disconnect();
currentOscillator = null;
}
}
function tts(text){
if (speechSynthesis.speaking || speechSynthesis.pending) {
return; // Don't speak if something is already going or queued
}
const wasSoundEnabled = soundEnabled; // Save current state
stopCurrentSound(); // Temporarily silence oscillator
const message = text;
const utterance = new SpeechSynthesisUtterance(message);
utterance.rate = 1;
utterance.pitch = 1;
utterance.volume = 1;
utterance.lang = 'en-US';
utterance.onend = () => {
if (wasSoundEnabled) {
triggerInitialSound();
}
};
speechSynthesis.cancel();
speechSynthesis.speak(utterance);
}
function ttsDetailedDescription() {
const percentX = ((cursorX + cursorSize / 2) / image.width * 100).toFixed(0);
const percentY = ((cursorY + cursorSize / 2) / image.height * 100).toFixed(0);
message = `Cursor centered at about ${percentX} percent across and ${percentY} percent down the image. With size ${cursorSize}`;
tts(message);
}
function ttsGeneralDescription() {
const width = image.width;
const height = image.height;
if (width > height) {
const ratio = (width / height).toFixed(2);
spokenRatio = `${ratio} by 1`;
} else if (height > width) {
const ratio = (height / width).toFixed(2);
spokenRatio = `1 by ${ratio}`;
} else {
spokenRatio = `1 by 1`;
}
const message = `The image aspect ratio is ${spokenRatio}. Image Width is ${width} pixels and height is ${height} pixels.`;
tts(message);
}
function adjustCursorSize(factor) {
const imageWidth = image?.width || canvas.width;
const imageHeight = image?.height || canvas.height;
// Largest power of 2 ≤ min dimension
const maxBase = Math.floor(Math.log2(Math.min(imageWidth, imageHeight)));
const maxAllowedSize = Math.pow(2, maxBase);
let newSize = cursorSize;
if (factor > 1) {
newSize *= 2;
} else if (factor < 1) {
newSize /= 2;
}
// Clamp to power-of-two bounds
// Allow any integer size between 1 and max
newSize = Math.round(Math.max(1, Math.min(newSize, maxAllowedSize)));
// Don't proceed if size didn't change
if (newSize === cursorSize) return;
// Compute center of current cursor
const centerX = cursorX + cursorSize / 2;
const centerY = cursorY + cursorSize / 2;
// Update cursor size
cursorSize = newSize;
// Recalculate top-left position so new cursor is centered at same spot
cursorX = Math.round(centerX - cursorSize / 2);
cursorY = Math.round(centerY - cursorSize / 2);
// Clamp position so the full cursor fits in bounds
cursorX = Math.max(0, Math.min(cursorX, imageWidth - cursorSize));
cursorY = Math.max(0, Math.min(cursorY, imageHeight - cursorSize));
drawCanvas();
const closeness = getAverageColorCloseness(cursorX, cursorY, cursorSize);
if (soundEnabled) {
playContinuousSoundForValue(closeness);
}
}
// WASD + Arrow Key movement handling
window.addEventListener('keydown', (e) => {
switch (e.key) {
case 'w': moveCursor(0, -1); break;
case 'a': moveCursor(-1, 0); break;
case 's': moveCursor(0, 1); break;
case 'd': moveCursor(1, 0); break;
case 'p': toggleImageVisibility(); break;
case 'm': toggleSound(); break;
case 'u': ttsGeneralDescription(); break;
case 'i': ttsDetailedDescription(); break;
case 'k': adjustCursorSize(0.5); break;
case 'l': adjustCursorSize(2); break;
}
});
function toggleImageVisibility() {
imageVisible = !imageVisible;
drawCanvas();
}
function toggleSound() {
soundEnabled = !soundEnabled;
if (!soundEnabled) {
stopCurrentSound();
}
if (soundEnabled) {
moveCursor(0, 0) // start play the sound after clicking M
}
}
toggleVisibilityButton.addEventListener('click', toggleImageVisibility);
function hexToRGB(hex) {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return [r, g, b];
}
function rgbToHex([r, g, b]) {
if (typeof r !== 'number' || typeof g !== 'number' || typeof b !== 'number') return '#000000';
return '#' + [r, g, b].map(x => {
return Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0');
}).join('');
}
function parseColor(str) {
const temp = document.createElement('div');
temp.style.color = str;
document.body.appendChild(temp);
const computed = getComputedStyle(temp).color;
document.body.removeChild(temp);
const match = computed.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
if (!match) return null;
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
}
function setupColorBinding({ inputId, pickerId, targetArray }) {
const input = document.getElementById(inputId);
const picker = document.getElementById(pickerId);
// Helper to set RGB array + sync fields
const updateFromRGB = (rgb) => {
if (!rgb) return;
targetArray[0] = rgb[0];
targetArray[1] = rgb[1];
targetArray[2] = rgb[2];
const hex = rgbToHex(rgb);
picker.value = hex;
input.value = hex;
};
// Text input → color picker
input.addEventListener('change', () => {
const rgb = parseColor(input.value);
updateFromRGB(rgb);
});
// Color picker → text input
picker.addEventListener('input', () => {
const rgb = hexToRGB(picker.value);
updateFromRGB(rgb);
});
// Initialize both on page load
updateFromRGB(targetArray);
}
setupColorBinding({
inputId: 'lowColorInput',
pickerId: 'lowColorPicker',
targetArray: low
});
setupColorBinding({
inputId: 'highColorInput',
pickerId: 'highColorPicker',
targetArray: high
});
</script>
</body>
</html>