203 lines
5.1 KiB
Svelte
203 lines
5.1 KiB
Svelte
<script>
|
|
export let isAnimated = false;
|
|
|
|
import FilePicker from './FilePicker.svelte';
|
|
|
|
import { onMount, onDestroy, tick as nextTick } from 'svelte';
|
|
import { config } from './stores.js';
|
|
import { aggregateWordFixations } from './helpers';
|
|
|
|
let observer, canvas, progress, ctx, show_sequential, fixation_data, sequential_fixation_data, fixation_backdrop, data, container;
|
|
|
|
$: {
|
|
data = (show_sequential) ? sequential_fixation_data : fixation_data;
|
|
reset();
|
|
}
|
|
|
|
let i = 0, t = null, isInitialized = false;
|
|
|
|
onMount(() => {
|
|
ctx = canvas.getContext('2d');
|
|
observer = new ResizeObserver( entries => {
|
|
canvas.height = entries[0].contentRect.height;
|
|
canvas.width = entries[0].contentRect.width;
|
|
});
|
|
observer.observe(container);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
observer.disconnect();
|
|
});
|
|
|
|
async function onLoad(event) {
|
|
fixation_data = event.detail.fixation_data;
|
|
sequential_fixation_data = aggregateWordFixations(event.detail.fixation_data);
|
|
fixation_backdrop = event.detail.image;
|
|
isInitialized = true;
|
|
await nextTick();
|
|
//config.update(c => {c.$config.zoom = canvas.width/fixation_backdrop.width; return c;});
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
function tick(time) {
|
|
if (!data || !fixation_backdrop) {
|
|
requestAnimationFrame(tick);
|
|
return;
|
|
}
|
|
|
|
let dt;
|
|
|
|
if (isAnimated){
|
|
if (t == null) t = time;
|
|
dt = (time - t)*$config.speed/100
|
|
if (dt > data[i].dur) {
|
|
i = (i<data.length-1)?i+1:0;
|
|
t = time;
|
|
progress.style.width = (100/data.length*i)+'%';
|
|
}
|
|
} else {
|
|
dt = data[i].dur;
|
|
progress.style.width = (100/data.length*i)+'%';
|
|
}
|
|
|
|
ctx.clearRect(0, 0, 5000, 5000);
|
|
ctx.save();
|
|
|
|
drawBackdrop();
|
|
drawFixation(data[i], dt);
|
|
drawTrace(data.slice((i-$config.trace_length>0)?i-$config.trace_length:0,i+1))
|
|
ctx.restore();
|
|
|
|
requestAnimationFrame(tick);
|
|
}
|
|
|
|
export function reset() {
|
|
i = 0;
|
|
t = null;
|
|
}
|
|
|
|
function transform({ x, y }) {
|
|
// transforms coordinates from image space into drawing space
|
|
return [$config.zoom/100 * x + $config.translatex, $config.zoom/100 * y + $config.translatey]
|
|
}
|
|
|
|
function drawBackdrop() {
|
|
ctx.drawImage(fixation_backdrop, $config.translatex, $config.translatey, $config.zoom/100*fixation_backdrop.width, $config.zoom/100*fixation_backdrop.height);
|
|
}
|
|
|
|
function drawFixation({ x, y, dur }, dt) {
|
|
ctx.save();
|
|
ctx.beginPath();
|
|
let radius = Math.pow(dt/dur, $config.duration_exponent)*dur*$config.duration_multiplier;
|
|
let [xt, yt] = transform({x, y});
|
|
let gradient;
|
|
try {
|
|
gradient = ctx.createRadialGradient(xt,yt,0, xt,yt,radius);
|
|
} catch(e) {
|
|
console.error(e);
|
|
return;
|
|
}
|
|
gradient.addColorStop(0, 'rgb(200, 0, 0, 0.5)');
|
|
gradient.addColorStop(1, 'rgb(200, 0, 0, 0)');
|
|
ctx.fillStyle = gradient;
|
|
ctx.arc(xt, yt, radius, 0, Math.PI*2, true);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawTrace(trace) {
|
|
ctx.save()
|
|
ctx.lineWidth = $config.trace_width;
|
|
let alpha = 0.2 + 0.8/$config.trace_length;
|
|
let i=0, xt, yt, xtn, ytn;
|
|
for (i; i<trace.length-1; i++) {
|
|
let { x, y } = trace[i];
|
|
let { x:xn, y:yn } = trace[i+1];
|
|
[xt, yt] = transform({x, y});
|
|
[xtn, ytn] = transform({x:xn, y:yn});
|
|
ctx.strokeStyle = `rgba(0,0,0,${alpha}`;
|
|
ctx.beginPath();
|
|
ctx.moveTo(xt, yt);
|
|
ctx.arc(xt, yt, $config.trace_radius, 0, Math.PI*2, true);
|
|
ctx.lineTo(xtn, ytn);
|
|
ctx.stroke();
|
|
alpha += 0.8/$config.trace_length;
|
|
}
|
|
ctx.beginPath();
|
|
ctx.arc(xt, yt, $config.trace_radius, 0, Math.PI*2, true);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
|
|
function translate(e) {
|
|
if (e.buttons == 1) {
|
|
$config.translatex += e.movementX;
|
|
$config.translatey += e.movementY;
|
|
}
|
|
}
|
|
|
|
function setZoom(e) {
|
|
$config.zoom += (e.deltaY > 0) ? -1 : 1;
|
|
}
|
|
</script>
|
|
|
|
<div bind:this={container} id="container">
|
|
{#if !isInitialized}
|
|
<FilePicker on:load={onLoad} />
|
|
{/if}
|
|
<span id="loadFile" class:hide={!isInitialized} title="Close and load other files" on:click={() => isInitialized = false}>🞩</span>
|
|
<div id="controls" class:hide={!isInitialized}>
|
|
<label title="Aggregates multiple (re-)visits into one fixation per word. Averages the fixation position and plays back in word order. Use to compare to fixation prediction models."><input type="checkbox" bind:checked={show_sequential}> As sequential word fixations</label>
|
|
</div>
|
|
<canvas class:hide={!isInitialized} bind:this={canvas} on:mousemove={translate} on:wheel|preventDefault={setZoom} height="1080" width="1920">This browser does not support canvas animations, which are required to render the visualization.</canvas>
|
|
<div id="progress" bind:this={progress}></div>
|
|
</div>
|
|
|
|
<style>
|
|
canvas {
|
|
margin: auto;
|
|
}
|
|
|
|
label {
|
|
font-weight: normal;
|
|
}
|
|
|
|
#loadFile {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
cursor: pointer;
|
|
margin: 10px;
|
|
}
|
|
|
|
#container {
|
|
position: relative;
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
#progress {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 0;
|
|
height: 20px;
|
|
background: #9b4dca;
|
|
}
|
|
|
|
#controls {
|
|
position: absolute;
|
|
right: 0;
|
|
bottom: 0;
|
|
text-align: right;
|
|
background: rgba(255,255,255,0.5);
|
|
padding: 10px;
|
|
}
|
|
|
|
.hide {
|
|
display: none;
|
|
}
|
|
</style> |