// 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 = $("", { "width": "100%", "height": "100%" }); $parent.append($canvas); new Chart( $canvas[0], { type: "bar", data: { labels: dataset.map(di => di.x), datasets: [{ data: dataset.map(di => di.y) }] }, options: { elements: { bar: { backgroundColor: "#086CD9", }, }, scales: { x: { min: 0.0, max: 1.0, grid: { display: false, }, ticks: { callback: function (value, index, ticks) { return (100.0 * value).toFixed(0) + "%" }, }, }, y: { grid: { display: false, }, afterFit: function (scaleInstance) { // Let's set a constant label width of 120px scaleInstance.width = 128; }, }, }, indexAxis: "y", responsive: true, plugins: { legend: { display: false, }, title: { display: false, }, }, } } ); } function populateTable($parent, headers, fields) { $parent.removeClass(EMPTY_CLASS_NAME) .removeClass(LOADING_CLASS_NAME) .removeClass(POPULATED_CLASS_NAME) .addClass(POPULATED_CLASS_NAME); $("") .addClass("testdrive") .append($("") .append($("") .append(headers.map(hi => $("") .append(fields.map(fi => $("") .append($("
").text(hi))))) .append($("
").text(fi.name)) .append($("").append(fi.value))))) .appendTo($parent); } function populateJson($parent, value) { $parent.removeClass(EMPTY_CLASS_NAME) .removeClass(LOADING_CLASS_NAME) .removeClass(POPULATED_CLASS_NAME) .addClass(POPULATED_CLASS_NAME) .append(prettyPrinted(value)); } function populateLoading($parent) { $parent.removeClass(EMPTY_CLASS_NAME) .removeClass(LOADING_CLASS_NAME) .removeClass(POPULATED_CLASS_NAME) .addClass(LOADING_CLASS_NAME) .append($("") .addClass("spinner") .attr("src", SPINNER_IMAGE_URL)); } function populatePlaceholder($parent) { $parent.removeClass(EMPTY_CLASS_NAME) .removeClass(LOADING_CLASS_NAME) .removeClass(POPULATED_CLASS_NAME) .addClass(EMPTY_CLASS_NAME) .append($("

") .addClass("placeholder") .text("No data.")); }