view exif-graphr.js @ 11:aaf1d6954a2a default tip

Ensure we always have data dir for ExifGraphr cache
author IBBoard <dev@ibboard.co.uk>
date Sun, 13 Aug 2023 16:56:40 +0100
parents a42497c3964c
children
line wrap: on
line source

var debug = false;
var photo_data = {};

document.getElementById('submit').onclick = visualise;
document.getElementById('username').onkeypress = function() { if (event.keyCode==13) { visualise(); return false; } }

/* Helper functions */
function byID(d) { return d.id; }
function byValue(d) { return d; }

function makeUrl(method, vals) {
	vals = vals || {};
	var parts = [];
	if (debug) {
		for (var key in vals) {
			parts.push(encodeURIComponent(key) + '-' + encodeURIComponent(vals[key]));
		}
		var extra = parts.join('-');
		if (extra != '') {
			extra = '-' + extra;
		}
		var url = "./data-" + method + extra + ".json";
	} else {
		for (var key in vals) {
			parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(vals[key]));
		}
		var extra = parts.join('&');
		if (extra != '') {
			extra = '&' + extra;
		}
		var url = "https://ibboard.co.uk/exif-graphr/api.php?method=" + method + extra;
	}

	return url;
}

/* The real code */
function visualise() {
	photo_data = {};
	var userFoundFunc = function(error,data) {
		if (error) {
			alert(error.responseText ? error.responseText : error.statusText);
			return;
		}
		if (data.stat != 'ok') {
			if (data.message) {
				alert(data.message);
			} else {
				alert("Unknown error");
			}
			return;
		}
		var nsid = getNSID(data);
		getPhotos(nsid);
	}

	var username = document.getElementById('username').value;

	if (/[0-9]+@N[0-9]+/.test(username)) {
		getPhotos(username);
	} else if (/https?:\/\/www\.flickr\.com\/photos\/[0-9a-z]+(\/.*)+/.test(username)) {
		getNSID = function(data) {
			return data.user.id;
		};
		d3.json(makeUrl('flickr.urls.lookupUser', { 'url': username }), userFoundFunc);
	} else {
		getNSID = function(data) {
			return data.user.nsid;
		};
		d3.json(makeUrl('flickr.people.findByUsername', { 'username': username }), userFoundFunc);
	}
}

function getPhotos(nsid) {
	d3.json(makeUrl('flickr.people.getPublicPhotos', { 'user_id': nsid, 'per_page' : 100, 'extras': 'url_t' }), function(error, data) {
		data.photos.photo.forEach(getExif);
	});
}

function getExif(photo) {
	var photo_id = photo.id;
	var thumb_url = photo.url_t;
	var url = "https://flickr.com/photos/" + photo.owner + "/" + photo_id;
	d3.json(makeUrl('flickr.photos.getExif', { 'photo_id' : photo_id }), function(error, data) {
		if (error || data.code) {
			console.log("Error fetching details for " + photo_id + ": " + data.message);
			return;
		}
		var exifs = data.photo.exif;
		var len = exifs.length;
		var exposure = '';
		var f_number = '';
		var iso = '';
		var focal_length = '';
		var timestamp = '';
		var make = '';
		var model = '';

		for (var i = 0; i < len; i++) {
			var exif = exifs[i];
			if (exif.tagspace == 'ExifIFD') {
				if (exif.tag == 'ExposureTime') {
					if (exif.clean) {
						exposure = exif.clean._content;
					} else if (/^[0-9]+(\.[0-9]+)?$/.test(exif.raw._content)) {
						exposure = exif.raw._content + " sec (" + exif.raw._content + ")";
					} else {
						console.log("No clean exposure time for "+photo.id+". Raw was "+exif.raw._content); continue;
					}
				} else if (exif.tag == 'FNumber') {
					f_number = exif.raw._content;
				} else if (exif.tag == 'ISO') {
					iso = exif.raw._content;
				} else if (exif.tag == 'FocalLength') {
					if (/^[0-9\.]+ mm/.test(exif.raw._content)) {
						focal_length = parseInt(exif.raw._content);
					}
				} else if (exif.tag == 'DateTimeOriginal') {
					timestamp = Date.parse(exif.raw._content.replace(/^([0-9][0-9][0-9][0-9]):([0-9][0-9]):([0-9][0-9]) /, '$1/$2/$3 '));
				}
			} else if (exif.tagspace == 'IFD0') {
				if (exif.tag == 'Make') {
					make = exif.raw._content;
				} else if (exif.tag == 'Model') {
					model = exif.raw._content;
				} else if (exif.tag == 'FocalLength') {
					if (!focal_length) { focal_length = exif.raw._content.replace(/^([0-9]+(\.[0-9]+)?).*$/, '$1'); }
				} else if (exif.tag == 'ExposureTime') {
					if (!exposure) { exposure = exif.raw._content + " sec (" + exif.raw._content + ")"; }
				} else if (exif.tag == 'FNumber') {
					if (!f_number) { f_number = Math.round(exif.raw._content / 100); /* Trial and error guess - it is a rational number */ }
				}

			}
		}

		var exp_matches = exposure.match(/^(1\/)?([0-9\.]+) sec( \(([^\)]+)\))?/);
		var exp_parts = [ '', '' ];
		if (exp_matches) {
			if (exp_matches[1]) {
				// We got a non-decimal numeric version
				exp_parts[0] = 1 / exp_matches[2];
			} else {
				exp_parts[0] = exp_matches[2];
			}

			if (exp_matches[4]) {
				exp_parts[1] = exp_matches[4];
				var exp_fraction = exp_parts[1].match(/^1\/([0-9]+)$/);
				if (exp_fraction) {
					//Override for accuracy - the "clean" version rounds too much - 1/909 through 1/2000 ⇒ 0.001
					exp_parts[0] = 1 / exp_fraction[1];
				}
			} else {
				exp_parts[1] = exp_parts[0] >= 1 ? exp_parts[0] : "1/"+(1/exp_parts[0]);
			}
		}
		var exp_sec = exp_parts[0];
		var exp_str = exp_parts[1];

		var camera = '';
		make = make.replace(/ CORPORATION$/, '');
		if (make && model && !(model.indexOf(make) === 0)) {
			camera = make + " " + model;
		} else {
			camera = model;
		}
		
		photo_data[photo.id] = { 'id': photo.id, 'exposure': +exp_sec, 'exposure_string': exp_str, 'f_number': f_number, 'iso': +iso, 'focal_length': +focal_length, 'camera': camera, 'make': make, 'model' : model, 'timestamp': timestamp, 'thumb': thumb_url, 'url': url };
		update();
	});
}

function clone(obj) {
	if (null == obj || "object" != typeof obj) return obj;
	var copy = obj.constructor();
	for (var attr in obj) {
		if (obj.hasOwnProperty(attr)) copy[attr] = clone(obj[attr]);
	}
	return copy;
}

function hashcode(s){
	return s.split("").reduce(function(a,b){a=((a<<5)-a)+b.charCodeAt(0);return a&a},0);
}

var margin = {top: 40, right: 20, bottom: 50, left: 55},
	width = 960 - margin.left - margin.right,
	height = 600 - margin.top - margin.bottom,
	axisHeight = height - margin.bottom;
var svg = d3.select("#graph").append("svg")
	.attr("width", width + margin.left + margin.right)
	.attr("height", height + margin.top + margin.bottom)
  .append("g")
	.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
var photoTable = d3.select("#photoTable");
var legend = d3.select("#legend").append("svg").attr("height", 0);
var legendGradients = legend.append("defs");
var legends = legend.append("g");

var tip = d3.tip()
	.attr('class', 'd3-tip')
	.offset([-10, 0])
	.html(function(d) {
		return "Photo " + d.id + '<br /><img src="' + d.thumb + '" alt="Photo ' + d.id + '" />' + "<br />Aperture: f/" + d.f_number + "<br />Focal Length: " + d.focal_length + "<br />Exposure: " + d.exposure_string + "<br />ISO: " + d.iso + "<br />Camera: " + d.camera;
	});
svg.call(tip);

var flAxisScale = d3.scaleLinear()
	.rangeRound([axisHeight, 0]);
var flAxis = d3.axisLeft(flAxisScale);
var expAxisScale = d3.scaleLog()
	.rangeRound([0, width]);
var expAxis = d3.axisTop(expAxisScale)
	.tickFormat(function(d) { if (d < 1) { return "1∕" + (1 / d).toFixed(2).replace(/\.?0+$/, ''); } else { return d; } });
var circlesG = svg.append("svg:g");
var axises = svg.append("svg:g");
var saturationScale = d3.scaleLog().range([30,90]);
axises.append("g")
	.attr("class", "y axis")
	.call(flAxis);
axises.append("g")
	.attr("class", "x axis")
	.attr("transform", "translate(0," + (axisHeight) + ")")
	.call(expAxis)
		.selectAll("text")
		.style("text-anchor", "start")
		.attr("y", 0)
		.attr("x", 9)
		.attr("dy", ".35em")
		.attr("transform", function(d) {
			return "rotate(90)" 
		});
axises.append("text")
	.attr("transform", "translate(" + (width / 2) + " ," + (height + (margin.bottom/2)) + ")")
	.style("text-anchor", "middle")
	.text("Shutter Speed (Exposure) in Seconds");
svg.append("text")
	.attr("transform", "rotate(-90)")
	.attr("y", 0 - margin.left)
	.attr("x",0 - (height / 2))
	.attr("dy", "1em")
	.style("text-anchor", "middle")
	.text("Focal Length in mm");

var timeline = d3.select("#timeline").append("svg")
	.attr("width", 960)
	.attr("height", 150);
var timelineScale = d3.scaleTime().rangeRound([0,960]);
var timelineAxis = d3.axisBottom(timelineScale);
var timelineAxes = timeline.append("g").attr("transform", "translate(0,75)");
var timelineNodes = timeline.append("g").attr("transform", "translate(0,75)");
var furthestBee = -Infinity;
var maxBeeDistance = 70;

function update() {
	var photos = [];
	for (var key in photo_data) {
		if (photo_data.hasOwnProperty(key)) {
			photos.push(photo_data[key]);
		}
	}

	var photosWithExif = photos.filter(function(d) { return d.focal_length != '' && d.exposure != '' && d.f_number != '' && d.iso != '';});
	saturationScale.domain([d3.min(photosWithExif, function(val) { return val.iso; }), d3.max(photosWithExif, function(val) { return val.iso; })]);

	update_graph(photosWithExif);
	update_table(photos);
	update_legend(photosWithExif);
	update_beeswarm(photosWithExif);
}

function update_graph(filteredPhotos) {
	flAxisScale.domain([0, d3.max(filteredPhotos, function(val) { return val.focal_length; }) * 1.1]);
	flAxis.scale(flAxisScale);
	axises.selectAll(".y.axis").call(flAxis);
	expAxisScale.domain([d3.min(filteredPhotos, function(val) { return val.exposure; }) * 0.9, d3.max(filteredPhotos, function(val) { return val.exposure; }) * 1.1]);
	expAxis.scale(expAxisScale);
	axises.selectAll(".x.axis").call(expAxis)
		.selectAll("text")
		.style("text-anchor", "start")
		.attr("y", 0)
		.attr("x", 9)
		.attr("dy", ".35em")
		.attr("transform", function(d) {
			return "rotate(90)" 
		});
	var circles = circlesG.selectAll("circle")
		.data(filteredPhotos, byID);
	circles.exit().remove();
	var circleEnter = circles.enter()
		.append("circle")
		.on('mouseover', tip.show)
		.on('mouseout', tip.hide)
//		.transition()
//		.duration(750);
	
	circles/*.transition().duration(250)*/
		.merge(circleEnter)
		.attr("cx", function (d) { return expAxisScale(d.exposure); })
		.attr("cy", function (d) { return flAxisScale(d.focal_length); })
		.attr("r", function (d) { return 50 / d.f_number; })
		.attr("fill", function (d) { var h = make_camera_hue(d.camera); var s = saturationScale(d.iso); return "hsl("+h+","+s+"%,50%)";})
		.attr("stroke", "#000")
		.attr("stroke-width", 1);
	circles.sort(function(a, b) { var f_sort = a.f_number - b.f_number; if (f_sort != 0) { return f_sort; } else { return a.exposure - b.exposure; } });
}

function make_camera_hue(camera) {
	return (hashcode(camera) % 90) * 4;
}

function update_table(photos) {
	var photoRows = photoTable.selectAll("tr").data(photos, byID);
	photoRows.exit().remove();
	photoRows.enter()
		.append("tr");
	photoRows.sort(function(a,b){ return b.timestamp - a.timestamp;});
	photoCells = photoRows.selectAll("td")
		.data(function(photo) {
			var thumb = '<a href="' + photo.url + '"><img src="' + photo.thumb + '" alt="Photo ' + photo.id + '" /></a>';
			return [ thumb, photo.camera, photo.exposure_string != '' ? photo.exposure_string + "s" : '', photo.focal_length != '' ? photo.focal_length + "mm" : '', photo.f_number != '' ? "f/" + photo.f_number : '', photo.iso ]; })
		.enter()
		.append("td")
		.html(function(d) { return d; });
}

function update_legend(photos) {
	var legendBlockWidth = 200;
	var legendBlockHeight = 20;
	var cameras = d3.set(photos.map(function(d){ return d.camera ? d.camera : "Unknown"; })).values();
	legend.attr("width", 400)
		.attr("height", cameras.length * legendBlockHeight + legendBlockHeight);
	var gradients = legendGradients.selectAll("linearGradient").data(cameras, byValue);
	gradients.exit().remove();
	var newGradients = gradients.enter().append("linearGradient")
		.attr("id", function(d,i) { return "Gradient" + i; });
	newGradients.append("stop")
		.attr("offset", "0%")
		.attr("stop-color", function(camera){var h = make_camera_hue(camera); return "hsl("+h+","+saturationScale.range()[0]+"%,50%)";});
	newGradients.append("stop")
		.attr("offset", "100%")
		.attr("stop-color", function(camera){var h = make_camera_hue(camera); return "hsl("+h+","+saturationScale.range()[1]+"%,50%)";});
	var cameraLegends = legends.selectAll("g").data(cameras, byValue);
	cameraLegends.exit().remove();
	var newCameraLegends = cameraLegends.enter().append("g")
		.on('mouseover', function(d) { setCircleOpacity(d, 0.1); })
		.on('mouseout', function(d) { setCircleOpacity(d, 1); });
	newCameraLegends.append("title").text(function(d){return d;});
	newCameraLegends.append("text")
		.text(function(d){ return ": " + d; }) //Fudge positioning so that hover opacity doesn't stop between gradient and label
		.attr("x", legendBlockWidth - 5)
		.attr("y", function(d,i) { return i * legendBlockHeight + (legendBlockHeight / 4) * 3; })
	newCameraLegends.append("rect")
		.attr("x", 0)
		.attr("y", function(d,i){ return i * legendBlockHeight; })
		.attr("width", legendBlockWidth)
		.attr("height", legendBlockHeight)
		.attr("fill", function(d,i) { return "url(#Gradient" + i + ")"; })
}

function setCircleOpacity(camera, opacity) {
	// Set opacity of all circles that AREN'T photos from this camera to highlight the ones that are
	circlesG.selectAll("circle")
		.attr('opacity', function (d) { return (d.camera != camera) ? opacity : 1; });
}

function update_beeswarm(photos) {
	var datedPhotos = photos.filter(function(d) { return d.timestamp != ''; });
	var domain = [d3.min(datedPhotos, function(val) { return new Date(val.timestamp); }), d3.max(datedPhotos, function(val) { return new Date(val.timestamp); })];
	var domainSpread = domain[1].getTime() - domain[0].getTime();
	domain = [new Date(domain[0].getTime() - (domainSpread * 0.01)), new Date(domain[1].getTime() + (domainSpread * 0.01))];
	timelineScale.domain(domain);
	timelineAxes.call(timelineAxis);
	var swarm = d3.beeswarm()
		.data(datedPhotos)
		.distributeOn(function (d) { return timelineScale(new Date(d.timestamp)); })
		.radius(4)
		.orientation('horizontal')
		.side('symetric')
		.arrange();
	furthestBee = d3.max(swarm, function(bee) { return Math.abs(bee.y);});
	var nodes = timelineNodes.selectAll('circle')
		.data(swarm);
	nodes.exit().remove();
	var newNodes = nodes.enter().append('circle');
	nodes.merge(newNodes)
		.attr('cx', function(bee) { return bee.x; })
		.attr('cy', function(bee) { return calculateBeeY(bee); })
		.attr('r', 4)
		.attr("fill", function (bee) { var d = bee.datum; var h = make_camera_hue(d.camera); var s = saturationScale(d.iso); return "hsl("+h+","+s+"%,50%)";})
		.attr("stroke", "#000")
		.attr("stroke-width", 1);
}

function calculateBeeY(bee) {
	if (furthestBee < maxBeeDistance) {
		return bee.y;
	} else {
		return bee.y % maxBeeDistance;
	}
}