// CONSTANTS //////////////////////////////////////////////////////////////////
const DEMOGRAPHICS_ENDPOINT_PARSE_HUMAN_NAME = "/v1/humans/names/parse";
const DEMOGRAPHICS_ENDPOINT_PARSE_LOCATION_NAME = "/v1/locations/names/parse";
const DEMOGRAPHICS_ENDPOINT_ANALYZE_HUMAN_FACE = "/v1/humans/faces/analyze";
const DEMOGRAPHICS_ENDPOINT_ANALYZE_HUMAN = "/v1/humans/analyze";
const HUMANGRAPHICS_API_BASE_URL = "https://api.humangraphics.io";
const MAX_CHART_VALUES = 8;
const MAX_IMAGE_SIZE = 1 * 1024 * 1024; // 1 MiB
const SPINNER_IMAGE_URL = "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64f919657f2a717952216ad3_1490.gif";
const PLACEHOLDER_IMAGE_URL = "https://d3e54v103j8qbb.cloudfront.net/plugins/Basic/assets/placeholder.60f9b1840c.svg";
const LOADING_CLASS_NAME = "loading";
const EMPTY_CLASS_NAME = "empty";
const POPULATED_CLASS_NAME = "populated";
const DEMOGRAPHICS_EXAMPLES = {
"jeff-bezos": {
name: "Jeff Bezos",
location: "Seattle",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64f8f0d8984ac7cba3db0cab_jeff-bezos.400.jpg",
},
"michelle-yeoh": {
name: "Michelle Yeoh",
location: "Geneva",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64f935002324ecc4fbd7bb83_michelle-yeoh.400.jpg",
},
"taylor-swift": {
name: "Taylor Swift",
location: "Nashville",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64f8f0d8463667d6a683343a_taylor-swift.400.jpg",
},
"idris-elba": {
name: "Idris Elba",
location: "London",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64f8f0d892206e93ba249455_idris-elba.400.jpg",
},
"bill-gates": {
name: "Bill Gates",
location: "Seattle, WA",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64f8f0d892206e93ba249472_bill-gates.400.jpg",
},
"shigeru-miyamoto": {
name: "宮本茂",
location: "京都府",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64fb4a1c693d83955246b04c_shigeru-miyamoto.400.jpg",
},
"tony-jaa": {
name: "จา พนม",
location: "กรุงเทพมหานคร",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64fb44aa236a70d80a381948_tony-jaa.400.jpg",
},
"jae-sang-park": {
name: "박재상",
location: "서울",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64fb46617d732f1165c7b34c_park-jae-sang.jpg",
},
"shahrukh-khan": {
name: "शाहरुख़ ख़ान",
location: "नई दिल्ली",
face: "https://uploads-ssl.webflow.com/64c95ed9cc28c8438964e261/64fbef5179f83432f6170963_sharukh-khan.400.jpg",
},
};
const HUMAN_NAME_PROPERTY_NAMES = [
"givenName", "middleName", "secondMiddleName", "nickName", "familyName", "secondFamilyName"
];
const LOCATION_PROPERTY_NAMES = [
"country", "state", "city"
];
// UTILITY FUNCTIONS //////////////////////////////////////////////////////////
function standardizeText(s) {
if (s == null)
return null;
s = s.trim();
if(s == "")
return null;
return s;
}
/**
*
* @param {*} s A data URL
*/
function standardizeImage(s) {
if (s == null)
return null;
if (s == "")
return null;
return s;
}
function xrange(from, to) {
const result = [];
for (let i = from; i < to; i++)
result.push(i);
return result;
}
function escapeHTML(unsafeText) {
let div = document.createElement('div');
div.innerText = unsafeText;
return div.innerHTML;
}
function camelCaseToFieldName(text) {
const parts = text.split(/(?=[A-Z])/);
parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1, parts[0].length);
for (let i = 1; i < parts.length; i++)
parts[i] = parts[i].charAt(0).toLowerCase() + parts[i].slice(1, parts[i].length);
return parts.join(" ");
}
// CONTENT HANDLING ///////////////////////////////////////////////////////////
/**
* @param {*} The value to be pretty-printed as JSON
* @returns a jQuery object containing a single tag with the pretty-printed JSON
*/
function prettyPrinted(value) {
// Care of https://stackoverflow.com/questions/4810841/pretty-print-json-using-javascript
const html = JSON.stringify(value, undefined, 2)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
var cls = "number";
if (/^"/.test(match)) {
if (/:$/.test(match)) {
cls = "key";
} else {
cls = "string";
}
} else if (/true|false/.test(match)) {
cls = "boolean";
} else if (/null/.test(match)) {
cls = "null";
}
return `${match}`;
});
const result = document.createElement("pre");
result.classList.add("json");
result.innerHTML = html;
return $(result);
}
/**
* @param {*} url An absolute URL of a publicly-available image
* @returns A promise containing a data URL of the image
*/
function fetchRemoteImage(url) {
const controller = new AbortController();
const signal = controller.signal;
return fetch(url, { signal })
.then(response => {
if (response.status === 401)
throw new Error("The given file could not be retrieved because it is not publicly available.");
if (response.status === 403)
throw new Error("The given file could not be retrieved because it is not publicly available.");
if (response.status === 404)
throw new Error("The given file could not be retrieved because it does not exist.");
if (response.status / 100 === 5)
throw new Error("The given file could not be retrieved because of a remote server error.");
if (!response.ok)
throw new Error("There was a problem retrieving the file. Try again?");
// Check image type
const contentType = response.headers.get("content-type");
if (contentType === null) {
controller.abort();
throw new Error("The given file is not valid because it cannot be confirmed to be an image. Try another file.");
}
if (!contentType.startsWith("image/")) {
controller.abort();
throw new Error("The given file is not valid because it does not appear to be an image. Try another file.");
}
if (!["image/png", "image/jpeg"].some(ct => contentType === ct)) {
controller.abort();
throw new Error("The given file is not valid because it does not appear to be an image of a valid type. Try another file.");
}
// Check content length from headers
const contentLength = response.headers.get("content-length");
if (contentLength === null) {
controller.abort();
throw new Error("The given image could not be retrieved because its size could not be determined. Try another image.");
}
if (contentLength && parseInt(contentLength) > MAX_IMAGE_SIZE) {
controller.abort();
throw new Error("The given file could not be retrieved because it is too big.");
}
// So far, so good. This should be an image.
return response.blob();
}).then(blob => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {
resolve(reader.result);
}, false);
reader.addEventListener("error", (event) => {
reject(new Error("The given file failed to decode. Try again?"));
}, false);
reader.readAsDataURL(blob);
}));
}
/**
* @param {*} file A local file from a file selector
* @returns A promise containing a data URL of the image
*/
function loadLocalImage(file) {
return new Promise((resolve, reject) => {
// Check image type
try {
const contentType = file.type;
if (contentType == null) {
throw new Error("The given file is not valid because it cannot be confirmed to be an image. Try another file.");
}
if (!contentType.startsWith("image/")) {
throw new Error("The given file is not valid because it does not appear to be an image. Try another file.");
}
if (!["image/png", "image/jpeg"].some(ct => contentType === ct)) {
throw new Error("The given file is not valid because it does not appear to be an image of a valid type. Try another file.");
}
// Check content length from headers
const contentLength = file.size;
if (contentLength == null) {
throw new Error("The given image could not be retrieved because its size could not be determined. Try another image.");
}
if (contentLength > MAX_IMAGE_SIZE) {
throw new Error("The given file could not be retrieved because it is too big.");
}
}
catch (reason) {
reject(reason);
return;
}
// Square deal. This looks like an image. Let's load it.
const reader = new FileReader();
reader.addEventListener("load", () => {
resolve(reader.result);
}, false);
reader.addEventListener("error", (event) => {
reject(new Error("The given file failed to decode. Try again?"));
}, false);
reader.readAsDataURL(file);
});
}
// INITIALIZATION /////////////////////////////////////////////////////////////
$(document).ready(function () {
// CHARTJS CONFIGURATION //////////////////////////////////////////////////
Chart.defaults.font.family = "Inter, sans-serif";
});
// DEMOGRAPHICS HELPERS ///////////////////////////////////////////////////////
function prepareImage(image) {
if (image == null)
return null;
const index = image.indexOf(",");
if (index === -1)
return null;
const bytes = image.substring(index + 1, image.length);
return {
type: "binary",
binaryImageAttributes: {
bytes: bytes
}
};
}
function prepareDemographicsAjax(name, location, face) {
const standardizedName = standardizeText(name);
const standardizedLocation = standardizeText(location);
const standardizedFace = standardizeImage(face);
const count = [standardizedName, standardizedLocation, standardizedFace].filter(x => x != null).length;
if (count === 0)
return null;
const endpoint = DEMOGRAPHICS_ENDPOINT_ANALYZE_HUMAN;
const data = {};
if(standardizedName != null)
data.humanNameText = standardizedName;
if(standardizedLocation != null)
data.locationNameText = standardizedLocation;
if(standardizedFace != null)
data.humanFaceImage = prepareImage(standardizedFace);
return {
url: HUMANGRAPHICS_API_BASE_URL + endpoint,
method: "POST",
data: data,
contentType: "application/json",
processData: false,
beforeSend: function (xhr, options) {
// This is a little bit of pokery jiggery. We want the result we return from the
// surrounding prepareDemographicsAjax function to contain a raw request body, not a
// stringified one. Therefore, we use this beforeSend to defer the stringify call
// until the request is made. This needs processData: false as well.
if (options.contentType === "application/json" && typeof options.data !== "string") {
options.data = JSON.stringify(options.data);
}
},
headers: {
"authorization": "bearer " + HUMANGRAPHICS_API_BEARER_TOKEN,
},
};
}
function fetchDemographicsData(ajax) {
return $.ajax(ajax);
}
/**
* @param {*} estimate An estimate is a discrete categorical distribution represented as an object
* with category names as keys and likelihoods as values. All values should be between 0 and 1,
* inclusive. The values should add up to (rougly) 1.
* @returns An array of `{x: category, y: value}` objects ordered by `y` descending. Some buckets
* may have been combined under the special category `other` to reduce total buckets.
*/
function estimateToDataset(estimate) {
// Get the keys in order of value descending
const keys = Object.keys(estimate).toSorted((a, b) => (estimate[b] ?? 0) - (estimate[a] ?? 0));
if (keys.length <= MAX_CHART_VALUES)
return keys.map(ki => ({ x: ki, y: estimate[ki] }));
const subkeys = keys.slice(0, MAX_CHART_VALUES);
const subvalues = subkeys.map(ki => estimate[ki]);
const subtotal = subvalues.reduce((r, vi) => r + vi, 0);
return xrange(0, MAX_CHART_VALUES)
.map(i => ({ x: subkeys[i], y: subvalues[i] }))
.concat({ x: "other", y: 1.0 - subtotal });
}
function populateEstimateChart($parent, estimate) {
$parent.removeClass(EMPTY_CLASS_NAME)
.removeClass(LOADING_CLASS_NAME)
.removeClass(POPULATED_CLASS_NAME)
.addClass(POPULATED_CLASS_NAME);
const dataset = estimateToDataset(estimate);
const $canvas = $("