|
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); |
|
|
|
const svgHeight = window.innerHeight - document.getElementById("top-banner").clientHeight - 50; |
|
const svgWidth = window.innerWidth * 0.70; |
|
|
|
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); |
|
|
|
|
|
|
|
|
|
const g = svg.append("g").classed("canvas", true); |
|
|
|
|
|
|
|
|
|
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})` |
|
); |
|
|
|
|
|
|
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
svg.call(zoom); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'); |
|
|
|
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)"); |
|
|
|
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)"); |
|
|
|
axes.selectAll(".tick text") |
|
.style("fill", "blue") |
|
.style("font-weight", "bold"); |
|
|
|
|
|
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; |
|
} else if (i === nodes.length - 1) { |
|
return dimensionX.idRight; |
|
} |
|
return d; |
|
}); |
|
axes.selectAll(".yAxis .tick text") |
|
.text((d, i, nodes) => { |
|
if (i === 0) { |
|
return dimensionY.idLeft; |
|
} else if (i === nodes.length - 1) { |
|
return dimensionY.idRight;; |
|
} |
|
return d; |
|
}); |
|
|
|
|
|
|
|
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; |
|
|
|
|
|
const totalDocuments = docsData.length; |
|
|
|
|
|
const percentageXGreaterThanZeroAndYGreaterThanZero = (xGreaterThanZeroAndYGreaterThanZero / totalDocuments) * 100; |
|
const percentageXLessThanZeroAndYGreaterThanZero = (xLessThanZeroAndYGreaterThanZero / totalDocuments) * 100; |
|
const percentageXGreaterThanZeroAndYLessThanZero = (xGreaterThanZeroAndYLessThanZero / totalDocuments) * 100; |
|
const percentageXLessThanZeroAndYLessThanZero = (xLessThanZeroAndYLessThanZero / totalDocuments) * 100; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const xMid = -d3.max(docsData, (d) => d.x) / 2; |
|
const yMid = d3.max(docsData, (d) => d.y) / 2; |
|
|
|
|
|
g |
|
.append("text") |
|
.attr("x", xScale(xMid)) |
|
.attr("y", yScale(yMid)) |
|
.text(`${percentageXGreaterThanZeroAndYGreaterThanZero.toFixed(0)}%`) |
|
.style("text-anchor", "middle") |
|
.style("fill", "dark") |
|
.style("font-size", "100px") |
|
.style("opacity", 0.1); |
|
|
|
|
|
g |
|
.append("text") |
|
.attr("x", xScale(-xMid)) |
|
.attr("y", yScale(yMid)) |
|
.text(`${percentageXLessThanZeroAndYGreaterThanZero.toFixed(0)}%`) |
|
.style("text-anchor", "middle") |
|
.style("fill", "dark") |
|
.style("font-size", "100px") |
|
.style("opacity", 0.1); |
|
|
|
|
|
g |
|
.append("text") |
|
.attr("x", xScale(xMid)) |
|
.attr("y", yScale(-yMid)) |
|
.text(`${percentageXGreaterThanZeroAndYLessThanZero.toFixed(0)}%`) |
|
.style("text-anchor", "middle") |
|
.style("fill", "dark") |
|
.style("font-size", "100px") |
|
.style("opacity", 0.1); |
|
|
|
|
|
g |
|
.append("text") |
|
.attr("x", xScale(-xMid)) |
|
.attr("y", yScale(-yMid)) |
|
.text(`${percentageXLessThanZeroAndYLessThanZero.toFixed(0)}%`) |
|
.style("text-anchor", "middle") |
|
.style("fill", "dark") |
|
.style("font-size", "100px") |
|
.style("opacity", 0.1); |
|
|
|
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; |
|
|
|
|
|
|
|
|
|
g.append(() => axes.node()) |
|
|
|
topicsPolygons.on("click", (event, d) => { |
|
|
|
if (currentlyClickedPolygon !== null) { |
|
currentlyClickedPolygon.style("fill", "transparent"); |
|
currentlyClickedPolygon.style("stroke", "transparent"); |
|
} |
|
|
|
|
|
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) { |
|
|
|
setSelectedDocument(d); |
|
} |
|
}); |
|
}; |
|
|
|
useEffect(() => { |
|
if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) { |
|
setMapLoading(true); |
|
|
|
fetch(`/${bunkaDocs}`) |
|
.then((response) => response.json()) |
|
.then((docsData) => { |
|
|
|
fetch(`/${bunkaTopics}`) |
|
.then((response) => response.json()) |
|
.then((topicsData) => { |
|
fetch(`/${bunkaQuery}`) |
|
.then((response) => response.json()) |
|
.then((queryData) => { |
|
|
|
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 { |
|
|
|
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; |
|
|