Skip to content

Instantly share code, notes, and snippets.

@henryjameslau
Last active May 13, 2019 20:01
Show Gist options
  • Save henryjameslau/4d857052a44f64c83bf7a3e2c6c04af9 to your computer and use it in GitHub Desktop.
Save henryjameslau/4d857052a44f64c83bf7a3e2c6c04af9 to your computer and use it in GitHub Desktop.
Odd's ratio
{
"essential" : {
"graphic_data_url": "data.csv",
"colour_palette": [ "#274796"],
"negative_colour":["#D32F2F"],
"sourceText":["Labour Market Statistics, February 2015, Table A02"],
"sourceURL":["http://www.ons.gov.uk/ons/publications/re-reference-tables.html?edition=tcm%3A77-350752"],
"annotationChart": [
],
"annotationBullet": [
],
"annotationXY":[
],
"annotationAlign":[
],
"barHeight_sm_md_lg" : [40,40,40],
"xAxisLabel":"Odds ratio",
"xAxisScale":[0.125,0.25,0.5,1,2,4]
},
"optional" : {
"margin_sm": [5, 20, 5, 135],
"margin_md": [5, 20, 5, 135],
"margin_lg": [5, 20, 5, 170],
"aspectRatio_sm" : [4,7],
"aspectRatio_md" : [1,1],
"aspectRatio_lg" : [16,12],
"mobileBreakpoint" : 510,
"x_num_ticks_sm_md_lg" : [6,8,10],
"vertical_line" : false,
"annotateLineX1_Y1_X2_Y2":[ [[25,"South East"],[25,"East"]]],
"annotateRect" : false,
"annotateRectX_Y" : [ [[40, "South West"],[75, "West Midlands"]]],
"lineColor_opcty" : [["#888", 0.2]],
"centre_line" : false,
"centre_line_value" : 10
}
}
name value
North West 1
North East 2
Wales 3
South East 0.125
London 0.25
South West 0.5
East Midlands 1
Yorkshire and The Humber 2
Scotland 4
East 0.125
West Midlands 0.25
Northern Ireland 0.5
<!DOCTYPE html>
<html lang="en">
<head>
<link href='https://fonts.googleapis.com/css?family=Open+Sans:400,700' rel='stylesheet' type='text/css'>
<title></title>
<meta name="description" content="">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no" />
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<link rel="stylesheet" href="../lib/globalStyle.css" />
<style type="text/css">
</style>
</head>
<body>
<div id="graphic">
<img src="fallback.png" alt="[Chart]" />
</div>
<div id="keypoints">
<p></p>
</div>
<div class="footer">
</div>
<div id="footer"></div>
<script src="https://cdn.ons.gov.uk/vendor/d3/4.2.7/d3.min.js" type="text/javascript"></script>
<script src="modernizr.svg.min.js" type="text/javascript"></script>
<script src="pym.js" type="text/javascript"></script>
<script>
var graphic = d3.select('#graphic');
var keypoints = d3.select('#keypoints');
var footer = d3.select(".footer");
var pymChild = null;
function drawGraphic(width) {
var threshold_md = 788;
var threshold_sm = dvc.optional.mobileBreakpoint;
//set variables for chart dimensions dependent on width of #graphic
if (parseInt(graphic.style("width")) < threshold_sm) {
var margin = {
top: dvc.optional.margin_sm[0],
right: dvc.optional.margin_sm[1],
bottom: dvc.optional.margin_sm[2],
left: dvc.optional.margin_sm[3]
};
var chart_width = parseInt(graphic.style("width")) - margin.left - margin.right;
var height = dvc.essential.barHeight_sm_md_lg[0] * graphic_data.length - margin.top - margin.bottom;
} else if (parseInt(graphic.style("width")) < threshold_md) {
var margin = {
top: dvc.optional.margin_md[0],
right: dvc.optional.margin_md[1],
bottom: dvc.optional.margin_md[2],
left: dvc.optional.margin_md[3]
};
var chart_width = parseInt(graphic.style("width")) - margin.left - margin.right;
var height = dvc.essential.barHeight_sm_md_lg[1] * graphic_data.length - margin.top - margin.bottom;
} else {
var margin = {
top: dvc.optional.margin_lg[0],
right: dvc.optional.margin_lg[1],
bottom: dvc.optional.margin_lg[2],
left: dvc.optional.margin_lg[3]
}
var chart_width = parseInt(graphic.style("width")) - margin.left - margin.right;
var height = dvc.essential.barHeight_sm_md_lg[2] * graphic_data.length - margin.top - margin.bottom;
}
// clear out existing graphics
graphic.selectAll("*").remove();
keypoints.selectAll("*").remove();
footer.selectAll("*").remove();
var x = d3.scaleOrdinal()
.range([0, 1*chart_width/5, 2*chart_width/5,3*chart_width/5,4*chart_width/5, chart_width])
var x2 = d3.scaleLog()
.range([0,chart_width])
.domain([0.125,4])
var y = d3.scaleBand()
.rangeRound([0, height])
.paddingInner(0.1);
y.domain(graphic_data.map(function(d) {
return d.name;
}));
var yAxis = d3.axisLeft(y)
var xAxis = d3.axisBottom(x)
.tickSize(-height, 0, 0);
//specify number or ticks on x axis
if (parseInt(graphic.style("width")) <= threshold_sm) {
xAxis.ticks(dvc.optional.x_num_ticks_sm_md_lg[0])
} else if (parseInt(graphic.style("width")) <= threshold_md) {
xAxis.ticks(dvc.optional.x_num_ticks_sm_md_lg[1])
} else {
xAxis.ticks(dvc.optional.x_num_ticks_sm_md_lg[2])
}
// parse data into columns
var bars = {};
for (var column in graphic_data[0]) {
if (column == 'name') continue;
bars[column] = graphic_data.map(function(d) {
return {
'name': d.name,
'amt': d[column]
};
});
}
//y domain calculations : zero to intelligent max choice, or intelligent min and max choice, or interval chosen manually
if (dvc.essential.xAxisScale == "auto_zero_max") {
var xDomain = [
0,
d3.max(d3.entries(bars), function(c) {
return d3.max(c.value, function(v) {
var n = v.amt;
return Math.ceil(n);
});
})
];
} else if (dvc.essential.xAxisScale == "auto_min_max") {
var xDomain = [
d3.min(d3.entries(bars), function(c) {
return d3.min(c.value, function(v) {
var n = v.amt;
return Math.floor(n);
});
}),
d3.max(d3.entries(bars), function(c) {
return d3.max(c.value, function(v) {
var n = v.amt;
return Math.ceil(n);
});
})
];
} else {
var xDomain = dvc.essential.xAxisScale;
}
x.domain(xDomain);
d3.select("#buttonid").on("click", function() {
saveSvgAsPng(document.getElementById("chart"), "diagram.png")
});
d3.select('#graphic').select('svg')
.append("g")
.attr("id", "source")
.append("text")
.attr("text-anchor", "start")
.style("font-size", "12px")
.style("fill", "#666")
.attr('y', height + margin.top + margin.bottom - 10)
.attr('x', 0)
.text("Source: ")
.append("a")
.style("fill", "#4774CC")
.attr("href", dvc.essential.sourceURL)
.attr("target", "_blank")
.text(dvc.essential.sourceText);
//create svg for chart
var svg = d3.select('#graphic').append('svg')
.attr("id", "chart")
.style("background-color", "#fff")
.attr("width", chart_width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom + 30)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
svg.append("rect")
.attr("class", "svgRect")
.attr("width", chart_width)
.attr("height", height)
.attr("fill", "transparent")
svg.append('g')
.attr('class', 'x axis')
.attr("transform", "translate(0, " + height + ")")
.call(xAxis).append("text")
.attr("y", 25)
.attr("x", chart_width)
.attr("dy", ".71em")
.style("text-anchor", "end")
.attr("font-size", "12px")
.attr("fill", "#666")
.text(dvc.essential.xAxisLabel);
//create y axis, if x axis doesn't start at 0 drop x axis accordingly
svg.append('g')
.attr('class', 'y axis')
.attr('transform', function(d) {
if (xDomain[0] != 0) {
return 'translate(' + (0) + ',0)'
} else {
return 'translate(' + 0 + ', 0)'
}
})
.call(yAxis);
if (parseInt(graphic.style("width")) < threshold_sm) {
d3.selectAll(".y .tick text")
.call(wrap, dvc.optional.margin_sm[3]-10);
} else if (parseInt(graphic.style("width")) < threshold_md) {
d3.selectAll(".y .tick text")
.call(wrap, dvc.optional.margin_md[3]-10);
} else {
d3.selectAll(".y .tick text")
.call(wrap, dvc.optional.margin_lg[3]-10);
};
svg.append('g').attr("class", "bars").selectAll('rect')
.data(bars["value"])
.enter()
.append('rect')
.attr("fill", function(d) {
if (d.amt > 0) {
return dvc.essential.colour_palette[0]
} else {
return dvc.essential.negative_colour
}
})
.attr("width", function(d) {
return 0 + Math.abs(x2(d.amt) - x2(1));
})
.attr("x", function(d) {
return x2(Math.min(1, d.amt));
})
.attr("y", function(d) {
return y(d.name);
})
.attr("height", y.bandwidth())
//create centre line if required
if (dvc.optional.centre_line == true) {
svg.append("line")
.attr("id", "centreline")
.attr('y1', 0)
.attr('y2', height)
.attr('x1', x(dvc.optional.centre_line_value))
.attr('x2', x(dvc.optional.centre_line_value))
}
function wrap(text, width) {
text.each(function() {
var text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
y = text.attr("y"),
x = text.attr("x"),
dy = parseFloat(text.attr("dy")),
tspan = text.text(null).append("tspan").attr("x", x).attr("y", y).attr("dy", dy + "em");
if (words.length > 2) {
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
if (lineNumber == 0) {
tspan.attr("dy", dy - 1.1 + "em")
} else {
tspan.attr("dy", -dy + 0.55 + "em")
}
line.pop();
tspan.text(line.join(" "));
line = [word];
++lineNumber;
tspan = text.append("tspan").attr("x", x).attr("y", y).attr("dy", (0.55 * lineNumber - dy + 0.55) + "em").text(word);
}
}
} else {
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > width) {
if (lineNumber == 0) {
tspan.attr("dy", dy - 0.55 + "em")
} else {
tspan.attr("dy", -dy + 0.55 + "em")
}
line.pop();
tspan.text(line.join(" "));
line = [word];
++lineNumber;
tspan = text.append("tspan").attr("x", x).attr("y", y).attr("dy", (1.1 * lineNumber - dy + 0.55) + "em").text(word);
}
}
}
});
}
writeAnnotation();
function writeAnnotation() {
if (parseInt(graphic.style("width")) < threshold_sm) {
dvc.essential.annotationBullet.forEach(function(d, i) {
d3.select("#keypoints").append("svg")
.attr("width", "20px")
.attr("height", "20px")
.attr("class", "circles")
.append("circle")
.attr("class", "annocirc" + (i))
.attr("r", "2")
.attr('cy', "12px")
.attr("cx", "10px");
d3.select("#keypoints").append("p").text(dvc.essential.annotationBullet[i])
.attr("font-family", "'Open Sans', sans-serif")
.attr("font-size", "13px")
.attr("color", "#666")
.attr("font-weight", "500");
}) // end foreach
} else {
dvc.essential.annotationChart.forEach(function(d, i) {
// draw annotation text based on content of var annotationArray ...
svg.append("text")
.text(dvc.essential.annotationChart[i])
.attr("class", "annotext" + i)
.attr("text-anchor", dvc.essential.annotationAlign[i])
.attr('y', y(dvc.essential.annotationXY[i][1]))
.attr('x', x(dvc.essential.annotationXY[i][0]))
.attr("font-family", "'Open Sans', sans-serif")
.attr("font-size", "13px")
.attr("fill", "#666")
.attr("font-weight", "500");
d3.selectAll(".annotext" + (i))
.each(insertLinebreaks)
.each(createBackRect);
function insertLinebreaks() {
var str = this;
var el1 = dvc.essential.annotationChart[i];
var el = el1.data;
var words = el1.split(' ');
d3.select(this /*str*/ ).text('');
for (var j = 0; j < words.length; j++) {
var tspan = d3.select(this).append('tspan').text(words[j]);
if (j > 0)
tspan.attr('x', x(dvc.essential.annotationXY[i][0])).attr('dy', '22');
}
};
function createBackRect() {
var BBox = this.getBBox()
svg.insert("rect", ".annotext" + (i))
.attr("width", BBox.width)
.attr("height", BBox.height)
.attr("x", BBox.x)
.attr("y", BBox.y)
.attr("fill", "white")
.attr("opacity", 0.4);
}; // end function createBackRect()
}); // end foreach
} // end else ...
// If you have labels rather than years then you can split the lines using a double space (in the CSV file).
if (dvc.optional.vertical_line == true) {
dvc.optional.annotateLineX1_Y1_X2_Y2.forEach(function(d, i) {
svg.append("line").attr('x1', x(dvc.optional.annotateLineX1_Y1_X2_Y2[i][0][0]))
.attr('x2', x(dvc.optional.annotateLineX1_Y1_X2_Y2[i][1][0]))
.style('stroke', '#888')
.style('stroke-width', 2)
.attr('font-size', '13px')
//.style('stroke-dasharray', '5 5') .attr('y1',y(dvc.optional.annotateLineX1_Y1_X2_Y2[i][0][1]))
.attr('y2', y(dvc.optional.annotateLineX1_Y1_X2_Y2[i][1][1]));
})
}
d3.selectAll("path").attr("fill", "none");
d3.selectAll(".x line")
.attr("stroke", "#CCC")
.attr("stroke-width", "1px")
.attr("shape-rendering", "crispEdges");
d3.selectAll("text").attr("font-family", "'Open Sans', sans-serif");
d3.selectAll(".y text").attr("font-size", "12px").attr("fill", "#666");
d3.selectAll(".x text").attr("font-size", "12px").attr("fill", "#666"); // dates - timelines
d3.selectAll(".y line")
.attr("stroke", "#CCC")
.attr("stroke-width", "1px")
.style("shape-rendering", "crispEdges");
if (dvc.optional.annotateRect == true) {
dvc.optional.annotateRectX_Y.forEach(function(d, i) {
svg.append("rect")
.attr('x', x(dvc.optional.annotateRectX_Y[i][0][0]))
.attr('y', y(dvc.optional.annotateRectX_Y[i][0][1]))
.attr('height', y(dvc.optional.annotateRectX_Y[i][1][1]) - y(dvc.optional.annotateRectX_Y[i][0][1]))
.attr('width', x(dvc.optional.annotateRectX_Y[i][1][0]) - x(dvc.optional.annotateRectX_Y[i][0][0]))
.style('fill', dvc.optional.lineColor_opcty[i][0])
.style('stroke-width', 2)
.style('opacity', dvc.optional.lineColor_opcty[i][1]);
})
}
d3.select(".y").select("path").style("stroke", "#666")
// function insertLinebreaksLabels() {
// var str = this.textContent;
//
// var words = str.split(' ');
//
// d3.select(this/*str*/).text('');
//
// for (var j = 0; j < words.length; j++) {
// var tspan = d3.select(this).append('tspan').text(words[j]);
// if (j > 0)
// tspan
// .attr('x', -10)
// .attr('dy', '1em');
// }
// };
// d3.selectAll(".y text").each(insertLinebreaksLabels)
} // end function writeAnnotation()
d3.select('#graphic').select('svg')
.append("g")
.attr("id", "source")
.append("text")
.attr("text-anchor", "start")
.style("font-size", "12px")
.style("fill", "#666")
.attr('y', height + margin.top + margin.bottom + 28)
.attr('x', 0)
.text("Source: ")
.append("a")
.style("fill", "#4774CC")
.attr("xlink:href", dvc.essential.sourceURL)
.attr("target", "_blank")
.text(dvc.essential.sourceText);
d3.selectAll("text").attr("font-family", "'Open Sans', sans-serif");
//use pym to calculate chart dimensions
if (pymChild) {
pymChild.sendHeight();
}
}
//check whether browser can cope with svg
if (Modernizr.svg) {
//load config
d3.json("config.json", function(error, config) {
dvc = config
//load chart data
d3.csv(dvc.essential.graphic_data_url, function(error, data) {
graphic_data = data;
// graphic_data.forEach(function(d) {
// d.name = d3.time.format(dvc.essential.dateFormat).parse(d.name);
// });
names = d3.keys( /*data*/ graphic_data[0]).filter(function(key) {
return key !== "state";
});
/*data*/
graphic_data.forEach(function(d) {
//d.ages = names.map(function(name) { return {name: name, value: +d[name]}; });
});
//use pym to create iframed chart dependent on specified variables
pymChild = new pym.Child({
renderCallback: drawGraphic
});
});
})
} else {
//use pym to create iframe containing fallback image (which is set as default)
pymChild = new pym.Child();
if (pymChild) {
pymChild.sendHeight();
}
}
</script>
</body>
</html>
/* Modernizr 2.7.1 (Custom Build) | MIT & BSD
* Build: http://modernizr.com/download/#-svg
*/
;window.Modernizr=function(a,b,c){function u(a){i.cssText=a}function v(a,b){return u(prefixes.join(a+";")+(b||""))}function w(a,b){return typeof a===b}function x(a,b){return!!~(""+a).indexOf(b)}function y(a,b,d){for(var e in a){var f=b[a[e]];if(f!==c)return d===!1?a[e]:w(f,"function")?f.bind(d||b):f}return!1}var d="2.7.1",e={},f=b.documentElement,g="modernizr",h=b.createElement(g),i=h.style,j,k={}.toString,l={svg:"http://www.w3.org/2000/svg"},m={},n={},o={},p=[],q=p.slice,r,s={}.hasOwnProperty,t;!w(s,"undefined")&&!w(s.call,"undefined")?t=function(a,b){return s.call(a,b)}:t=function(a,b){return b in a&&w(a.constructor.prototype[b],"undefined")},Function.prototype.bind||(Function.prototype.bind=function(b){var c=this;if(typeof c!="function")throw new TypeError;var d=q.call(arguments,1),e=function(){if(this instanceof e){var a=function(){};a.prototype=c.prototype;var f=new a,g=c.apply(f,d.concat(q.call(arguments)));return Object(g)===g?g:f}return c.apply(b,d.concat(q.call(arguments)))};return e}),m.svg=function(){return!!b.createElementNS&&!!b.createElementNS(l.svg,"svg").createSVGRect};for(var z in m)t(m,z)&&(r=z.toLowerCase(),e[r]=m[z](),p.push((e[r]?"":"no-")+r));return e.addTest=function(a,b){if(typeof a=="object")for(var d in a)t(a,d)&&e.addTest(d,a[d]);else{a=a.toLowerCase();if(e[a]!==c)return e;b=typeof b=="function"?b():b,typeof enableClasses!="undefined"&&enableClasses&&(f.className+=" "+(b?"":"no-")+a),e[a]=b}return e},u(""),h=j=null,e._version=d,e}(this,this.document);
/*! pym.js - v1.3.2 - 2018-02-13 */
/*
* Pym.js is library that resizes an iframe based on the width of the parent and the resulting height of the child.
* Check out the docs at http://blog.apps.npr.org/pym.js/ or the readme at README.md for usage.
*/
/** @module pym */
(function(factory) {
if (typeof define === 'function' && define.amd) {
define(factory);
}
else if (typeof module !== 'undefined' && module.exports) {
module.exports = factory();
} else {
window.pym = factory.call(this);
}
})(function() {
var MESSAGE_DELIMITER = 'xPYMx';
var lib = {};
/**
* Create and dispatch a custom pym event
*
* @method _raiseCustomEvent
* @inner
*
* @param {String} eventName
*/
var _raiseCustomEvent = function(eventName) {
var event = document.createEvent('Event');
event.initEvent('pym:' + eventName, true, true);
document.dispatchEvent(event);
};
/**
* Generic function for parsing URL params.
* Via http://stackoverflow.com/questions/901115/how-can-i-get-query-string-values-in-javascript
*
* @method _getParameterByName
* @inner
*
* @param {String} name The name of the paramter to get from the URL.
*/
var _getParameterByName = function(name) {
var regex = new RegExp("[\\?&]" + name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]') + '=([^&#]*)');
var results = regex.exec(location.search);
if (results === null) {
return '';
}
return decodeURIComponent(results[1].replace(/\+/g, " "));
};
/**
* Check the message to make sure it comes from an acceptable xdomain.
* Defaults to '*' but can be overriden in config.
*
* @method _isSafeMessage
* @inner
*
* @param {Event} e The message event.
* @param {Object} settings Configuration.
*/
var _isSafeMessage = function(e, settings) {
if (settings.xdomain !== '*') {
// If origin doesn't match our xdomain, return.
if (!e.origin.match(new RegExp(settings.xdomain + '$'))) { return; }
}
// Ignore events that do not carry string data #151
if (typeof e.data !== 'string') { return; }
return true;
};
var _isSafeUrl = function(url) {
// Adapted from angular 2 url sanitizer
var SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp):|[^&:/?#]*(?:[/?#]|$))/gi;
if (!url.match(SAFE_URL_PATTERN)) { return; }
return true;
};
/**
* Construct a message to send between frames.
*
* NB: We use string-building here because JSON message passing is
* not supported in all browsers.
*
* @method _makeMessage
* @inner
*
* @param {String} id The unique id of the message recipient.
* @param {String} messageType The type of message to send.
* @param {String} message The message to send.
*/
var _makeMessage = function(id, messageType, message) {
var bits = ['pym', id, messageType, message];
return bits.join(MESSAGE_DELIMITER);
};
/**
* Construct a regex to validate and parse messages.
*
* @method _makeMessageRegex
* @inner
*
* @param {String} id The unique id of the message recipient.
*/
var _makeMessageRegex = function(id) {
var bits = ['pym', id, '(\\S+)', '(.*)'];
return new RegExp('^' + bits.join(MESSAGE_DELIMITER) + '$');
};
/**
* Underscore implementation of getNow
*
* @method _getNow
* @inner
*
*/
var _getNow = Date.now || function() {
return new Date().getTime();
};
/**
* Underscore implementation of throttle
*
* @method _throttle
* @inner
*
* @param {function} func Throttled function
* @param {number} wait Throttle wait time
* @param {object} options Throttle settings
*/
var _throttle = function(func, wait, options) {
var context, args, result;
var timeout = null;
var previous = 0;
if (!options) {options = {};}
var later = function() {
previous = options.leading === false ? 0 : _getNow();
timeout = null;
result = func.apply(context, args);
if (!timeout) {context = args = null;}
};
return function() {
var now = _getNow();
if (!previous && options.leading === false) {previous = now;}
var remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) {context = args = null;}
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
};
/**
* Clean autoInit Instances: those that point to contentless iframes
* @method _cleanAutoInitInstances
* @inner
*/
var _cleanAutoInitInstances = function() {
var length = lib.autoInitInstances.length;
// Loop backwards to avoid index issues
for (var idx = length - 1; idx >= 0; idx--) {
var instance = lib.autoInitInstances[idx];
// If instance has been removed or is contentless then remove it
if (instance.el.getElementsByTagName('iframe').length &&
instance.el.getElementsByTagName('iframe')[0].contentWindow) {
continue;
}
else {
// Remove the reference to the removed or orphan instance
lib.autoInitInstances.splice(idx,1);
}
}
};
/**
* Store auto initialized Pym instances for further reference
* @name module:pym#autoInitInstances
* @type Array
* @default []
*/
lib.autoInitInstances = [];
/**
* Initialize Pym for elements on page that have data-pym attributes.
* Expose autoinit in case we need to call it from the outside
* @instance
* @method autoInit
* @param {Boolean} doNotRaiseEvents flag to avoid sending custom events
*/
lib.autoInit = function(doNotRaiseEvents) {
var elements = document.querySelectorAll('[data-pym-src]:not([data-pym-auto-initialized])');
var length = elements.length;
// Clean stored instances in case needed
_cleanAutoInitInstances();
for (var idx = 0; idx < length; ++idx) {
var element = elements[idx];
/*
* Mark automatically-initialized elements so they are not
* re-initialized if the user includes pym.js more than once in the
* same document.
*/
element.setAttribute('data-pym-auto-initialized', '');
// Ensure elements have an id
if (element.id === '') {
element.id = 'pym-' + idx + "-" + Math.random().toString(36).substr(2,5);
}
var src = element.getAttribute('data-pym-src');
// List of data attributes to configure the component
// structure: {'attribute name': 'type'}
var settings = {'xdomain': 'string', 'title': 'string', 'name': 'string', 'id': 'string',
'sandbox': 'string', 'allowfullscreen': 'boolean',
'parenturlparam': 'string', 'parenturlvalue': 'string',
'optionalparams': 'boolean', 'trackscroll': 'boolean',
'scrollwait': 'number'};
var config = {};
for (var attribute in settings) {
// via https://developer.mozilla.org/en-US/docs/Web/API/Element/getAttribute#Notes
if (element.getAttribute('data-pym-'+attribute) !== null) {
switch (settings[attribute]) {
case 'boolean':
config[attribute] = !(element.getAttribute('data-pym-'+attribute) === 'false'); // jshint ignore:line
break;
case 'string':
config[attribute] = element.getAttribute('data-pym-'+attribute);
break;
case 'number':
var n = Number(element.getAttribute('data-pym-'+attribute));
if (!isNaN(n)) {
config[attribute] = n;
}
break;
default:
console.err('unrecognized attribute type');
}
}
}
// Store references to autoinitialized pym instances
var parent = new lib.Parent(element.id, src, config);
lib.autoInitInstances.push(parent);
}
// Fire customEvent
if (!doNotRaiseEvents) {
_raiseCustomEvent("pym-initialized");
}
// Return stored autoinitalized pym instances
return lib.autoInitInstances;
};
/**
* The Parent half of a response iframe.
*
* @memberof module:pym
* @class Parent
* @param {String} id The id of the div into which the iframe will be rendered. sets {@link module:pym.Parent~id}
* @param {String} url The url of the iframe source. sets {@link module:pym.Parent~url}
* @param {Object} [config] Configuration for the parent instance. sets {@link module:pym.Parent~settings}
* @param {string} [config.xdomain='*'] - xdomain to validate messages received
* @param {string} [config.title] - if passed it will be assigned to the iframe title attribute
* @param {string} [config.name] - if passed it will be assigned to the iframe name attribute
* @param {string} [config.id] - if passed it will be assigned to the iframe id attribute
* @param {boolean} [config.allowfullscreen] - if passed and different than false it will be assigned to the iframe allowfullscreen attribute
* @param {string} [config.sandbox] - if passed it will be assigned to the iframe sandbox attribute (we do not validate the syntax so be careful!!)
* @param {string} [config.parenturlparam] - if passed it will be override the default parentUrl query string parameter name passed to the iframe src
* @param {string} [config.parenturlvalue] - if passed it will be override the default parentUrl query string parameter value passed to the iframe src
* @param {string} [config.optionalparams] - if passed and different than false it will strip the querystring params parentUrl and parentTitle passed to the iframe src
* @param {boolean} [config.trackscroll] - if passed it will activate scroll tracking on the parent
* @param {number} [config.scrollwait] - if passed it will set the throttle wait in order to fire scroll messaging. Defaults to 100 ms.
* @see {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe iFrame}
*/
lib.Parent = function(id, url, config) {
/**
* The id of the container element
*
* @memberof module:pym.Parent
* @member {string} id
* @inner
*/
this.id = id;
/**
* The url that will be set as the iframe's src
*
* @memberof module:pym.Parent
* @member {String} url
* @inner
*/
this.url = url;
/**
* The container DOM object
*
* @memberof module:pym.Parent
* @member {HTMLElement} el
* @inner
*/
this.el = document.getElementById(id);
/**
* The contained child iframe
*
* @memberof module:pym.Parent
* @member {HTMLElement} iframe
* @inner
* @default null
*/
this.iframe = null;
/**
* The parent instance settings, updated by the values passed in the config object
*
* @memberof module:pym.Parent
* @member {Object} settings
* @inner
*/
this.settings = {
xdomain: '*',
optionalparams: true,
parenturlparam: 'parentUrl',
parenturlvalue: window.location.href,
trackscroll: false,
scrollwait: 100,
};
/**
* RegularExpression to validate the received messages
*
* @memberof module:pym.Parent
* @member {String} messageRegex
* @inner
*/
this.messageRegex = _makeMessageRegex(this.id);
/**
* Stores the registered messageHandlers for each messageType
*
* @memberof module:pym.Parent
* @member {Object} messageHandlers
* @inner
*/
this.messageHandlers = {};
// ensure a config object
config = (config || {});
/**
* Construct the iframe.
*
* @memberof module:pym.Parent
* @method _constructIframe
* @inner
*/
this._constructIframe = function() {
// Calculate the width of this element.
var width = this.el.offsetWidth.toString();
// Create an iframe element attached to the document.
this.iframe = document.createElement('iframe');
// Save fragment id
var hash = '';
var hashIndex = this.url.indexOf('#');
if (hashIndex > -1) {
hash = this.url.substring(hashIndex, this.url.length);
this.url = this.url.substring(0, hashIndex);
}
// If the URL contains querystring bits, use them.
// Otherwise, just create a set of valid params.
if (this.url.indexOf('?') < 0) {
this.url += '?';
} else {
this.url += '&';
}
// Append the initial width as a querystring parameter
// and optional params if configured to do so
this.iframe.src = this.url + 'initialWidth=' + width +
'&childId=' + this.id;
if (this.settings.optionalparams) {
this.iframe.src += '&parentTitle=' + encodeURIComponent(document.title);
this.iframe.src += '&'+ this.settings.parenturlparam + '=' + encodeURIComponent(this.settings.parenturlvalue);
}
this.iframe.src +=hash;
// Set some attributes to this proto-iframe.
this.iframe.setAttribute('width', '100%');
this.iframe.setAttribute('scrolling', 'no');
this.iframe.setAttribute('marginheight', '0');
this.iframe.setAttribute('frameborder', '0');
if (this.settings.title) {
this.iframe.setAttribute('title', this.settings.title);
}
if (this.settings.allowfullscreen !== undefined && this.settings.allowfullscreen !== false) {
this.iframe.setAttribute('allowfullscreen','');
}
if (this.settings.sandbox !== undefined && typeof this.settings.sandbox === 'string') {
this.iframe.setAttribute('sandbox', this.settings.sandbox);
}
if (this.settings.id) {
if (!document.getElementById(this.settings.id)) {
this.iframe.setAttribute('id', this.settings.id);
}
}
if (this.settings.name) {
this.iframe.setAttribute('name', this.settings.name);
}
// Replace the child content if needed
// (some CMSs might strip out empty elements)
while(this.el.firstChild) { this.el.removeChild(this.el.firstChild); }
// Append the iframe to our element.
this.el.appendChild(this.iframe);
// Add an event listener that will handle redrawing the child on resize.
window.addEventListener('resize', this._onResize);
// Add an event listener that will send the child the viewport.
if (this.settings.trackscroll) {
window.addEventListener('scroll', this._throttleOnScroll);
}
};
/**
* Send width on resize.
*
* @memberof module:pym.Parent
* @method _onResize
* @inner
*/
this._onResize = function() {
this.sendWidth();
if (this.settings.trackscroll) {
this.sendViewportAndIFramePosition();
}
}.bind(this);
/**
* Send viewport and iframe info on scroll.
*
* @memberof module:pym.Parent
* @method _onScroll
* @inner
*/
this._onScroll = function() {
this.sendViewportAndIFramePosition();
}.bind(this);
/**
* Fire all event handlers for a given message type.
*
* @memberof module:pym.Parent
* @method _fire
* @inner
*
* @param {String} messageType The type of message.
* @param {String} message The message data.
*/
this._fire = function(messageType, message) {
if (messageType in this.messageHandlers) {
for (var i = 0; i < this.messageHandlers[messageType].length; i++) {
this.messageHandlers[messageType][i].call(this, message);
}
}
};
/**
* Remove this parent from the page and unbind it's event handlers.
*
* @memberof module:pym.Parent
* @method remove
* @instance
*/
this.remove = function() {
window.removeEventListener('message', this._processMessage);
window.removeEventListener('resize', this._onResize);
this.el.removeChild(this.iframe);
// _cleanAutoInitInstances in case this parent was autoInitialized
_cleanAutoInitInstances();
};
/**
* Process a new message from the child.
*
* @memberof module:pym.Parent
* @method _processMessage
* @inner
*
* @param {Event} e A message event.
*/
this._processMessage = function(e) {
// First, punt if this isn't from an acceptable xdomain.
if (!_isSafeMessage(e, this.settings)) {
return;
}
// Discard object messages, we only care about strings
if (typeof e.data !== 'string') {
return;
}
// Grab the message from the child and parse it.
var match = e.data.match(this.messageRegex);
// If there's no match or too many matches in the message, punt.
if (!match || match.length !== 3) {
return false;
}
var messageType = match[1];
var message = match[2];
this._fire(messageType, message);
}.bind(this);
/**
* Resize iframe in response to new height message from child.
*
* @memberof module:pym.Parent
* @method _onHeightMessage
* @inner
*
* @param {String} message The new height.
*/
this._onHeightMessage = function(message) {
/*
* Handle parent height message from child.
*/
var height = parseInt(message);
this.iframe.setAttribute('height', height + 'px');
};
/**
* Navigate parent to a new url.
*
* @memberof module:pym.Parent
* @method _onNavigateToMessage
* @inner
*
* @param {String} message The url to navigate to.
*/
this._onNavigateToMessage = function(message) {
/*
* Handle parent scroll message from child.
*/
if (!_isSafeUrl(message)) {return;}
document.location.href = message;
};
/**
* Scroll parent to a given child position.
*
* @memberof module:pym.Parent
* @method _onScrollToChildPosMessage
* @inner
*
* @param {String} message The offset inside the child page.
*/
this._onScrollToChildPosMessage = function(message) {
// Get the child container position using getBoundingClientRect + pageYOffset
// via https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
var iframePos = document.getElementById(this.id).getBoundingClientRect().top + window.pageYOffset;
var totalOffset = iframePos + parseInt(message);
window.scrollTo(0, totalOffset);
};
/**
* Bind a callback to a given messageType from the child.
*
* Reserved message names are: "height", "scrollTo" and "navigateTo".
*
* @memberof module:pym.Parent
* @method onMessage
* @instance
*
* @param {String} messageType The type of message being listened for.
* @param {module:pym.Parent~onMessageCallback} callback The callback to invoke when a message of the given type is received.
*/
this.onMessage = function(messageType, callback) {
if (!(messageType in this.messageHandlers)) {
this.messageHandlers[messageType] = [];
}
this.messageHandlers[messageType].push(callback);
};
/**
* @callback module:pym.Parent~onMessageCallback
* @param {String} message The message data.
*/
/**
* Send a message to the the child.
*
* @memberof module:pym.Parent
* @method sendMessage
* @instance
*
* @param {String} messageType The type of message to send.
* @param {String} message The message data to send.
*/
this.sendMessage = function(messageType, message) {
// When used alongside with pjax some references are lost
if (this.el.getElementsByTagName('iframe').length) {
if (this.el.getElementsByTagName('iframe')[0].contentWindow) {
this.el.getElementsByTagName('iframe')[0].contentWindow
.postMessage(_makeMessage(this.id, messageType, message), '*');
}
else {
// Contentless child detected remove listeners and iframe
this.remove();
}
}
};
/**
* Transmit the current iframe width to the child.
*
* You shouldn't need to call this directly.
*
* @memberof module:pym.Parent
* @method sendWidth
* @instance
*/
this.sendWidth = function() {
var width = this.el.offsetWidth.toString();
this.sendMessage('width', width);
};
/**
* Transmit the current viewport and iframe position to the child.
* Sends viewport width, viewport height
* and iframe bounding rect top-left-bottom-right
* all separated by spaces
*
* You shouldn't need to call this directly.
*
* @memberof module:pym.Parent
* @method sendViewportAndIFramePosition
* @instance
*/
this.sendViewportAndIFramePosition = function() {
var iframeRect = this.iframe.getBoundingClientRect();
var vWidth = window.innerWidth || document.documentElement.clientWidth;
var vHeight = window.innerHeight || document.documentElement.clientHeight;
var payload = vWidth + ' ' + vHeight;
payload += ' ' + iframeRect.top + ' ' + iframeRect.left;
payload += ' ' + iframeRect.bottom + ' ' + iframeRect.right;
this.sendMessage('viewport-iframe-position', payload);
};
// Add any overrides to settings coming from config.
for (var key in config) {
this.settings[key] = config[key];
}
/**
* Throttled scroll function.
*
* @memberof module:pym.Parent
* @method _throttleOnScroll
* @inner
*/
this._throttleOnScroll = _throttle(this._onScroll.bind(this), this.settings.scrollwait);
// Bind required message handlers
this.onMessage('height', this._onHeightMessage);
this.onMessage('navigateTo', this._onNavigateToMessage);
this.onMessage('scrollToChildPos', this._onScrollToChildPosMessage);
this.onMessage('parentPositionInfo', this.sendViewportAndIFramePosition);
// Add a listener for processing messages from the child.
window.addEventListener('message', this._processMessage, false);
// Construct the iframe in the container element.
this._constructIframe();
return this;
};
/**
* The Child half of a responsive iframe.
*
* @memberof module:pym
* @class Child
* @param {Object} [config] Configuration for the child instance. sets {@link module:pym.Child~settings}
* @param {function} [config.renderCallback=null] Callback invoked after receiving a resize event from the parent, sets {@link module:pym.Child#settings.renderCallback}
* @param {string} [config.xdomain='*'] - xdomain to validate messages received
* @param {number} [config.polling=0] - polling frequency in milliseconds to send height to parent
* @param {number} [config.id] - parent container id used when navigating the child iframe to a new page but we want to keep it responsive.
* @param {string} [config.parenturlparam] - if passed it will be override the default parentUrl query string parameter name expected on the iframe src
*/
lib.Child = function(config) {
/**
* The initial width of the parent page
*
* @memberof module:pym.Child
* @member {string} parentWidth
* @inner
*/
this.parentWidth = null;
/**
* The id of the parent container
*
* @memberof module:pym.Child
* @member {String} id
* @inner
*/
this.id = null;
/**
* The title of the parent page from document.title.
*
* @memberof module:pym.Child
* @member {String} parentTitle
* @inner
*/
this.parentTitle = null;
/**
* The URL of the parent page from window.location.href.
*
* @memberof module:pym.Child
* @member {String} parentUrl
* @inner
*/
this.parentUrl = null;
/**
* The settings for the child instance. Can be overriden by passing a config object to the child constructor
* i.e.: var pymChild = new pym.Child({renderCallback: render, xdomain: "\\*\.npr\.org"})
*
* @memberof module:pym.Child.settings
* @member {Object} settings - default settings for the child instance
* @inner
*/
this.settings = {
renderCallback: null,
xdomain: '*',
polling: 0,
parenturlparam: 'parentUrl'
};
/**
* The timerId in order to be able to stop when polling is enabled
*
* @memberof module:pym.Child
* @member {String} timerId
* @inner
*/
this.timerId = null;
/**
* RegularExpression to validate the received messages
*
* @memberof module:pym.Child
* @member {String} messageRegex
* @inner
*/
this.messageRegex = null;
/**
* Stores the registered messageHandlers for each messageType
*
* @memberof module:pym.Child
* @member {Object} messageHandlers
* @inner
*/
this.messageHandlers = {};
// Ensure a config object
config = (config || {});
/**
* Bind a callback to a given messageType from the child.
*
* Reserved message names are: "width".
*
* @memberof module:pym.Child
* @method onMessage
* @instance
*
* @param {String} messageType The type of message being listened for.
* @param {module:pym.Child~onMessageCallback} callback The callback to invoke when a message of the given type is received.
*/
this.onMessage = function(messageType, callback) {
if (!(messageType in this.messageHandlers)) {
this.messageHandlers[messageType] = [];
}
this.messageHandlers[messageType].push(callback);
};
/**
* @callback module:pym.Child~onMessageCallback
* @param {String} message The message data.
*/
/**
* Fire all event handlers for a given message type.
*
* @memberof module:pym.Child
* @method _fire
* @inner
*
* @param {String} messageType The type of message.
* @param {String} message The message data.
*/
this._fire = function(messageType, message) {
/*
* Fire all event handlers for a given message type.
*/
if (messageType in this.messageHandlers) {
for (var i = 0; i < this.messageHandlers[messageType].length; i++) {
this.messageHandlers[messageType][i].call(this, message);
}
}
};
/**
* Process a new message from the parent.
*
* @memberof module:pym.Child
* @method _processMessage
* @inner
*
* @param {Event} e A message event.
*/
this._processMessage = function(e) {
/*
* Process a new message from parent frame.
*/
// First, punt if this isn't from an acceptable xdomain.
if (!_isSafeMessage(e, this.settings)) {
return;
}
// Discard object messages, we only care about strings
if (typeof e.data !== 'string') {
return;
}
// Get the message from the parent.
var match = e.data.match(this.messageRegex);
// If there's no match or it's a bad format, punt.
if (!match || match.length !== 3) { return; }
var messageType = match[1];
var message = match[2];
this._fire(messageType, message);
}.bind(this);
/**
* Resize iframe in response to new width message from parent.
*
* @memberof module:pym.Child
* @method _onWidthMessage
* @inner
*
* @param {String} message The new width.
*/
this._onWidthMessage = function(message) {
/*
* Handle width message from the child.
*/
var width = parseInt(message);
// Change the width if it's different.
if (width !== this.parentWidth) {
this.parentWidth = width;
// Call the callback function if it exists.
if (this.settings.renderCallback) {
this.settings.renderCallback(width);
}
// Send the height back to the parent.
this.sendHeight();
}
};
/**
* Send a message to the the Parent.
*
* @memberof module:pym.Child
* @method sendMessage
* @instance
*
* @param {String} messageType The type of message to send.
* @param {String} message The message data to send.
*/
this.sendMessage = function(messageType, message) {
/*
* Send a message to the parent.
*/
window.parent.postMessage(_makeMessage(this.id, messageType, message), '*');
};
/**
* Transmit the current iframe height to the parent.
*
* Call this directly in cases where you manually alter the height of the iframe contents.
*
* @memberof module:pym.Child
* @method sendHeight
* @instance
*/
this.sendHeight = function() {
// Get the child's height.
var height = document.getElementsByTagName('body')[0].offsetHeight.toString();
// Send the height to the parent.
this.sendMessage('height', height);
return height;
}.bind(this);
/**
* Ask parent to send the current viewport and iframe position information
*
* @memberof module:pym.Child
* @method sendHeight
* @instance
*/
this.getParentPositionInfo = function() {
// Send the height to the parent.
this.sendMessage('parentPositionInfo');
};
/**
* Scroll parent to a given element id.
*
* @memberof module:pym.Child
* @method scrollParentTo
* @instance
*
* @param {String} hash The id of the element to scroll to.
*/
this.scrollParentTo = function(hash) {
this.sendMessage('navigateTo', '#' + hash);
};
/**
* Navigate parent to a given url.
*
* @memberof module:pym.Child
* @method navigateParentTo
* @instance
*
* @param {String} url The url to navigate to.
*/
this.navigateParentTo = function(url) {
this.sendMessage('navigateTo', url);
};
/**
* Scroll parent to a given child element id.
*
* @memberof module:pym.Child
* @method scrollParentToChildEl
* @instance
*
* @param {String} id The id of the child element to scroll to.
*/
this.scrollParentToChildEl = function(id) {
// Get the child element position using getBoundingClientRect + pageYOffset
// via https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect
var topPos = document.getElementById(id).getBoundingClientRect().top + window.pageYOffset;
this.scrollParentToChildPos(topPos);
};
/**
* Scroll parent to a particular child offset.
*
* @memberof module:pym.Child
* @method scrollParentToChildPos
* @instance
*
* @param {Number} pos The offset of the child element to scroll to.
*/
this.scrollParentToChildPos = function(pos) {
this.sendMessage('scrollToChildPos', pos.toString());
};
/**
* Mark Whether the child is embedded or not
* executes a callback in case it was passed to the config
*
* @memberof module:pym.Child
* @method _markWhetherEmbedded
* @inner
*
* @param {module:pym.Child~onMarkedEmbeddedStatus} The callback to execute after determining whether embedded or not.
*/
var _markWhetherEmbedded = function(onMarkedEmbeddedStatus) {
var htmlElement = document.getElementsByTagName('html')[0],
newClassForHtml,
originalHtmlClasses = htmlElement.className;
try {
if(window.self !== window.top) {
newClassForHtml = "embedded";
}else{
newClassForHtml = "not-embedded";
}
}catch(e) {
newClassForHtml = "embedded";
}
if(originalHtmlClasses.indexOf(newClassForHtml) < 0) {
htmlElement.className = originalHtmlClasses ? originalHtmlClasses + ' ' + newClassForHtml : newClassForHtml;
if(onMarkedEmbeddedStatus){
onMarkedEmbeddedStatus(newClassForHtml);
}
_raiseCustomEvent("marked-embedded");
}
};
/**
* @callback module:pym.Child~onMarkedEmbeddedStatus
* @param {String} classname "embedded" or "not-embedded".
*/
/**
* Unbind child event handlers and timers.
*
* @memberof module:pym.Child
* @method remove
* @instance
*/
this.remove = function() {
window.removeEventListener('message', this._processMessage);
if (this.timerId) {
clearInterval(this.timerId);
}
};
// Initialize settings with overrides.
for (var key in config) {
this.settings[key] = config[key];
}
// Identify what ID the parent knows this child as.
this.id = _getParameterByName('childId') || config.id;
this.messageRegex = new RegExp('^pym' + MESSAGE_DELIMITER + this.id + MESSAGE_DELIMITER + '(\\S+)' + MESSAGE_DELIMITER + '(.*)$');
// Get the initial width from a URL parameter.
var width = parseInt(_getParameterByName('initialWidth'));
// Get the url of the parent frame
this.parentUrl = _getParameterByName(this.settings.parenturlparam);
// Get the title of the parent frame
this.parentTitle = _getParameterByName('parentTitle');
// Bind the required message handlers
this.onMessage('width', this._onWidthMessage);
// Set up a listener to handle any incoming messages.
window.addEventListener('message', this._processMessage, false);
// If there's a callback function, call it.
if (this.settings.renderCallback) {
this.settings.renderCallback(width);
}
// Send the initial height to the parent.
this.sendHeight();
// If we're configured to poll, create a setInterval to handle that.
if (this.settings.polling) {
this.timerId = window.setInterval(this.sendHeight, this.settings.polling);
}
_markWhetherEmbedded(config.onMarkedEmbeddedStatus);
return this;
};
// Initialize elements with pym data attributes
// if we are not in server configuration
if(typeof document !== "undefined") {
lib.autoInit(true);
}
return lib;
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment