Initial Release

This commit is contained in:
Ekta Sood 2020-11-19 15:49:22 +01:00
parent 0cc1bebf18
commit 5cc0ae8c18
24 changed files with 1753 additions and 1 deletions

127
src/App.svelte Normal file
View file

@ -0,0 +1,127 @@
<script>
import Visualization from './Visualization.svelte';
import { config } from './stores.js';
import './vendor/normalize.css';
import './vendor/milligram.min.css';
let isAnimated = false;
let splitscreen = false;
let controlsOpen = false;
let left, right;
function handleKeyboard(event) {
switch (event.code) {
case 'ArrowLeft':
//i = (i>0) ? i-1 : data.length-1;
event.preventDefault();
break;
case 'ArrowRight':
//i = (i<data.length-1) ? i+1 : 0;
event.preventDefault();
break;
case 'ArrowUp':
$config.speed += 10;
event.preventDefault();
break;
case 'ArrowDown':
$config.speed -= 10;
event.preventDefault();
break;
case 'Space':
isAnimated = !isAnimated;
event.preventDefault();
break;
}
//log current index on the lower right
}
function reset() {
left.reset();
if (splitscreen) {
right.reset();
}
}
</script>
<svelte:window on:keydown={handleKeyboard} />
<main>
<Visualization bind:this={left} {isAnimated}/>
{#if splitscreen}<Visualization bind:this={right} {isAnimated}/>{/if}
</main>
<aside class:open={controlsOpen}>
<label class="hideCheckbox toggle" title="Open Controls"><input type="checkbox" bind:checked={controlsOpen}></label>
<div id="controls">
<h3>Controls</h3>
<p><small>Press Space to pause and use arrow keys to skip or change speed</small></p>
<label>Zoom:<br/><input type="number" id="zoom" step="1" value={$config.zoom} on:change={e => $config.zoom = e.target.value}></label>
<label>Speed:<br/><input type="number" id="speed" step="1" value={$config.speed} on:change={e => $config.speed = e.target.value}></label>
<label>Duration Multiplier:<br/><input type="number" id="duration_multiplier" step="0.1" value={$config.duration_multiplier} on:change={e => $config.duration_multiplier = e.target.value}></label>
<label>Trace Length:<br/><input type="number" id="trace_length" step="1" value={$config.trace_length} on:change={e => $config.trace_length = e.target.value}></label>
<label>Duration Function Exponent:<br/><input type="number" id="exp" step="0.1" value={$config.duration_exponent} on:change={e => $config.duration_exponent = e.target.value}></label>
</div>
<label id="run" class="hideCheckbox toggle" title="Run Animation">{#if !isAnimated}{:else}{/if}<input type="checkbox" bind:checked={isAnimated}></label>
<label id="split" class="hideCheckbox toggle" title="Make Splitscreen">{#if !splitscreen}🗖{:else}🗗{/if}<input type="checkbox" bind:checked={splitscreen}></label>
<span id="reset" class="toggle" title="Reset Animation" on:click={reset}>↶</span>
</aside>
<style>
main {
display: flex;
height: 100%;
width: 100%;
}
aside {
position: absolute;
left: -20vw;
transition: left 0.5s;
top: 0;
bottom: 0;
width: 20vw;
padding: 20px;
background: white;
}
.open {
left: 0;
}
.toggle {
position: absolute;
right: -30px;
width: 20px;
font-size: 20px;
line-height: 20px;
cursor: pointer;
text-align: center;
transition: left 0.5s;
margin: 0;
font-family: monospace;
}
.hideCheckbox > input {
position: absolute;
left: -9999px;
}
#run {
bottom: 100px;
}
#split {
bottom: 60px;
}
#reset {
bottom: 20px;
}
</style>

52
src/FilePicker.svelte Normal file
View file

@ -0,0 +1,52 @@
<script>
import { createEventDispatcher } from 'svelte';
import { readFileAsync } from './helpers';
import Papa from './vendor/papaparse.min.js';
let datafiles, imagefiles, isValid, fixation_data, sequential_fixation_data;
const image = new Image();
const dispatch = createEventDispatcher();
$: isValid = datafiles && imagefiles && datafiles[0] && imagefiles[0];
async function visualize() {
image.src = await readFileAsync(imagefiles[0]);
let parsing = new Promise( resolve => {
Papa.parse(datafiles[0], {
comments: '#',
header: true,
dynamicTyping: true,
complete: results => {
if (!results.data[0].x || !results.data[0].y || !results.data[0].dur || !results.data[0].word_id || !results.data[0].word) {
throw Error('Data malformed. Please use csv style tab-separated format with headers x,y,dur,word_id, word as first line.');
}
fixation_data = results.data;
resolve();
}
});
});
parsing.then( () => {
dispatch('load', {image, fixation_data})
});
}
</script>
<div id="overlay">
<h1>Choose Fixation Data and Image</h1>
<label>Fixation Data<br/><input bind:files={datafiles} type="file" accept=".txt" /></label>
<label>Image<br /><input bind:files={imagefiles} type="file" accept=".png, .jpg" /></label>
<button id="run-button" class="button" on:click={visualize} disabled={!isValid}>Visualize!</button>
</div>
<style>
#overlay {
width: 400px;
height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

203
src/Visualization.svelte Normal file
View file

@ -0,0 +1,203 @@
<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>

40
src/helpers.js Normal file
View file

@ -0,0 +1,40 @@
export function readFileAsync(file) {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function aggregateWordFixations(data) {
//slicing off first fixation since it is the calibration in center
let sorted = data.filter( item => item.word_id ).slice(1).sort((a,b) => a.word_id-b.word_id);
//add first back in
sorted.unshift(data[0]);
//aggregate fixations for same words in bags
let aggregate = sorted.reduce( (acc, val) => {
if (acc[0].length == 0 || val.word_id == acc[acc.length-1][0].word_id) {
acc[acc.length-1].push(val)
} else {
acc.push([val]);
}
return acc;
}, [[]]);
//average fixation coordinates and sum duration for each bag
return aggregate.map( bag => {
let word = bag[0].word;
let word_id = bag[0].word_id;
let x=0;
let y=0;
let dur=0;
for (let fixation of bag) {
x += fixation.x/bag.length;
y += fixation.y/bag.length;
dur += fixation.dur;
}
return {x, y, dur, word_id, word};
});
}

9
src/main.js Normal file
View file

@ -0,0 +1,9 @@
import App from './App.svelte';
const app = new App({
target: document.body,
props: {
}
});
export default app;

41
src/stores.js Normal file
View file

@ -0,0 +1,41 @@
import { writable } from 'svelte/store';
export const config = writable({
zoom: 100,
translatex: 0,
translatey: 0,
duration_multiplier: 0.1,
duration_exponent: 0.2,
speed: 100,
show_sequential: false,
trace_length: 3,
trace_radius: 5,
trace_width: 2,
canvas_width: 1920,
canvas_height: 1080
});
// export const time = readable(new Date(), function start(set) {
// const interval = setInterval(() => {
// set(new Date());
// }, 1000);
// return function stop() {
// clearInterval(interval);
// };
// });
// function tickGenerator() {
// const { subscribe, set, update } = writable(0);
// let interval;
// return {
// subscribe,
// start: () => setInterval(),
// stop: () => update(n => n - 1),
// reset: () => set(0)
// };
// }
//export const count = createCount();

11
src/vendor/milligram.min.css vendored Normal file

File diff suppressed because one or more lines are too long

349
src/vendor/normalize.css vendored Normal file
View file

@ -0,0 +1,349 @@
/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
/* Document
========================================================================== */
/**
* 1. Correct the line height in all browsers.
* 2. Prevent adjustments of font size after orientation changes in iOS.
*/
html {
line-height: 1.15; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/* Sections
========================================================================== */
/**
* Remove the margin in all browsers.
*/
body {
margin: 0;
}
/**
* Render the `main` element consistently in IE.
*/
main {
display: block;
}
/**
* Correct the font size and margin on `h1` elements within `section` and
* `article` contexts in Chrome, Firefox, and Safari.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/* Grouping content
========================================================================== */
/**
* 1. Add the correct box sizing in Firefox.
* 2. Show the overflow in Edge and IE.
*/
hr {
box-sizing: content-box; /* 1 */
height: 0; /* 1 */
overflow: visible; /* 2 */
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
pre {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/* Text-level semantics
========================================================================== */
/**
* Remove the gray background on active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* 1. Remove the bottom border in Chrome 57-
* 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
*/
abbr[title] {
border-bottom: none; /* 1 */
text-decoration: underline; /* 2 */
text-decoration: underline dotted; /* 2 */
}
/**
* Add the correct font weight in Chrome, Edge, and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
* 1. Correct the inheritance and scaling of font size in all browsers.
* 2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp {
font-family: monospace, monospace; /* 1 */
font-size: 1em; /* 2 */
}
/**
* Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` elements from affecting the line height in
* all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/* Embedded content
========================================================================== */
/**
* Remove the border on images inside links in IE 10.
*/
img {
border-style: none;
}
/* Forms
========================================================================== */
/**
* 1. Change the font styles in all browsers.
* 2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 1 */
line-height: 1.15; /* 1 */
margin: 0; /* 2 */
}
/**
* Show the overflow in IE.
* 1. Show the overflow in Edge.
*/
button,
input { /* 1 */
overflow: visible;
}
/**
* Remove the inheritance of text transform in Edge, Firefox, and IE.
* 1. Remove the inheritance of text transform in Firefox.
*/
button,
select { /* 1 */
text-transform: none;
}
/**
* Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
/**
* Remove the inner border and padding in Firefox.
*/
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
/**
* Restore the focus styles unset by the previous rule.
*/
button:-moz-focusring,
[type="button"]:-moz-focusring,
[type="reset"]:-moz-focusring,
[type="submit"]:-moz-focusring {
outline: 1px dotted ButtonText;
}
/**
* Correct the padding in Firefox.
*/
fieldset {
padding: 0.35em 0.75em 0.625em;
}
/**
* 1. Correct the text wrapping in Edge and IE.
* 2. Correct the color inheritance from `fieldset` elements in IE.
* 3. Remove the padding so developers are not caught out when they zero out
* `fieldset` elements in all browsers.
*/
legend {
box-sizing: border-box; /* 1 */
color: inherit; /* 2 */
display: table; /* 1 */
max-width: 100%; /* 1 */
padding: 0; /* 3 */
white-space: normal; /* 1 */
}
/**
* Add the correct vertical alignment in Chrome, Firefox, and Opera.
*/
progress {
vertical-align: baseline;
}
/**
* Remove the default vertical scrollbar in IE 10+.
*/
textarea {
overflow: auto;
}
/**
* 1. Add the correct box sizing in IE 10.
* 2. Remove the padding in IE 10.
*/
[type="checkbox"],
[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Correct the cursor style of increment and decrement buttons in Chrome.
*/
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Correct the odd appearance in Chrome and Safari.
* 2. Correct the outline style in Safari.
*/
[type="search"] {
-webkit-appearance: textfield; /* 1 */
outline-offset: -2px; /* 2 */
}
/**
* Remove the inner padding in Chrome and Safari on macOS.
*/
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* 1. Correct the inability to style clickable types in iOS and Safari.
* 2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button; /* 1 */
font: inherit; /* 2 */
}
/* Interactive
========================================================================== */
/*
* Add the correct display in Edge, IE 10+, and Firefox.
*/
details {
display: block;
}
/*
* Add the correct display in all browsers.
*/
summary {
display: list-item;
}
/* Misc
========================================================================== */
/**
* Add the correct display in IE 10+.
*/
template {
display: none;
}
/**
* Add the correct display in IE 10.
*/
[hidden] {
display: none;
}

7
src/vendor/papaparse.min.js vendored Normal file

File diff suppressed because one or more lines are too long