572 lines
21 KiB
JavaScript
572 lines
21 KiB
JavaScript
var parade;
|
|
var interview_sorter;
|
|
|
|
var config = {
|
|
speedFactor: 0.01, // speed of the shapes
|
|
distanceFactor: 20, // distance between the shapes
|
|
startOffset: 0.001, // starting position
|
|
shapeWidth: 100, // 8 shape of animation: width
|
|
shapeHeight: 1300, // 8 shape of animation: height;
|
|
playDuration: 16, // seconds. how long should audio play after dragging/pressing next. Something like audio duration / nr of images. Note that if everything is sorted interview will just continue;
|
|
textColor: "orange",
|
|
font: "bold 70px Arial",
|
|
returnToSpectatorCamTimeout: 3000, // ms, when dragging weight slider, after release, how long should it take before reversing to spectator camera position
|
|
}
|
|
|
|
// polyfill
|
|
window.performance = window.performance || {};
|
|
performance.now = (function() {
|
|
return performance.now ||
|
|
performance.mozNow ||
|
|
performance.msNow ||
|
|
performance.oNow ||
|
|
performance.webkitNow ||
|
|
Date.now /*none found - fallback to browser default */
|
|
})();
|
|
|
|
|
|
const request = new Request('dataset/images_prop.json');
|
|
fetch(request)
|
|
.then(response => {
|
|
if (response.status === 200) {
|
|
return response.json();
|
|
} else {
|
|
throw new Error('Something went wrong on api server!');
|
|
}
|
|
})
|
|
.then(data => {
|
|
console.debug(data);
|
|
// loaded data from json, initialise interface
|
|
const canvas = document.getElementById("renderCanvas");
|
|
parade = new Parade(canvas, data['image'], config);
|
|
// parade.scene.debugLayer.show();
|
|
}).catch(error => {
|
|
console.error(error);
|
|
});
|
|
|
|
|
|
class Parade {
|
|
constructor(canvasEl, images, config) {
|
|
// animation parameters:
|
|
this.config = config;
|
|
|
|
this.canvasEl = canvasEl;
|
|
this.engine = new BABYLON.Engine(canvasEl, true); // create 3d engine
|
|
this.isPlaying = false;
|
|
this.t = 0; // playback timer
|
|
|
|
// initialise image objects:
|
|
this.images = [];
|
|
this.imagePositions = []; // index: position, value: image-index
|
|
let storedPositions = localStorage.getItem('imagePositions');
|
|
if(storedPositions) {
|
|
this.imagePositions = JSON.parse(storedPositions);
|
|
}
|
|
// else or invalid:
|
|
if(!storedPositions || this.imagePositions.length != images.length) {
|
|
// fill with default: order of the JSON file
|
|
for (let index = 0; index < images.length; index++) {
|
|
this.imagePositions[index] = index;
|
|
}
|
|
}
|
|
|
|
for(let i in images) {
|
|
i = parseInt(i);
|
|
let pos = this.imagePositions.indexOf(i);
|
|
// console.log('pos', , pos);
|
|
let img = new ParadeImage(images[i], pos);
|
|
this.images.push(img);
|
|
|
|
// for testing:
|
|
// if(this.images.length > 5)
|
|
// break;
|
|
}
|
|
|
|
this.setupSubtitles();
|
|
this.setupSorter();
|
|
this.restoreTime();
|
|
|
|
// Watch for browser/canvas resize events
|
|
window.addEventListener("resize", function () {
|
|
this.engine.resize();
|
|
}.bind(this));
|
|
|
|
//Register a render loop to repeatedly render the scene
|
|
this.engine.runRenderLoop(this.renderLoop.bind(this));
|
|
|
|
this.createScene();
|
|
|
|
// browsers block auto-play of audio
|
|
// if(this.imagesToSort.length < 1){
|
|
// this.play();
|
|
// }
|
|
}
|
|
|
|
setupSubtitles() {
|
|
this.audioEl = document.getElementById('interview'); // audio player
|
|
this.subtitleEls = {
|
|
'en': document.getElementById('sub_en'),
|
|
'nl': document.getElementById('sub_nl'),
|
|
};
|
|
this.trackEls = this.audioEl.querySelectorAll('track'); // the subtitle track
|
|
this.timeEl = document.getElementById('time'); // the subtitle track
|
|
|
|
// set up subtitles:
|
|
for(let trackEl of this.trackEls){
|
|
trackEl.addEventListener("cuechange", (event) => {
|
|
let lang = event.srcElement.srclang;
|
|
let cues = event.target.track.activeCues;
|
|
let content = "";
|
|
for(let cue of cues) {
|
|
content += "<p>"+cue.text+"</p>";
|
|
}
|
|
this.subtitleEls[lang].innerHTML = content;
|
|
});
|
|
}
|
|
for(let track of this.audioEl.textTracks){
|
|
// by default only one (or none) is set to showing.
|
|
// for some reason setting it on trackEl doesn't work, while this does the trick.
|
|
track.mode = 'showing';
|
|
}
|
|
|
|
|
|
this.audioEl.addEventListener('timeupdate', this.updateTimestring.bind(this));
|
|
this.audioEl.addEventListener('timeupdate', this.storeTime.bind(this));
|
|
|
|
|
|
this.updateTimestring();
|
|
}
|
|
|
|
updateTimestring() {
|
|
let now = this.audioEl.currentTime;
|
|
let duration = this.audioEl.duration;
|
|
|
|
let timestring = String(Math.floor(now/60)).padStart(2, '0') + ":"
|
|
+ String(Math.floor(now)%60).padStart(2, '0')
|
|
+ " / " + String(Math.floor(duration/60)).padStart(2, '0') + ":"
|
|
+ String(Math.floor(duration)%60).padStart(2, '0');
|
|
|
|
this.timeEl.innerHTML = timestring;
|
|
}
|
|
|
|
storeTime() {
|
|
// do we use this.t or audioEl...?
|
|
localStorage.setItem('playhead', String(this.audioEl.currentTime))
|
|
}
|
|
restoreTime() {
|
|
let time = localStorage.getItem('playhead');
|
|
|
|
this.audioEl.addEventListener('playing', (e) => {
|
|
if(!this.isPlaying) this.play(); // make sure we're playing.
|
|
});
|
|
// causes odd glitch
|
|
// this.audioEl.addEventListener('pause', (e) => {
|
|
// if(this.isPlaying) this.pause(true); // make sure we're playing.
|
|
// });
|
|
this.audioEl.addEventListener('seeking',(e)=>{
|
|
this.t = parseInt(e.target.currentTime * 1000);
|
|
// console.log('restore timer',this.t,e);
|
|
});
|
|
|
|
if(!time) return;
|
|
time = parseFloat(time);
|
|
this.audioEl.currentTime = time;
|
|
}
|
|
|
|
setupSorter() {
|
|
this.sorterTemplate = document.getElementById('annotationContentTemplate');
|
|
this.sorter = document.getElementById('annotation');
|
|
this.pauseTimeout = null;
|
|
let imagesToSort = localStorage.getItem('imagesToSort');
|
|
if(imagesToSort) {
|
|
this.imagesToSort = JSON.parse(imagesToSort);
|
|
}
|
|
// else or invalid:
|
|
if(!imagesToSort || this.imagesToSort.length > this.images.length) {
|
|
console.log('createimages to sort')
|
|
this.imagesToSort = []; // we will keep track of images that needs weighing
|
|
for (let index = 0; index < this.images.length; index++) {
|
|
this.imagesToSort[index] = index;
|
|
}
|
|
}
|
|
|
|
let nextButton = document.getElementById('nextButton');
|
|
nextButton.addEventListener('click', this.nextSorterImage.bind(this));
|
|
|
|
let resetButton = document.getElementById('resetButton');
|
|
resetButton.addEventListener('click', this.resetSortedImages.bind(this));
|
|
let resumeButton = document.getElementById('resumeButton');
|
|
resumeButton.addEventListener('click', this.play.bind(this));
|
|
|
|
// set maximum to last image index
|
|
this.sorterTemplate.content.querySelector('.weight').max = this.images.length - 1;
|
|
|
|
this.followCameraTimeout = null;
|
|
document.body.addEventListener('mouseup', () => {
|
|
// because the movement of the image can be a bit slower, we set a timeout instead
|
|
// of going straight back into spectator position
|
|
this.followCameraTimeout = setTimeout(this.stopFollowSortingImage.bind(this), this.config['returnToSpectatorCamTimeout']);
|
|
});
|
|
|
|
this.changeSorterImage();
|
|
}
|
|
|
|
resetSortedImages() {
|
|
this.imagesToSort = []; // we will keep track of images that needs weighing
|
|
for (let index = 0; index < this.images.length; index++) {
|
|
this.imagesToSort[index] = index;
|
|
}
|
|
this.changeSorterImage(); // display an image to sort
|
|
this.play(); // make sure playback timer gets set
|
|
}
|
|
|
|
changeSorterImage() {
|
|
|
|
let sorterContent = this.sorter.querySelector('#annotationContent');
|
|
|
|
if(!this.imagesToSort.length) {
|
|
document.body.classList.add('finished');
|
|
sorterContent.innerHTML = "";
|
|
this.audioEl.controls = true;
|
|
return;
|
|
} else {
|
|
document.body.classList.remove('finished');
|
|
this.audioEl.controls = false;
|
|
}
|
|
|
|
// pick random image
|
|
this.imageIdToSort = this.imagesToSort[Math.floor(Math.random()*this.imagesToSort.length)];
|
|
this.imageToSort = this.images[this.imageIdToSort];
|
|
|
|
let clone = this.sorterTemplate.content.cloneNode(true);
|
|
let imgEl = clone.querySelector(".img");
|
|
let descriptionEl = clone.querySelector(".description");
|
|
let nameEl = clone.querySelector(".name");
|
|
let weightEl = clone.querySelector(".weight");
|
|
imgEl.src = this.imageToSort.getImageUrl();
|
|
nameEl.innerHTML = this.imageToSort.info['image'];
|
|
descriptionEl.innerHTML = this.imageToSort.info['set'];
|
|
|
|
// weightEl.value = parseInt(image.info['weight']);
|
|
weightEl.value = parseInt(this.imageToSort.position); // don't use weight, but the image's actual position
|
|
weightEl.addEventListener('input', this.changeSorterWeight.bind(this));
|
|
weightEl.addEventListener('mousedown', this.followSortingImage.bind(this));
|
|
|
|
sorterContent.innerHTML = "";
|
|
sorterContent.appendChild(clone);
|
|
}
|
|
|
|
followSortingImage() {
|
|
clearTimeout(this.followCameraTimeout); // cancel any pending return to spectator cam
|
|
// this.imgCamera.lockedTarget = this.imageToSort.mesh;
|
|
this.imgCamera.parent = this.imageToSort.mesh;
|
|
this.scene.activeCamera = this.imgCamera;
|
|
}
|
|
|
|
stopFollowSortingImage() {
|
|
this.scene.activeCamera = this.spectatorCamera;
|
|
}
|
|
|
|
changeSorterWeight(ev) {
|
|
this.play();
|
|
|
|
// make sure nr is never too big.
|
|
let newIndex = Math.min(this.images.length -1 , parseInt(ev.target.value));
|
|
this.setImagePosition(this.imageToSort, newIndex);
|
|
}
|
|
|
|
setImagePosition(image, new_position) {
|
|
let id = this.images.indexOf(image);
|
|
let old_position = this.imagePositions.indexOf(id);
|
|
// move from old to new position in the array, see https://stackoverflow.com/a/5306832
|
|
this.imagePositions.splice(
|
|
new_position, 0,
|
|
this.imagePositions.splice(old_position, 1)[0]
|
|
);
|
|
|
|
// update positions of image objects
|
|
for(let i in this.images) {
|
|
i = parseInt(i);
|
|
this.images[i].setPosition(this.imagePositions.indexOf(i), this.t + 2000);
|
|
}
|
|
|
|
// save:
|
|
localStorage.setItem('imagePositions', JSON.stringify(this.imagePositions));
|
|
}
|
|
|
|
nextSorterImage() {
|
|
this.play();
|
|
let idx = this.imagesToSort.indexOf(this.imageIdToSort);
|
|
this.imagesToSort.splice(idx, 1); // remove from images to position
|
|
// store the array
|
|
localStorage.setItem('imagesToSort', JSON.stringify(this.imagesToSort));
|
|
|
|
this.changeSorterImage();
|
|
}
|
|
|
|
reset() {
|
|
// for use in terminal only. Quick way to test
|
|
localStorage.clear();
|
|
location.reload();
|
|
}
|
|
|
|
play() {
|
|
// play audio for n seconds:
|
|
// extend timeout if it's already playing
|
|
const duration = this.config.playDuration; // seconds
|
|
if(this.pauseTimeout) {
|
|
clearTimeout(this.pauseTimeout);
|
|
}
|
|
if(this.imagesToSort.length > 0){
|
|
this.pauseTimeout = setTimeout(() => {
|
|
this.pause();
|
|
}, duration * 1000);
|
|
} else {
|
|
// don't pause anymore when everything is sorted.
|
|
this.pauseTimeout = null;
|
|
}
|
|
this.audioEl.play();
|
|
document.body.classList.remove('paused');
|
|
this.isPlaying = true;
|
|
}
|
|
|
|
pause(dopause) {
|
|
if(this.imagesToSort.length < 1 && (typeof dopause == 'undefined' || false)){
|
|
console.log('do not pause');
|
|
return;
|
|
}
|
|
// pause audio and animation
|
|
document.body.classList.add('paused');
|
|
this.isPlaying = false;
|
|
this.audioEl.pause();
|
|
}
|
|
|
|
renderLoop() {
|
|
// limit camera movement:
|
|
// let camera = this.scene.cameras[0];
|
|
// if(camera.rotation['x'] < 0.1) {
|
|
// camera.rotation['x'] = 0.1
|
|
// }
|
|
// if(camera.rotation['x'] > .3) {
|
|
// camera.rotation['x'] = .3
|
|
// }
|
|
this.animate(); // update positions of the objects
|
|
this.scene.render();
|
|
}
|
|
|
|
animate() {
|
|
let n = window.performance.now();
|
|
let dt = 0;
|
|
if(this.isPlaying) {
|
|
// todo: make it come to a slow halt, instead of sudden;
|
|
dt = n - this.lastRenderTime;
|
|
if(dt < 0)
|
|
console.log('change',dt);
|
|
this.t += dt;
|
|
}
|
|
this.lastRenderTime = n; // also increment when paused;
|
|
|
|
// console.log(maxStepSize);
|
|
for(let i of this.images){
|
|
i.updateMeshPosition(this.t, dt, this.config);
|
|
|
|
}
|
|
}
|
|
|
|
createScene() {
|
|
// This creates a basic Babylon Scene object
|
|
this.scene = new BABYLON.Scene(this.engine);
|
|
|
|
this.scene.clearColor = new BABYLON.Color3(98,44,246); // color RGB
|
|
// this.scene.clearColor = new BABYLON.Color4(0.6, 0.6, 0.,0); // transparent - RGBA
|
|
this.scene.fogMode = BABYLON.Scene.FOGMODE_EXP;
|
|
this.scene.fogColor = this.scene.clearColor;
|
|
this.scene.fogDensity = 0.000;
|
|
|
|
// let camera = new BABYLON.UniversalCamera("camera1", new BABYLON.Vector3( 0.0, 0.0, 120.86337575312979), this.scene);
|
|
// camera.rotation = new BABYLON.Vector3(0, Math.PI, 0);
|
|
// camera.fov = 1.1; // default: 0.8
|
|
|
|
// CAM 1: the specatator position.
|
|
this.spectatorCamera = new BABYLON.UniversalCamera("SpectatorCam", new BABYLON.Vector3( -11.79906036421707, -0.7028999316894499, 32.43524104627659 ), this.scene);
|
|
this.spectatorCamera.rotation = new BABYLON.Vector3(-0.16070762395712468, 2.4005702973639886, 0);
|
|
this.spectatorCamera.fov = 1.5;
|
|
// this.spectatorCamera.rotation = new BABYLON.Vector3(0.1, 0,0);
|
|
// this.spectatorCamera.lowerRadiusLimit = 6;
|
|
// this.spectatorCamera.upperRadiusLimit = 20;
|
|
// This attaches the camera to the canvas
|
|
this.spectatorCamera.attachControl(this.canvasEl, true);
|
|
|
|
|
|
// CAM 2: follow/track the image when we're editing its weight.
|
|
// Parameters: name, position, scene
|
|
this.imgCamera = new BABYLON.ArcRotateCamera("FollowCam", 20, 20, 10, new BABYLON.Vector3(0, 0, 0), this.scene);
|
|
this.imgCamera.position = new BABYLON.Vector3(20,10,-20);
|
|
|
|
// // this.imgCamera = new BABYLON.FollowCamera("FollowCam", new BABYLON.Vector3(0, 10, -10), this.scene);
|
|
// // The goal distance of camera from target
|
|
// this.imgCamera.radius = 50;
|
|
// // The goal height of camera above local origin (centre) of target
|
|
// this.imgCamera.heightOffset = 20;
|
|
// // The goal rotation of camera around local origin (centre) of target in x y plane
|
|
// this.imgCamera.rotationOffset = 120; //degree
|
|
// // Acceleration of camera in moving from current to goal position
|
|
// this.imgCamera.cameraAcceleration = 0.1;
|
|
// // The speed at which acceleration is halted
|
|
// this.imgCamera.maxCameraSpeed = 100
|
|
// This attaches the camera to the canvas (disabled for now)
|
|
// this.imgCamera.attachControl(this.canvasEl, true);
|
|
// this.imgCamera.lockedTarget = null; // So far there's nothing to track yet
|
|
|
|
|
|
this.scene.activeCamera = this.spectatorCamera;
|
|
|
|
for(let i in this.images) {
|
|
// create the Meshes with the images in the scene
|
|
this.images[i].makeMesh(this.scene, this.config);
|
|
}
|
|
|
|
|
|
this.lastRenderTime = window.performance.now();
|
|
|
|
// this.scene.registerBeforeRender(this.animate.bind(this)); // we now do this in renderLoop().. for some reason works better/different?
|
|
|
|
return this.scene;
|
|
}
|
|
}
|
|
|
|
class ParadeImage {
|
|
constructor(info, position) {
|
|
this.info = info;
|
|
this.weight = info['weight'];
|
|
this.position = position; // index in list
|
|
this.target_position = position;
|
|
this.target_position_time = 0;
|
|
}
|
|
|
|
getImageUrl() {
|
|
return 'dataset/small/'+ this.info['image'];
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {int} new_position new position in parade
|
|
* @param {float} time_complete time at which it should have transitioned to this position
|
|
*/
|
|
setPosition(new_position, time_complete) {
|
|
if(this.target_position == new_position) return;
|
|
// console.log('changepos', this.target_position, new_position);
|
|
this.target_position = new_position;
|
|
this.target_position_time = time_complete;
|
|
}
|
|
|
|
makeMesh(scene, config) {
|
|
this.mesh = new BABYLON.TransformNode();
|
|
|
|
|
|
|
|
var img = new Image();
|
|
let self = this; // use in callback
|
|
img.onload = function() {
|
|
// we preload the image, so we can create the plane based on image dimensions
|
|
// see https://www.html5gamedevs.com/topic/8917-textures-source-image-aspect-ratio/?do=findComment&comment=52949
|
|
var planeOptions = {
|
|
// TODO: should size be dynamic?
|
|
height: this.height/100,
|
|
width: this.width/100,
|
|
sideOrientation: BABYLON.Mesh.DOUBLESIDE // texture should be visible from front and back
|
|
};
|
|
|
|
let imagePlane = BABYLON.MeshBuilder.CreatePlane("image", planeOptions, scene);
|
|
|
|
var imageMat = new BABYLON.StandardMaterial("m", scene);
|
|
|
|
imageMat.diffuseTexture = new BABYLON.Texture(self.getImageUrl(), scene);
|
|
imageMat.roughness = 1;
|
|
imageMat.emissiveColor = new BABYLON.Color3.White();
|
|
imagePlane.material = imageMat;
|
|
imagePlane.parent = self.mesh;
|
|
}
|
|
img.src = this.getImageUrl();
|
|
|
|
|
|
// text https://www.babylonjs-playground.com/#TMHF80
|
|
// TODO: cleanup this mess of the dynamic texture:
|
|
let text = this.info['image'];
|
|
var temp = new BABYLON.DynamicTexture("DynamicTexture", 64, scene);
|
|
var tmpctx = temp.getContext();
|
|
let maxWidth = 1700; // at some point texture starts doing messy things.. let's avoid that by squeezing text.
|
|
tmpctx.font = config['font'];
|
|
var DTWidth = Math.min(tmpctx.measureText(text).width, maxWidth);
|
|
var planeHeight = 3;
|
|
var DTHeight = 256; //or set as wished
|
|
var ratio = planeHeight/DTHeight;
|
|
var planeWidth = DTWidth * ratio;
|
|
|
|
var dynamicTexture = new BABYLON.DynamicTexture("DynamicTexture", {width:DTWidth, height:DTHeight}, scene, false);
|
|
dynamicTexture.hasAlpha = true;
|
|
var ctx = dynamicTexture.getContext();
|
|
ctx.font = config['font'];
|
|
ctx.fillStyle = config['textColor'];
|
|
ctx.fillText(text, 0, 50, maxWidth);
|
|
dynamicTexture.update();
|
|
|
|
var mat = new BABYLON.StandardMaterial("mat", scene);
|
|
mat.diffuseTexture = dynamicTexture;
|
|
mat.opacityTexture = dynamicTexture; // smoother transparency: https://www.html5gamedevs.com/topic/19647-quality-of-dynamictexture-text-with-transparent-background/?do=findComment&comment=111383
|
|
mat.roughness = 1;
|
|
mat.emissiveColor = new BABYLON.Color3.White();
|
|
|
|
// var textPlane = BABYLON.MeshBuilder.CreatePlane("text", {width:planeWidth, height:planeHeight, sideOrientation: BABYLON.Mesh.DOUBLESIDE}, scene);
|
|
// textPlane.material = mat;
|
|
// textPlane.parent = this.mesh;
|
|
// textPlane.position.y = -4.3;
|
|
|
|
|
|
|
|
this.mesh.rotation.y = Math.PI; // rotate 180 deg. to avoid looking at backside from the start.
|
|
|
|
}
|
|
|
|
updateMeshPosition(t, dt, config){
|
|
// first see how we are with our moving positions:
|
|
|
|
if(dt < 1) {
|
|
// don't change positions, because we're paused
|
|
}
|
|
else if(Math.abs(this.position - this.target_position) > 0.00001) {
|
|
if(t > this.target_position_time) {
|
|
console.error('were we too slow?');
|
|
this.position = this.target_position;
|
|
}
|
|
|
|
let tdiff = this.target_position_time - t;
|
|
if(tdiff != 0) {
|
|
if(tdiff < dt) {
|
|
// never place it too far...
|
|
this.position = this.target_position;
|
|
} else {
|
|
this.position += (this.target_position - this.position) / (tdiff/dt);
|
|
// console.log(this.position, this.target_position_time, t);
|
|
}
|
|
}
|
|
|
|
} else{
|
|
// it's practically the same, but set it to avoid any rounding uglyness.
|
|
this.position = this.target_position;
|
|
}
|
|
|
|
let p = t; //0; // if 0, disable movement for a bit.
|
|
p *= config.speedFactor/1000
|
|
p -= config.startOffset/config.speedFactor; // start offset ( milliseconds)
|
|
p -= config.distanceFactor * this.position / 1000;
|
|
|
|
// factors determine scale of animation
|
|
let target_x = Math.sin(2*p)*config.shapeWidth;
|
|
let target_z = Math.sin(p)*config.shapeHeight;
|
|
this.mesh.position.x = target_x;
|
|
this.mesh.position.z = target_z;
|
|
}
|
|
|
|
|
|
}
|