Nectar-Topics / src /Bourdieu.jsx
Charles De Dampierre
first push
beea437
raw
history blame
17.6 kB
import * as d3 from "d3";
import * as d3Contour from "d3-contour";
import { Backdrop, CircularProgress, Box, Button } from "@mui/material";
import Typography from '@mui/material/Typography';
import RepeatIcon from '@mui/icons-material/Repeat';
import React, { useEffect, useRef, useState, useContext } from "react";
import TextContainer, { topicsSizeFraction } from "./TextContainer";
import { TopicsContext } from "./UploadFileContext";
import QueryView from "./QueryView";
import HelpIcon from '@mui/icons-material/Help';
import { HtmlTooltip } from "./Map";
const bunkaDocs = "bunka_bourdieu_docs.json";
const bunkaTopics = "bunka_bourdieu_topics.json";
const bunkaQuery = "bunka_bourdieu_query.json";
const { REACT_APP_API_ENDPOINT } = process.env;
function Bourdieu() {
const [selectedDocument, setSelectedDocument] = useState(null);
const [mapLoading, setMapLoading] = useState(false);
const [topicsCentroids, setTopicsCentroids] = useState([])
const { bourdieuData: apiData, isLoading: isFileProcessing } = useContext(TopicsContext);
const svgRef = useRef(null);
const scatterPlotContainerRef = useRef(null);
// Set the SVG height to match your map's desired height
const svgHeight = window.innerHeight - document.getElementById("top-banner").clientHeight - 50;
const svgWidth = window.innerWidth * 0.70; // Set the svg container height to match the layout
const createScatterPlot = (docsData, topicsData, queryData) => {
const margin = {
top: 20,
right: 20,
bottom: 50,
left: 50,
};
const plotWidth = svgWidth;
const plotHeight = svgHeight;
d3.select(svgRef.current).selectAll("*").remove();
const svg = d3
.select(svgRef.current)
.attr("width", "100%")
.attr("height", svgHeight);
/**
* SVG canvas group on which transforms apply.
*/
const g = svg.append("g").classed("canvas", true);
/**
* Setup Zoom.
*/
const zoom = d3.zoom()
.scaleExtent([1, 3])
.translateExtent([[0,0], [plotWidth, plotHeight]])
.on("zoom", function ({ transform }) {
g.attr(
"transform",
`translate(${transform.x ?? 0}, ${transform.y ?? 0}) scale(${transform.k ?? 1})`
);
// props.setTransform?.({
// x: transform.x,
// y: transform.y,
// k: transform.k
// })
});
/**
* Initial zoom.
*/
svg.call(zoom);
// const defaultTransform = { k: 1 };
// const initialTransform = defaultTransform?.k != null
// ? new ZoomTransform(
// defaultTransform.k ?? 1,
// defaultTransform.x ?? 0,
// defaultTransform.y ?? 0
// )
// : d3.zoomIdentity;
// svg.call(zoom.transform, initialTransform);
// Axes
const dimensionX = { idLeft: queryData.x_left_words[0], idRight: queryData.x_right_words[0] };
const dimensionY = { idLeft: queryData.y_bottom_words[0], idRight: queryData.y_top_words[0] };
const xMin = d3.min(docsData, (d) => d.x);
const xMax = d3.max(docsData, (d) => d.x);
const yMin = d3.min(docsData, (d) => d.y);
const yMax = d3.max(docsData, (d) => d.y);
const maxDomainValue = Math.max(xMax, -xMin, yMax, -yMin);
var xScale = d3.scaleLinear()
.domain([-maxDomainValue, maxDomainValue])
.range([ 0, plotWidth ]);
var yScale = d3.scaleLinear()
.domain([-maxDomainValue, maxDomainValue])
.range([ plotHeight, 0 ]);
const axes = d3.create("svg:g").classed("axes", true);
svg
.append('defs')
.append('marker')
.attr('id', 'arrowhead-right')
.attr('refX', 5)
.attr('refY', 5)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.append('path')
.attr('d', 'M 0 0 L 5 5 L 0 10')
.attr('stroke', 'grey')
.attr('stroke-width', 1)
.attr('fill', 'none');
svg
.append('defs')
.append('marker')
.attr('id', 'arrowhead-left')
.attr('refX', 0)
.attr('refY', 5)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.append('path')
.attr('d', 'M 5 0 L 0 5 L 5 10')
.attr('stroke', 'grey')
.attr('stroke-width', 1)
.attr('fill', 'none');
svg
.append('defs')
.append('marker')
.attr('id', 'arrowhead-top')
.attr('refX', 5)
.attr('refY', 0)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.append('path')
.attr('d', 'M 0 5 L 5 0 L 10 5')
.attr('stroke', 'grey')
.attr('stroke-width', 1)
.attr('fill', 'none');
svg
.append('defs')
.append('marker')
.attr('id', 'arrowhead-bottom')
.attr('refX', 5)
.attr('refY', 5)
.attr('markerWidth', 10)
.attr('markerHeight', 10)
.append('path')
.attr('d', 'M 0 0 L 5 5 L 10 0')
.attr('stroke', 'grey')
.attr('stroke-width', 1)
.attr('fill', 'none');
// X axis
axes.append("g")
.attr("transform", `translate(0,${plotHeight / 2})`)
.call(
d3.axisBottom(xScale)
.tickSizeInner(0)
.tickSizeOuter(0)
.tickPadding(10)
)
.attr("class", "axis xAxis")
.datum({ dimension: dimensionX })
.select('path.domain')
.attr("marker-start", "url(#arrowhead-left)")
.attr("marker-end", "url(#arrowhead-right)");
// Y axis
axes.append("g")
.attr("transform", `translate(${plotWidth / 2},0)`)
.call(
d3.axisRight(yScale)
.tickSizeInner(0)
.tickSizeOuter(0)
.tickPadding(10)
)
.attr("class", "axis yAxis")
.datum({ dimension: dimensionY })
.select('path.domain')
.attr("marker-end", "url(#arrowhead-top)")
.attr("marker-start", "url(#arrowhead-bottom)");
// Style the tick texts
axes.selectAll(".tick text")
.style("fill", "blue") // Color of the text
.style("font-weight", "bold");
// Show only first and last ticks
axes.selectAll(".xAxis .tick text")
.style('text-anchor', "middle")
.attr('transform', (d, i, nodes) => (i === 0 || i === nodes.length - 1) ? "rotate(-90)" : "")
.attr("visibility", (d, i, nodes) => (i === 0 || i === nodes.length - 1) ? "visible" : "hidden");
axes.selectAll(".yAxis .tick text")
.style('text-anchor', "start")
.attr("visibility", (d, i, nodes) => (i === 0 || i === nodes.length - 1) ? "visible" : "hidden");
axes.selectAll(".xAxis .tick text")
.text((d, i, nodes) => {
if (i === 0) {
return dimensionX.idLeft; // Custom text for the first tick
} else if (i === nodes.length - 1) {
return dimensionX.idRight; // Custom text for the last tick
}
return d; // Default text for all other ticks
});
axes.selectAll(".yAxis .tick text")
.text((d, i, nodes) => {
if (i === 0) {
return dimensionY.idLeft; // Custom text for the first tick
} else if (i === nodes.length - 1) {
return dimensionY.idRight;; // Custom text for the last tick
}
return d; // Default text for all other ticks
});
/**
* Draw Bourdieu map contents
*/
const contourData = d3Contour
.contourDensity()
.x((d) => xScale(-d.x))
.y((d) => yScale(d.y))
.size([plotWidth, plotHeight])
.bandwidth(30)(docsData);
const contourLineColor = "rgb(94, 163, 252)";
g
.selectAll("path.contour")
.data(contourData)
.enter()
.append("path")
.attr("class", "contour")
.attr("d", d3.geoPath())
.style("fill", "none")
.style("stroke", contourLineColor)
.style("stroke-width", 1);
const centroids = topicsData.filter((d) => d.x_centroid && d.y_centroid);
setTopicsCentroids(centroids);
g
.selectAll("circle.topic-centroid")
.data(centroids)
.enter()
.append("circle")
.attr("class", "topic-centroid")
.attr("cx", (d) => xScale(-d.x_centroid))
.attr("cy", (d) => yScale(d.y_centroid))
.attr("r", 8)
.style("fill", "red")
.style("stroke", "black")
.style("stroke-width", 2)
.on("click", (event, d) => {
setSelectedDocument(d);
});
g
.selectAll("text.topic-label")
.data(centroids)
.enter()
.append("text")
.attr("class", "topic-label")
.attr("x", (d) => xScale(-d.x_centroid))
.attr("y", (d) => yScale(d.y_centroid) - 12)
.text((d) => d.name)
.style("text-anchor", "middle");
const convexHullData = topicsData.filter((d) => d.convex_hull);
for (const d of convexHullData) {
const hull = d.convex_hull;
if (hull) {
const hullPoints = hull.x_coordinates.map((x, i) => [xScale(-x), yScale(hull.y_coordinates[i])]);
g
.append("path")
.datum(d3.polygonHull(hullPoints))
.attr("class", "convex-hull-polygon")
.attr("d", (dAttr) => `M${dAttr.join("L")}Z`)
.style("fill", "none")
.style("stroke", "rgba(255, 255, 255, 0.5)")
.style("stroke-width", 2);
}
}
const xGreaterThanZeroAndYGreaterThanZero = docsData.filter((d) => d.x > 0 && d.y > 0).length;
const xLessThanZeroAndYGreaterThanZero = docsData.filter((d) => d.x < 0 && d.y > 0).length;
const xGreaterThanZeroAndYLessThanZero = docsData.filter((d) => d.x > 0 && d.y < 0).length;
const xLessThanZeroAndYLessThanZero = docsData.filter((d) => d.x < 0 && d.y < 0).length;
// Calculate the total number of documents
const totalDocuments = docsData.length;
// Calculate the percentages
const percentageXGreaterThanZeroAndYGreaterThanZero = (xGreaterThanZeroAndYGreaterThanZero / totalDocuments) * 100;
const percentageXLessThanZeroAndYGreaterThanZero = (xLessThanZeroAndYGreaterThanZero / totalDocuments) * 100;
const percentageXGreaterThanZeroAndYLessThanZero = (xGreaterThanZeroAndYLessThanZero / totalDocuments) * 100;
const percentageXLessThanZeroAndYLessThanZero = (xLessThanZeroAndYLessThanZero / totalDocuments) * 100;
// Add labels to display percentages in the squares
// const squareSize = 300; // Adjust this based on your map's layout
// const labelOffsetX = 10; // Adjust these offsets as needed
// const labelOffsetY = 20;
// Calculate the maximum X and Y coordinates
// Calculate the midpoints for the squares
const xMid = -d3.max(docsData, (d) => d.x) / 2;
const yMid = d3.max(docsData, (d) => d.y) / 2;
// Labels for X > 0 and Y > 0 square
g
.append("text")
.attr("x", xScale(xMid))
.attr("y", yScale(yMid))
.text(`${percentageXGreaterThanZeroAndYGreaterThanZero.toFixed(0)}%`) // Remove the prefix
.style("text-anchor", "middle")
.style("fill", "dark") // Change the text color to blue
.style("font-size", "100px") // Adjust the font size
.style("opacity", 0.1); // Adjust the opacity (0.7 means slightly transparent)
// Labels for X < 0 and Y > 0 square
g
.append("text")
.attr("x", xScale(-xMid))
.attr("y", yScale(yMid))
.text(`${percentageXLessThanZeroAndYGreaterThanZero.toFixed(0)}%`) // Remove the prefix
.style("text-anchor", "middle")
.style("fill", "dark") // Change the text color to light blue
.style("font-size", "100px") // Adjust the font size
.style("opacity", 0.1); // Adjust the opacity (0.05 means slightly transparent)
// Labels for X > 0 and Y < 0 square
g
.append("text")
.attr("x", xScale(xMid))
.attr("y", yScale(-yMid))
.text(`${percentageXGreaterThanZeroAndYLessThanZero.toFixed(0)}%`) // Remove the prefix
.style("text-anchor", "middle")
.style("fill", "dark") // Change the text color to light blue
.style("font-size", "100px") // Adjust the font size
.style("opacity", 0.1); // Adjust the opacity (0.05 means slightly transparent)
// Labels for X > 0 and Y < 0 square
g
.append("text")
.attr("x", xScale(-xMid))
.attr("y", yScale(-yMid))
.text(`${percentageXLessThanZeroAndYLessThanZero.toFixed(0)}%`) // Remove the prefix
.style("text-anchor", "middle")
.style("fill", "dark") // Change the text color to light blue
.style("font-size", "100px") // Adjust the font size
.style("opacity", 0.1); // Adjust the opacity (0.05 means slightly transparent)
const topicsPolygons = g
.selectAll("polygon.topic-polygon")
.data(centroids)
.enter()
.append("polygon")
.attr("class", "topic-polygon")
.attr("points", (d) => {
const hull = d.convex_hull;
if (hull) {
const hullPoints = hull.x_coordinates.map((x, i) => [xScale(-x), yScale(hull.y_coordinates[i])]);
return hullPoints.map((point) => point.join(",")).join(" ");
}
})
.style("fill", "transparent")
.style("stroke", "transparent")
.style("stroke-width", 2);
let currentlyClickedPolygon = null;
/**
* Render Axes
*/
g.append(() => axes.node())
topicsPolygons.on("click", (event, d) => {
// Reset the fill color of the previously clicked polygon to transparent light grey
if (currentlyClickedPolygon !== null) {
currentlyClickedPolygon.style("fill", "transparent");
currentlyClickedPolygon.style("stroke", "transparent");
}
// Set the fill color of the clicked polygon to transparent light grey and add a red border
const clickedPolygon = d3.select(event.target);
clickedPolygon.style("fill", "rgba(200, 200, 200, 0.4)");
clickedPolygon.style("stroke", "red");
currentlyClickedPolygon = clickedPolygon;
if (d.top_doc_content) {
// Render the TextContainer component with topic details
setSelectedDocument(d);
}
});
};
useEffect(() => {
if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) {
setMapLoading(true);
// Fetch the JSON data locally
fetch(`/${bunkaDocs}`)
.then((response) => response.json())
.then((docsData) => {
// Fetch the local topics data and merge it with the existing data
fetch(`/${bunkaTopics}`)
.then((response) => response.json())
.then((topicsData) => {
fetch(`/${bunkaQuery}`)
.then((response) => response.json())
.then((queryData) => {
// Call the function to create the scatter plot after data is loaded
createScatterPlot(docsData, topicsData, queryData);
})
.catch((error) => {
console.error("Error fetching bourdieu query data:", error);
})
.finally(() => {
setMapLoading(false);
});
})
.catch((error) => {
console.error("Error fetching topics data:", error);
})
.finally(() => {
setMapLoading(false);
});
})
.catch((error) => {
console.error("Error fetching documents data:", error);
})
.finally(() => {
setMapLoading(false);
});
} else {
// Call the function to create the scatter plot with the data provided by TopicsContext
createScatterPlot(apiData.docs, apiData.topics, apiData.query);
}
}, [apiData]);
const mapDescription = "This map is generated by projecting documents onto a two-dimensional space, where the axes are defined by the user. Two documents are positioned close to each other if they share a similar relationship with the axes. The documents themselves are not directly represented on the map; rather, they are aggregated into clusters. Each cluster represents a group of documents that exhibit similarities.";
return (
<div className="json-display">
{(isFileProcessing || mapLoading) ? (
<Backdrop open={isFileProcessing || mapLoading} style={{ zIndex: 9999 }}>
<CircularProgress color="primary" />
</Backdrop>
) : (
<div className="scatter-plot-and-text-container">
<div className="scatter-plot-container" ref={scatterPlotContainerRef}>
<HtmlTooltip
title={
<React.Fragment>
<Typography color="inherit">{mapDescription}</Typography>
</React.Fragment>
}
followCursor
>
<HelpIcon style={{
position: "relative",
top: 10,
left: 40,
border: "none"
}}/>
</HtmlTooltip>
<svg ref={svgRef} />
</div>
<div className="text-container">
{selectedDocument !== null ? (
<>
<Box sx={{ marginBottom: "1em" }}>
<Button sx={{ width: "100%" }} component="label" variant="outlined" startIcon={<RepeatIcon />} onClick={() => setSelectedDocument(null)}>
Upload another CSV file
</Button>
</Box>
<TextContainer topicName={selectedDocument.name} topicSizeFraction={topicsSizeFraction(topicsCentroids, selectedDocument.size)} content={selectedDocument.top_doc_content} />
</>
) : <QueryView />}
</div>
</div>
)}
</div>
);
}
export default Bourdieu;