diff --git a/index.html b/index.html index 52e3ab34f8e3d474e42e12d003f48c644646f947..555b1394946de0c08c4f3a4a70d18fbdf08ae749 100644 --- a/index.html +++ b/index.html @@ -31,10 +31,26 @@ <div id="time-series-container"></div> </div> +<div class="visualization-container"> + <div id="loading">Loading...</div> + <div class="dropdown-container button"> + <select id="state-dropdown" class="button-inner"> + <option value="">All States</option> + </select> + </div> + <div id="parallel-coordinates-container"></div> +</div> + +<div class="visualization-container"> + <div id="bubble-container"></div> +</div> + <div id="bottom-spacer"></div> -<script src="tree.js"></script> -<script src="time-series.js"></script> +<script src="modules/tree.js" type="module"></script> +<script src="modules/time-series.js" type="module"></script> +<script src="modules/parallel.js" type="module"></script> +<script src="modules/bubble.js" type="module"></script> </body> </html> \ No newline at end of file diff --git a/modules/bubble.js b/modules/bubble.js new file mode 100644 index 0000000000000000000000000000000000000000..ed711a803d72f6721b4d6976550b48bdae9729b2 --- /dev/null +++ b/modules/bubble.js @@ -0,0 +1,130 @@ +import {width, height, margin, colors} from "./constants.js"; +import {legend} from "./country-legend.js"; + + +// Load the combined dataset +Promise.all([ + d3.csv("schengen_data.csv"), // Schengen data + d3.csv("world-data-2023.csv") // Country data +]).then(([schengenData1, countryData]) => { + // Map country data by country name for quick lookup + const countryMap = {}; + countryData.forEach(d => { + countryMap[d.Country] = { + gdp: +d.GDP.replace(/[$,]/g, ''), // Parse GDP + population: +d.Population.replace(/[$,]/g, '') // Parse population + }; + }); + + // Merge GDP and population data into Schengen dataset + schengenData1.forEach(d => { + const countryInfo = countryMap[d["Schengen State"]]; + d.GDP = countryInfo ? countryInfo.gdp : 0; + d.Population = countryInfo ? countryInfo.population : 0; + }); + + const schengenData = Array.from(d3.rollup( + schengenData1, + v => ({ + GDP: v[0].GDP, + Population: v[0].Population, + "Visas Issued": d3.sum(v, d => d["Total uniform visas issued (including MEV)"]), + "Visas Applied for": d3.sum(v, d => d["Total ATVs and uniform visas applied for"]) + }), + d => d["Schengen State"] + )).map(([key, value]) => ({ + "Schengen State": key, + GDP: value.GDP, + Population: value.Population, + "Visas Issued": value["Visas Issued"], + "Visas Applied for": value["Visas Applied for"] + })); + + console.log(schengenData); + + + const xScale = d3.scaleLog() + .domain([d3.min(schengenData, d => d.GDP), d3.max(schengenData, d => d.GDP)]) + .range([0, width-margin.left-margin.right-130]); + + const yScale = d3.scaleLog() + .domain([d3.min(schengenData, d => d.Population), d3.max(schengenData, d => d.Population)]) + .range([height-margin.top-margin.bottom-110, 0]); + + const sizeScale = d3.scaleSqrt() + .domain([0, d3.max(schengenData, d => d["Visas Issued"] || 1)]) + .range([8, 60]); + + const opacityScale = d3.scaleSqrt() + .domain([0, d3.max(schengenData, d => d["Visas Applied for"] || 1)]) + .range([0.1, 1]); + + //const colorScale = d3.scaleOrdinal(d3.schemeCategory10); + const colorScale = d3 + .scaleOrdinal() + .domain(schengenData) + .range(colors) + + + // Append SVG container + const svg = d3.select("#bubble-container") + .append("svg") + .attr("width", width) + .attr("height", height) + .append("g") + .attr("transform", `translate(${margin.left+80},${margin.top+50})`); + + // Add X axis + svg.append("g") + .attr("transform", `translate(0,${height-margin.top-margin.bottom-110})`) + .call(d3.axisBottom(xScale).ticks(10, ".1s")) + .append("text") + .attr("class", "axis-label") + .attr("transform", `translate(${(width-margin.left-margin.right-150)/2},50)`) + .text("GDP") + .style("fill", "black"); + + // Add Y axis + svg.append("g") + .call(d3.axisLeft(yScale)) + .append("text") + .attr("class", "axis-label") + //.attr("x", -height / 2) + .attr("y", -50) + .attr("transform", `rotate(-90) translate(${-(height-margin.top-margin.bottom-120)/2},-10)`) + .text("Population") + .style("fill", "black"); + + // Draw bubbles + const tooltip = d3.select("body") + .append("div") + .attr("class", "tooltip") + .style("visibility", "hidden"); + + svg.selectAll("circle") + .data(schengenData) + .enter() + .append("circle") + .attr("cx", d => xScale(d.GDP || 1e10)) + .attr("cy", d => yScale(d.Population || 1)) + .attr("r", d => Math.round(sizeScale(d["Visas Issued"] || 1))) + .style("opacity", d => opacityScale(d["Visas Applied for"] || 1)) + .attr("fill", d => colorScale(d["Schengen State"])) + .on("mouseover", (event, d) => { + tooltip.style("visibility", "visible") + .html(` + <strong>${d["Schengen State"]}</strong><br> + GDP: $${d.GDP.toLocaleString()}<br> + Population: ${d.Population.toLocaleString()}<br> + Total Visas Issued: ${d["Visas Issued"].toLocaleString()} + `) + .style("left", `${event.pageX + 10}px`) + .style("top", `${event.pageY - 20}px`); + }) + .on("mouseout", () => { + tooltip.style("visibility", "hidden"); + }); + + const allStates = new Set(schengenData.map(d => d["Schengen State"])); + legend(svg, allStates, colorScale) +}); diff --git a/modules/constants.js b/modules/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..7ca34a6e7a7db214d392abc85be465cf8a14d761 --- /dev/null +++ b/modules/constants.js @@ -0,0 +1,6 @@ +//const container = Array.from(document.querySelector('.visualization-container'))[0]; + +export const margin = { top: 20, right: 30, bottom: 50, left: 50 }; +export const width = window.innerWidth - margin.left - margin.right; +export const height = window.innerHeight - margin.top - margin.bottom; +export const colors = ["#a6cee3","#1f78b4","#b2df8a","#33a02c","#fb9a99","#e31a1c","#fdbf6f","#ff7f00","#cab2d6","#6a3d9a","#ffff99","#b15928","#8dd3c7","#ffffb3","#bebada","#fb8072","#80b1d3","#fdb462","#b3de69","#fccde5","#d9d9d9","#bc80bd","#ccebc5","#ffed6f"] \ No newline at end of file diff --git a/modules/country-legend.js b/modules/country-legend.js new file mode 100644 index 0000000000000000000000000000000000000000..df205421c844d023faa969b0227d58a052b2c3b2 --- /dev/null +++ b/modules/country-legend.js @@ -0,0 +1,53 @@ +import {width, height} from "./constants.js"; + + +export function legend(svg, allStates, color) { + const legend = svg + .selectAll(".legend-group") + .data([null]) + .enter() + .append("g") + .attr("class", "legend-group") + .attr("transform", `translate(${width - 200}, 0)`) + .selectAll(".legend-item") + .data(Array.from(allStates)) + .enter() + .append("g") + .attr("class", "legend-item") + .attr("transform", (d, i) => `translate(0, ${i * 20})`) + .each(function(d, i) { + const group = d3.select(this); + + group.append("rect") + .attr("width", 10) + .attr("height", 10) + .attr("fill", color(d)) + .on("mouseover", () => { + svg.selectAll(".line").classed("highlighted", line => line === d); + }) + .on("mouseout", () => { + svg.selectAll(".line").classed("highlighted", false); + }); + + group.append("text") + .attr("x", 20) + .attr("y", 10) + .attr("text-anchor", "start") + .style("text-transform", "capitalize") + .text(d); + }); + + const legendBBox = svg.select(".legend-group").node().getBBox() + + svg.select(".legend-group") + .insert("rect", ":first-child") + .attr("width", legendBBox.width+20) + .attr("height", legendBBox.height+20) + .attr("transform", `translate(-10, -10)`) + .attr("fill", "white") + .attr("fill-opacity", 0.7) + .attr("stroke", "#c6c6c6") + .attr("stroke-width", 2) + .attr("rx", 5) + .attr("ry", 5) +} \ No newline at end of file diff --git a/modules/parallel.js b/modules/parallel.js new file mode 100644 index 0000000000000000000000000000000000000000..66a71919f0a2a0f1df29251c53d66c0477e2a655 --- /dev/null +++ b/modules/parallel.js @@ -0,0 +1,139 @@ +import {width, height, margin} from "./constants.js"; + +// Append SVG +const svg = d3.select("#parallel-coordinates-container") + .append("svg") + .attr("width", width) + .attr("height", height) + .append("g") + .attr("transform", `translate(${margin.left+margin.right-10},${margin.top+margin.bottom})`); + +const loadingIndicator = d3.select("#loading"); + +// Show loading indicator +function showLoading() { + loadingIndicator.style("display", "block"); +} + +// Hide loading indicator +function hideLoading() { + loadingIndicator.style("display", "none"); +} + +// Load data +showLoading(); +d3.csv("schengen_data.csv").then((data) => { + // Parse data + data.forEach(d => { + for (const key in d) { + if (!isNaN(+d[key])) { + d[key] = +d[key]; + } + } + }); + + // Define dimensions and scales + const dimensions = [ + "Airport transit visas (ATVs) applied for", + "ATVs issued (including multiple)", + "Not issued rate for ATVs", + "Uniform visas applied for", + "Total uniform visas issued (including MEV)", + "Not issued rate for uniform visas" + ]; + + const yScales = {}; + dimensions.forEach(dim => { + yScales[dim] = d3.scaleLinear() + .domain(d3.extent(data, d => d[dim])) + .range([height-margin.top-margin.bottom-30, 0]); + }); + + const xScale = d3.scalePoint() + .domain(dimensions) + .range([0, width-margin.left-margin.right]); + + // Line generator + const lineGenerator = d3.line() + .defined(d => !isNaN(d[1])) + .x(d => xScale(d[0])) + .y(d => yScales[d[0]](d[1])); + + // Draw axes with labels + dimensions.forEach(dim => { + svg.append("g") + .attr("transform", `translate(${xScale(dim)},0)`) + .call(d3.axisLeft(yScales[dim])); + + // Add axis label + svg.append("text") + .attr("transform", `translate(${xScale(dim)},${height-margin.left-margin.right})`) + .style("text-anchor", "middle") + .style("font-size", "12px") + .text(dim); + }); + + + // Group data by state + const groupedData = d3.group(data, d => d["Schengen State"]); + const states = Array.from(groupedData.keys()); + + // Populate dropdown + const dropdown = d3.select("#state-dropdown"); + dropdown.selectAll("option") + .data(states) + .enter() + .append("option") + .attr("value", d => d) + .text(d => d); + + // Update plot function + function updatePlot(state) { + const filteredData = state ? groupedData.get(state) : data; + + if (!filteredData) { + console.warn(`No data available for state: ${state}`); + svg.selectAll(".data-line").remove(); + return; + } + + const paths = svg.selectAll(".data-line") + .data(filteredData); + + // Enter and update paths + paths.enter() + .append("path") + .attr("class", "data-line") + .merge(paths) + .attr("d", d => lineGenerator(dimensions.map(dim => [dim, d[dim]]))) + .style("stroke", "steelblue") + .style("opacity", 0.1) + .attr("fill", "none"); + + + // Remove old paths + paths.exit().remove(); + } + + // Initialize with full dataset + updatePlot(null); + + // Dropdown change event + dropdown.on("change", function() { + const selectedState = d3.select(this).property("value"); + updatePlot(selectedState); + }); + + // Add reset button functionality + d3.select("#reset-button") + .on("click", () => { + dropdown.property("value", ""); + updatePlot(null); + }); + + // Hide loading indicator after rendering + hideLoading(); +}).catch(err => { + console.error("Error loading data: ", err); + hideLoading(); +}); \ No newline at end of file diff --git a/time-series.js b/modules/time-series.js similarity index 61% rename from time-series.js rename to modules/time-series.js index aebd213114f13ecf97fe40d3a2d386305d9e3552..fc0106081ce51b3b2dfb32822b6435b9a54719bb 100644 --- a/time-series.js +++ b/modules/time-series.js @@ -1,4 +1,6 @@ -//const margin = { top: 20, right: 30, bottom: 50, left: 50 }; +import {legend} from "./country-legend.js"; +import {width, height, margin, colors} from "./constants.js"; + function resize() { const container = document.getElementById('time-series-container'); @@ -10,7 +12,10 @@ function resize() { d3.csv("schengen_data.csv").then((data) => { const { width, height } = resize(); const allStates = new Set(data.map(d => d["Schengen State"])); - const color = d3.scaleOrdinal(d3.schemeCategory10).domain(allStates); + const color = d3 + .scaleOrdinal() + .domain(data) + .range(colors) const svg = d3.select("#time-series-container") .append("svg") @@ -79,55 +84,7 @@ d3.csv("schengen_data.csv").then((data) => { }) } - const legend = svg - .selectAll(".legend-group") - .data([null]) - .enter() - .append("g") - .attr("class", "legend-group") - .attr("transform", `translate(${width - 150}, 0)`) - .selectAll(".legend-item") - .data(Array.from(allStates)) - .enter() - .append("g") - .attr("class", "legend-item") - .attr("transform", (d, i) => `translate(0, ${i * 20})`) - .each(function(d, i) { - const group = d3.select(this); - - group.append("rect") - .attr("width", 10) - .attr("height", 10) - .attr("fill", color(d)) - .on("mouseover", () => { - svg.selectAll(".line").classed("highlighted", line => line === d); - }) - .on("mouseout", () => { - svg.selectAll(".line").classed("highlighted", false); - }); - - group.append("text") - .attr("x", 20) - .attr("y", 10) - .attr("text-anchor", "start") - .style("text-transform", "capitalize") - .text(d); - }); - - const legendBBox = svg.select(".legend-group").node().getBBox() - console.log(legendBBox) - - svg.select(".legend-group") - .insert("rect", ":first-child") - .attr("width", legendBBox.width+20) - .attr("height", legendBBox.height+20) - .attr("transform", `translate(-10, -10)`) - .attr("fill", "white") - .attr("fill-opacity", 0.7) - .attr("stroke", "#c6c6c6") - .attr("stroke-width", 2) - .attr("rx", 5) - .attr("ry", 5) + legend(svg, allStates, color) } document.getElementById("visa-type-selector").addEventListener("change", (event) => { diff --git a/tree.js b/modules/tree.js similarity index 94% rename from tree.js rename to modules/tree.js index e39a8b50af20dfa3b399a6e01e094fc333acd068..6fecec6922d98210e0c43aa00c423569800b4f01 100644 --- a/tree.js +++ b/modules/tree.js @@ -1,7 +1,5 @@ -const margin = { top: 20, right: 30, bottom: 50, left: 50 }; -const container = document.getElementById('tree-container'); -const width = container.offsetWidth-15//- margin.left - margin.right; -const height = container.offsetHeight-15//- margin.top - margin.bottom; +import {width, height, margin} from "./constants.js"; + let currentData; let hierarchyStack = ["Schengen States"]; diff --git a/style.css b/style.css index 5e6f19a5b4e06e8728f505e38c86abe3476b2bed..58bb909669d1d47232f30fa951f94b9bfd49949c 100644 --- a/style.css +++ b/style.css @@ -10,6 +10,19 @@ html, body, #tree-container, #time-series-container { padding: 0; } +#loading { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 1.5em; + background: rgba(255, 255, 255, 0.8); + padding: 10px; + border: 1px solid #ccc; + border-radius: 5px; + display: none; +} + #bottom-spacer { height: 1px; }