Spaces:
Running
Running
import { Backdrop, CircularProgress, Button, Box } from "@mui/material"; | |
import HelpIcon from '@mui/icons-material/Help'; | |
import Tooltip, { tooltipClasses } from '@mui/material/Tooltip'; | |
import Typography from '@mui/material/Typography'; | |
import RepeatIcon from '@mui/icons-material/Repeat'; | |
import { styled } from '@mui/material/styles'; | |
import * as d3 from "d3"; | |
import * as d3Contour from "d3-contour"; | |
import React, { useContext, useEffect, useRef, useState } from "react"; | |
import TextContainer, { topicsSizeFraction } from "./TextContainer"; | |
import { TopicsContext } from "./UploadFileContext"; | |
import QueryView from "./QueryView"; | |
const bunkaDocs = "bunka_docs.json"; | |
const bunkaTopics = "bunka_topics.json"; | |
const { REACT_APP_API_ENDPOINT } = process.env; | |
/** | |
* Generic tooltip | |
*/ | |
export const HtmlTooltip = styled(({ className, ...props }) => ( | |
<Tooltip {...props} classes={{ popper: className }} /> | |
))(({ theme }) => ({ | |
[`& .${tooltipClasses.popper}`]: { | |
backgroundColor: '#fff', | |
color: 'rgba(0, 0, 0, 0.87)', | |
maxWidth: 220, | |
fontSize: theme.typography.pxToRem(12), | |
}, | |
})); | |
function MapView() { | |
const [selectedDocument, setSelectedDocument] = useState(null); | |
const [mapLoading, setMapLoading] = useState(false); | |
const [topicsCentroids, setTopicsCentroids] = useState([]) | |
const { data: apiData, isLoading: isFileProcessing } = useContext(TopicsContext); | |
const svgRef = useRef(null); | |
const scatterPlotContainerRef = useRef(null); | |
const createScatterPlot = (data) => { | |
const margin = { | |
top: 20, | |
right: 20, | |
bottom: 50, | |
left: 50, | |
}; | |
const plotWidth = window.innerWidth * 0.6; | |
const plotHeight = window.innerHeight - document.getElementById("top-banner").clientHeight - 50; // Adjust the height as desired | |
d3.select(svgRef.current).selectAll("*").remove(); | |
const svg = d3 | |
.select(svgRef.current) | |
.attr("width", "100%") | |
.attr("height", plotHeight); | |
/** | |
* SVG canvas group on which transforms apply. | |
*/ | |
const g = svg.append("g") | |
.classed("canvas", true) | |
.attr("transform", `translate(${margin.left}, ${margin.top})`); | |
/** | |
* TODO Zoom. | |
*/ | |
const zoom = d3.zoom() | |
.scaleExtent([1, 3]) | |
.translateExtent([[0, 0], [1000, 1000]]) | |
.on("zoom", function ({ transform }) { | |
g.attr( | |
"transform", | |
`translate(${transform.x ?? 0}, ${transform.y ?? 0}) scale(${transform.k ?? 1})` | |
) | |
//positionLabels() | |
// props.setTransform?.({ | |
// x: transform.x, | |
// y: transform.y, | |
// k: transform.k | |
// }) | |
}); | |
svg.call(zoom); | |
/** | |
* Initial 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); | |
const xMin = d3.min(data, (d) => d.x); | |
const xMax = d3.max(data, (d) => d.x); | |
const yMin = d3.min(data, (d) => d.y); | |
const yMax = d3.max(data, (d) => d.y); | |
const xScale = d3 | |
.scaleLinear() | |
.domain([xMin, xMax]) // Use the full range of your data | |
.range([0, plotWidth]); | |
const yScale = d3 | |
.scaleLinear() | |
.domain([yMin, yMax]) // Use the full range of your data | |
.range([plotHeight, 0]); | |
// Add contours | |
const contourData = d3Contour | |
.contourDensity() | |
.x((d) => xScale(d.x)) | |
.y((d) => yScale(d.y)) | |
.size([plotWidth, plotHeight]) | |
.bandwidth(30)( | |
// Adjust the bandwidth as needed | |
data, | |
); | |
// Define a custom color for the contour lines | |
const contourLineColor = "rgb(94, 163, 252)"; | |
// Append the contour path to the SVG with a custom color | |
g | |
.selectAll("path.contour") | |
.data(contourData) | |
.enter() | |
.append("path") | |
.attr("class", "contour") | |
.attr("d", d3.geoPath()) | |
.style("fill", "none") | |
.style("stroke", contourLineColor) // Set the contour line color to the custom color | |
.style("stroke-width", 1); | |
/* | |
const circles = svg.selectAll('circle') | |
.data(data) | |
.enter() | |
.append('circle') | |
.attr('cx', (d) => xScale(d.x)) | |
.attr('cy', (d) => yScale(d.y)) | |
.attr('r', 5) | |
.style('fill', 'lightblue') | |
.on('click', (event, d) => { | |
// Show the content and topic name of the clicked point in the text container | |
setSelectedDocument(d); | |
// Change the color to pink on click | |
circles.style('fill', (pointData) => (pointData === d) ? 'pink' : 'lightblue'); | |
}); | |
*/ | |
const centroids = data.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) // Adjust the radius as needed | |
.style("fill", "red") // Adjust the fill color as needed | |
.style("stroke", "black") | |
.style("stroke-width", 2) | |
.on("click", (event, d) => { | |
// Show the content and topic name of the clicked topic centroid in the text container | |
setSelectedDocument(d); | |
}); | |
// Add text labels for topic names | |
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) // Adjust the vertical position | |
.text((d) => d.name) // Use the 'name' property for topic names | |
.style("text-anchor", "middle"); // Center-align the text | |
const convexHullData = data.filter((d) => d.convex_hull); | |
for (const d of convexHullData) { | |
const hull = d.convex_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", (d1) => `M${d1.join("L")}Z`) | |
.style("fill", "none") | |
.style("stroke", "rgba(255, 255, 255, 0.5)") // White with 50% transparency | |
.style("stroke-width", 2); | |
} | |
// Add polygons for topics. Delete if no clicking on polygons | |
const topicsPolygons = g | |
.selectAll("polygon.topic-polygon") | |
.data(centroids) | |
.enter() | |
.append("polygon") | |
.attr("class", "topic-polygon") | |
.attr("points", (d) => { | |
const hull = d.convex_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); // Adjust the border width as needed | |
let currentlyClickedPolygon = null; | |
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; | |
// Display the topic name and content from top_doc_content with a scroll system | |
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((localData) => { | |
// Fetch the local topics data and merge it with the existing data | |
fetch(`/${bunkaTopics}`) | |
.then((response) => response.json()) | |
.then((topicsData) => { | |
// Merge the topics data with the existing data | |
const mergedData = localData.concat(topicsData); | |
// Call the function to create the scatter plot after data is loaded | |
createScatterPlot(mergedData); | |
}) | |
.catch((error) => { | |
console.error("Error fetching topics data:", error); | |
}) | |
.finally(() => { | |
setMapLoading(false); | |
}); | |
}) | |
.catch((error) => { | |
console.error("Error fetching JSON data:", error); | |
}) | |
.finally(() => { | |
setMapLoading(false); | |
}); | |
} else { | |
// Call the function to create the scatter plot with the data provided by TopicsContext | |
createScatterPlot(apiData.docs.concat(apiData.topics)); | |
} | |
// After the data is loaded, set the default topic | |
if (apiData && apiData.topics && apiData.topics.length > 0) { | |
// Set the default topic to the first topic in the list | |
setSelectedDocument(apiData.topics[0]); | |
} | |
}, [apiData]); | |
const mapDescription = "This map is created by embedding documents in a two-dimensional space. Two documents are close to each other if they share similar semantic features, such as vocabulary, expressions, and language. The documents are not directly represented on the map; instead, they are grouped into clusters. A cluster is a set of documents that share similarities. A cluster is automatically described by a few words that best describes it."; | |
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 MapView; | |