treemap/main.js
2023-08-12 18:29:54 +10:00

281 lines
8.1 KiB
JavaScript

/// <reference path="./d3.js" />
const DATA_SOURCES = {
movies: {
title: "Movie Sales",
description: "Top 100 Highest Grossing Movies Grouped By Genre",
source: "movie-sales-data.json",
},
videogames: {
title: "Video Game Sales",
description: "Top 100 Most Sold Video Games Grouped by Platform",
source: "video-game-sales-data.json",
},
kickstarter: {
title: "Kickstarter Pledges",
description:
"Top 100 Most Pledged Kickstarter Campaigns Grouped By Category",
source: "kickstarter-funding-data.json",
},
};
const ASPECT_RATIO = window.innerWidth / window.innerHeight;
const CANVAS_WIDTH = window.innerWidth ?? 1000;
const CANVAS_HEIGHT = window.innerHeight ?? 500;
const CHART_WIDTH = CANVAS_WIDTH * 0.8;
const CHART_HEIGHT = CANVAS_HEIGHT * 0.8;
const BASE_SIZE =
CHART_WIDTH > CHART_HEIGHT ? CHART_HEIGHT * 0.03 : CHART_WIDTH * 0.03;
const CHART_X_OFFSET = (CANVAS_WIDTH - CHART_WIDTH) / 2;
const CHART_Y_OFFSET = BASE_SIZE * 4;
const STROKE_WIDTH = BASE_SIZE * 0.03;
const STROKE_COLOR = "black";
const FONT_FAMILY = "quicksand, Sans-serif";
const binaryTree = (parent, x0, x1, y0, y1) => {
// source: https://github.com/d3/d3-hierarchy/blob/main/src/treemap/binary.js
const sums = [0];
for (let i = 0; i < parent.children.length; i++) {
sums.push(sums[i] + Number(parent.children[i].value));
}
const partition = (first, last, x0, x1, y0, y1) => {
const value = sums[last + 1] - sums[first];
if (first >= last || value <= 0) {
for (let i = first; i <= last; i++) {
parent.children[i].x0 = x0;
parent.children[i].x1 = x1;
parent.children[i].y0 = y0;
parent.children[i].y1 = y1;
}
return;
}
const offsetValue = sums[first];
const midValue = (sums[last + 1] + offsetValue) / 2;
let i = first,
j = last,
mid;
while (i < j) {
mid = Math.trunc((i + j) / 2);
if (sums[mid + 1] > midValue) {
j = mid;
} else {
i = mid + 1;
}
}
if (mid === undefined) {
throw new Error("mid cannot be undefined");
}
const leftValue = sums[mid + 1] - offsetValue;
const rightValue = value - leftValue;
if (x1 - x0 > y1 - y0) {
const xMid = (x0 * rightValue + x1 * leftValue) / value;
partition(first, mid, x0, xMid, y0, y1);
partition(mid + 1, last, xMid, x1, y0, y1);
} else {
const yMid = (y0 * rightValue + y1 * leftValue) / value;
partition(first, mid, x0, x1, y0, yMid);
partition(mid + 1, last, x0, x1, yMid, y1);
}
};
partition(0, parent.children.length - 1, x0, x1, y0, y1);
};
const fetchData = async () => {
const params = new URLSearchParams(window.location.search);
const id = params.get("data");
let data_source = DATA_SOURCES.kickstarter;
if (id && DATA_SOURCES[id]) {
data_source = DATA_SOURCES[id];
}
return {
dataset: await (await fetch(data_source.source)).json(),
metadata: data_source,
};
};
// max is exclusive
const randomInt = (min, max) => Math.trunc(Math.random() * (max - min) + min);
fetchData()
.then(({ dataset, metadata }) => {
const menuHtml = Object.entries(DATA_SOURCES).map(
([k, v]) => `<a href="/?data=${k}">${v.title}</a>`
);
d3.select("body")
.append("header")
.style("top", BASE_SIZE * 0.33 + "px")
.style("font-size", BASE_SIZE * 1.25 + "px")
.append("p")
.html(menuHtml.join(" | "));
console.log(metadata);
console.log(dataset);
for (let child of dataset.children) {
// source: https://gist.github.com/bendc/76c48ce53299e6078a76
child.color = `hsl(${randomInt(0, 361)}, ${randomInt(
50,
101
)}%, ${randomInt(25, 56)}%)`;
child.value = child.children.reduce(
(sum, childObj) => sum + Number(childObj.value),
0
);
}
console.log(
window.innerWidth,
window.innerHeight,
CHART_X_OFFSET,
CHART_WIDTH,
CHART_Y_OFFSET,
CHART_HEIGHT
);
binaryTree(dataset, 0, CHART_WIDTH, 0, CHART_HEIGHT);
const legendColumns = 4;
const canvas = d3
.select("body")
.append("svg")
.attr("width", CANVAS_WIDTH)
// Additional height based on number of legend items
.attr(
"height",
CANVAS_HEIGHT +
Math.trunc(dataset.children.length / legendColumns) * BASE_SIZE * 1.5
);
canvas
.append("text")
.attr("id", "title")
.attr("x", "50%")
.attr("text-anchor", "middle")
.attr("y", BASE_SIZE * 1.7)
.attr("font-size", BASE_SIZE * 1.7)
.text(metadata.title);
canvas
.append("text")
.attr("id", "description")
.attr("x", "50%")
.attr("text-anchor", "middle")
.attr("y", BASE_SIZE * 3)
.attr("font-size", BASE_SIZE)
.text(metadata.description);
const mouseover = (e) => {
const obj = JSON.parse(JSON.stringify(e.target.dataset));
tooltip
.html(`${obj.name}, ${obj.category}, ${obj.value}`)
.style("background-color", "#000000")
.style("opacity", 1)
.attr("data-value", obj.value);
};
const mousemove = (e) => {
tooltip.style("left", e.pageX + "px").style("top", e.pageY + "px");
};
const mouseleave = (d) => {
tooltip.style("opacity", 0);
};
const treeMap = canvas
.append("g")
.attr("id", "treemap")
.attr("transform", `translate(${CHART_X_OFFSET}, ${CHART_Y_OFFSET})`);
let i = 0;
for (let child of dataset.children) {
binaryTree(child, child.x0, child.x1, child.y0, child.y1);
treeMap
.selectAll(null)
.data(child.children)
.enter()
.append("rect")
.attr("class", "tile")
.attr("data-name", (d) => d.name)
.attr("data-category", (d) => d.category)
.attr("data-value", (d) => d.value)
.attr("width", (d) => d.x1 - d.x0)
.attr("height", (d) => d.y1 - d.y0)
.attr("x", (d) => d.x0)
.attr("y", (d) => d.y0)
.attr("fill", (d) => child.color)
.style("stroke-width", STROKE_WIDTH)
.style("stroke", "#ffffff")
.on("mouseover", mouseover)
.on("mousemove", mousemove)
.on("mouseleave", mouseleave);
i++;
}
canvas.selectAll("path").style("stroke-width", STROKE_WIDTH);
canvas.selectAll("line").style("stroke-width", STROKE_WIDTH);
let tooltip = d3
.select("body")
.append("div")
.attr("id", "tooltip")
.attr("class", "tooltip")
.attr("data-value", "")
.style("opacity", 0)
.style("position", "absolute")
.style("padding", `${CHART_WIDTH / 200}px ${CHART_WIDTH / 100}px`)
.style("margin", `0 ${CHART_WIDTH / 50}px`)
.style("border-radius", `${CHART_WIDTH / 100}px`)
.style("text-align", "center")
.style("font-size", BASE_SIZE + "px");
const legend = canvas
.append("g")
.attr("id", "legend")
.attr(
"transform",
`translate(${CHART_X_OFFSET}, ${CHART_Y_OFFSET * 1.2 + CHART_HEIGHT})`
);
legend
.selectAll(null)
.data(dataset.children)
.enter()
.append("rect")
.attr("class", "legend-item")
.attr("x", (d, i) => (i % legendColumns) * (CHART_WIDTH / legendColumns))
.attr("y", (d, i) => Math.trunc(i / legendColumns) * BASE_SIZE * 1.5)
.attr("width", BASE_SIZE)
.attr("height", BASE_SIZE)
.style("fill", (d) => d.color);
legend
.selectAll(null)
.data(dataset.children)
.enter()
.append("text")
.attr(
"x",
(d, i) =>
(i % legendColumns) * (CHART_WIDTH / legendColumns) + BASE_SIZE * 1.5
)
.attr(
"y",
(d, i) =>
BASE_SIZE * 0.8 + Math.trunc(i / legendColumns) * BASE_SIZE * 1.5
)
.attr("font-size", BASE_SIZE)
.text((d) => d.name);
d3.select("body")
.append("footer")
.style("bottom", BASE_SIZE * 0.33 + "px")
.style("font-size", BASE_SIZE * 0.8 + "px")
.append("p")
.append("a")
.attr("href", "https://radii.dev/freecodecamp-data-visualization/treemap")
.text("</> Source Code & License");
})
.catch((e) => console.error("Error occurred!", e));