Skip to content

Instantly share code, notes, and snippets.

@k-izzo
Last active August 29, 2015 14:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save k-izzo/d7592234c7373e7eee96 to your computer and use it in GitHub Desktop.
Save k-izzo/d7592234c7373e7eee96 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<meta charset="utf-8">
<head><link rel='stylesheet' type='text/css' href='style.css'></head>
<body>
<div id='head'>
</div>
<div id='container'></div>
<script src='http://d3js.org/d3.v3.js'></script>
<script src='http://d3js.org/topojson.v1.min.js'></script>
<script src="tabletop.js"></script>
<script src='script.js'></script>
var scale = 0.70,
w = 850 * scale,
h = 900 * scale,
data = [];
d3.select('#head')
.style({
'margin-left': -1 * w / 2 + 'px',
width: w + 'px',
height: (150 * scale) + 'px'
});
d3.select('#container')
.style({
'margin-top': (150 * scale) + 'px',
'margin-left': -1 * w / 2 + 'px',
width: w + 'px',
height: h + 'px'
});
var star_d = 'M150,180l11,41l41,-11l-30,30l30,30l-41,-11l-11,41l-11,-41l-41,11l30,-30l-30,-30l41,11z';
var head_groups = d3.select('#head')
.append('svg')
.attr({width: w, height: h})
.selectAll('.star_group')
.data(d3.range(4))
.enter()
.append('g')
.classed('star_group', true)
.attr('transform', function (d) { return 'translate(' + [70 + d*100, -70] + ')scale(' + 0.5 + ')'; });
d3.selectAll('.star_group')
.append('path')
.classed('star', true)
.attr('d', star_d)
var zoom = function () {
vis.attr('transform', 'translate(' + d3.event.translate +')scale(' + d3.event.scale + ')');
};
var vis = d3.select('#container')
.append('svg')
.attr({
id: 'main',
width: w,
height: h
})
.append('g')
.attr('transform', 'scale(' + scale + ')')
.call(d3.behavior.zoom()
.scaleExtent([1, 10])
.on('zoom', zoom)
);
var projection = d3.geo.albers()
.translate([w / 2, h / 2])
.scale(120000)
.rotate([87.685, 0])
.center([0, 41.905])
var path = d3.geo.path()
.projection(projection);
var init_img_group = function () {
vis.append('g')
.attr({
id: 'img_group',
transform: 'translate(470, 50)'
})
.style('opacity', 0);
};
var mem_actions = {
enter: function () {
d3.select(this)
.select('text')
.transition().duration(500)
.style('opacity', 0.9);
d3.select(this)
.select('circle')
.transition().duration(500)
.attr('r', 10)
.style('opacity', 1);
},
leave: function (id) {
d3.select(this)
.select('text')
.transition().duration(500)
.style('opacity', 0);
d3.select(this)
.select('circle')
.transition().duration(500)
.attr('r', 4.5)
.style('opacity', 0.4);
}
};
var render_memory = function (row) {
var group = vis.append('g')
.on('mouseenter', mem_actions.enter)
.on('mouseleave', mem_actions.leave);
group.append('circle')
.classed('mem', true)
.attr({
cx: projection(row.loc)[0],
cy: projection(row.loc)[1],
r: 4.5
});
group.append('text')
.text('"' + row.memory + '" -' + row.name + ' (' + row.age + ')')
.classed('hidden', true)
.attr({
x: projection(row.loc)[0] - 2,
y: projection(row.loc)[1] - 11
});
};
var lm_actions = {
enter: function (row) {
return function () {
var img_group = d3.select('#img_group');
d3.select(this)
.select('circle')
.transition().duration(500)
.attr('r', 10)
.style('opacity', 1);
img_group.append('image')
.attr({
id: 'large_img',
'xlink:href': row.imageurl,
width: 310,
height: 310 / row.img_ratio
});
img_group.append('text')
.text('"' + row.caption + '"')
.attr({
id: 'large_cap',
x: 310 / 2,
y: (310 / row.img_ratio) + 31,
'text-anchor': 'middle'
});
img_group.append('text')
.text('-' + row.name + ' (' + row.age + ')')
.attr({
id: 'small_cap',
x: 310 / 2,
y: (310 / row.img_ratio) + 65,
'text-anchor': 'middle'
});
img_group
.transition().duration(500)
.style('opacity', 1);
};
},
leave: function (row) {
return function () {
d3.select(this)
.select('circle')
.transition().duration(500)
.attr('r', 4.5)
.style('opacity', 0.4);
d3.select('#img_group')
.transition().duration(500)
.style('opacity', 0);
d3.selectAll('#large_img').transition().duration(500).style('opacity', 0).remove();
d3.selectAll('#large_cap').transition().duration(500).style('opacity', 0).remove();
d3.selectAll('#small_cap').transition().duration(500).style('opacity', 0).remove();
};
}
};
var render_landmark = function (row) {
var group = vis.append('g')
.on('mouseenter', lm_actions.enter(row))
.on('mouseleave', lm_actions.leave(row));
group.append('circle')
.classed('lm', true)
.attr({
cx: projection(row.loc)[0],
cy: projection(row.loc)[1],
r: 4.5
});
};
var render_dream = function (row) {
if (row.memory) {
render_memory(row);
} else {
render_landmark(row);
}
};
render_map = function () {
d3.json('wards.json', function(wards_topo) {
var wards_geo = topojson.feature(wards_topo, wards_topo.objects.wards).features;
vis.selectAll('path')
.data(wards_geo)
.enter()
.append('path')
.attr('id', function (d, i) { return i; })
.attr('d', path);
var key = 'https://docs.google.com/spreadsheets/d/1R2XHnq9IgJ6edLN2Dn_Tn_U3ZoPE5BEe0Mk1wupmsa8/pubhtml';
var build_url = function (intersection) {
var start = 'http://maps.googleapis.com/maps/api/geocode/json?address=',
end = '&sensor=false',
mid = intersection.split(' ').join('+') + ',+Chicago,+IL';
return start + mid + end;
};
var process_data = function (rows) {
rows.forEach(function (row) {
var clean = row;
if (row.imageurl) {
var img = new Image();
img.src = row.imageurl;
img.onload = function () { clean.img_ratio = img.width / img.height; };
}
d3.json(build_url(row.intersection), function(goog_json) {
var lng = goog_json.results[0].geometry.location.lng,
lat = goog_json.results[0].geometry.location.lat;
clean.loc = [lng, lat];
delete clean.intersection;
render_dream(clean);
});
});
};
var load_data = function () {
Tabletop.init({
key: key,
callback: process_data,
simpleSheet: true
});
};
init_img_group();
load_data();
});
};
render_map();
#head, #container {
position: absolute;
left: 50%;
}
#main {
background-color: rgb(200,218,233);
}
path {
stroke: white;
stroke-opacity: 1;
fill: #bdbdbd;
stroke-width: 0.3px;
}
.star {
fill: red;
opacity: 0.5;
}
.mem, .lm {
opacity: 0.4;
}
.mem {
fill: red;
}
.lm {
fill: blue;
}
text {
font-family: Helvetica;
stroke: none;
font-size: 18px;
font-style: italic;
pointer-events: none;
fill: rgb(36,36,37);
}
#large_cap {
font-size: 24px;
font-style: italic;
opacity: 0.9;
}
#small_cap {
font-size: 22px;
font-style: italic;
opacity: 0.9;
}
.hidden {
opacity: 0;
}
(function(global) {
"use strict";
var inNodeJS = false;
if (typeof module !== 'undefined' && module.exports) {
inNodeJS = true;
var request = require('request');
}
var supportsCORS = false;
var inLegacyIE = false;
try {
var testXHR = new XMLHttpRequest();
if (typeof testXHR.withCredentials !== 'undefined') {
supportsCORS = true;
} else {
if ("XDomainRequest" in window) {
supportsCORS = true;
inLegacyIE = true;
}
}
} catch (e) { }
// Create a simple indexOf function for support
// of older browsers. Uses native indexOf if
// available. Code similar to underscores.
// By making a separate function, instead of adding
// to the prototype, we will not break bad for loops
// in older browsers
var indexOfProto = Array.prototype.indexOf;
var ttIndexOf = function(array, item) {
var i = 0, l = array.length;
if (indexOfProto && array.indexOf === indexOfProto) return array.indexOf(item);
for (; i < l; i++) if (array[i] === item) return i;
return -1;
};
/*
Initialize with Tabletop.init( { key: '0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc' } )
OR!
Initialize with Tabletop.init( { key: 'https://docs.google.com/spreadsheet/pub?hl=en_US&hl=en_US&key=0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc&output=html&widget=true' } )
OR!
Initialize with Tabletop.init('0AjAPaAU9MeLFdHUxTlJiVVRYNGRJQnRmSnQwTlpoUXc')
*/
var Tabletop = function(options) {
// Make sure Tabletop is being used as a constructor no matter what.
if(!this || !(this instanceof Tabletop)) {
return new Tabletop(options);
}
if(typeof(options) === 'string') {
options = { key : options };
}
this.callback = options.callback;
this.wanted = options.wanted || [];
this.key = options.key;
this.simpleSheet = !!options.simpleSheet;
this.parseNumbers = !!options.parseNumbers;
this.wait = !!options.wait;
this.reverse = !!options.reverse;
this.postProcess = options.postProcess;
this.debug = !!options.debug;
this.query = options.query || '';
this.orderby = options.orderby;
this.endpoint = options.endpoint || "https://spreadsheets.google.com";
this.singleton = !!options.singleton;
this.simple_url = !!options.simple_url;
this.callbackContext = options.callbackContext;
if(typeof(options.proxy) !== 'undefined') {
// Remove trailing slash, it will break the app
this.endpoint = options.proxy.replace(/\/$/,'');
this.simple_url = true;
this.singleton = true;
// Let's only use CORS (straight JSON request) when
// fetching straight from Google
supportsCORS = false
}
this.parameterize = options.parameterize || false;
if(this.singleton) {
if(typeof(Tabletop.singleton) !== 'undefined') {
this.log("WARNING! Tabletop singleton already defined");
}
Tabletop.singleton = this;
}
/* Be friendly about what you accept */
if(/key=/.test(this.key)) {
this.log("You passed an old Google Docs url as the key! Attempting to parse.");
this.key = this.key.match("key=(.*?)&")[1];
}
if(/pubhtml/.test(this.key)) {
this.log("You passed a new Google Spreadsheets url as the key! Attempting to parse.");
this.key = this.key.match("d\\/(.*?)\\/pubhtml")[1];
}
if(!this.key) {
this.log("You need to pass Tabletop a key!");
return;
}
this.log("Initializing with key " + this.key);
this.models = {};
this.model_names = [];
this.base_json_path = "/feeds/worksheets/" + this.key + "/public/basic?alt=";
if (inNodeJS || supportsCORS) {
this.base_json_path += 'json';
} else {
this.base_json_path += 'json-in-script';
}
if(!this.wait) {
this.fetch();
}
};
// A global storage for callbacks.
Tabletop.callbacks = {};
// Backwards compatibility.
Tabletop.init = function(options) {
return new Tabletop(options);
};
Tabletop.sheets = function() {
this.log("Times have changed! You'll want to use var tabletop = Tabletop.init(...); tabletop.sheets(...); instead of Tabletop.sheets(...)");
};
Tabletop.prototype = {
fetch: function(callback) {
if(typeof(callback) !== "undefined") {
this.callback = callback;
}
this.requestData(this.base_json_path, this.loadSheets);
},
/*
This will call the environment appropriate request method.
In browser it will use JSON-P, in node it will use request()
*/
requestData: function(path, callback) {
if (inNodeJS) {
this.serverSideFetch(path, callback);
} else {
//CORS only works in IE8/9 across the same protocol
//You must have your server on HTTPS to talk to Google, or it'll fall back on injection
var protocol = this.endpoint.split("//").shift() || "http";
if (supportsCORS && (!inLegacyIE || protocol === location.protocol)) {
this.xhrFetch(path, callback);
} else {
this.injectScript(path, callback);
}
}
},
/*
Use Cross-Origin XMLHttpRequest to get the data in browsers that support it.
*/
xhrFetch: function(path, callback) {
//support IE8's separate cross-domain object
var xhr = inLegacyIE ? new XDomainRequest() : new XMLHttpRequest();
xhr.open("GET", this.endpoint + path);
var self = this;
xhr.onload = function() {
try {
var json = JSON.parse(xhr.responseText);
} catch (e) {
console.error(e);
}
callback.call(self, json);
};
xhr.send();
},
/*
Insert the URL into the page as a script tag. Once it's loaded the spreadsheet data
it triggers the callback. This helps you avoid cross-domain errors
http://code.google.com/apis/gdata/samples/spreadsheet_sample.html
Let's be plain-Jane and not use jQuery or anything.
*/
injectScript: function(path, callback) {
var script = document.createElement('script');
var callbackName;
if(this.singleton) {
if(callback === this.loadSheets) {
callbackName = 'Tabletop.singleton.loadSheets';
} else if (callback === this.loadSheet) {
callbackName = 'Tabletop.singleton.loadSheet';
}
} else {
var self = this;
callbackName = 'tt' + (+new Date()) + (Math.floor(Math.random()*100000));
// Create a temp callback which will get removed once it has executed,
// this allows multiple instances of Tabletop to coexist.
Tabletop.callbacks[ callbackName ] = function () {
var args = Array.prototype.slice.call( arguments, 0 );
callback.apply(self, args);
script.parentNode.removeChild(script);
delete Tabletop.callbacks[callbackName];
};
callbackName = 'Tabletop.callbacks.' + callbackName;
}
var url = path + "&callback=" + callbackName;
if(this.simple_url) {
// We've gone down a rabbit hole of passing injectScript the path, so let's
// just pull the sheet_id out of the path like the least efficient worker bees
if(path.indexOf("/list/") !== -1) {
script.src = this.endpoint + "/" + this.key + "-" + path.split("/")[4];
} else {
script.src = this.endpoint + "/" + this.key;
}
} else {
script.src = this.endpoint + url;
}
if (this.parameterize) {
script.src = this.parameterize + encodeURIComponent(script.src);
}
document.getElementsByTagName('script')[0].parentNode.appendChild(script);
},
/*
This will only run if tabletop is being run in node.js
*/
serverSideFetch: function(path, callback) {
var self = this
request({url: this.endpoint + path, json: true}, function(err, resp, body) {
if (err) {
return console.error(err);
}
callback.call(self, body);
});
},
/*
Is this a sheet you want to pull?
If { wanted: ["Sheet1"] } has been specified, only Sheet1 is imported
Pulls all sheets if none are specified
*/
isWanted: function(sheetName) {
if(this.wanted.length === 0) {
return true;
} else {
return (ttIndexOf(this.wanted, sheetName) !== -1);
}
},
/*
What gets send to the callback
if simpleSheet === true, then don't return an array of Tabletop.this.models,
only return the first one's elements
*/
data: function() {
// If the instance is being queried before the data's been fetched
// then return undefined.
if(this.model_names.length === 0) {
return undefined;
}
if(this.simpleSheet) {
if(this.model_names.length > 1 && this.debug) {
this.log("WARNING You have more than one sheet but are using simple sheet mode! Don't blame me when something goes wrong.");
}
return this.models[ this.model_names[0] ].all();
} else {
return this.models;
}
},
/*
Add another sheet to the wanted list
*/
addWanted: function(sheet) {
if(ttIndexOf(this.wanted, sheet) === -1) {
this.wanted.push(sheet);
}
},
/*
Load all worksheets of the spreadsheet, turning each into a Tabletop Model.
Need to use injectScript because the worksheet view that you're working from
doesn't actually include the data. The list-based feed (/feeds/list/key..) does, though.
Calls back to loadSheet in order to get the real work done.
Used as a callback for the worksheet-based JSON
*/
loadSheets: function(data) {
var i, ilen;
var toLoad = [];
this.foundSheetNames = [];
for(i = 0, ilen = data.feed.entry.length; i < ilen ; i++) {
this.foundSheetNames.push(data.feed.entry[i].title.$t);
// Only pull in desired sheets to reduce loading
if( this.isWanted(data.feed.entry[i].content.$t) ) {
var linkIdx = data.feed.entry[i].link.length-1;
var sheet_id = data.feed.entry[i].link[linkIdx].href.split('/').pop();
var json_path = "/feeds/list/" + this.key + "/" + sheet_id + "/public/values?alt="
if (inNodeJS || supportsCORS) {
json_path += 'json';
} else {
json_path += 'json-in-script';
}
if(this.query) {
json_path += "&sq=" + this.query;
}
if(this.orderby) {
json_path += "&orderby=column:" + this.orderby.toLowerCase();
}
if(this.reverse) {
json_path += "&reverse=true";
}
toLoad.push(json_path);
}
}
this.sheetsToLoad = toLoad.length;
for(i = 0, ilen = toLoad.length; i < ilen; i++) {
this.requestData(toLoad[i], this.loadSheet);
}
},
/*
Access layer for the this.models
.sheets() gets you all of the sheets
.sheets('Sheet1') gets you the sheet named Sheet1
*/
sheets: function(sheetName) {
if(typeof sheetName === "undefined") {
return this.models;
} else {
if(typeof(this.models[ sheetName ]) === "undefined") {
// alert( "Can't find " + sheetName );
return;
} else {
return this.models[ sheetName ];
}
}
},
/*
Parse a single list-based worksheet, turning it into a Tabletop Model
Used as a callback for the list-based JSON
*/
loadSheet: function(data) {
var model = new Tabletop.Model( { data: data,
parseNumbers: this.parseNumbers,
postProcess: this.postProcess,
tabletop: this } );
this.models[ model.name ] = model;
if(ttIndexOf(this.model_names, model.name) === -1) {
this.model_names.push(model.name);
}
this.sheetsToLoad--;
if(this.sheetsToLoad === 0)
this.doCallback();
},
/*
Execute the callback upon loading! Rely on this.data() because you might
only request certain pieces of data (i.e. simpleSheet mode)
Tests this.sheetsToLoad just in case a race condition happens to show up
*/
doCallback: function() {
if(this.sheetsToLoad === 0) {
this.callback.apply(this.callbackContext || this, [this.data(), this]);
}
},
log: function(msg) {
if(this.debug) {
if(typeof console !== "undefined" && typeof console.log !== "undefined") {
Function.prototype.apply.apply(console.log, [console, arguments]);
}
}
}
};
/*
Tabletop.Model stores the attribute names and parses the worksheet data
to turn it into something worthwhile
Options should be in the format { data: XXX }, with XXX being the list-based worksheet
*/
Tabletop.Model = function(options) {
var i, j, ilen, jlen;
this.column_names = [];
this.name = options.data.feed.title.$t;
this.elements = [];
this.raw = options.data; // A copy of the sheet's raw data, for accessing minutiae
if(typeof(options.data.feed.entry) === 'undefined') {
options.tabletop.log("Missing data for " + this.name + ", make sure you didn't forget column headers");
this.elements = [];
return;
}
for(var key in options.data.feed.entry[0]){
if(/^gsx/.test(key))
this.column_names.push( key.replace("gsx$","") );
}
for(i = 0, ilen = options.data.feed.entry.length ; i < ilen; i++) {
var source = options.data.feed.entry[i];
var element = {};
for(var j = 0, jlen = this.column_names.length; j < jlen ; j++) {
var cell = source[ "gsx$" + this.column_names[j] ];
if (typeof(cell) !== 'undefined') {
if(options.parseNumbers && cell.$t !== '' && !isNaN(cell.$t))
element[ this.column_names[j] ] = +cell.$t;
else
element[ this.column_names[j] ] = cell.$t;
} else {
element[ this.column_names[j] ] = '';
}
}
if(element.rowNumber === undefined)
element.rowNumber = i + 1;
if( options.postProcess )
options.postProcess(element);
this.elements.push(element);
}
};
Tabletop.Model.prototype = {
/*
Returns all of the elements (rows) of the worksheet as objects
*/
all: function() {
return this.elements;
},
/*
Return the elements as an array of arrays, instead of an array of objects
*/
toArray: function() {
var array = [],
i, j, ilen, jlen;
for(i = 0, ilen = this.elements.length; i < ilen; i++) {
var row = [];
for(j = 0, jlen = this.column_names.length; j < jlen ; j++) {
row.push( this.elements[i][ this.column_names[j] ] );
}
array.push(row);
}
return array;
}
};
if(inNodeJS) {
module.exports = Tabletop;
} else {
global.Tabletop = Tabletop;
}
})(this);
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment