diff --git a/src/style.css b/src/style.css index 8b137891791fe96927ad78e64b0aad7bded08bdc..2b1b6e378fa59d1100032672b878c6682cdd2720 100644 --- a/src/style.css +++ b/src/style.css @@ -1 +1,24 @@ +body { + margin: 0; + width: 100%; + height: 100%; +} +.main_view { + float: left; + background-color: aliceblue; + width: 100%; + margin: 0; +} +svg { + float: left; + background-color: white; + border: 2px solid black; + border-radius: 5px; + margin: 1%; +} +.filters-container{ + float: left; + margin-left: 3%; + margin-top:2% +} \ No newline at end of file diff --git a/src/visualizacion.html b/src/visualizacion.html index 2bf892f5cfce7d82df378396dcd34d69fedb7df3..df4a10985ccc89bf8160b98a37fd522c98d94ea9 100644 --- a/src/visualizacion.html +++ b/src/visualizacion.html @@ -7,19 +7,37 @@ <link href="style.css" rel="stylesheet"> </head> <body> - <div> + <div class="main_view"> + + <div class="container-leyenda"> + <!-- En esta seccion la leyenda --> + <!-- Interaccion con D3 para modificar HTML aqui --> + </div> + <div class="filters-container"> <label for="color_scheme"> - Coloreado por: + <h3>Coloreado por:</h3> </label> <select id="color_scheme" onchange="changeSelection(event)"> <option value="platform">Plataforma</option> <option value="developer">Desarrollador</option> </select> + <br> + <label> + <h3>Búsqueda:</h3> + <input type="search" id="search"> + </label> + </div> - <div class="container" id="div-leyenda"> - <!-- En esta seccion la leyenda --> - <!-- Interaccion con D3 para modificar HTML aqui --> + <div class="filters-container"> + <h3>Filtros</h3> + <div id="filters"> + <!-- Aquà se generarán los checkboxes dinámicamente --> + </div> </div> - <script src="visualizacion.js"></script> + + </div> + + <script src="visualizacion.js"></script> + </body> </html> \ No newline at end of file diff --git a/src/visualizacion.js b/src/visualizacion.js index 719f154dfeb90f7268c06b894622e83f95c20dea..f6c74a3be06cb639e8a59784aae7350df7b0436f 100644 --- a/src/visualizacion.js +++ b/src/visualizacion.js @@ -50,60 +50,82 @@ const colores = { } - - - - let selection = "platform"; +let scatter; let elegibleData; let xScale; +let radiusScale; let yScale; let xAxis; let yAxis; - -let radiusScale; +let transform; let min_x = 0; let min_y = 0; let max_x = 10; let max_y = 10; -const width = 1000; -const height = 500; +const width = 800; +const height = 400; const margin_x = 62; const margin_y = 60; const right_margin = 300; -const rectAparitionTime = 300; +const rectAparitionTime = 150; const radiusTransition = 300; +const maxBubbleCount = 50; + + +const svg = d3.select('.main_view').insert('svg', '.filters-container') + .attr("width", width + 2 * margin_x).attr("height", height + 2 * margin_y); -const svg = d3.select('body').append('svg') - .attr("width", width + 2 * margin_x).attr("height", height + 2 * margin_y) - .append('g').attr("transform", `translate(${margin_x},${margin_y})`); +svg.append('defs').append('clipPath') + .attr("id", "content-clip") + .append('rect') + .attr('width', width - right_margin) + .attr('height', height) + .attr('x', margin_x) + .attr('y', margin_y); +const contentGroup = svg.append('g') + .attr('clip-path', 'url(#content-clip)'); +const translate_group = svg.append('g').attr("transform", `translate(${margin_x},${margin_y})`); function createAxis() { xAxis = d3.axisBottom(xScale).ticks(10); yAxis = d3.axisLeft(yScale).ticks(10); - svg.append("g") + translate_group.append("g") + .attr("class", "title") + .attr("transform", "translate(10, -20)") + .append('text') + .style("font-family", "Georgia, serif") + .style("font-size", "26px") + .style("font-weight", "bold") + .text("CrÃticas en los Videojuegos: Usuarios y Expertos") + + + translate_group.append("g") .attr("class", "xAxis") .attr("transform", `translate(0,${height})`) .call(xAxis); - svg.append('text') + + translate_group.append('text') .attr('class', 'xAxisLabel') - .attr('x', width / 5) + .attr('x', width / 10) .attr('y', height + 40) .style('font-weight', 'bold') .style('font-size', '20px') .style('font-family', 'Georgia, serif') .text("Calificación dada por usuarios") - svg.append("g") + + translate_group.append("g") .attr("class", "yAxis") .call(yAxis); - svg.append('text') + + translate_group.append('text') .attr('class', 'yAxisLabel') .attr('x', -height*4/5) .attr('y', -30) @@ -112,137 +134,251 @@ function createAxis() { .style('font-family', 'Georgia, serif') .attr('transform', 'rotate(-90)') .text("Calificación dada por expertos") + + contentGroup.selectAll("line.horizontal-grid") + .data(yScale.ticks(10)) + .enter() + .append("line") + .attr("class", "horizontal-grid") + .attr("x1", 0) + .attr("x2", width) + .attr("y1", d => yScale(d) + margin_y) + .attr("y2", d => yScale(d) + margin_y) + .attr("stroke", "gray") + .attr("stroke-width", 0.5); + + contentGroup.selectAll("line.vertical-grid") + .data(xScale.ticks(10)) + .enter() + .append("line") + .attr("class", "vertical-grid") + .attr("x1", d => xScale(d) + margin_x) + .attr("x2", d => xScale(d) + margin_x) + .attr("y1", 0) + .attr("y2", height + margin_y) + .attr("stroke", "gray") + .attr("stroke-width", 0.5); + + translate_group.append('defs').append('clipPath') + .attr("id", "clip") + .append('rect') + .attr('width', width) + .attr('height', height) + .attr('x', 0) + .attr('y', 0); + + scatter = contentGroup.append('g'); } d3.csv('files/games-data.csv').then((data) => { data.forEach(elem => { elem["user score"] = +elem["user score"]; - if(!elem["user score"]) elem["critics"] = -14; - elem["score"] = +elem["score"] / 10; + if(!elem["user score"]) elem.critics = -14; + // Datos que no se puedan representar se eliminan del gráfico asà + elem.score = +elem.score / 10; // Los datos estan entre 0 y 100, asi que normalizo a entre 0 y 10 - elem["critics"] = +elem["critics"]; - elem["users"] = +elem["users"]; + elem.critics = +elem.critics; + elem.users = +elem.users; elem.genre = [...new Set(elem.genre.split(','))]; - elem.id = elem.name.replaceAll(/[ :'"()\[\]]*/g, ''); + elem.id = elem.name.replaceAll(/[ :'"()\[\]]*/g, '') + "-" + elem.platform; }); + xScale = d3.scaleLinear().domain([min_x, max_x]).range([0, width-right_margin]); yScale = d3.scaleLinear().domain([min_y, max_y]).range([height, 0]); radiusScale = d3.scaleSqrt().domain([0, d3.max(data, elem => elem["critics"]) || 1]).range([5, 15]); elegibleData = data.sort((a, b) => { return (b["critics"] + b["users"]) - (a["critics"] + a["users"]) - }).slice(0, 50); + }).slice(0, maxBubbleCount); + generateFilters(data); + enableSearch(data); createAxis(); createBubbles(elegibleData); - }); -function handleMouseOver(event, elem) { +function handleMouseOver(event, d) { + transform = d3.zoomTransform(svg.node()); // Obtiene la transformación actual (zoom y traslación) + d3.select(this) .transition() .duration(radiusTransition) .ease(d3.easeQuadInOut) - .attr("r", radiusScale(elem.critics) * 1.4) - const rectGroup = svg.append('g') - .attr('class', 'rects') - - const textGroup = svg.append('g') - .attr('class', 'texts') - - const text = textGroup.append('text') - .attr('id', `text${elem.id}-${elem.platform}`) - .attr('class', 'videogame_title') - .attr('x', () => xScale(elem["user score"]) + 35) - .attr('y', () => yScale(elem["score"]) - 5) - .attr('font-family', 'Georgia, serif') - .attr('font-weight', 'bold') - .attr('font-size', '15px') - .attr('fill', 'black') - .attr('opacity', 0) + .attr("r", radiusScale(d.critics) * 1.4) + + // Calcula las posiciones del cÃrculo transformadas + const cx = transform.applyX(xScale(d["user score"])); + const cy = transform.applyY(yScale(d["score"])); + + // Define la distancia visual fija para el tooltip + const visualOffsetX = 70; // Distancia horizontal fija en pÃxeles + const visualOffsetY = 55; // Distancia vertical fija en pÃxeles + + // Crea el grupo del tooltip + const rects = svg.append("g").attr("class", "rects"); + const texts = svg.append("g").attr("class", "texts"); + + // Añade el texto al tooltip + const text = texts.append("text") + .attr("x", cx + visualOffsetX + 10) // Posiciona el texto con offset + .attr("y", cy + visualOffsetY + 20) + .style("font-size", "15px") + .style("font-family", "Georgia, serif") + .style("fill", "black") + + text.append("tspan") + .text(`${d.name}`) + .style("font-size", "18px") + .style("font-weight", "bold") + .attr("x", cx + visualOffsetX + 10) + .attr("dy", "1.2em") + .style("opacity", 0) .transition() .duration(rectAparitionTime) - .ease(d3.easeQuadInOut) - .attr('opacity', 1) - - const lines = [ - elem.name, - `Generos: ${elem.genre}`, - `Fecha de lanzamiento: ${elem["r-date"]}` - ]; - svg.select(`#text${elem.id}-${elem.platform}`).selectAll('tspan') - .data(lines) - .enter() - .append('tspan') - .attr('x', () => xScale(elem["user score"]) + 35) - .attr('dy', (d, i) => (i === 0 ? 0 : 20)) - .text(d => d); - + .ease(d3.easeQuadOut) + .style("opacity", 1) + + text.append("tspan") + .text(`Géneros: ${d.genre.join(", ")}`) + .attr("x", cx + visualOffsetX + 10) + .attr("dy", "1.2em") + .style("opacity", 0) + .transition() + .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 1) + + text.append("tspan") + .text(`Fecha: ${d["r-date"]}`) + .attr("x", cx + visualOffsetX + 10) + .attr("dy", "1.2em") + .style("opacity", 0) + .transition() + .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 1) + + text.append("tspan") + .text(`Desarrolladores: ${d.developer}`) + .attr("x", cx + visualOffsetX + 10) + .attr("dy", "1.2em") + .style("opacity", 0) + .transition() + .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 1) + + text.append("tspan") + .text(`Consola: ${d.platform}`) + .attr("x", cx + visualOffsetX + 10) + .attr("dy", "1.2em") + .style("opacity", 0) + .transition() + .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 1) + + text.append("tspan") + .text(`Jugadores: ${d.players}`) + .attr("x", cx + visualOffsetX + 10) + .attr("dy", "1.2em") + .style("opacity", 0) + .transition() + .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 1) + // Usa el bounding box del texto para calcular el tamaño del rectángulo const bbox = text.node().getBBox(); - rectGroup.append("rect") - .attr('id', `rect${elem.id}-${elem.platform}`) - .attr("x", bbox.x - 5) + rects.insert("rect") + .attr("x", bbox.x - 10) // Deja un margen alrededor del texto .attr("y", bbox.y - 5) - .attr("width", bbox.width + 10) + .attr("width", bbox.width + 20) .attr("height", bbox.height + 10) - .attr('stroke', 'black') - .attr('stroke-width', 1) - .attr('rx', 4) - .attr('ry', 4) - .attr('opacity', 0) - .attr("fill", () => colores[selection][elem[selection]]) + .attr("fill", () => { + if (colores[selection][d[selection]]){ + return colores[selection][d[selection]]; + } else return "#aaaaaa"; + }) + .attr("rx", 5) + .attr("ry", 5) + .style("opacity", 0) + .transition() + .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 0.9) + +} + +function handleMouseOut(event, d) { + svg.selectAll(".rects") .transition() .duration(rectAparitionTime) + .ease(d3.easeQuadOut) + .style("opacity", 0) + .remove(); + svg.selectAll(".texts").remove(); + + d3.select(this) + .transition() + .duration(radiusTransition/2) .ease(d3.easeQuadInOut) - .attr('opacity', 0.85); + .attr("r", radiusScale(d.critics)) +} - svg.select(`#rect${elem.id}-${elem.platform}`).lower(); +function zoomed(event) { + transform = event.transform; + if (transform.k < 1) { + transform.k = 1.1 + return; + } + const newXScale = transform.rescaleX(xScale); + const newYScale = transform.rescaleY(yScale); + newXScale.domain([Math.max(min_x, newXScale.domain()[0]), Math.min(max_x, newXScale.domain()[1])]); + newYScale.domain([Math.max(min_y, newYScale.domain()[0]), Math.min(max_y, newYScale.domain()[1])]); -} -function handleMouseOut(event, elem) { - d3.select(this) - .transition() - .duration(radiusTransition) - .ease(d3.easeQuadInOut) - .attr('r', d => radiusScale(d.critics)); - d3.select(`#text${elem.id}-${elem.platform}`) - .transition() - .duration(rectAparitionTime/2) - .ease(d3.easeQuadInOut) - .style('opacity', 0) - .remove(); + translate_group.select(".xAxis").call(d3.axisBottom(newXScale)); + translate_group.select(".yAxis").call(d3.axisLeft(newYScale)); - d3.select(`#rect${elem.id}-${elem.platform}`) - .transition() - .duration(rectAparitionTime/2) - .ease(d3.easeQuadInOut) - .style('opacity', 0) - .remove(); + contentGroup.selectAll('.vertical-grid') + .attr('x1', d => newXScale(d) + margin_x) + .attr('x2', d => newXScale(d) + margin_x) + + contentGroup.selectAll('.horizontal-grid') + .attr('y1', d => newYScale(d) + margin_y) + .attr('y2', d => newYScale(d) + margin_y) + + scatter.select('.bubbles').selectAll('circle') + .attr('cx', d => newXScale(d["user score"]) + margin_x) + .attr('cy', d => newYScale(d["score"]) + margin_y) } function createBubbles(data) { - const bubbleGroup = svg.append('g') + const bubbleGroup = scatter.append('g') .attr('class', 'bubbles'); - + const zoom = d3.zoom() + .scaleExtent([1, 10]) + .translateExtent([[0, 0], [width + 2*margin_x, height + 2*margin_y]]) + .on("zoom", zoomed) + .filter((event) => event.type === "mousedown" || event.type === "mousemove" || event.type === "wheel"); bubbleGroup.selectAll("circle") .data(data) .enter() .append("circle") - .attr("cx", d => xScale(d["user score"])) - .attr("cy", d => yScale(d["score"])) + .attr("cx", d => xScale(d["user score"]) + margin_x) + .attr("cy", d => yScale(d["score"]) + margin_y) .attr("r", d => radiusScale(d["critics"])) .attr("fill", d => { if (colores[selection][d[selection]]){ @@ -255,11 +391,12 @@ function createBubbles(data) { .on("mouseover", handleMouseOver) .on("mouseout", handleMouseOut) + svg.call(zoom); } function removeBubbles() { - svg.selectAll('circle').remove() + d3.select(".bubbles").remove(); } function changeSelection (event) { @@ -267,3 +404,73 @@ function changeSelection (event) { selection = event.target.value; createBubbles(elegibleData); } + +function generateFilters(data) { + const filtersDiv = d3.select("#filters"); + filtersDiv.html(""); // Limpia los filtros existentes + + // Obtiene valores únicos para el filtro seleccionado + // Genera un checkbox para cada valor único + const generos = [ + "Fantasy", + "Sports", + "Open-World", + "Action", + "Shooter", + "Arcade", + "2D", + "3D", + "Rhythm", + "Team", + "Roguelike" + ]; + + generos.forEach(value => { + const filter = filtersDiv.append("div").attr("class", "filter"); + + filter.append("input") + .attr("type", "checkbox") + .attr("id", `filter-${value}`) + .attr("value", value) + .on("change", () => applyFilters(data)); // Aplica los filtros al cambiar + + filter.append("label") + .attr("for", `filter-${value}`) + .text(value); + }); +} + +function applyFilters(data) { + let selectedFilters = d3.selectAll("#filters input[type='checkbox']") + .nodes() + .filter(input => input.checked) + .map(input => input.value); + + + // Filtra los datos en base a los filtros seleccionados + elegibleData = data.filter(d => { + if (selectedFilters.length === 0) return true; + for (filter of selectedFilters) { + if (!d.genre.includes(filter)) return false; + } + return true; + }).splice(0, maxBubbleCount); + // Actualiza la visualización con los datos filtrados + removeBubbles(); + createBubbles(elegibleData); +} + +function enableSearch(data){ + d3.select("#search") + .on("input", search) + + function search(event) { + const keyName = event.target.value.toLowerCase(); + removeBubbles(); + elegibleData = data.filter(d => d.name.toLowerCase().includes(keyName)).splice(0, maxBubbleCount); + + createBubbles(elegibleData); + } + +} +