charts/shared/toolbelt.js (643 lines of code) (raw):
export function getJson(url) {
return fetch(`${url}`).then(r => r.json())
}
export function getTemplate(path) {
return fetch(`${path}`).then(r => r.text())
}
export async function premerge(data) {
const clean = JSON.parse(JSON.stringify(data))
let templateFields = ["title", "subtitle", "footnote", "source", "tooltip"]
if (!clean.sheets.template) {
clean.sheets.template = [{ "title" : "" }, { "subtitle" : "" }, { "footnote" : "" }, { "source" : "" }, {"tooltip" : ""}]
}
let template = Object.keys(data.sheets.template[0])
clean.sheets.template = [{}]
if (!clean.sheets.options) {
clean.sheets.options = [{}]
}
for (const item of template) {
if (contains(templateFields, item)) {
clean.sheets.template[0][item] = data.sheets.template[0][item]
} else {
if (data.sheets.template[0][item] != "") {
clean.sheets.options[0][item] = data.sheets.template[0][item]
}
}
}
return clean
};
export const checkAppForDarkMode = () => {
// eg AMP pages this is not present so just return. Dark mode can't work on iframed atoms in AMP
if(!window.parent) {
return;
}
// check the parent window to see if this atom is embedded in an app
const parentIsIos = window.parent.document.querySelector(".ios") // null if not present
const parentIsAndroid = window.parent.document.querySelector(".android")
// if it is in an app, add the 'in-app' class name to the body
if(parentIsIos || parentIsAndroid){ document.querySelector("body").classList.add("in-app") }
// hack for android app - it also needs this for an annoying reason
const parentIsInDarkMode = window.parent.document.querySelector(".dark-mode-on")
if(parentIsInDarkMode) { document.querySelector("body").classList.add("dark-mode-on") }
}
/*
export function wrap(text, width, padding=20) {
let w = width - padding
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"),
dy = parseFloat(text.attr("dy")),
tspan = text.text(null).append("tspan").attr("x", -10).attr("y", y).attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > w) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan").attr("x", -10).attr("y", y).attr("dy", ++lineNumber * lineHeight + dy + "em").text(word);
}
}
});
}
*/
export function wrap(textSelection, maxWidth, padding=20) {
textSelection.each(function() {
let text = d3.select(this),
words = text.text().split(/\s+/).reverse(),
word,
line = [],
lineNumber = 0,
lineHeight = 1.1, // ems
y = text.attr("y"),
dy = parseFloat(text.attr("dy") || 0),
tspan = text.text(null).append("tspan").attr("x", text.attr("x")).attr("y", y).attr("dy", dy + "em");
while (word = words.pop()) {
line.push(word);
tspan.text(line.join(" "));
if (tspan.node().getComputedTextLength() > maxWidth) {
line.pop();
tspan.text(line.join(" "));
line = [word];
tspan = text.append("tspan").attr("x", text.attr("x")).attr("y", y).attr("dy", `${++lineNumber * lineHeight + dy}em`).text(word);
}
}
});
}
export function dodge(data, radius) {
const radius2 = radius ** 2;
const circles = data
const epsilon = 1e-3;
let head = null, tail = null;
// Returns true if circle ⟨x,y⟩ intersects with any circle in the queue.
function intersects(x, y) {
let a = head;
while (a) {
if (radius2 - epsilon > (a.x - x) ** 2 + (a.y - y) ** 2) {
return true;
}
a = a.next;
}
return false;
}
// Place each circle sequentially.
for (const b of circles) {
// Remove circles from the queue that can’t intersect the new circle b.
while (head && head.x < b.x - radius2) head = head.next;
// Choose the minimum non-intersecting tangent.
if (intersects(b.x, b.y = 0)) {
let a = head;
b.y = Infinity;
do {
let y1 = a.y + Math.sqrt(radius2 - (a.x - b.x) ** 2);
let y2 = a.y - Math.sqrt(radius2 - (a.x - b.x) ** 2);
if (Math.abs(y1) < Math.abs(b.y) && !intersects(b.x, y1)) b.y = y1;
if (Math.abs(y2) < Math.abs(b.y) && !intersects(b.x, y2)) b.y = y2;
a = a.next;
} while (a);
}
// Add b to the queue.
b.next = null;
if (head === null) head = tail = b;
else tail = tail.next = b;
}
return circles;
}
export function relax(data, width, height) {
var spacing = 16;
var dy = 2;
var repeat = false;
var count = 0;
data.forEach(function(dA, i) {
var yA = dA.labelY;
data.forEach(function(dB, j) {
var yB = dB.labelY;
if (i === j) {
return;
}
let diff = yA - yB;
if (Math.abs(diff) > spacing) {
return;
}
repeat = true;
let magnitude = diff > 0 ? 1 : -1;
let adjust = magnitude * dy;
dA.labelY = +yA + adjust;
dB.labelY = +yB - adjust;
dB.labelY = dB.labelY > height ? height : dB.labelY
dA.labelY = dA.labelY > height ? height : dA.labelY
})
})
if (repeat) {
relax(data);
}
}
export function merge(to, from) {
for (const n in from) {
if (typeof to[n] != 'object') {
to[n] = from[n];
} else if (typeof from[n] == 'object') {
to[n] = merge(to[n], from[n]);
}
}
return to;
};
export function isWithinRange(arr, target, range=10) {
for (let i = 0; i < arr.length; i++) {
if (Math.abs(arr[i] - target) <= range) {
return true;
}
}
return false;
}
export function contains(a, b) {
if (Array.isArray(b)) {
return b.some(x => a.indexOf(x) > -1);
}
return a.indexOf(b) > -1;
}
export function commas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
export function getMargins(settings) {
let margins = {
top: settings["margintop"],
right: settings["marginright"],
bottom: settings["marginbottom"],
left: settings["marginleft"]
}
return margins
}
export function mobileCheck() {
var windowWidth = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0
)
return windowWidth < 610 ? true : false
}
export function getLongestKeyLength($svg, keys, isMobile, lineLabelling) {
if (lineLabelling) {
d3.select("#dummyText").remove()
const longestKey = keys.sort(function (a, b) {
return b.length - a.length
})[0]
const dummyText = $svg
.append("text")
.attr("x", -50)
.attr("y", -50)
.attr("id", "dummyText")
.attr("class", "annotationText")
.style("font-weight", "bold")
.style("font-size", "15px")
.text(longestKey)
return dummyText.node().getBBox().width
}
return 0
}
/*
export function numberFormat(num) {
if ( num > 0 ) {
if ( num >= 1000000000 ) {
if ((num / 1000000000) % 1 == 0) {
return ( num / 1000000000 ) + 'bn'
}
else {
return ( num / 1000000000 ).toFixed(1) + 'bn'
}
}
if ( num >= 1000000 ) {
if (( num / 1000000 ) % 1 == 0) {
return ( num / 1000000 ) + 'm'
}
else {
return ( num / 1000000 ).toFixed(1) + 'm'
}
}
if ( num >= 1000 ) {
if (( num / 1000 ) % 1 == 0) {
return ( num / 1000 ) + 'k'
}
else {
return ( num / 1000 ).toFixed(1) + 'k'
}
}
if (num % 1 != 0) {
return num
} else {
return num }
}
if ( num < 0 ) {
var posNum = num * -1;
// if ( posNum >= 1000000000 ) return [ "-" + String(( posNum / 1000000000 ).toFixed(1)) + 'bn'];
// if ( posNum >= 1000000 ) return ["-" + String(( posNum / 1000000 ).toFixed(1)) + 'm'];
// if ( posNum >= 1000 ) return ["-" + String(( posNum / 1000 ).toFixed(1)) + 'k'];
// else { return num }
if ( posNum >= 1000000000 ) {
if ((posNum/ 1000000000) % 1 == 0) {
return "-" + ( posNum / 1000000000 ) + 'bn'
}
else {
return "-" + ( posNum / 1000000000 ).toFixed(1) + 'bn'
}
}
if ( posNum >= 1000000 ) {
if (( posNum / 1000000 ) % 1 == 0) {
return "-" + ( posNum / 1000000 ) + 'm'
}
else {
return "-" + ( posNum / 1000000 ).toFixed(1) + 'm'
}
}
if ( posNum >= 1000 ) {
if (( posNum / 1000 ) % 1 == 0) {
return "-" + ( posNum / 1000 ) + 'k'
}
else {
return "-" + ( posNum / 1000 ).toFixed(1) + 'k'
}
}
if (posNum % 1 != 0) {
return "-" + posNum
} else {
return "-" + posNum }
}
return num
}
*/
export function numberFormat(num) {
const absNum = Math.abs(num);
const sign = num < 0 ? '-' : '';
// If the number is less than 1000, just return it.
if (absNum < 1000) {
return sign + absNum;
}
// Define thresholds in ascending order.
const thresholds = [
{ value: 1e3, suffix: 'k' },
{ value: 1e6, suffix: 'm' },
{ value: 1e9, suffix: 'bn' },
];
let formattedNumber, suffix;
// Loop through each threshold.
for (let i = 0; i < thresholds.length; i++) {
const { value, suffix: currentSuffix } = thresholds[i];
const divided = absNum / value;
// Round to one decimal and drop trailing zeros.
const rounded = parseFloat(divided.toFixed(1));
// If rounding bumps the value to 1000 or more and there's a higher threshold, skip to the next one.
if (rounded >= 1000 && i < thresholds.length - 1) {
continue;
} else {
formattedNumber = rounded;
suffix = currentSuffix;
break;
}
}
return sign + formattedNumber + suffix;
}
export function mustache(template, self, parent, invert) {
var render = mustache
var output = ""
var i
function get (ctx, path) {
path = path.pop ? path : path.split(".")
ctx = ctx[path.shift()]
ctx = ctx != null ? ctx : ""
return (0 in path) ? get(ctx, path) : ctx
}
self = Array.isArray(self) ? self : (self ? [self] : [])
self = invert ? (0 in self) ? [] : [1] : self
for (i = 0; i < self.length; i++) {
var childCode = ''
var depth = 0
var inverted
var ctx = (typeof self[i] == "object") ? self[i] : {}
ctx = Object.assign({}, parent, ctx)
ctx[""] = {"": self[i]}
template.replace(/([\s\S]*?)({{((\/)|(\^)|#)(.*?)}}|$)/g,
function(match, code, y, z, close, invert, name) {
if (!depth) {
output += code.replace(/{{{(.*?)}}}|{{(!?)(&?)(>?)(.*?)}}/g,
function(match, raw, comment, isRaw, partial, name) {
return raw ? get(ctx, raw)
: isRaw ? get(ctx, name)
: partial ? render(get(ctx, name), ctx)
: !comment ? new Option(get(ctx, name)).innerHTML
: ""
}
)
inverted = invert
} else {
childCode += depth && !close || depth > 1 ? match : code
}
if (close) {
if (!--depth) {
name = get(ctx, name)
if (/^f/.test(typeof name)) {
output += name.call(ctx, childCode, function (template) {
return render(template, ctx)
})
} else {
output += render(childCode, name, ctx, inverted)
}
childCode = ""
}
} else {
++depth
}
}
)
}
return output
}
export function createElement(element, attribute, inner) {
if (typeof(element) === "undefined") {
return false;
}
if (typeof(inner) === "undefined") {
inner = "";
}
var el = document.createElement(element);
if (typeof(attribute) === 'object') {
for (var key in attribute) {
el.setAttribute(key, attribute[key]);
}
}
if (!Array.isArray(inner)) {
inner = [inner];
}
for (var k = 0; k < inner.length; k++) {
if (inner[k].tagName) {
el.appendChild(inner[k]);
} else {
el.appendChild(document.createTextNode(inner[k]));
}
}
return el;
}
export function getURLParams(paramName) {
var params = ""
if (top !== self) {
params = window.location.search.substring(1).split("&")
} else {
params = window.parent.location.search.substring(1).split("&")
}
for (let i = 0; i < params.length; i++) {
let val = params[i].split("=")
if (val[0] == paramName) {
return val[1]
}
}
return null
}
export function preflight(array, chart) {
let charts = array.map(item => item.type)
return (chart=="") ? false :
(contains(charts,chart)) ? chart : false ;
}
export function isNumber(n) {
// Needs to be === to ensure zero doesn't get caught as a number
if (n === "") {
return false
}
else {
return !isNaN(n)
}
}
export function getMinMax(array) {
}
export function setMinToMax(array) {
let range = d3.extent(array)
let min = 0
let max = range[1]
let status = false
if (range[0] < 0) {
range[0] = Math.floor(range[0])
range[1] = Math.ceil(range[1])
max = (Math.abs(range[0]) > range[1]) ? Math.abs(range[0]) : range[1]
min = -max
status = true
}
return { min : min , max : max , status : status }
}
// Bufferize now takes a percetange as the third argument, and adds a buffer to the
// Min and Max which is a % of the overall unit range
// If either the default min or max is zero, then return zero instead of the buffered value
export function bufferize(min, max, buff=0.05) {
let newMin = min
let newMax = max
if (min == 0 && max > 0) {
newMax = max + (max * buff)
}
if (min > 0 && max > 0) {
newMax = max + (max * buff)
newMin = min - (min * buff)
}
else if (min < 0 && max > 0) {
newMax = max + (max * buff)
newMin = min + (min * buff)
}
else if (min < 0 && max < 0) {
newMax = max + (max * buff)
newMin = min + (min * buff)
}
else if (min < 0 && max == 0) {
newMin = min + (min * buff)
}
return [newMin, newMax]
}
export function textPadding(d) {
if (d.y2 > 0) {
return 12
} else {
return -2
}
}
export function textPaddingMobile(d) {
if (d.y2 > 0) {
return 12
} else {
return 4
}
}
export function stackMin(serie) {
return d3.min(serie, function (d) {
return d[0]
})
}
export function stackMax(serie) {
return d3.max(serie, function (d) {
return d[1]
})
}
export function validate(value, type) {
return (typeof value == type) ? true : false
}
export function validateString(value, array=[]) {
let status = (typeof value == 'string') ? true : false
if (array.length > 0) {
status = (status && contains(array, value)) ? true : false
}
if (value == "") {
status = false
}
return status
}
// export function groupByValues(array, value) {
// console.log("value",value)
// console.log("array",array)
// console.log(array.map())
// // const groupedObj = array.map(d => d[value]).reduce(
// // (prev, current) => ({
// // ...prev,
// // [current]: [...(prev[current] || []), current],
// // }),
// // {}
// // );
// return groupedObj
// }
export function getMaxDuplicate(array, yColumn, xColumn) {
const groupedObj = d3.group(array, d => d[yColumn], d => d[xColumn])
let max = 0
groupedObj.forEach((d) => {
console.log(d)
d.forEach((dd) => {
let len = Object.keys(dd).length
if (len > max) {
max = len
}
})
})
console.log("max", max)
return max
}
export function timeCheck(timeInterval, data, xColumn) {
if (timeInterval != "") {
switch(timeInterval) {
case "year" :
return d3.timeYear
.range(data[0][xColumn], d3.timeYear
.offset(data[data.length - 1][xColumn], 1))
break;
case "day" :
return d3.timeDay
.range(data[0][xColumn], d3.timeDay
.offset(data[data.length - 1][xColumn], 1))
break;
case "month" :
return d3.timeMonth
.range(data[0][xColumn], d3.timeMonth
.offset(data[data.length - 1][xColumn], 1))
break;
case "week" :
return d3.timeWeek
.range(data[0][xColumn], d3.timeWeek
.offset(data[data.length - 1][xColumn], 1))
break;
default:
return data.map((d) => d[xColumn])
break;
}
} else {
return data.map((d) => d[xColumn])
}
}
export function tickTok(isMob, array, width) {
if (isMob) {
return 4
}
let diff = Math.round( array[1] - array[0] )
let val = Math.round(width / 100)
return (val > diff) ? diff : val
}
export function sorter(arr, value) {
return arr.sort((a, b) => (a[value] < b[value]) ? 1 : -1).reverse()
}
export function xFormatting(settings) {
let xData = { date : false, string : false , number : false, status : "", type : null }
if (!settings["xColumn"]) {
xData.status = "The xColumn is not defined"
} else {
let data = settings.data.map(d => d[settings["xColumn"]].toString() )
const dateFormat =
testDateFormatSp1(data) ||
testDateFormatSp2(data) ||
testDateFormats(data, formatList, 5) ||
testDateFormatSp3(data) ||
testDateFormatSp4(data);
console.log("dateFormat", dateFormat)
if (dateFormat && settings["dateFormat"]) {
xData.date = true;
xData.type = 'date';
xData.status = "".concat(settings.data[0][settings["xColumn"]], " from the ").concat(settings["xColumn"], " inferred as date based on dateFormat");
}
else if (typeof settings.data[0][settings["xColumn"]] == 'number') {
xData.number = true
xData.type = 'number'
xData.status = `The ${settings["xColumn"]} contains number data`
}
else if (typeof settings.data[0][settings["xColumn"]] == 'string') {
xData.string = true
xData.type = 'string'
xData.status = `The ${settings["xColumn"]} contains string data`
}
}
return xData
}
const formatList = [
// time
//"%m/%d/%Y", "%m/%d/%y", // vs.
//"%d/%m/%Y", "%d/%m/%y" // extend
// -> parse sp.1 (*)
"%d-%b-%y", "%d %b %Y",
"%d %b",
"%d-%B-%y", "%d %B %Y",
"%d %B",
"%Y-%m-%d", // // -> parse sp.4
"%Y-%m-%dT%H:%M:%S%Z", // iso format timestamp
"%m/%d/%y %H:%M",
"%m/%d/%y %I:%M %p",
"%H:%M:%S", // extend
// linear
//"%Y", // -> parse sp.2
"%b-%y", "%b %y", // hijack
"%Y %b", "%b %Y",
"%B-%y", "%B %y",
"%Y %B", "%B %Y",
"%Y-%y", "%Y/%y", // hijack
"%b", "%B" // extend
//"%Y Q*", "Q* %Y" // -> parse sp.3
];
const formatSp1 = [
"%m/%d/%y", "%m/%d/%Y"
];
function testDateFormats(data, formats) {
let dateParser;
let dateFormat = formats.find(f => {
dateParser = d3.timeParse(f);
return dateParser(data[0]);
});
return dateFormat && data.every(d => dateParser(d)) ? dateFormat : "";
}
// format(s): "%m/%d/%Y", "%m/%d/%y" vs. "%d/%m/%Y", "%d/%m/%y"
function testDateFormatSp1(data) {
let format = testDateFormats(data, formatSp1, 1);
if (format) {
const isMonthFirst = data.every(d => d.split("/")[0] <= 12);
const isDaySecond = data.some(d => d.split("/")[1] > 12);
format = isMonthFirst && isDaySecond ? format : "%d/%m/" + format.slice(-2);
// both first and second parts of are smaller than 12
if (isMonthFirst && !isDaySecond) console.warn("format unclear!!!");
}
return format ? format : "";
}
// format(s): "%Y"
function testDateFormatSp2(data) {
// format
const format = "%Y";
const isYear = testDateFormats(data, [format], 2) === format;
console.log("isYear", isYear)
// filter, strict
const is4Digits = data.every(d => d.length === 4);
return isYear && is4Digits ? format : "";
}
// format(s): "%Y Q*", "Q* %Y"
function testDateFormatSp3(data) {
// filter
const isSp3 = data.every(d => (d[0] === "Q" || d[5] === "Q") && d.length === 7);
// format without Q
const dataYear = data.map(d => d.replace(/Q([1-4])/g, "").trim());
const isYear = testDateFormats(dataYear, ["%Y"], 3) === "%Y";
return isSp3 && isYear ? "Q*" : "";
}
// format(s): ""%Y%m%d""
function testDateFormatSp4(data) {
// format
const format = "%Y%m%d";
const isYmd = testDateFormats(data, [format], 4) === format;
// filter, strict
const isMonth = data.every(ymd => {const m = ymd.slice(4, 6); return m >= 1 && m <= 12;});
const isDay = data.every(ymd => {const d = ymd.slice(6); return d >= 1 && d <= 31;});
return isYmd && isMonth && isDay ? format : "";
}
/* 2. dates to scale values */
export function getDateScaleValues(dates, format, hasDay, isEditor = false) {
let parser;
let getDateParsed = (parser) => dates.map(d => parser(d));
switch (true) {
/* d3.scaleLinear, return number */
case ["%Y"].includes(format):
return dates.map(d => +d);
case ["Q*"].includes(format): {
const indexQ = dates[0].indexOf("Q");
return dates.map(d => +(d.replace(/Q([1-4])/g, "").trim()) + ((+d[indexQ + 1]) - 1) * 0.25);
}
case ["%Y-%y", "%Y/%y"].includes(format):
return dates.map(d => +d.slice(0, 4));
case ["%b", "%B"].includes(format):
parser = d3.timeParse(!isEditor ? format : "%b");
return getDateParsed(parser).map(d => d.getMonth());
// %b %Y x 4 sets
case !hasDay:
parser = d3.timeParse(!isEditor ? format : "%b %Y");
return getDateParsed(parser).map(d => d.getFullYear() + d.getMonth()/12);
/* d3.scaleTime, return timestamp */
default:
parser = d3.timeParse(format);
return getDateParsed(parser);
}
}
/* 3. dates to label texts */
export function dateNumToTxt(value, format, hasDay) {
let year = value.toString().split(".")[0];
let deci = value % 1; // get decimal portion
let date, month, toText;
switch (true) {
case ["%Y"].includes(format):
return value.toString();
case ["Q*"].includes(format): {
const quad = (value % 1) * 4 + 1;
return "Q" + quad + " " + year;
}
case ["%Y-%y", "%Y/%y"].includes(format):
return value + "-" + (value+1).toString().slice(-2);
case ["%b", "%B"].includes(format):
date = new Date(2017, value);
toText = d3.timeFormat("%b");
return toText(date);
// %b %Y x 4 sets
case !hasDay:
month = Math.round(parseFloat(deci*12));
date = new Date(year, month || 0);
toText = d3.timeFormat("%b %Y");
//console.log(value, year, deci, month, date, toText(date))
return toText(date);
/* dynamic formats, see below */
default:
return null;
}
}
// dynamic
export function getDateTextFormat(domain) {
const diffYear = domain[1].getFullYear() - domain[0].getFullYear();
const diffMonth = domain[1].getMonth() - domain[0].getMonth();
const diffDay = domain[1].getDate() - domain[0].getDate();
const diffHour = domain[1].getHours() - domain[0].getHours();
switch (true) {
case diffYear > 4: return "%Y"; //console.log("[Y] 2017")
case diffYear > 0: return "%b %Y"; //console.log("[M] Feb 2017")
case diffMonth > 4: return "%b"; //console.log("[M] Feb")
case diffMonth > 0: return "%d %b"; //console.log("[M] 15 Feb")
case diffDay > 0: return "%d %I%p"; //console.log("[D] 15 6pm")
case diffHour > 0: return "%H:%M"; //console.log("[H] 18:30")
default: console.error("a new time format is required!");
}
}
export function capitalizeFirstLetter(word) {
if (typeof word === 'string' && word.length > 0) {
return word.charAt(0).toUpperCase() + word.slice(1);
}
return word; // Return the original input if it's not a non-empty string
}
export function getLabelFromColumn(jsonData, columnName) {
// Iterate through each object in the JSON array
for (const item of jsonData) {
// Check if the column name matches
if (item.column === columnName) {
return item.label; // Return the corresponding label
}
}
return ""; // Return an empty string if no match is found
}