const app = document.querySelector(".inflation-app"); app.classList.add("loaded"); const form = document.querySelector(".inflation-app form"); const geoSelect = document.querySelector("#geography"); const geoIds = [2, 3, 5, 7, 9, 11, 14, 18, 20, 23, 26, 29, 30 /*, 31*/]; const itemSelect = document.querySelector("#items"); const itemsIds = [ 3, 4, 75, 23, 24, 28, 31, 5, 7, 11, 17, 40, 25, 50, 53, 36, 37, 26, 66, 38, 263, 264, 74, 59, 265, 267, ]; const liveUrl = "/content/dam/common/exceltojson/food-inflation.txt"; //! const TEST = false; //! const customFetch = (test) => { if (test) { return fetch("./api/filter", { headers: { "Content-Type": "application/json" }, method: "POST", body: JSON.stringify({ geoIds: geoIds, itemsIds: itemsIds }), }); } else { return fetch(liveUrl); } }; let dataCache = {}; customFetch(TEST) .then((res) => res.json()) .then((raw) => { dataCache.items = raw.items; dataCache.geography = raw.geography.map((geo) => { const formattedItems = {}; for (let i in geo.items) { formattedItems[i] = formatData(geo.items[i]); } return { ...geo, items: formattedItems, }; }); if (TEST) { console.log(JSON.stringify(raw)); } setOptions(); }); const formatData = (raw) => { const changeSinceMonth = 12; const data = []; raw.forEach((month, i, arr) => { const obj = {}; obj.date = month.date; obj.value = month.value; if (i >= changeSinceMonth) { const past = arr[i - changeSinceMonth].value; const curr = month.value; const change = (100 * (curr - past)) / past; obj.change = change; } data.push(obj); }); return data; }; const fetchAndUpdate = (animate = true) => { const geoVal = geoSelect.value; const itemVal = itemSelect.value; const body = { geo: geoVal, item: itemVal }; const geo = dataCache.geography.find((d) => String(d.id) === geoVal); const data = geo.items[itemVal]; itemSelect.querySelectorAll("option").forEach((option) => { option.disabled = geo.items[option.value].length === 0; }); if (data.length === 0) { } if (data !== undefined) { return updateChart(data, animate); } fetch("./api", { headers: { "Content-Type": "application/json" }, method: "POST", body: JSON.stringify(body), }) .then((res) => res.json()) .then((raw) => { const data = formatData(raw); cache(body, data); updateChart(data, animate); }); }; form.addEventListener("change", fetchAndUpdate); window.addEventListener("resize", () => fetchAndUpdate(false)); async function fetchOptions() { if (dataCache.geography !== null && dataCache.items !== null) { return { geography: dataCache.geography, items: dataCache.items }; } else { const raw = await fetch("./api/options"); const json = await raw.json(); const geography = json.geography.map((d) => { return { items: [], ...d }; }); dataCache.geography = geography; dataCache.items = json.items; return { geography: geography, items: json.items }; } } async function setOptions() { // const { geography, items } = await fetchOptions(); const { geography, items } = dataCache; const geoSelect = document.querySelector("#geography"); const showList = (array, select, ids = null) => { array.forEach((item) => { if (ids === null || ids.includes(item.id)) { const option = document.createElement("option"); option.value = item.id; option.innerText = item.name.split("(")[0].trim(); select.append(option); } }); }; showList(geography, geoSelect, geoIds); showList(items, itemSelect, itemsIds); fetchAndUpdate(false); } async function fetchDomain() { if (dataCache.domain !== null) { return dataCache.domain; } const raw = await fetch("./api/domain"); const json = await raw.json(); return [json.min, json.max]; } const dataGroup = (parent, selection, dataFn = (d) => d) => { return parent .selectAll(selection) .data(dataFn) .join(selection.split(".")[0]) .attr("class", selection.split(".")[1]); }; const scaleBar = { save: null, }; const scaleLine = { save: null, }; const yAxisOpacity = (d, domain) => { const [low, high] = domain; const outOfBounds = d > high || d < low; const largeRange = high - low >= 35 && d % 10 !== 0; if (outOfBounds || largeRange) { return 0; } return 1; }; const barChart = (container, data, animate) => { if (data.length === 0) { return; } container .selectAll("h3") .data([1]) .join("h3") .text("12-month change, by month"); const chart = dataGroup(container, "svg.chart", [data]); const { width, height } = chart.node().getBoundingClientRect(); const pad = { top: 20, right: 40, bottom: 5, left: 20 }; const near = 20; const yExtent = d3.extent(data, (d) => d.change); const yDomain = [Math.min(yExtent[0], -5), Math.max(yExtent[1], 15)]; const yDomainRound = yDomain.map((d) => { const round = Math.ceil(Math.abs(d) / near) * near; return Math.sign(d) * round; }); yDomainRound[0] = yDomainRound[1] / -3; const x = d3 .scaleLinear() .domain([0, data.length]) .range([pad.left, width - pad.right]); const y = d3 .scaleLinear() .domain(yDomainRound) .range([height - pad.bottom, pad.top]); //TODO: calculate change domain const y0 = scaleBar.save || y; if (scaleBar.save === null) { scaleBar.save = y; } const makeLayer = (name) => dataGroup(chart, `g.${name}-layer`, (d) => [d]).attr( "class", `${name}-layer` ); const yAxisValues = []; for (let n = -25; n <= 50; n += 5) { yAxisValues.push(n); } const dur0 = animate ? 500 : 0; const delay = animate ? 250 : 0; const dur1 = animate ? 500 : 0; const xAxisLayer = makeLayer("x-axis"); const yAxisLayer = makeLayer("y-axis"); dataGroup(yAxisLayer, "text", yAxisValues) .attr("x", x(x.domain()[1]) + 8) .attr("y", (d) => y0(d) + 2) .transition() .delay(dur0) .duration(dur1) .attr("y", (d) => y(d) + 2) .text((d) => d + "%") .style("opacity", (d) => yAxisOpacity(d, yDomainRound)); const xAxisYears = [...new Set(data.map((d) => d.date.split("-")[0]))]; const xAxisValues = xAxisYears.map((year, i, arr) => { const start = data.findIndex((month) => month.date.split("-")[0] === year); const end = arr[i + 1] ? data.findIndex((d) => d.date.split("-")[0] === arr[i + 1]) : data.length; return { year: year, interval: [start, end], }; }); dataGroup(yAxisLayer, "line", yAxisValues) .attr("x1", x(0) - 3) .attr("x2", x(x.domain()[1]) + 3) .attr("y1", (d) => y0(d) - 1) .attr("y2", (d) => y0(d) - 1) .transition() .delay(dur0) .duration(dur1) .attr("y1", (d) => y(d) - 1) .attr("y2", (d) => y(d) - 1) .style("opacity", (d) => (d % 20 === 0 ? 1 : 0)); dataGroup(xAxisLayer, "rect", xAxisValues) .attr("x", (d) => x(d.interval[0])) .attr("width", (d) => x(d.interval[1]) - x(d.interval[0])) .attr("y", y(y.domain[1])) .attr("height", "100%") .style("fill", (d, i) => (i % 2 === 1 ? "#f3f3f3" : "#fff")); dataGroup(xAxisLayer, "text", xAxisValues) .attr("x", (d) => x((d.interval[1] + d.interval[0]) / 2)) .attr("y", y(y.domain()[0]) - 5) .text((d) => d.year) .style("opacity", (d) => (d.interval[1] - d.interval[0] > 3 ? 1 : 0)); // Bar layer const barLayer = makeLayer("bar"); dataGroup(barLayer, "rect") .attr("x", (d, i) => x(i)) .attr("width", x(1) - x(0) - 1) .transition() .duration(dur0) .attr("y", (d) => y0(Math.max(d.change, 0))) .attr("height", (d) => Math.abs(y0(0) - y0(d.change))) .transition() // .delay(delay) .duration(dur1) .attr("y", (d) => y(Math.max(d.change, 0))) .attr("height", (d) => Math.abs(y(0) - y(d.change))) .style("fill", "#7F8D92"); scaleBar.save = y; }; const lineChart = (container, data, animate = true) => { if (data.length === 0) { return; } container.selectAll("h3").data([1]).join("h3").text("Consumer Price Index"); const chart = dataGroup(container, "svg.chart", [data]); const { width, height } = chart.node().getBoundingClientRect(); const pad = { top: 20, right: 40, bottom: 5, left: 20 }; const near = 50; const yExtent = d3.extent(data, (d) => d.change); const yDomain = [Math.min(yExtent[0], 100), Math.max(yExtent[1], 300)]; const yDomainRound = yDomain.map((d) => { const round = Math.ceil(Math.abs(d) / near) * near; return Math.sign(d) * round; }); yDomainRound[0] = 50; const x = d3 .scaleLinear() .domain([0, data.length]) .range([pad.left, width - pad.right]); const y = d3 .scaleLinear() .domain(yDomainRound) .range([height - pad.bottom, pad.top]); const y0 = scaleLine.save || y; if (scaleLine.save === null) { scaleLine.save = y; } const makeLayer = (name) => dataGroup(chart, `g.${name}-layer`, (d) => [d]).attr( "class", `${name}-layer` ); const yAxisValues = []; for (let n = 0; n <= 300; n += 25) { yAxisValues.push(n); } const dur0 = animate ? 500 : 0; const delay = animate ? 750 : 0; const dur1 = animate ? 750 : 0; const yAxisLayer = makeLayer("y-axis"); dataGroup(yAxisLayer, "text", yAxisValues) .attr("x", x(x.domain()[1]) + 8) .attr("y", (d) => y0(d) + 2) .transition() .delay(delay) .duration(dur1) .attr("y", (d) => y(d) + 2) .text((d) => d) .style("opacity", (d) => yAxisOpacity(d, yDomainRound)); const xAxisYears = [...new Set(data.map((d) => d.date.split("-")[0]))]; const xAxisValues = xAxisYears.map((year, i, arr) => { const start = data.findIndex((month) => month.date.split("-")[0] === year); const end = arr[i + 1] ? data.findIndex((d) => d.date.split("-")[0] === arr[i + 1]) : data.length; return { year: year, interval: [start, end], }; }); const xAxisLayer = makeLayer("x-axis"); dataGroup(xAxisLayer, "rect", xAxisValues) .attr("x", (d) => x(d.interval[0])) .attr("width", (d) => x(d.interval[1]) - x(d.interval[0])) .attr("y", y(y.domain[1])) .attr("height", "100%") .style("fill", (d, i) => (i % 2 === 1 ? "#f3f3f3" : "#fff")); dataGroup(xAxisLayer, "text", xAxisValues) .attr("x", (d) => x((d.interval[1] + d.interval[0]) / 2)) .attr("y", y(y.domain()[0]) - 5) .text((d) => d.year) .style("opacity", (d) => (d.interval[1] - d.interval[0] > 3 ? 1 : 0)); dataGroup(xAxisLayer, "line", (d) => [d]) .attr("x1", x(0)) .attr("x2", x(x.domain()[1] - 1) + x(1) - x(0)) .attr("y1", y(100)) .attr("y2", y(100)); const area = d3 .area() .x((d, i) => x(i)) .y0((d) => y(100)); // .y1((d) => y(d.value)); const line = d3 .line() .x((d, i) => x(i + 0.5)) .y((d) => y(d.value)); const lineLayer = makeLayer("line"); dataGroup(lineLayer, "path.area", (d) => [d]) .transition() .duration(dur0); // .attr("d", area); dataGroup(lineLayer, "path.line", (d) => [d]) .transition() .duration(dur0) .attr("d", line); const labelLayer = makeLayer("label"); const labelGroup = dataGroup(labelLayer, "g", (d) => [d]); dataGroup(labelGroup, "circle", (d) => d) .classed("open", (d, i) => i === 0) .transition() .duration(dur0) .attr("cx", (d, i) => x(i + 0.5)) .attr("cy", (d) => y(d.value)) .attr("r", (d, i) => (i === 0 ? 3 : 4)) .style("opacity", (d, i, arr) => i === -1 || i === arr.length - 1 ? 1 : 0 ); }; const info = (container, data) => { function getCurrentDate(data) { const months = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const [year, month, day] = data[data.length - 1].date.split("-"); const monthWord = months[parseInt(month, 10) - 1]; return `${monthWord} ${year}`; } d3.select(".info-date").text(getCurrentDate(data)); const values = data.map((d) => d.value); const length = values.length; const getChange = (year1, year2) => (100 * (values[length - year1] - values[length - year2])) / values[length - year2]; const formatted = values .map((d, i) => { return { yearChange: i, value: getChange(1, i + 1), }; }) .filter((d) => d.yearChange > 0); const group = dataGroup(container, "div.info", formatted); dataGroup(group, "div.label", (d) => [d]).text((d) => { return `${d.yearChange}-year change:`; }); dataGroup(group, "div.number", (d) => [d]).text((d) => { const string = d.value.toLocaleString(undefined, { maximumFractionDigits: 1 }) + "%"; const symbol = d.value >= 0 ? "+" : ""; return `${symbol}${string}`; }); }; async function updateChart(raw, animate) { const domain = await fetchDomain(); d3.select(".inflation-app").classed("data-unavailable", raw.length === 0); if (raw.length === 0) { return; } const infoData = raw.filter( (d, i) => i === raw.length - 1 || i === raw.length - 13 || i === raw.length - 25 ); const barData = raw.filter((d, i) => i >= raw.length - 24); const lineData = raw.filter((d, i) => i >= raw.length - 36); info(d3.select(".info-container"), infoData); const barContainer = d3.select(".bar-chart.chart-container"); barChart(barContainer, barData, animate); const lineContainer = d3.select(".line-chart.chart-container"); lineChart(lineContainer, lineData, animate); return; } const cache = (body, data) => { const { item, geo } = body; const geoObj = dataCache.geography.find((d) => String(d.id) === geo); const cachedData = geoObj.items[item]; if (cachedData === undefined) { geoObj.items[item] = data; } };