Charles De Dampierre commited on
Commit
beea437
1 Parent(s): 2d2001b

first push

Browse files
.dockerignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .env
2
+ node_modules
.gitattributes copy ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.nvmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ 20
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use an official Node.js runtime as a parent image
2
+ FROM node:20 AS build
3
+
4
+ # Set the working directory in the container
5
+ WORKDIR /app
6
+
7
+ RUN chown node:node /app
8
+
9
+ # Copy package.json and package-lock.json to the working directory
10
+ COPY package*.json ./
11
+
12
+ USER node
13
+ COPY --chown=node:node package.json package-lock.json* ./
14
+ # Install project dependencies
15
+ RUN npm install
16
+
17
+ #RUN mkdir node_modules/.cache && chmod -R 777 node_modules/.cache
18
+
19
+ # Copy the rest of the application code to the working directory
20
+ COPY . .
21
+
22
+ # Build the React app
23
+ RUN npm run build
24
+
25
+ # Expose the application port (optional, adjust as needed)
26
+ EXPOSE 3000
27
+
28
+ # Start the React app
29
+ CMD ["npm", "start"]
Makefile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ docker_run:
2
+ docker run --env REACT_APP_API_ENDPOINT=$$REACT_APP_API_ENDPOINT --restart=always -d -p 8080:80 --name $$FRONT_CONTAINER_NAME $$FRONT_IMAGE_NAME
3
+
4
+ docker_build:
5
+ docker build --build-arg REACT_APP_API_ENDPOINT=$$REACT_APP_API_ENDPOINT -t $$FRONT_IMAGE_NAME .
6
+
7
+ docker_tag:
8
+ docker tag $$FRONT_IMAGE_NAME $$CONTAINER_REGISTRY_URL/$$FRONT_IMAGE_NAME:latest
9
+
10
+ docker_push:
11
+ docker push $$CONTAINER_REGISTRY_URL/$$FRONT_IMAGE_NAME:latest
12
+
13
+ #http://localhost:8080:80
14
+
15
+ registry__login:
16
+ docker login $$CONTAINER_REGISTRY_URL -u nologin --password $$SCW_SECRET_KEY
README.md CHANGED
@@ -1,11 +1,61 @@
1
  ---
2
- title: French PD Books Exploration
3
- emoji: 👀
4
- colorFrom: blue
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
- license: apache-2.0
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Test
3
+ emoji: 🏆
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
+ app_port: 3000
9
  ---
10
 
11
+
12
+ # BunkaTopics web app
13
+
14
+ This project was made to show the results of [BunkaTopics](https://github.com/charlesdedampierre/BunkaTopics).
15
+ Bunkatopics is a Topic Modeling Visualisation, Frame Analysis & Retrieval Augmented Generation (RAG) package that leverages LLMs
16
+ It is built around React and D3.js and made to work with the `api` in the same repository
17
+
18
+ ## Usage
19
+
20
+ - Please copy `env.model` to `.env` before starting the server
21
+ - `make docker_build`
22
+ - `make docker_run`
23
+
24
+ ## Developping
25
+
26
+ In the project directory, you can run a development server:
27
+
28
+ ### `npm start`
29
+
30
+ Runs the app in the development mode.\
31
+ Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
32
+
33
+ The page will reload when you make changes.\
34
+ You may also see any lint errors in the console.
35
+
36
+ ### `npm test`
37
+
38
+ Launches the test runner in the interactive watch mode.\
39
+ See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
40
+
41
+ ### `npm run build`
42
+
43
+ Builds the app for production to the `build` folder.\
44
+ It correctly bundles React in production mode and optimizes the build for the best performance.
45
+
46
+ The build is minified and the filenames include the hashes.\
47
+ Your app is ready to be deployed!
48
+
49
+ See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
50
+
51
+ ### Remove react-scripts helper : `npm run eject`
52
+
53
+ **Note: this is a one-way operation. Once you `eject`, you can't go back!**
54
+
55
+ If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
56
+
57
+ Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
58
+
59
+ You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
60
+
61
+ You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
biome.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9c5fa2ccdb7f58147dbdfae6df3bae62aad7e1d7de68db374164c3daa8d3bb2b
3
+ size 299
env.model ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6ebc18576089a9552b722b63dbe2977d95dbc49d479e7a40c62e845298ee97b3
3
+ size 80
nginx-configuration.conf ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Expires map
2
+ map $sent_http_content_type $expires {
3
+ default off;
4
+ text/html epoch;
5
+ text/css max;
6
+ application/json max;
7
+ application/javascript max;
8
+ ~image/ max;
9
+ }
10
+ server {
11
+ listen 80;
12
+ location / {
13
+ root /usr/share/nginx/html;
14
+ index index.html index.htm;
15
+ try_files $uri $uri/ /index.html =404;
16
+ }
17
+ expires $expires;
18
+ gzip on;
19
+ }
package-lock.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9600d2ccc1c6e3e589c7fb0f3b2215f4f8343f1f691a579d6b41a35f5ce31c14
3
+ size 748728
package.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:afa3399b70fb324486fc2414cde05ed8cfc59dff7168ae60005c7f1e86128d42
3
+ size 1754
public/android-chrome-192x192.png ADDED
public/android-chrome-512x512.png ADDED
public/apple-touch-icon.png ADDED
public/bunka_docs.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a56bce617815c07afe917ac1ef9093d3f2bd6ee7d4cc67b87ae9bfb256f52c2a
3
+ size 14459054
public/bunka_logo.png ADDED
public/bunka_topics.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d7e48546f68861161184df22d4745b865df372cebd3f6ae2743015fcfcd94778
3
+ size 1683542
public/favicon-16x16.png ADDED
public/favicon-32x32.png ADDED
public/favicon.ico ADDED
public/index.html ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="theme-color" content="#000000" />
8
+ <meta
9
+ name="description"
10
+ content="Web site created using create-react-app"
11
+ />
12
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/apple-touch-icon.png" />
13
+ <!--
14
+ manifest.json provides metadata used when your web app is installed on a
15
+ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
16
+ -->
17
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
18
+ <!--
19
+ Notice the use of %PUBLIC_URL% in the tags above.
20
+ It will be replaced with the URL of the `public` folder during the build.
21
+ Only files inside the `public` folder can be referenced from the HTML.
22
+
23
+ Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
24
+ work correctly both with client-side routing and a non-root public URL.
25
+ Learn how to configure a non-root public URL by running `npm run build`.
26
+ -->
27
+ <title>Bunka Topics Beta</title>
28
+ </head>
29
+ <body>
30
+ <noscript>You need to enable JavaScript to run this app.</noscript>
31
+ <div id="root"></div>
32
+ <!--
33
+ This HTML file is a template.
34
+ If you open it directly in the browser, you will see an empty page.
35
+
36
+ You can add webfonts, meta tags, or analytics to this file.
37
+ The build step will place the bundled scripts into the <body> tag.
38
+
39
+ To begin the development, run `npm start` or `yarn start`.
40
+ To create a production bundle, use `npm run build` or `yarn build`.
41
+ -->
42
+ </body>
43
+ </html>
public/linkedin_logo.png ADDED
public/manifest.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:155a92d563cb3eb3f919f78690a64df620baa3412712f322791fb19f29d1500e
3
+ size 532
public/robots.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
3
+ Disallow:
public/site.webmanifest ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "",
3
+ "short_name": "",
4
+ "icons": [
5
+ {
6
+ "src": "/android-chrome-192x192.png",
7
+ "sizes": "192x192",
8
+ "type": "image/png"
9
+ },
10
+ {
11
+ "src": "/android-chrome-512x512.png",
12
+ "sizes": "512x512",
13
+ "type": "image/png"
14
+ }
15
+ ],
16
+ "theme_color": "#ffffff",
17
+ "background_color": "#ffffff",
18
+ "display": "standalone"
19
+ }
src/App.css ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .App {
2
+ text-align: center;
3
+ }
4
+
5
+ .App-logo {
6
+ height: 40vmin;
7
+ pointer-events: none;
8
+ }
9
+
10
+ @media (prefers-reduced-motion: no-preference) {
11
+ .App-logo {
12
+ animation: App-logo-spin infinite 20s linear;
13
+ }
14
+ }
15
+
16
+ .App-header {
17
+ background-color: #282c34;
18
+ min-height: 100vh;
19
+ display: flex;
20
+ flex-direction: column;
21
+ align-items: center;
22
+ justify-content: center;
23
+ font-size: calc(10px + 2vmin);
24
+ color: white;
25
+ }
26
+
27
+ .App-link {
28
+ color: #61dafb;
29
+ }
30
+
31
+ @keyframes App-logo-spin {
32
+ from {
33
+ transform: rotate(0deg);
34
+ }
35
+
36
+ to {
37
+ transform: rotate(360deg);
38
+ }
39
+ }
src/App.jsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import Bourdieu from "./Bourdieu";
3
+ import DocsView from "./DocsView";
4
+ import DropdownMenu from "./DropdownMenu";
5
+ import MapView from "./Map";
6
+ import TreemapView from "./TreemapView";
7
+ import { TopicsProvider } from "./UploadFileContext";
8
+
9
+ function App() {
10
+ const [selectedView, setSelectedView] = useState("map"); // Default to 'map'
11
+
12
+ return (
13
+ <div className="App">
14
+ <div className="main-display">
15
+ <div className="top-right" id="top-banner">
16
+ <a href="https://www.linkedin.com/company/bunka-ai/" target="_blank" rel="noopener noreferrer" className="linkedin-icon">
17
+ <img src="/linkedin_logo.png" alt="LinkedIn" />
18
+ </a>
19
+ <img src="/bunka_logo.png" alt="Bunka Logo" className="bunka-logo" />
20
+ <DropdownMenu onSelectView={setSelectedView} selectedView={selectedView} />
21
+ </div>
22
+ <TopicsProvider onSelectView={setSelectedView} selectedView={selectedView}>
23
+ {selectedView === "map" ? (
24
+ <MapView />
25
+ ) : selectedView === "docs" ? (
26
+ <DocsView />
27
+ ) : selectedView === "treemap" ? (
28
+ /**
29
+ * Hidden view for the moment
30
+ */
31
+ <TreemapView />
32
+ ) : selectedView === "bourdieu" ? (
33
+ <Bourdieu />
34
+ ) : (
35
+ <MapView />
36
+ )}
37
+ </TopicsProvider>
38
+ </div>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export default App;
src/App.test.jsx ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import { render, screen } from "@testing-library/react";
2
+ import App from "./App";
3
+
4
+ test("renders learn react link", () => {
5
+ render(<App />);
6
+ const linkElement = screen.getByText(/learn react/i);
7
+ expect(linkElement).toBeInTheDocument();
8
+ });
src/Bourdieu.jsx ADDED
@@ -0,0 +1,486 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as d3 from "d3";
2
+ import * as d3Contour from "d3-contour";
3
+ import { Backdrop, CircularProgress, Box, Button } from "@mui/material";
4
+ import Typography from '@mui/material/Typography';
5
+ import RepeatIcon from '@mui/icons-material/Repeat';
6
+ import React, { useEffect, useRef, useState, useContext } from "react";
7
+ import TextContainer, { topicsSizeFraction } from "./TextContainer";
8
+ import { TopicsContext } from "./UploadFileContext";
9
+ import QueryView from "./QueryView";
10
+ import HelpIcon from '@mui/icons-material/Help';
11
+ import { HtmlTooltip } from "./Map";
12
+
13
+ const bunkaDocs = "bunka_bourdieu_docs.json";
14
+ const bunkaTopics = "bunka_bourdieu_topics.json";
15
+ const bunkaQuery = "bunka_bourdieu_query.json";
16
+ const { REACT_APP_API_ENDPOINT } = process.env;
17
+
18
+ function Bourdieu() {
19
+ const [selectedDocument, setSelectedDocument] = useState(null);
20
+ const [mapLoading, setMapLoading] = useState(false);
21
+ const [topicsCentroids, setTopicsCentroids] = useState([])
22
+
23
+ const { bourdieuData: apiData, isLoading: isFileProcessing } = useContext(TopicsContext);
24
+
25
+ const svgRef = useRef(null);
26
+ const scatterPlotContainerRef = useRef(null);
27
+ // Set the SVG height to match your map's desired height
28
+ const svgHeight = window.innerHeight - document.getElementById("top-banner").clientHeight - 50;
29
+ const svgWidth = window.innerWidth * 0.70; // Set the svg container height to match the layout
30
+
31
+ const createScatterPlot = (docsData, topicsData, queryData) => {
32
+ const margin = {
33
+ top: 20,
34
+ right: 20,
35
+ bottom: 50,
36
+ left: 50,
37
+ };
38
+ const plotWidth = svgWidth;
39
+ const plotHeight = svgHeight;
40
+
41
+ d3.select(svgRef.current).selectAll("*").remove();
42
+
43
+ const svg = d3
44
+ .select(svgRef.current)
45
+ .attr("width", "100%")
46
+ .attr("height", svgHeight);
47
+
48
+ /**
49
+ * SVG canvas group on which transforms apply.
50
+ */
51
+ const g = svg.append("g").classed("canvas", true);
52
+
53
+ /**
54
+ * Setup Zoom.
55
+ */
56
+ const zoom = d3.zoom()
57
+ .scaleExtent([1, 3])
58
+ .translateExtent([[0,0], [plotWidth, plotHeight]])
59
+ .on("zoom", function ({ transform }) {
60
+ g.attr(
61
+ "transform",
62
+ `translate(${transform.x ?? 0}, ${transform.y ?? 0}) scale(${transform.k ?? 1})`
63
+ );
64
+ // props.setTransform?.({
65
+ // x: transform.x,
66
+ // y: transform.y,
67
+ // k: transform.k
68
+ // })
69
+ });
70
+
71
+ /**
72
+ * Initial zoom.
73
+ */
74
+ svg.call(zoom);
75
+ // const defaultTransform = { k: 1 };
76
+ // const initialTransform = defaultTransform?.k != null
77
+ // ? new ZoomTransform(
78
+ // defaultTransform.k ?? 1,
79
+ // defaultTransform.x ?? 0,
80
+ // defaultTransform.y ?? 0
81
+ // )
82
+ // : d3.zoomIdentity;
83
+ // svg.call(zoom.transform, initialTransform);
84
+
85
+ // Axes
86
+ const dimensionX = { idLeft: queryData.x_left_words[0], idRight: queryData.x_right_words[0] };
87
+ const dimensionY = { idLeft: queryData.y_bottom_words[0], idRight: queryData.y_top_words[0] };
88
+
89
+ const xMin = d3.min(docsData, (d) => d.x);
90
+ const xMax = d3.max(docsData, (d) => d.x);
91
+ const yMin = d3.min(docsData, (d) => d.y);
92
+ const yMax = d3.max(docsData, (d) => d.y);
93
+ const maxDomainValue = Math.max(xMax, -xMin, yMax, -yMin);
94
+
95
+ var xScale = d3.scaleLinear()
96
+ .domain([-maxDomainValue, maxDomainValue])
97
+ .range([ 0, plotWidth ]);
98
+ var yScale = d3.scaleLinear()
99
+ .domain([-maxDomainValue, maxDomainValue])
100
+ .range([ plotHeight, 0 ]);
101
+
102
+ const axes = d3.create("svg:g").classed("axes", true);
103
+ svg
104
+ .append('defs')
105
+ .append('marker')
106
+ .attr('id', 'arrowhead-right')
107
+ .attr('refX', 5)
108
+ .attr('refY', 5)
109
+ .attr('markerWidth', 10)
110
+ .attr('markerHeight', 10)
111
+ .append('path')
112
+ .attr('d', 'M 0 0 L 5 5 L 0 10')
113
+ .attr('stroke', 'grey')
114
+ .attr('stroke-width', 1)
115
+ .attr('fill', 'none');
116
+ svg
117
+ .append('defs')
118
+ .append('marker')
119
+ .attr('id', 'arrowhead-left')
120
+ .attr('refX', 0)
121
+ .attr('refY', 5)
122
+ .attr('markerWidth', 10)
123
+ .attr('markerHeight', 10)
124
+ .append('path')
125
+ .attr('d', 'M 5 0 L 0 5 L 5 10')
126
+ .attr('stroke', 'grey')
127
+ .attr('stroke-width', 1)
128
+ .attr('fill', 'none');
129
+ svg
130
+ .append('defs')
131
+ .append('marker')
132
+ .attr('id', 'arrowhead-top')
133
+ .attr('refX', 5)
134
+ .attr('refY', 0)
135
+ .attr('markerWidth', 10)
136
+ .attr('markerHeight', 10)
137
+ .append('path')
138
+ .attr('d', 'M 0 5 L 5 0 L 10 5')
139
+ .attr('stroke', 'grey')
140
+ .attr('stroke-width', 1)
141
+ .attr('fill', 'none');
142
+ svg
143
+ .append('defs')
144
+ .append('marker')
145
+ .attr('id', 'arrowhead-bottom')
146
+ .attr('refX', 5)
147
+ .attr('refY', 5)
148
+ .attr('markerWidth', 10)
149
+ .attr('markerHeight', 10)
150
+ .append('path')
151
+ .attr('d', 'M 0 0 L 5 5 L 10 0')
152
+ .attr('stroke', 'grey')
153
+ .attr('stroke-width', 1)
154
+ .attr('fill', 'none');
155
+ // X axis
156
+ axes.append("g")
157
+ .attr("transform", `translate(0,${plotHeight / 2})`)
158
+ .call(
159
+ d3.axisBottom(xScale)
160
+ .tickSizeInner(0)
161
+ .tickSizeOuter(0)
162
+ .tickPadding(10)
163
+ )
164
+ .attr("class", "axis xAxis")
165
+ .datum({ dimension: dimensionX })
166
+ .select('path.domain')
167
+ .attr("marker-start", "url(#arrowhead-left)")
168
+ .attr("marker-end", "url(#arrowhead-right)");
169
+ // Y axis
170
+ axes.append("g")
171
+ .attr("transform", `translate(${plotWidth / 2},0)`)
172
+ .call(
173
+ d3.axisRight(yScale)
174
+ .tickSizeInner(0)
175
+ .tickSizeOuter(0)
176
+ .tickPadding(10)
177
+ )
178
+ .attr("class", "axis yAxis")
179
+ .datum({ dimension: dimensionY })
180
+ .select('path.domain')
181
+ .attr("marker-end", "url(#arrowhead-top)")
182
+ .attr("marker-start", "url(#arrowhead-bottom)");
183
+ // Style the tick texts
184
+ axes.selectAll(".tick text")
185
+ .style("fill", "blue") // Color of the text
186
+ .style("font-weight", "bold");
187
+
188
+ // Show only first and last ticks
189
+ axes.selectAll(".xAxis .tick text")
190
+ .style('text-anchor', "middle")
191
+ .attr('transform', (d, i, nodes) => (i === 0 || i === nodes.length - 1) ? "rotate(-90)" : "")
192
+ .attr("visibility", (d, i, nodes) => (i === 0 || i === nodes.length - 1) ? "visible" : "hidden");
193
+ axes.selectAll(".yAxis .tick text")
194
+ .style('text-anchor', "start")
195
+ .attr("visibility", (d, i, nodes) => (i === 0 || i === nodes.length - 1) ? "visible" : "hidden");
196
+ axes.selectAll(".xAxis .tick text")
197
+ .text((d, i, nodes) => {
198
+ if (i === 0) {
199
+ return dimensionX.idLeft; // Custom text for the first tick
200
+ } else if (i === nodes.length - 1) {
201
+ return dimensionX.idRight; // Custom text for the last tick
202
+ }
203
+ return d; // Default text for all other ticks
204
+ });
205
+ axes.selectAll(".yAxis .tick text")
206
+ .text((d, i, nodes) => {
207
+ if (i === 0) {
208
+ return dimensionY.idLeft; // Custom text for the first tick
209
+ } else if (i === nodes.length - 1) {
210
+ return dimensionY.idRight;; // Custom text for the last tick
211
+ }
212
+ return d; // Default text for all other ticks
213
+ });
214
+ /**
215
+ * Draw Bourdieu map contents
216
+ */
217
+ const contourData = d3Contour
218
+ .contourDensity()
219
+ .x((d) => xScale(-d.x))
220
+ .y((d) => yScale(d.y))
221
+ .size([plotWidth, plotHeight])
222
+ .bandwidth(30)(docsData);
223
+
224
+ const contourLineColor = "rgb(94, 163, 252)";
225
+
226
+ g
227
+ .selectAll("path.contour")
228
+ .data(contourData)
229
+ .enter()
230
+ .append("path")
231
+ .attr("class", "contour")
232
+ .attr("d", d3.geoPath())
233
+ .style("fill", "none")
234
+ .style("stroke", contourLineColor)
235
+ .style("stroke-width", 1);
236
+
237
+ const centroids = topicsData.filter((d) => d.x_centroid && d.y_centroid);
238
+ setTopicsCentroids(centroids);
239
+
240
+ g
241
+ .selectAll("circle.topic-centroid")
242
+ .data(centroids)
243
+ .enter()
244
+ .append("circle")
245
+ .attr("class", "topic-centroid")
246
+ .attr("cx", (d) => xScale(-d.x_centroid))
247
+ .attr("cy", (d) => yScale(d.y_centroid))
248
+ .attr("r", 8)
249
+ .style("fill", "red")
250
+ .style("stroke", "black")
251
+ .style("stroke-width", 2)
252
+ .on("click", (event, d) => {
253
+ setSelectedDocument(d);
254
+ });
255
+
256
+ g
257
+ .selectAll("text.topic-label")
258
+ .data(centroids)
259
+ .enter()
260
+ .append("text")
261
+ .attr("class", "topic-label")
262
+ .attr("x", (d) => xScale(-d.x_centroid))
263
+ .attr("y", (d) => yScale(d.y_centroid) - 12)
264
+ .text((d) => d.name)
265
+ .style("text-anchor", "middle");
266
+
267
+ const convexHullData = topicsData.filter((d) => d.convex_hull);
268
+ for (const d of convexHullData) {
269
+ const hull = d.convex_hull;
270
+ if (hull) {
271
+ const hullPoints = hull.x_coordinates.map((x, i) => [xScale(-x), yScale(hull.y_coordinates[i])]);
272
+
273
+ g
274
+ .append("path")
275
+ .datum(d3.polygonHull(hullPoints))
276
+ .attr("class", "convex-hull-polygon")
277
+ .attr("d", (dAttr) => `M${dAttr.join("L")}Z`)
278
+ .style("fill", "none")
279
+ .style("stroke", "rgba(255, 255, 255, 0.5)")
280
+ .style("stroke-width", 2);
281
+ }
282
+ }
283
+ const xGreaterThanZeroAndYGreaterThanZero = docsData.filter((d) => d.x > 0 && d.y > 0).length;
284
+ const xLessThanZeroAndYGreaterThanZero = docsData.filter((d) => d.x < 0 && d.y > 0).length;
285
+ const xGreaterThanZeroAndYLessThanZero = docsData.filter((d) => d.x > 0 && d.y < 0).length;
286
+ const xLessThanZeroAndYLessThanZero = docsData.filter((d) => d.x < 0 && d.y < 0).length;
287
+
288
+ // Calculate the total number of documents
289
+ const totalDocuments = docsData.length;
290
+
291
+ // Calculate the percentages
292
+ const percentageXGreaterThanZeroAndYGreaterThanZero = (xGreaterThanZeroAndYGreaterThanZero / totalDocuments) * 100;
293
+ const percentageXLessThanZeroAndYGreaterThanZero = (xLessThanZeroAndYGreaterThanZero / totalDocuments) * 100;
294
+ const percentageXGreaterThanZeroAndYLessThanZero = (xGreaterThanZeroAndYLessThanZero / totalDocuments) * 100;
295
+ const percentageXLessThanZeroAndYLessThanZero = (xLessThanZeroAndYLessThanZero / totalDocuments) * 100;
296
+
297
+ // Add labels to display percentages in the squares
298
+ // const squareSize = 300; // Adjust this based on your map's layout
299
+ // const labelOffsetX = 10; // Adjust these offsets as needed
300
+ // const labelOffsetY = 20;
301
+
302
+ // Calculate the maximum X and Y coordinates
303
+
304
+ // Calculate the midpoints for the squares
305
+ const xMid = -d3.max(docsData, (d) => d.x) / 2;
306
+ const yMid = d3.max(docsData, (d) => d.y) / 2;
307
+
308
+ // Labels for X > 0 and Y > 0 square
309
+ g
310
+ .append("text")
311
+ .attr("x", xScale(xMid))
312
+ .attr("y", yScale(yMid))
313
+ .text(`${percentageXGreaterThanZeroAndYGreaterThanZero.toFixed(0)}%`) // Remove the prefix
314
+ .style("text-anchor", "middle")
315
+ .style("fill", "dark") // Change the text color to blue
316
+ .style("font-size", "100px") // Adjust the font size
317
+ .style("opacity", 0.1); // Adjust the opacity (0.7 means slightly transparent)
318
+
319
+ // Labels for X < 0 and Y > 0 square
320
+ g
321
+ .append("text")
322
+ .attr("x", xScale(-xMid))
323
+ .attr("y", yScale(yMid))
324
+ .text(`${percentageXLessThanZeroAndYGreaterThanZero.toFixed(0)}%`) // Remove the prefix
325
+ .style("text-anchor", "middle")
326
+ .style("fill", "dark") // Change the text color to light blue
327
+ .style("font-size", "100px") // Adjust the font size
328
+ .style("opacity", 0.1); // Adjust the opacity (0.05 means slightly transparent)
329
+
330
+ // Labels for X > 0 and Y < 0 square
331
+ g
332
+ .append("text")
333
+ .attr("x", xScale(xMid))
334
+ .attr("y", yScale(-yMid))
335
+ .text(`${percentageXGreaterThanZeroAndYLessThanZero.toFixed(0)}%`) // Remove the prefix
336
+ .style("text-anchor", "middle")
337
+ .style("fill", "dark") // Change the text color to light blue
338
+ .style("font-size", "100px") // Adjust the font size
339
+ .style("opacity", 0.1); // Adjust the opacity (0.05 means slightly transparent)
340
+
341
+ // Labels for X > 0 and Y < 0 square
342
+ g
343
+ .append("text")
344
+ .attr("x", xScale(-xMid))
345
+ .attr("y", yScale(-yMid))
346
+ .text(`${percentageXLessThanZeroAndYLessThanZero.toFixed(0)}%`) // Remove the prefix
347
+ .style("text-anchor", "middle")
348
+ .style("fill", "dark") // Change the text color to light blue
349
+ .style("font-size", "100px") // Adjust the font size
350
+ .style("opacity", 0.1); // Adjust the opacity (0.05 means slightly transparent)
351
+
352
+ const topicsPolygons = g
353
+ .selectAll("polygon.topic-polygon")
354
+ .data(centroids)
355
+ .enter()
356
+ .append("polygon")
357
+ .attr("class", "topic-polygon")
358
+ .attr("points", (d) => {
359
+ const hull = d.convex_hull;
360
+ if (hull) {
361
+ const hullPoints = hull.x_coordinates.map((x, i) => [xScale(-x), yScale(hull.y_coordinates[i])]);
362
+ return hullPoints.map((point) => point.join(",")).join(" ");
363
+ }
364
+ })
365
+ .style("fill", "transparent")
366
+ .style("stroke", "transparent")
367
+ .style("stroke-width", 2);
368
+
369
+ let currentlyClickedPolygon = null;
370
+
371
+ /**
372
+ * Render Axes
373
+ */
374
+ g.append(() => axes.node())
375
+
376
+ topicsPolygons.on("click", (event, d) => {
377
+ // Reset the fill color of the previously clicked polygon to transparent light grey
378
+ if (currentlyClickedPolygon !== null) {
379
+ currentlyClickedPolygon.style("fill", "transparent");
380
+ currentlyClickedPolygon.style("stroke", "transparent");
381
+ }
382
+
383
+ // Set the fill color of the clicked polygon to transparent light grey and add a red border
384
+ const clickedPolygon = d3.select(event.target);
385
+ clickedPolygon.style("fill", "rgba(200, 200, 200, 0.4)");
386
+ clickedPolygon.style("stroke", "red");
387
+
388
+ currentlyClickedPolygon = clickedPolygon;
389
+ if (d.top_doc_content) {
390
+ // Render the TextContainer component with topic details
391
+ setSelectedDocument(d);
392
+ }
393
+ });
394
+ };
395
+
396
+ useEffect(() => {
397
+ if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) {
398
+ setMapLoading(true);
399
+ // Fetch the JSON data locally
400
+ fetch(`/${bunkaDocs}`)
401
+ .then((response) => response.json())
402
+ .then((docsData) => {
403
+ // Fetch the local topics data and merge it with the existing data
404
+ fetch(`/${bunkaTopics}`)
405
+ .then((response) => response.json())
406
+ .then((topicsData) => {
407
+ fetch(`/${bunkaQuery}`)
408
+ .then((response) => response.json())
409
+ .then((queryData) => {
410
+ // Call the function to create the scatter plot after data is loaded
411
+ createScatterPlot(docsData, topicsData, queryData);
412
+ })
413
+ .catch((error) => {
414
+ console.error("Error fetching bourdieu query data:", error);
415
+ })
416
+ .finally(() => {
417
+ setMapLoading(false);
418
+ });
419
+ })
420
+ .catch((error) => {
421
+ console.error("Error fetching topics data:", error);
422
+ })
423
+ .finally(() => {
424
+ setMapLoading(false);
425
+ });
426
+ })
427
+ .catch((error) => {
428
+ console.error("Error fetching documents data:", error);
429
+ })
430
+ .finally(() => {
431
+ setMapLoading(false);
432
+ });
433
+ } else {
434
+ // Call the function to create the scatter plot with the data provided by TopicsContext
435
+ createScatterPlot(apiData.docs, apiData.topics, apiData.query);
436
+ }
437
+ }, [apiData]);
438
+
439
+ 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.";
440
+
441
+ return (
442
+ <div className="json-display">
443
+ {(isFileProcessing || mapLoading) ? (
444
+ <Backdrop open={isFileProcessing || mapLoading} style={{ zIndex: 9999 }}>
445
+ <CircularProgress color="primary" />
446
+ </Backdrop>
447
+ ) : (
448
+ <div className="scatter-plot-and-text-container">
449
+ <div className="scatter-plot-container" ref={scatterPlotContainerRef}>
450
+ <HtmlTooltip
451
+ title={
452
+ <React.Fragment>
453
+ <Typography color="inherit">{mapDescription}</Typography>
454
+ </React.Fragment>
455
+ }
456
+ followCursor
457
+ >
458
+ <HelpIcon style={{
459
+ position: "relative",
460
+ top: 10,
461
+ left: 40,
462
+ border: "none"
463
+ }}/>
464
+ </HtmlTooltip>
465
+ <svg ref={svgRef} />
466
+ </div>
467
+
468
+ <div className="text-container">
469
+ {selectedDocument !== null ? (
470
+ <>
471
+ <Box sx={{ marginBottom: "1em" }}>
472
+ <Button sx={{ width: "100%" }} component="label" variant="outlined" startIcon={<RepeatIcon />} onClick={() => setSelectedDocument(null)}>
473
+ Upload another CSV file
474
+ </Button>
475
+ </Box>
476
+ <TextContainer topicName={selectedDocument.name} topicSizeFraction={topicsSizeFraction(topicsCentroids, selectedDocument.size)} content={selectedDocument.top_doc_content} />
477
+ </>
478
+ ) : <QueryView />}
479
+ </div>
480
+ </div>
481
+ )}
482
+ </div>
483
+ );
484
+ }
485
+
486
+ export default Bourdieu;
src/DocsView.jsx ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Backdrop, Box, Button, CircularProgress, Container, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
2
+ import React, { useContext, useEffect, useState } from "react";
3
+ import { TopicsContext } from "./UploadFileContext";
4
+
5
+ const bunkaDocs = "bunka_docs.json";
6
+ const bunkaTopics = "bunka_topics.json";
7
+ const { REACT_APP_API_ENDPOINT } = process.env;
8
+
9
+ function DocsView() {
10
+ const [docs, setDocs] = useState(null);
11
+ const [topics, setTopics] = useState(null);
12
+ const { data: apiData, isLoading } = useContext(TopicsContext);
13
+
14
+ useEffect(() => {
15
+ if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) {
16
+ // Fetch the JSON data locally
17
+ fetch(`/${bunkaDocs}`)
18
+ .then((response) => response.json())
19
+ .then((localData) => {
20
+ setDocs(localData);
21
+ // Fetch the topics data and merge it with the existing data
22
+ fetch(`/${bunkaTopics}`)
23
+ .then((response) => response.json())
24
+ .then((topicsData) => {
25
+ // Set the topics data with the existing data
26
+ setTopics(topicsData);
27
+ })
28
+ .catch((error) => {
29
+ console.error("Error fetching topics data:", error);
30
+ });
31
+ })
32
+ .catch((error) => {
33
+ console.error("Error fetching JSON data:", error);
34
+ });
35
+ } else {
36
+ // Call the function to create the scatter plot with the data provided by TopicsContext
37
+ setDocs(apiData.docs);
38
+ setTopics(apiData.topics);
39
+ }
40
+ }, [apiData]);
41
+
42
+ const docsWithTopics =
43
+ docs && topics
44
+ ? docs.map((doc) => ({
45
+ ...doc,
46
+ topic_name: topics.find((topic) => topic.topic_id === doc.topic_id)?.name || "Unknown",
47
+ }))
48
+ : [];
49
+
50
+ const downloadCSV = () => {
51
+ // Create a CSV content string from the data
52
+ const csvContent = `data:text/csv;charset=utf-8,${[
53
+ ["Doc ID", "Topic ID", "Topic Name", "Content"], // CSV header
54
+ ...docsWithTopics.map((doc) => [doc.doc_id, doc.topic_id, doc.topic_name, doc.content]), // CSV data
55
+ ]
56
+ .map((row) => row.map((cell) => `"${cell}"`).join(",")) // Wrap cells in double quotes
57
+ .join("\n")}`; // Join rows with newline
58
+
59
+ // Create a Blob containing the CSV data
60
+ const blob = new Blob([csvContent], { type: "text/csv" });
61
+
62
+ // Create a download URL for the Blob
63
+ const url = URL.createObjectURL(blob);
64
+
65
+ // Create a temporary anchor element to trigger the download
66
+ const a = document.createElement("a");
67
+ a.href = url;
68
+ a.download = "docs.csv"; // Set the filename for the downloaded file
69
+ a.click();
70
+
71
+ // Revoke the URL to free up resources
72
+ URL.revokeObjectURL(url);
73
+ };
74
+
75
+ return (
76
+ <Container fixed>
77
+ <div className="docs-view">
78
+ <h2>Data</h2>
79
+ {isLoading ? (
80
+ <Backdrop open={isLoading} style={{ zIndex: 9999 }}>
81
+ <CircularProgress color="primary" />
82
+ </Backdrop>
83
+ ) : (
84
+ <div>
85
+ <Button variant="contained" color="primary" onClick={downloadCSV} sx={{ marginBottom: "1em" }}>
86
+ Download CSV
87
+ </Button>
88
+ <Box
89
+ sx={{
90
+ height: "1000px", // Set the height of the table
91
+ overflow: "auto", // Add scroll functionality
92
+ }}
93
+ >
94
+ <TableContainer component={Paper}>
95
+ <Table>
96
+ <TableHead
97
+ sx={{
98
+ backgroundColor: "lightblue", // Set background color
99
+ position: "sticky", // Make the header sticky
100
+ top: 0, // Stick to the top
101
+ }}
102
+ >
103
+ <TableRow>
104
+ <TableCell>Doc ID</TableCell>
105
+ <TableCell>Topic ID</TableCell>
106
+ <TableCell>Topic Name</TableCell>
107
+ <TableCell>Content</TableCell>
108
+ </TableRow>
109
+ </TableHead>
110
+ <TableBody>
111
+ {docsWithTopics.map((doc, index) => (
112
+ <TableRow
113
+ key={doc.doc_id}
114
+ sx={{
115
+ borderBottom: "1px solid lightblue", // Add light blue border
116
+ }}
117
+ >
118
+ <TableCell>{doc.doc_id}</TableCell>
119
+ <TableCell>{doc.topic_id}</TableCell>
120
+ <TableCell>{doc.topic_name}</TableCell>
121
+ <TableCell>{doc.content}</TableCell>
122
+ </TableRow>
123
+ ))}
124
+ </TableBody>
125
+ </Table>
126
+ </TableContainer>
127
+ </Box>
128
+ </div>
129
+ )}
130
+ </div>
131
+ </Container>
132
+ );
133
+ }
134
+
135
+ export default DocsView;
src/DropdownMenu.jsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import PropTypes from "prop-types";
3
+ import { FormControl, InputLabel, MenuItem, Select } from "@mui/material";
4
+
5
+ export const LABELS = {
6
+ map: "Map View",
7
+ bourdieu: "Bourdieu View",
8
+ docs: "Data"
9
+ };
10
+
11
+ function DropdownMenu({ onSelectView, selectedView }) {
12
+ const handleSelectView = (event) => {
13
+ if (onSelectView) onSelectView(`${event.target.value}`);
14
+ };
15
+
16
+ return (
17
+ <FormControl variant="outlined" className="dropdown-menu" sx={{ minWidth: "200px", marginTop: "1em" }}>
18
+ <InputLabel htmlFor="view-select">Select a View</InputLabel>
19
+ <Select
20
+ label="Select a View"
21
+ value={selectedView}
22
+ onChange={handleSelectView}
23
+ inputProps={{
24
+ name: "view-select",
25
+ id: "view-select",
26
+ }}
27
+ >
28
+ <MenuItem value="map">{LABELS.map}</MenuItem>
29
+ {/* <MenuItem value="bourdieu">{LABELS.bourdieu}</MenuItem> */}
30
+ <MenuItem value="docs">{LABELS.docs}</MenuItem>
31
+ </Select>
32
+ </FormControl>
33
+ );
34
+ }
35
+
36
+ DropdownMenu.propTypes = {
37
+ onSelectView: PropTypes.func.isRequired,
38
+ selectedView: PropTypes.string.isRequired,
39
+ };
40
+
41
+ export default DropdownMenu;
src/Map.jsx ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Backdrop, CircularProgress, Button, Box } from "@mui/material";
2
+ import HelpIcon from '@mui/icons-material/Help';
3
+ import Tooltip, { tooltipClasses } from '@mui/material/Tooltip';
4
+ import Typography from '@mui/material/Typography';
5
+ import RepeatIcon from '@mui/icons-material/Repeat';
6
+ import { styled } from '@mui/material/styles';
7
+
8
+ import * as d3 from "d3";
9
+ import * as d3Contour from "d3-contour";
10
+ import React, { useContext, useEffect, useRef, useState } from "react";
11
+
12
+ import TextContainer, { topicsSizeFraction } from "./TextContainer";
13
+ import { TopicsContext } from "./UploadFileContext";
14
+ import QueryView from "./QueryView";
15
+
16
+ const bunkaDocs = "bunka_docs.json";
17
+ const bunkaTopics = "bunka_topics.json";
18
+ const { REACT_APP_API_ENDPOINT } = "local";
19
+
20
+ /**
21
+ * Generic tooltip
22
+ */
23
+ export const HtmlTooltip = styled(({ className, ...props }) => (
24
+ <Tooltip {...props} classes={{ popper: className }} />
25
+ ))(({ theme }) => ({
26
+ [`& .${tooltipClasses.popper}`]: {
27
+ backgroundColor: '#fff',
28
+ color: 'rgba(0, 0, 0, 0.87)',
29
+ maxWidth: 220,
30
+ fontSize: theme.typography.pxToRem(12),
31
+ },
32
+ }));
33
+
34
+ function MapView() {
35
+ const [selectedDocument, setSelectedDocument] = useState(null);
36
+ const [mapLoading, setMapLoading] = useState(false);
37
+ const [topicsCentroids, setTopicsCentroids] = useState([])
38
+
39
+ const { data: apiData, isLoading: isFileProcessing } = useContext(TopicsContext);
40
+
41
+
42
+ const svgRef = useRef(null);
43
+ const scatterPlotContainerRef = useRef(null);
44
+ const createScatterPlot = (data) => {
45
+ const margin = {
46
+ top: 20,
47
+ right: 20,
48
+ bottom: 50,
49
+ left: 50,
50
+ };
51
+ const plotWidth = window.innerWidth * 0.6;
52
+ const plotHeight = window.innerHeight - document.getElementById("top-banner").clientHeight - 50; // Adjust the height as desired
53
+
54
+ d3.select(svgRef.current).selectAll("*").remove();
55
+
56
+ const svg = d3
57
+ .select(svgRef.current)
58
+ .attr("width", "100%")
59
+ .attr("height", plotHeight);
60
+ /**
61
+ * SVG canvas group on which transforms apply.
62
+ */
63
+ const g = svg.append("g")
64
+ .classed("canvas", true)
65
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
66
+ /**
67
+ * TODO Zoom.
68
+ */
69
+ const zoom = d3.zoom()
70
+ .scaleExtent([1, 3])
71
+ .translateExtent([[0, 0], [1000, 1000]])
72
+ .on("zoom", function ({ transform }) {
73
+ g.attr(
74
+ "transform",
75
+ `translate(${transform.x ?? 0}, ${transform.y ?? 0}) scale(${transform.k ?? 1})`
76
+ )
77
+ //positionLabels()
78
+ // props.setTransform?.({
79
+ // x: transform.x,
80
+ // y: transform.y,
81
+ // k: transform.k
82
+ // })
83
+ });
84
+ svg.call(zoom);
85
+
86
+ /**
87
+ * Initial zoom.
88
+ */
89
+ // const defaultTransform = { k: 1 };
90
+ // const initialTransform = defaultTransform?.k != null
91
+ // ? new ZoomTransform(
92
+ // defaultTransform.k ?? 1,
93
+ // defaultTransform.x ?? 0,
94
+ // defaultTransform.y ?? 0
95
+ // )
96
+ // : d3.zoomIdentity;
97
+ // svg.call(zoom.transform, initialTransform);
98
+
99
+ const xMin = d3.min(data, (d) => d.x);
100
+ const xMax = d3.max(data, (d) => d.x);
101
+ const yMin = d3.min(data, (d) => d.y);
102
+ const yMax = d3.max(data, (d) => d.y);
103
+
104
+ const xScale = d3
105
+ .scaleLinear()
106
+ .domain([xMin, xMax]) // Use the full range of your data
107
+ .range([0, plotWidth]);
108
+
109
+ const yScale = d3
110
+ .scaleLinear()
111
+ .domain([yMin, yMax]) // Use the full range of your data
112
+ .range([plotHeight, 0]);
113
+
114
+ // Add contours
115
+ const contourData = d3Contour
116
+ .contourDensity()
117
+ .x((d) => xScale(d.x))
118
+ .y((d) => yScale(d.y))
119
+ .size([plotWidth, plotHeight])
120
+ .bandwidth(30)(
121
+ // Adjust the bandwidth as needed
122
+ data,
123
+ );
124
+
125
+ // Define a custom color for the contour lines
126
+
127
+ const contourLineColor = "rgb(94, 163, 252)";
128
+
129
+ // Append the contour path to the SVG with a custom color
130
+ g
131
+ .selectAll("path.contour")
132
+ .data(contourData)
133
+ .enter()
134
+ .append("path")
135
+ .attr("class", "contour")
136
+ .attr("d", d3.geoPath())
137
+ .style("fill", "none")
138
+ .style("stroke", contourLineColor) // Set the contour line color to the custom color
139
+ .style("stroke-width", 1);
140
+
141
+ /*
142
+ const circles = svg.selectAll('circle')
143
+ .data(data)
144
+ .enter()
145
+ .append('circle')
146
+ .attr('cx', (d) => xScale(d.x))
147
+ .attr('cy', (d) => yScale(d.y))
148
+ .attr('r', 5)
149
+ .style('fill', 'lightblue')
150
+ .on('click', (event, d) => {
151
+ // Show the content and topic name of the clicked point in the text container
152
+ setSelectedDocument(d);
153
+ // Change the color to pink on click
154
+ circles.style('fill', (pointData) => (pointData === d) ? 'pink' : 'lightblue');
155
+ });
156
+ */
157
+
158
+ const centroids = data.filter((d) => d.x_centroid && d.y_centroid);
159
+ setTopicsCentroids(centroids);
160
+
161
+ g
162
+ .selectAll("circle.topic-centroid")
163
+ .data(centroids)
164
+ .enter()
165
+ .append("circle")
166
+ .attr("class", "topic-centroid")
167
+ .attr("cx", (d) => xScale(d.x_centroid))
168
+ .attr("cy", (d) => yScale(d.y_centroid))
169
+ .attr("r", 8) // Adjust the radius as needed
170
+ .style("fill", "red") // Adjust the fill color as needed
171
+ .style("stroke", "black")
172
+ .style("stroke-width", 2)
173
+ .on("click", (event, d) => {
174
+ // Show the content and topic name of the clicked topic centroid in the text container
175
+ setSelectedDocument(d);
176
+ });
177
+
178
+
179
+ // Add text labels for topic names
180
+ g
181
+ .selectAll("rect.topic-label-background")
182
+ .data(centroids)
183
+ .enter()
184
+ .append("rect")
185
+ .attr("class", "topic-label-background")
186
+ .attr("x", (d) => {
187
+ // Calculate the width of the text
188
+ const first10Words = d.name.split(' ').slice(0, 8).join(' ');
189
+ const textLength = first10Words.length * 8; // Adjust the multiplier for width as needed
190
+
191
+ // Calculate the x position to center the box
192
+ return xScale(d.x_centroid) - textLength / 2;
193
+ }) // Center the box horizontally
194
+ .attr("y", (d) => yScale(d.y_centroid) - 20) // Adjust the y position
195
+ .attr("width", (d) => {
196
+ // Compute the width based on the text's length
197
+ const first10Words = d.name.split(' ').slice(0, 8).join(' ');
198
+ const textLength = first10Words.length * 8; // Adjust the multiplier for width as needed
199
+ return textLength;
200
+ })
201
+ .attr("height", 40) // Set the height of the white box
202
+ .style("fill", "white") // Set the white fill color
203
+ .style("stroke", "grey") // Set the blue border color
204
+ .style("stroke-width", 2); // Set the border width
205
+
206
+ // Add text labels in black within the white boxes
207
+ g
208
+ .selectAll("text.topic-label-text")
209
+ .data(centroids)
210
+ .enter()
211
+ .append("text")
212
+ .attr("class", "topic-label-text")
213
+ .attr("x", (d) => xScale(d.x_centroid))
214
+ .attr("y", (d) => yScale(d.y_centroid) + 4) // Adjust the vertical position
215
+ .text((d) => {
216
+ const first10Words = d.name.split(' ').slice(0, 8).join(' ');
217
+ return first10Words;
218
+ }) // Use the first 10 words
219
+ .style("text-anchor", "middle") // Center-align the text
220
+ .style("fill", "black"); // Set the text color
221
+
222
+ const convexHullData = data.filter((d) => d.convex_hull);
223
+
224
+ for (const d of convexHullData) {
225
+ const hull = d.convex_hull;
226
+ const hullPoints = hull.x_coordinates.map((x, i) => [xScale(x), yScale(hull.y_coordinates[i])]);
227
+
228
+ g
229
+ .append("path")
230
+ .datum(d3.polygonHull(hullPoints))
231
+ .attr("class", "convex-hull-polygon")
232
+ .attr("d", (d1) => `M${d1.join("L")}Z`)
233
+ .style("fill", "none")
234
+ .style("stroke", "rgba(255, 255, 255, 0.5)") // White with 50% transparency
235
+ .style("stroke-width", 2);
236
+ }
237
+
238
+ // Add polygons for topics. Delete if no clicking on polygons
239
+ const topicsPolygons = g
240
+ .selectAll("polygon.topic-polygon")
241
+ .data(centroids)
242
+ .enter()
243
+ .append("polygon")
244
+ .attr("class", "topic-polygon")
245
+ .attr("points", (d) => {
246
+ const hull = d.convex_hull;
247
+ const hullPoints = hull.x_coordinates.map((x, i) => [xScale(x), yScale(hull.y_coordinates[i])]);
248
+ return hullPoints.map((point) => point.join(",")).join(" ");
249
+ })
250
+ .style("fill", "transparent")
251
+ .style("stroke", "transparent")
252
+ .style("stroke-width", 2); // Adjust the border width as needed
253
+
254
+ let currentlyClickedPolygon = null;
255
+
256
+ topicsPolygons.on("click", (event, d) => {
257
+ // Reset the fill color of the previously clicked polygon to transparent light grey
258
+ if (currentlyClickedPolygon !== null) {
259
+ currentlyClickedPolygon.style("fill", "transparent");
260
+ currentlyClickedPolygon.style("stroke", "transparent");
261
+ }
262
+
263
+ // Set the fill color of the clicked polygon to transparent light grey and add a red border
264
+ const clickedPolygon = d3.select(event.target);
265
+ clickedPolygon.style("fill", "rgba(200, 200, 200, 0.4)");
266
+ clickedPolygon.style("stroke", "red");
267
+
268
+ currentlyClickedPolygon = clickedPolygon;
269
+
270
+ // Display the topic name and content from top_doc_content with a scroll system
271
+ if (d.top_doc_content) {
272
+ // Render the TextContainer component with topic details
273
+ setSelectedDocument(d);
274
+ }
275
+ });
276
+ };
277
+
278
+ useEffect(() => {
279
+ if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) {
280
+ setMapLoading(true);
281
+ // Fetch the JSON data locally
282
+ fetch(`/${bunkaDocs}`)
283
+ .then((response) => response.json())
284
+ .then((localData) => {
285
+ // Fetch the local topics data and merge it with the existing data
286
+ fetch(`/${bunkaTopics}`)
287
+ .then((response) => response.json())
288
+ .then((topicsData) => {
289
+ // Merge the topics data with the existing data
290
+ const mergedData = localData.concat(topicsData);
291
+
292
+ // Call the function to create the scatter plot after data is loaded
293
+ createScatterPlot(mergedData);
294
+ })
295
+ .catch((error) => {
296
+ console.error("Error fetching topics data:", error);
297
+ })
298
+ .finally(() => {
299
+ setMapLoading(false);
300
+ });
301
+ })
302
+ .catch((error) => {
303
+ console.error("Error fetching JSON data:", error);
304
+ })
305
+ .finally(() => {
306
+ setMapLoading(false);
307
+ });
308
+ } else {
309
+ // Call the function to create the scatter plot with the data provided by TopicsContext
310
+ createScatterPlot(apiData.docs.concat(apiData.topics));
311
+ }
312
+
313
+ // After the data is loaded, set the default topic
314
+ if (apiData && apiData.topics && apiData.topics.length > 0) {
315
+ // Set the default topic to the first topic in the list
316
+ setSelectedDocument(apiData.topics[0]);
317
+ }
318
+ }, [apiData]);
319
+
320
+
321
+ 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.";
322
+
323
+ return (
324
+ <div className="json-display">
325
+ {(isFileProcessing || mapLoading) ? (
326
+ <Backdrop open={isFileProcessing || mapLoading} style={{ zIndex: 9999 }}>
327
+ <CircularProgress color="primary" />
328
+ </Backdrop>
329
+ ) : (
330
+ <div className="scatter-plot-and-text-container">
331
+ <div className="scatter-plot-container" ref={scatterPlotContainerRef}>
332
+ <HtmlTooltip
333
+ title={
334
+ <React.Fragment>
335
+ <Typography color="inherit">{mapDescription}</Typography>
336
+ </React.Fragment>
337
+ }
338
+ followCursor
339
+ >
340
+ <HelpIcon style={{
341
+ position: "relative",
342
+ top: 10,
343
+ left: 40,
344
+ border: "none"
345
+ }} />
346
+ </HtmlTooltip>
347
+ <svg ref={svgRef} />
348
+ </div>
349
+ <div className="text-container">
350
+ {selectedDocument ? (
351
+ <TextContainer
352
+ topicName={selectedDocument.name}
353
+ topicSizeFraction={topicsSizeFraction(topicsCentroids, selectedDocument.size)}
354
+ content={selectedDocument.top_doc_content}
355
+ />
356
+ ) : (
357
+ // Display a default view or null if no document is selected
358
+ null
359
+ )}
360
+ </div>
361
+ </div>
362
+ )}
363
+ </div>
364
+ );
365
+ }
366
+
367
+ export default MapView;
src/Map_original.jsx ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Backdrop, CircularProgress, Button, Box } from "@mui/material";
2
+ import HelpIcon from '@mui/icons-material/Help';
3
+ import Tooltip, { tooltipClasses } from '@mui/material/Tooltip';
4
+ import Typography from '@mui/material/Typography';
5
+ import RepeatIcon from '@mui/icons-material/Repeat';
6
+ import { styled } from '@mui/material/styles';
7
+
8
+ import * as d3 from "d3";
9
+ import * as d3Contour from "d3-contour";
10
+ import React, { useContext, useEffect, useRef, useState } from "react";
11
+
12
+ import TextContainer, { topicsSizeFraction } from "./TextContainer";
13
+ import { TopicsContext } from "./UploadFileContext";
14
+ import QueryView from "./QueryView";
15
+
16
+ const bunkaDocs = "bunka_docs.json";
17
+ const bunkaTopics = "bunka_topics.json";
18
+ const { REACT_APP_API_ENDPOINT } = process.env;
19
+
20
+ /**
21
+ * Generic tooltip
22
+ */
23
+ export const HtmlTooltip = styled(({ className, ...props }) => (
24
+ <Tooltip {...props} classes={{ popper: className }} />
25
+ ))(({ theme }) => ({
26
+ [`& .${tooltipClasses.popper}`]: {
27
+ backgroundColor: '#fff',
28
+ color: 'rgba(0, 0, 0, 0.87)',
29
+ maxWidth: 220,
30
+ fontSize: theme.typography.pxToRem(12),
31
+ },
32
+ }));
33
+
34
+ function MapView() {
35
+ const [selectedDocument, setSelectedDocument] = useState(null);
36
+ const [mapLoading, setMapLoading] = useState(false);
37
+ const [topicsCentroids, setTopicsCentroids] = useState([])
38
+
39
+ const { data: apiData, isLoading: isFileProcessing } = useContext(TopicsContext);
40
+
41
+ const svgRef = useRef(null);
42
+ const scatterPlotContainerRef = useRef(null);
43
+ const createScatterPlot = (data) => {
44
+ const margin = {
45
+ top: 20,
46
+ right: 20,
47
+ bottom: 50,
48
+ left: 50,
49
+ };
50
+ const plotWidth = window.innerWidth * 0.6;
51
+ const plotHeight = window.innerHeight - document.getElementById("top-banner").clientHeight - 50; // Adjust the height as desired
52
+
53
+ d3.select(svgRef.current).selectAll("*").remove();
54
+
55
+ const svg = d3
56
+ .select(svgRef.current)
57
+ .attr("width", "100%")
58
+ .attr("height", plotHeight);
59
+ /**
60
+ * SVG canvas group on which transforms apply.
61
+ */
62
+ const g = svg.append("g")
63
+ .classed("canvas", true)
64
+ .attr("transform", `translate(${margin.left}, ${margin.top})`);
65
+ /**
66
+ * TODO Zoom.
67
+ */
68
+ const zoom = d3.zoom()
69
+ .scaleExtent([1, 3])
70
+ .translateExtent([[0, 0], [1000, 1000]])
71
+ .on("zoom", function ({ transform }) {
72
+ g.attr(
73
+ "transform",
74
+ `translate(${transform.x ?? 0}, ${transform.y ?? 0}) scale(${transform.k ?? 1})`
75
+ )
76
+ //positionLabels()
77
+ // props.setTransform?.({
78
+ // x: transform.x,
79
+ // y: transform.y,
80
+ // k: transform.k
81
+ // })
82
+ });
83
+ svg.call(zoom);
84
+
85
+ /**
86
+ * Initial zoom.
87
+ */
88
+ // const defaultTransform = { k: 1 };
89
+ // const initialTransform = defaultTransform?.k != null
90
+ // ? new ZoomTransform(
91
+ // defaultTransform.k ?? 1,
92
+ // defaultTransform.x ?? 0,
93
+ // defaultTransform.y ?? 0
94
+ // )
95
+ // : d3.zoomIdentity;
96
+ // svg.call(zoom.transform, initialTransform);
97
+
98
+ const xMin = d3.min(data, (d) => d.x);
99
+ const xMax = d3.max(data, (d) => d.x);
100
+ const yMin = d3.min(data, (d) => d.y);
101
+ const yMax = d3.max(data, (d) => d.y);
102
+
103
+ const xScale = d3
104
+ .scaleLinear()
105
+ .domain([xMin, xMax]) // Use the full range of your data
106
+ .range([0, plotWidth]);
107
+
108
+ const yScale = d3
109
+ .scaleLinear()
110
+ .domain([yMin, yMax]) // Use the full range of your data
111
+ .range([plotHeight, 0]);
112
+
113
+ // Add contours
114
+ const contourData = d3Contour
115
+ .contourDensity()
116
+ .x((d) => xScale(d.x))
117
+ .y((d) => yScale(d.y))
118
+ .size([plotWidth, plotHeight])
119
+ .bandwidth(30)(
120
+ // Adjust the bandwidth as needed
121
+ data,
122
+ );
123
+
124
+ // Define a custom color for the contour lines
125
+
126
+ const contourLineColor = "rgb(94, 163, 252)";
127
+
128
+ // Append the contour path to the SVG with a custom color
129
+ g
130
+ .selectAll("path.contour")
131
+ .data(contourData)
132
+ .enter()
133
+ .append("path")
134
+ .attr("class", "contour")
135
+ .attr("d", d3.geoPath())
136
+ .style("fill", "none")
137
+ .style("stroke", contourLineColor) // Set the contour line color to the custom color
138
+ .style("stroke-width", 1);
139
+
140
+ /*
141
+ const circles = svg.selectAll('circle')
142
+ .data(data)
143
+ .enter()
144
+ .append('circle')
145
+ .attr('cx', (d) => xScale(d.x))
146
+ .attr('cy', (d) => yScale(d.y))
147
+ .attr('r', 5)
148
+ .style('fill', 'lightblue')
149
+ .on('click', (event, d) => {
150
+ // Show the content and topic name of the clicked point in the text container
151
+ setSelectedDocument(d);
152
+ // Change the color to pink on click
153
+ circles.style('fill', (pointData) => (pointData === d) ? 'pink' : 'lightblue');
154
+ });
155
+ */
156
+
157
+ const centroids = data.filter((d) => d.x_centroid && d.y_centroid);
158
+ setTopicsCentroids(centroids);
159
+
160
+ g
161
+ .selectAll("circle.topic-centroid")
162
+ .data(centroids)
163
+ .enter()
164
+ .append("circle")
165
+ .attr("class", "topic-centroid")
166
+ .attr("cx", (d) => xScale(d.x_centroid))
167
+ .attr("cy", (d) => yScale(d.y_centroid))
168
+ .attr("r", 8) // Adjust the radius as needed
169
+ .style("fill", "red") // Adjust the fill color as needed
170
+ .style("stroke", "black")
171
+ .style("stroke-width", 2)
172
+ .on("click", (event, d) => {
173
+ // Show the content and topic name of the clicked topic centroid in the text container
174
+ setSelectedDocument(d);
175
+ });
176
+
177
+ // Add text labels for topic names
178
+ g
179
+ .selectAll("text.topic-label")
180
+ .data(centroids)
181
+ .enter()
182
+ .append("text")
183
+ .attr("class", "topic-label")
184
+ .attr("x", (d) => xScale(d.x_centroid))
185
+ .attr("y", (d) => yScale(d.y_centroid) - 12) // Adjust the vertical position
186
+ .text((d) => d.name) // Use the 'name' property for topic names
187
+ .style("text-anchor", "middle"); // Center-align the text
188
+
189
+ const convexHullData = data.filter((d) => d.convex_hull);
190
+
191
+ for (const d of convexHullData) {
192
+ const hull = d.convex_hull;
193
+ const hullPoints = hull.x_coordinates.map((x, i) => [xScale(x), yScale(hull.y_coordinates[i])]);
194
+
195
+ g
196
+ .append("path")
197
+ .datum(d3.polygonHull(hullPoints))
198
+ .attr("class", "convex-hull-polygon")
199
+ .attr("d", (d1) => `M${d1.join("L")}Z`)
200
+ .style("fill", "none")
201
+ .style("stroke", "rgba(255, 255, 255, 0.5)") // White with 50% transparency
202
+ .style("stroke-width", 2);
203
+ }
204
+
205
+ // Add polygons for topics. Delete if no clicking on polygons
206
+ const topicsPolygons = g
207
+ .selectAll("polygon.topic-polygon")
208
+ .data(centroids)
209
+ .enter()
210
+ .append("polygon")
211
+ .attr("class", "topic-polygon")
212
+ .attr("points", (d) => {
213
+ const hull = d.convex_hull;
214
+ const hullPoints = hull.x_coordinates.map((x, i) => [xScale(x), yScale(hull.y_coordinates[i])]);
215
+ return hullPoints.map((point) => point.join(",")).join(" ");
216
+ })
217
+ .style("fill", "transparent")
218
+ .style("stroke", "transparent")
219
+ .style("stroke-width", 2); // Adjust the border width as needed
220
+
221
+ let currentlyClickedPolygon = null;
222
+
223
+ topicsPolygons.on("click", (event, d) => {
224
+ // Reset the fill color of the previously clicked polygon to transparent light grey
225
+ if (currentlyClickedPolygon !== null) {
226
+ currentlyClickedPolygon.style("fill", "transparent");
227
+ currentlyClickedPolygon.style("stroke", "transparent");
228
+ }
229
+
230
+ // Set the fill color of the clicked polygon to transparent light grey and add a red border
231
+ const clickedPolygon = d3.select(event.target);
232
+ clickedPolygon.style("fill", "rgba(200, 200, 200, 0.4)");
233
+ clickedPolygon.style("stroke", "red");
234
+
235
+ currentlyClickedPolygon = clickedPolygon;
236
+
237
+ // Display the topic name and content from top_doc_content with a scroll system
238
+ if (d.top_doc_content) {
239
+ // Render the TextContainer component with topic details
240
+ setSelectedDocument(d);
241
+ }
242
+ });
243
+ };
244
+
245
+ useEffect(() => {
246
+ if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) {
247
+ setMapLoading(true);
248
+ // Fetch the JSON data locally
249
+ fetch(`/${bunkaDocs}`)
250
+ .then((response) => response.json())
251
+ .then((localData) => {
252
+ // Fetch the local topics data and merge it with the existing data
253
+ fetch(`/${bunkaTopics}`)
254
+ .then((response) => response.json())
255
+ .then((topicsData) => {
256
+ // Merge the topics data with the existing data
257
+ const mergedData = localData.concat(topicsData);
258
+
259
+ // Call the function to create the scatter plot after data is loaded
260
+ createScatterPlot(mergedData);
261
+ })
262
+ .catch((error) => {
263
+ console.error("Error fetching topics data:", error);
264
+ })
265
+ .finally(() => {
266
+ setMapLoading(false);
267
+ });
268
+ })
269
+ .catch((error) => {
270
+ console.error("Error fetching JSON data:", error);
271
+ })
272
+ .finally(() => {
273
+ setMapLoading(false);
274
+ });
275
+ } else {
276
+ // Call the function to create the scatter plot with the data provided by TopicsContext
277
+ createScatterPlot(apiData.docs.concat(apiData.topics));
278
+ }
279
+
280
+ // After the data is loaded, set the default topic
281
+ if (apiData && apiData.topics && apiData.topics.length > 0) {
282
+ // Set the default topic to the first topic in the list
283
+ setSelectedDocument(apiData.topics[0]);
284
+ }
285
+ }, [apiData]);
286
+
287
+
288
+ 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.";
289
+
290
+ return (
291
+ <div className="json-display">
292
+ {(isFileProcessing || mapLoading) ? (
293
+ <Backdrop open={isFileProcessing || mapLoading} style={{ zIndex: 9999 }}>
294
+ <CircularProgress color="primary" />
295
+ </Backdrop>
296
+ ) : (
297
+ <div className="scatter-plot-and-text-container">
298
+ <div className="scatter-plot-container" ref={scatterPlotContainerRef}>
299
+ <HtmlTooltip
300
+ title={
301
+ <React.Fragment>
302
+ <Typography color="inherit">{mapDescription}</Typography>
303
+ </React.Fragment>
304
+ }
305
+ followCursor
306
+ >
307
+ <HelpIcon style={{
308
+ position: "relative",
309
+ top: 10,
310
+ left: 40,
311
+ border: "none"
312
+ }} />
313
+ </HtmlTooltip>
314
+ <svg ref={svgRef} />
315
+ </div>
316
+ <div className="text-container" >
317
+ {selectedDocument !== null ? (
318
+ <>
319
+ {/* <Box sx={{ marginBottom: "1em" }}>
320
+ <Button sx={{ width: "100%" }} component="label" variant="outlined" startIcon={<RepeatIcon />} onClick={() => setSelectedDocument(null)}>
321
+ Upload another CSV file
322
+ </Button>
323
+ </Box> */}
324
+ <TextContainer topicName={selectedDocument.name} topicSizeFraction={topicsSizeFraction(topicsCentroids, selectedDocument.size)} content={selectedDocument.top_doc_content} />
325
+ </>
326
+ ) : <QueryView />}
327
+ </div>
328
+ </div>
329
+ )}
330
+ </div>
331
+ );
332
+ }
333
+
334
+ export default MapView;
src/QueryView.jsx ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ScheduleSendIcon from '@mui/icons-material/ScheduleSend';
2
+ import {
3
+ Backdrop, // Import Backdrop component
4
+ Box,
5
+ Button,
6
+ CircularProgress, // Import CircularProgress component
7
+ Container,
8
+ FormControl,
9
+ InputLabel,
10
+ MenuItem,
11
+ Paper,
12
+ Select,
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableContainer,
17
+ TableHead,
18
+ TableRow,
19
+ TextField,
20
+ RadioGroup,
21
+ Radio,
22
+ FormControlLabel,
23
+ FormLabel,
24
+ Alert
25
+ } from "@mui/material";
26
+ import { styled } from "@mui/material/styles";
27
+ import Papa from "papaparse";
28
+ import React, { useContext, useState, useCallback } from "react";
29
+ import { TopicsContext } from "./UploadFileContext";
30
+
31
+ const VisuallyHiddenInput = styled("input")({
32
+ clip: "rect(0 0 0 0)",
33
+ clipPath: "inset(50%)",
34
+ height: 1,
35
+ overflow: "hidden",
36
+ position: "absolute",
37
+ bottom: 0,
38
+ left: 0,
39
+ whiteSpace: "nowrap",
40
+ width: 1,
41
+ });
42
+
43
+ function QueryView() {
44
+ const [fileData, setFileData] = useState([]);
45
+ const [selectedColumn, setSelectedColumn] = useState("");
46
+ const [selectedFile, setSelectedFile] = useState(null);
47
+ const [selectedColumnData, setSelectedColumnData] = useState([]);
48
+ const [openSelector, setOpenSelector] = React.useState(false);
49
+ const [xLeftWord, setXLeftWord] = useState("past");
50
+ const [xRightWord, setXRightWord] = useState("future");
51
+ const [yTopWord, setYTopWord] = useState("positive");
52
+ const [yBottomWord, setYBottomWord] = useState("negative");
53
+ const [radiusSize, setRadiusSize] = useState(0.5);
54
+ const [nClusters, setNClusters] = useState(15);
55
+ const [minCountTerms, setMinCountTerms] = useState(1);
56
+ const [nameLength, setNameLength] = useState(3);
57
+ const [cleanTopics, setCleanTopics] = useState(false);
58
+ const [language, setLanguage] = useState("english");
59
+ const { uploadFile, isLoading, selectedView, refreshBourdieuQuery } = useContext(TopicsContext);
60
+ const [fileDataTooLong, setFileDataTooLong] = useState(false);
61
+ const [fileDataError, setFileDataError] = useState(null);
62
+
63
+ /**
64
+ * Column name selector handler
65
+ */
66
+ const handleClose = () => {
67
+ setOpenSelector(false);
68
+ };
69
+
70
+ const handleOpen = () => {
71
+ setOpenSelector(true);
72
+ };
73
+
74
+ /**
75
+ * Parse the CSV and take a sample to display the preview
76
+ * @param {*} file
77
+ * @param {*} sampleSize
78
+ * @returns
79
+ */
80
+ const parseCSVFile = (file, sampleSize = 100) =>
81
+ new Promise((resolve, reject) => {
82
+ const reader = new FileReader();
83
+
84
+ reader.onload = (e) => {
85
+ const csvData = e.target.result;
86
+ const lines = csvData.split("\n");
87
+
88
+ setFileDataTooLong(lines.length > 10000);
89
+ // Take a sample of the first 500 lines to display preview
90
+ const sampleLines = lines.slice(0, sampleSize).join("\n");
91
+
92
+ Papa.parse(sampleLines, {
93
+ complete: (result) => {
94
+ resolve(result.data);
95
+ },
96
+ error: (parseError) => {
97
+ reject(parseError.message);
98
+ },
99
+ });
100
+ };
101
+ reader.readAsText(file);
102
+ });
103
+
104
+ /**
105
+ * Handler the file selection ui workflow
106
+ * @param {Event} e
107
+ * @returns
108
+ */
109
+ const handleFileChange = async (e) => {
110
+ const file = e.target.files[0];
111
+ setSelectedFile(file);
112
+
113
+ if (!file) return;
114
+ // prepare data for the preview Table
115
+ try {
116
+ const parsedData = await parseCSVFile(file);
117
+ setFileData(parsedData);
118
+ setSelectedColumn(""); // Clear the selected column when a new file is uploaded
119
+ if (fileDataTooLong === false) {
120
+ handleOpen();
121
+ }
122
+ else {
123
+ handleClose();
124
+ }
125
+ } catch (exc) {
126
+ setFileDataError("Error parsing the CSV file, please check your file before uploading");
127
+ console.error("Error parsing CSV:", exc);
128
+ }
129
+ };
130
+
131
+ const handleColumnSelect = (e) => {
132
+ const columnName = e.target.value;
133
+ setSelectedColumn(columnName);
134
+
135
+ // Extract the content of the selected column
136
+ const columnIndex = fileData[0].indexOf(columnName);
137
+ const columnData = fileData.slice(1).map((row) => row[columnIndex]);
138
+
139
+ setSelectedColumnData(columnData);
140
+ };
141
+
142
+ /**
143
+ * Launch the upload and processing
144
+ */
145
+ const handleProcessTopics = async () => {
146
+ // Return if no column selected
147
+ if (selectedColumnData.length === 0) return;
148
+
149
+ if (selectedFile && !isLoading) {
150
+ uploadFile(selectedFile, {
151
+ nClusters,
152
+ selectedColumn,
153
+ selectedView,
154
+ xLeftWord,
155
+ xRightWord,
156
+ yTopWord,
157
+ yBottomWord,
158
+ radiusSize,
159
+ nameLength,
160
+ minCountTerms,
161
+ language,
162
+ cleanTopics
163
+ });
164
+ }
165
+ };
166
+
167
+ const handleRefreshQuery = useCallback(async () => {
168
+ if (!isLoading) {
169
+ await refreshBourdieuQuery({
170
+ topic_param: {
171
+ n_clusters: nClusters,
172
+ name_lenght: nameLength,
173
+ min_count_terms: minCountTerms,
174
+ language: language,
175
+ clean_topics: cleanTopics
176
+ },
177
+ bourdieu_query: {
178
+ x_left_words: xLeftWord.split(","),
179
+ x_right_words: xRightWord.split(","),
180
+ y_top_words: yTopWord.split(","),
181
+ y_bottom_words: yBottomWord.split(","),
182
+ radius_size: radiusSize,
183
+ }
184
+ });
185
+ }
186
+ });
187
+
188
+ const openTableContainer = selectedColumnData.length > 0 && fileData.length > 0 && fileData.length <= 10000 && fileDataTooLong === false && fileDataError == null;
189
+
190
+ return (
191
+ <Container component="form">
192
+ {selectedView === "map" && (
193
+ <>
194
+ <Box marginBottom={2}>
195
+ <Button component="label" variant="outlined" endIcon={<ScheduleSendIcon />}>
196
+ Upload a CSV (max 10 000 lines) and queue processing
197
+ <VisuallyHiddenInput type="file" onChange={handleFileChange} required />
198
+ </Button>
199
+ </Box>
200
+ <Box marginBottom={2}>
201
+ <FormControl variant="outlined" fullWidth>
202
+ <InputLabel>Select a Column</InputLabel>
203
+ <Select value={selectedColumn} onChange={handleColumnSelect} onClose={handleClose} onOpen={handleOpen} open={openSelector}>
204
+ {fileData[0]?.map((header, index) => (
205
+ <MenuItem key={`${header}`} value={header}>
206
+ {header}
207
+ </MenuItem>
208
+ ))}
209
+ </Select>
210
+ </FormControl>
211
+ </Box>
212
+ </>
213
+ )}
214
+ {isLoading ? (
215
+ <Backdrop open={isLoading} style={{ zIndex: 9999 }}>
216
+ <CircularProgress color="primary" />
217
+ </Backdrop>
218
+ ) : (
219
+ // Content when not loading
220
+ <div>
221
+ {openTableContainer && (
222
+ <TableContainer component={Paper} style={{ maxHeight: "400px", overflowY: "auto" }}>
223
+ <Table>
224
+ <TableHead>
225
+ <TableRow>
226
+ <TableCell>{selectedColumn}</TableCell>
227
+ </TableRow>
228
+ </TableHead>
229
+ <TableBody>
230
+ {selectedColumnData.map((cell, index) => (
231
+ <TableRow key={`table-${index}`}>
232
+ <TableCell>{cell}</TableCell>
233
+ </TableRow>
234
+ ))}
235
+ </TableBody>
236
+ </Table>
237
+ </TableContainer>
238
+ )}
239
+ {fileDataTooLong && (
240
+ <Alert severity="error">CSV must have less than 10 000 lines (this is a demo)</Alert>
241
+ )}
242
+ {fileDataError && (
243
+ <Alert severity="error">CSV must have less than 10 000 lines (this is a demo)</Alert>
244
+ )}
245
+ {selectedView === "bourdieu" && (
246
+ <Box marginTop={2} display="flex" alignItems="center" flexDirection="column">
247
+ <FormControl variant="outlined">
248
+ <TextField required id="input-bourdieu-xl" sx={{ marginBottom: "0.5em" }} label="X left words (comma separated)" variant="outlined" onChange={e => setXLeftWord(e.target.value)} value={xLeftWord} />
249
+ <TextField required id="input-bourdieu-xr" sx={{ marginBottom: "1em" }} label="X right words (comma separated)" variant="outlined" onChange={e => setXRightWord(e.target.value)} value={xRightWord} />
250
+ <TextField required id="input-bourdieu-yt" sx={{ marginBottom: "1em" }} label="Y top words (comma separated)" variant="outlined" onChange={e => setYTopWord(e.target.value)} value={yTopWord} />
251
+ <TextField required id="input-bourdieu-yb" sx={{ marginBottom: "1em" }} label="Y bottom words (comma separated)" variant="outlined" onChange={e => setYBottomWord(e.target.value)} value={yBottomWord} />
252
+ <TextField required id="input-bourdieu-radius" sx={{ marginBottom: "1em" }} label="Radius Size" variant="outlined" onChange={e => setRadiusSize(e.target.value)} value={radiusSize} />
253
+ <TextField required id="input-map-nclusters" sx={{ marginBottom: "1em" }} label="N° Clusters" variant="outlined" onChange={e => setNClusters(e.target.value)} value={nClusters} />
254
+ <TextField required id="input-map-namelength" sx={{ marginBottom: "1em" }} label="Name length" variant="outlined" onChange={e => setNameLength(e.target.value)} value={nameLength} />
255
+ <TextField required id="input-map-mincountterms" sx={{ marginBottom: "1em" }} label="Min Count Terms" variant="outlined" onChange={e => setMinCountTerms(e.target.value)} value={minCountTerms} />
256
+ <RadioGroup required name="cleantopics-radio-group" defaultValue={cleanTopics} onChange={e => setCleanTopics(e.target.value)} variant="outlined" sx={{ marginBottom: "1em" }} disabled>
257
+ <FormLabel id="clean-topics-group-label">Clean Topics</FormLabel>
258
+ <FormControlLabel value={true} label="Yes" control={<Radio />} disabled />
259
+ <FormControlLabel value={false} label="No" control={<Radio />} disabled />
260
+ </RadioGroup>
261
+ </FormControl>
262
+ <Button variant="contained" color="primary" onClick={handleRefreshQuery} disabled={isLoading || fileDataTooLong === true || fileDataError !== null}>
263
+ {isLoading ? "Processing..." : "Refresh Bourdieu Axes"}
264
+ </Button>
265
+ </Box>
266
+ )}
267
+ {selectedView === "map" && (
268
+ <Box marginTop={2} display="flex" alignItems="center" flexDirection="column">
269
+ <Button variant="contained" color="primary" onClick={handleProcessTopics} disabled={selectedColumnData.length === 0 || isLoading || fileDataTooLong === true || fileDataError !== null}>
270
+ {isLoading ? "Processing..." : "Process Topics"}
271
+ </Button>
272
+ <FormControl variant="outlined" sx={{ marginTop: "1em", marginLeft: "1em" }}>
273
+ <TextField required id="input-map-nclusters" sx={{ marginBottom: "1em" }} label="N° Clusters" variant="outlined" onChange={e => setNClusters(e.target.value)} value={nClusters} />
274
+ <TextField required id="input-map-namelength" sx={{ marginBottom: "1em" }} label="Name length" variant="outlined" onChange={e => setNameLength(e.target.value)} value={nameLength} />
275
+ <TextField required id="input-map-mincountterms" sx={{ marginBottom: "1em" }} label="Min Count Terms" variant="outlined" onChange={e => setMinCountTerms(e.target.value)} value={minCountTerms} />
276
+ <RadioGroup required name="cleantopics-radio-group" defaultValue={cleanTopics} onChange={e => setCleanTopics(e.target.value)} variant="outlined" sx={{ marginBottom: "1em" }} disabled>
277
+ <FormLabel id="clean-topics-group-label">Clean Topics</FormLabel>
278
+ <FormControlLabel value={true} label="Yes" control={<Radio />} disabled />
279
+ <FormControlLabel value={false} label="No" control={<Radio />} disabled />
280
+ </RadioGroup>
281
+ <RadioGroup required name="language-radio-group" defaultValue={language} onChange={e => setLanguage(e.target.value)} variant="outlined" sx={{ marginBottom: "1em" }}>
282
+ <FormLabel id="language-group-label">Language</FormLabel>
283
+ <FormControlLabel value="french" label="fr" control={<Radio />} />
284
+ <FormControlLabel value="english" label="en" control={<Radio />} />
285
+ </RadioGroup>
286
+ </FormControl>
287
+ </Box>
288
+ )}
289
+ </div>
290
+ )}
291
+ </Container>
292
+ );
293
+ }
294
+
295
+ export default QueryView;
src/TextContainer.jsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import PropTypes from "prop-types";
3
+ import Box from "@mui/material/Box";
4
+ import Typography from "@mui/material/Typography";
5
+ import Paper from "@mui/material/Paper";
6
+ import List from "@mui/material/List";
7
+ import ListItem from "@mui/material/ListItem";
8
+ import ListItemText from "@mui/material/ListItemText";
9
+ import ListItemIcon from "@mui/material/ListItemIcon";
10
+ import DescriptionIcon from '@mui/icons-material/Description';
11
+
12
+ export const topicsSizeFraction = (topicsCentroids, topicSize) => {
13
+ const totalSize = topicsCentroids.reduce((sum, topic) => sum + topic.size, 0);
14
+ return Math.round((topicSize / totalSize) * 100);
15
+ }
16
+
17
+ function TextContainer({ topicName, topicSizeFraction, content }) {
18
+ const [selectedDocument, setSelectedDocument] = useState(null);
19
+
20
+ const handleDocumentClick = (docIndex) => {
21
+ if (selectedDocument === docIndex) {
22
+ setSelectedDocument(null);
23
+ } else {
24
+ setSelectedDocument(docIndex);
25
+ }
26
+ };
27
+
28
+ return (
29
+ <div id="topic-box-container">
30
+ <Box className="topic-box">
31
+ <Box
32
+ style={{
33
+ display: "flex",
34
+ flexDirection: "column",
35
+ alignItems: "center",
36
+ background: "rgb(94, 163, 252)",
37
+ padding: "8px",
38
+ color: "white",
39
+ textAlign: "center",
40
+ borderRadius: "20px", // Make it rounder
41
+ margin: "0 auto", // Center horizontally
42
+ width: "80%", // Adjust the width as needed
43
+ }}
44
+ >
45
+ <Typography variant="h4" style={{ marginBottom: "8px" }}>
46
+ {topicName}
47
+ </Typography>
48
+ </Box>
49
+ <Typography
50
+ variant="h5"
51
+ style={{
52
+ marginBottom: "20px",
53
+ marginTop: "20px",
54
+ textAlign: "center",
55
+ }}
56
+ >
57
+ {topicSizeFraction}% of the Territory
58
+ </Typography>
59
+ <Paper elevation={3} style={{ maxHeight: "70vh", overflowY: "auto" }}>
60
+ <List>
61
+ {content.map((doc, index) => (
62
+ <ListItem button key={`textcontainerdoc-${index}`} onClick={() => handleDocumentClick(index)} selected={selectedDocument === index}>
63
+ <ListItemIcon>
64
+ <DescriptionIcon /> {/* Display a document icon */}
65
+ </ListItemIcon>
66
+ <ListItemText primary={<span style={{ fontSize: "14px" }}>{doc}</span>} />
67
+ </ListItem>
68
+ ))}
69
+ </List>
70
+ </Paper>
71
+ </Box>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ TextContainer.propTypes = {
77
+ topicName: PropTypes.string.isRequired,
78
+ topicSizeFraction: PropTypes.number.isRequired,
79
+ content: PropTypes.array.isRequired,
80
+ };
81
+
82
+ export default TextContainer;
src/TreemapView.jsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Backdrop, CircularProgress, List, ListItem, Paper, Typography } from "@mui/material";
2
+ import * as d3 from "d3";
3
+ import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
4
+ import { TopicsContext } from "./UploadFileContext";
5
+
6
+ const bunkaTopics = "bunka_topics.json";
7
+ const { REACT_APP_API_ENDPOINT } = process.env;
8
+
9
+ function TreemapView() {
10
+ const svgRef = useRef(null);
11
+ const [selectedTopic, setSelectedTopic] = useState({ name: "", content: [] });
12
+ const { data: apiData, isLoading } = useContext(TopicsContext);
13
+
14
+ const createTreemap = useCallback((data) => {
15
+ const width = window.innerWidth * 0.6; // Adjust the width for the treemap
16
+ const height = 800; // Adjust the height as needed
17
+
18
+ const svg = d3.select(svgRef.current).attr("width", width).attr("height", height);
19
+
20
+ const root = d3.hierarchy({ children: data }).sum((d) => d.size);
21
+
22
+ const treemapLayout = d3.treemap().size([width, height]).padding(1).round(true);
23
+
24
+ treemapLayout(root);
25
+
26
+ const cell = svg
27
+ .selectAll("g")
28
+ .data(root.leaves())
29
+ .enter()
30
+ .append("g")
31
+ .attr("transform", (d) => `translate(${d.x0},${d.y0})`)
32
+ .on("click", (event, d) => {
33
+ const topicName = d.data.name;
34
+ const topicContent = d.data.top_doc_content || [];
35
+
36
+ setSelectedTopic({ name: topicName, content: topicContent });
37
+ });
38
+
39
+ cell
40
+ .append("rect")
41
+ .attr("width", (d) => d.x1 - d.x0)
42
+ .attr("height", (d) => d.y1 - d.y0)
43
+ .style("fill", "lightblue")
44
+ .style("stroke", "blue");
45
+
46
+ cell
47
+ .append("text")
48
+ .selectAll("tspan")
49
+ .data((d) => {
50
+ const text = d.data.name.split(/(?=[A-Z][^A-Z])/g); // Split topic name on capital letters
51
+ return text;
52
+ })
53
+ .enter()
54
+ .append("tspan")
55
+ .attr("x", 3)
56
+ .attr("y", (d, i) => 13 + i * 10)
57
+ .text((d) => d);
58
+
59
+ svg.selectAll("text").attr("font-size", 13).attr("fill", "black");
60
+ }, []);
61
+
62
+ useEffect(() => {
63
+ if (REACT_APP_API_ENDPOINT === "local" || apiData === undefined) {
64
+ // Fetch the JSON data locally
65
+ fetch(`/${bunkaTopics}`)
66
+ .then((response) => response.json())
67
+ .then((localData) => {
68
+ createTreemap(localData);
69
+ })
70
+ .catch((error) => {
71
+ console.error("Error fetching JSON data:", error);
72
+ });
73
+ } else {
74
+ // Call the function with the data provided by TopicsContext
75
+ createTreemap(apiData.topics);
76
+ }
77
+ }, [apiData, createTreemap]);
78
+
79
+ return (
80
+ <div>
81
+ <h2>Treemap View</h2>
82
+ {isLoading ? (
83
+ <Backdrop open={isLoading} style={{ zIndex: 9999 }}>
84
+ <CircularProgress color="primary" />
85
+ </Backdrop>
86
+ ) : (
87
+ <div style={{ display: "flex" }}>
88
+ <svg ref={svgRef} style={{ marginRight: "20px" }} />
89
+ <div
90
+ style={{
91
+ width: window.innerWidth * 0.25,
92
+ maxHeight: "800px",
93
+ overflowY: "auto",
94
+ }}
95
+ >
96
+ <Paper>
97
+ <Typography
98
+ variant="h4"
99
+ style={{
100
+ position: "sticky",
101
+ top: 0,
102
+ backgroundColor: "white",
103
+ color: "blue",
104
+ }}
105
+ >
106
+ {selectedTopic.name}
107
+ </Typography>
108
+ {selectedTopic.content.map((doc, index) => (
109
+ <List key={doc.id}>
110
+ <ListItem>
111
+ <Typography variant="h5">{doc}</Typography>
112
+ </ListItem>
113
+ </List>
114
+ ))}
115
+ {selectedTopic.content.length === 0 && <Typography variant="h4">Click on a Square.</Typography>}
116
+ </Paper>
117
+ </div>
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export default TreemapView;
src/UploadFileContext.css ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CSS for Error Message */
2
+ .errorMessage {
3
+ position: fixed;
4
+ top: 0;
5
+ width: 100%;
6
+ text-align: center;
7
+ padding: 10px;
8
+ z-index: 1000;
9
+ }
10
+
11
+ /* CSS for Loader */
12
+ .loader {
13
+ border: 4px solid #f3f3f3; /* Light grey */
14
+ border-top: 4px solid #3498db; /* Blue */
15
+ border-radius: 50%;
16
+ width: 400px;
17
+ height: 400px;
18
+ animation: spin 2s linear infinite;
19
+
20
+ /* Positioning */
21
+ position: fixed;
22
+ top: 100px; /* Adjust as needed */
23
+ left: 50%;
24
+ transform: translateX(-50%);
25
+ z-index: 1000;
26
+ }
27
+
28
+ @keyframes spin {
29
+ 0% { transform: rotate(0deg); }
30
+ 100% { transform: rotate(360deg); }
31
+ }
src/UploadFileContext.jsx ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Alert, Box, Typography, Backdrop } from "@mui/material";
2
+ import CircularProgress from '@mui/material/CircularProgress';
3
+ import axios from "axios";
4
+ import PropTypes from "prop-types";
5
+ import React, { createContext, useCallback, useEffect, useMemo, useState } from "react";
6
+
7
+ // Create the Context
8
+ export const TopicsContext = createContext();
9
+
10
+ const { REACT_APP_API_ENDPOINT } = process.env;
11
+
12
+ const TOPICS_ENDPOINT_PATH = `${REACT_APP_API_ENDPOINT}/topics/csv/`;
13
+ const BOURDIEU_ENDPOINT_PATH = `${REACT_APP_API_ENDPOINT}/bourdieu/csv/`;
14
+ const REFRESH_BOURDIEU_ENDPOINT_PATH = `${REACT_APP_API_ENDPOINT}/bourdieu/refresh/`;
15
+
16
+ // Fetcher functions
17
+ const postForm = (url, data) =>
18
+ axios
19
+ .post(url, data, {
20
+ headers: {
21
+ "Content-Type": "multipart/form-data",
22
+ },
23
+ })
24
+ .then((res) => res.data);
25
+
26
+ const postJson = (url, data) =>
27
+ axios
28
+ .post(url, data, {
29
+ headers: {
30
+ "Content-Type": "application/json",
31
+ },
32
+ })
33
+ .then((res) => res.data);
34
+
35
+ // Provider Component
36
+ export function TopicsProvider({ children, onSelectView, selectedView }) {
37
+ const [isLoading, setIsLoading] = useState(false);
38
+ const [data, setData] = useState();
39
+ const [bourdieuData, setBourdieuData] = useState();
40
+ const [error, setError] = useState();
41
+ const [errorText, setErrorText] = useState("");
42
+ const [taskProgress, setTaskProgress] = useState(0); // TODO Add state for task progress when the backend is ready
43
+ const [taskID, setTaskID] = useState(null); // Add state for task ID
44
+ const [currentDatasetId, setCurrentDatasetId] = useState(null); // Current Dataset Id equals Task Id for the moment
45
+
46
+ const monitorTaskProgress = async (selectedView, taskId) => {
47
+ const evtSource = new EventSource(`${REACT_APP_API_ENDPOINT}/tasks/${selectedView === "map" ? "topics" : "bourdieu"}/${taskId}/progress`);
48
+ evtSource.onmessage = function (event) {
49
+ try {
50
+ const data = JSON.parse(event.data);
51
+ const progress = !isNaN(Math.ceil(data.progress)) ? Math.ceil(data.progress) : 0;
52
+ console.log("Task Progress:", progress);
53
+ setTaskProgress(progress); // Update progress in state
54
+ if (data.state === "SUCCESS") {
55
+ if (selectedView === "map") {
56
+ setData({
57
+ docs: data.result.docs,
58
+ topics: data.result.topics
59
+ });
60
+ setBourdieuData(data.result.bourdieu_response);
61
+ } else if (selectedView === "bourdieu") {
62
+ setBourdieuData(data.result);
63
+ }
64
+ setTaskProgress(100);
65
+ evtSource.close();
66
+ setIsLoading(false);
67
+ setTaskID(null);
68
+ if (onSelectView) onSelectView(selectedView);
69
+ } else if (data.state === "FAILURE") {
70
+ setError(data.error);
71
+ setTaskProgress(0);
72
+ evtSource.close();
73
+ setIsLoading(false);
74
+ evtSource.close();
75
+ }
76
+ } catch (error) {
77
+ console.error("EventSource exception");
78
+ console.error(error);
79
+ setError(error);
80
+ evtSource.close();
81
+ setIsLoading(false);
82
+ }
83
+ };
84
+ };
85
+
86
+ // Handle File Upload and POST Request
87
+ const uploadFile = useCallback(
88
+ async (file, params) => {
89
+ setIsLoading(true);
90
+ setErrorText("");
91
+ const { nClusters, selectedColumn, selectedView, xLeftWord, xRightWord, yTopWord, yBottomWord, radiusSize } = params;
92
+ const { nameLength, language, cleanTopics, minCountTerms } = params;
93
+
94
+ try {
95
+ // Generate SHA-256 hash of the file
96
+ const formData = new FormData();
97
+ formData.append("file", file);
98
+ formData.append("selected_column", selectedColumn);
99
+ formData.append("n_clusters", nClusters);
100
+ formData.append("name_length", nameLength);
101
+ formData.append("language", language);
102
+ formData.append("clean_topics", cleanTopics);
103
+ formData.append("min_count_terms", minCountTerms);
104
+ // Append bourdieu parameters, processing activated by defaut
105
+ formData.append("process_bourdieu", true);
106
+ formData.append("x_left_words", xLeftWord);
107
+ formData.append("x_right_words", xRightWord);
108
+ formData.append("y_top_words", yTopWord);
109
+ formData.append("y_bottom_words", yBottomWord);
110
+ formData.append("radius_size", radiusSize);
111
+
112
+ const apiURI = `${selectedView === "map" ? TOPICS_ENDPOINT_PATH : BOURDIEU_ENDPOINT_PATH}`;
113
+ // Perform the POST request
114
+ const response = await postForm(apiURI, formData);
115
+ setTaskID(response.task_id);
116
+ setCurrentDatasetId(response.task_id);
117
+ await monitorTaskProgress(selectedView, response.task_id); // Start monitoring task progress
118
+ } catch (errorExc) {
119
+ // Handle error
120
+ setError(errorExc);
121
+ setTaskID(null);
122
+ setCurrentDatasetId(null);
123
+ } finally {
124
+ setIsLoading(false);
125
+ }
126
+ },
127
+ [monitorTaskProgress],
128
+ );
129
+
130
+ const refreshBourdieuQuery = useCallback(
131
+ async (params) => {
132
+ setIsLoading(true);
133
+ setErrorText("");
134
+ if (currentDatasetId !== null) {
135
+ try {
136
+ const apiURI = `${REFRESH_BOURDIEU_ENDPOINT_PATH}${currentDatasetId}`;
137
+ // Perform the POST request
138
+ const response = await postJson(apiURI, params);
139
+ setBourdieuData(response);
140
+ } catch (errorExc) {
141
+ // Handle error
142
+ setError(errorExc);
143
+ } finally {
144
+ setIsLoading(false);
145
+ }
146
+ } else {
147
+ setIsLoading(false);
148
+ setError("Please import a CSV from the Map view before querying");
149
+ }
150
+ },
151
+ [monitorTaskProgress],
152
+ );
153
+
154
+ /**
155
+ * Handle request errors
156
+ */
157
+ useEffect(() => {
158
+ if (error) {
159
+ const message = error.response?.data?.message || error.message || `${error}` || "An unknown error occurred";
160
+ setErrorText(`Error uploading file.\n${message}`);
161
+ console.error("Error uploading file:", message);
162
+ }
163
+ }, [error]);
164
+
165
+ /**
166
+ * Shared functions and variables of this TopicsContext and TopicsProvider
167
+ */
168
+ const providerValue = useMemo(
169
+ () => ({
170
+ data,
171
+ bourdieuData,
172
+ uploadFile,
173
+ isLoading,
174
+ error,
175
+ selectedView,
176
+ refreshBourdieuQuery
177
+ }),
178
+ [data, uploadFile, isLoading, error, selectedView, refreshBourdieuQuery],
179
+ );
180
+
181
+ // const normalisePercentage = (value) => Math.ceil((value * 100) / 100);
182
+
183
+ return (
184
+ <TopicsContext.Provider value={providerValue}>
185
+ <>
186
+ {isLoading && <div className="loader" />}
187
+ {/* Display a progress bar based on task progress */}
188
+ {taskID && (
189
+ <Backdrop
190
+ sx={{ zIndex: 99999 }}
191
+ open={taskID !== undefined}
192
+ >
193
+ <Box display={"flex"} width="30%" alignItems={"center"} flexDirection={"column"} sx={{ backgrounColor: "#FFF", fontSize: 20, fontWeight: 'medium' }}>
194
+ <Box minWidth={200}>
195
+ <Typography variant="h4">Bunka is cooking your data, please wait few seconds</Typography>
196
+ </Box>
197
+ <CircularProgress />
198
+ {/* <Box minWidth={35}>
199
+ <Typography variant="subtitle">{`${normalisePercentage(taskProgress)}%`}</Typography>
200
+ </Box> */}
201
+ </Box>
202
+ </Backdrop>
203
+ )}
204
+
205
+ {errorText && (
206
+ <Alert severity="error" className="errorMessage">
207
+ {errorText}
208
+ </Alert>
209
+ )}
210
+ {children}
211
+ </>
212
+ </TopicsContext.Provider>
213
+ );
214
+ }
215
+
216
+ TopicsProvider.propTypes = {
217
+ children: PropTypes.func.isRequired,
218
+ onSelectView: PropTypes.func.isRequired,
219
+ };
src/index.css ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ margin: 0;
3
+ padding: 0;
4
+ }
5
+
6
+ .json-display {
7
+ font-family: Arial, sans-serif;
8
+ display: flex;
9
+ flex-direction: column;
10
+ min-height: 90vh;
11
+ overflow-y: hidden;
12
+
13
+ }
14
+
15
+ .top-right {
16
+ display: flex;
17
+ flex-direction: row;
18
+ align-items: center;
19
+ /* Center vertically */
20
+ min-height: 3vh;
21
+ }
22
+
23
+ .linkedin-icon img {
24
+ width: 50px;
25
+ /* Adjust the width to your desired size */
26
+ height: auto;
27
+ /* Maintain aspect ratio */
28
+ }
29
+
30
+ /* Style for the Bunka logo */
31
+ .bunka-logo {
32
+ /* Set the width and height to make the logo smaller */
33
+ width: 200px;
34
+ /* Adjust the width as desired */
35
+ height: fit-content;
36
+ /* Maintain aspect ratio */
37
+ margin-top: 1em;
38
+ }
39
+
40
+ .topic-title {
41
+ word-spacing: 10px;
42
+ /* Adjust the spacing as needed */
43
+ }
44
+
45
+
46
+ /* Add or modify the following CSS for the LinkedIn icon */
47
+ .linkedin-icon {
48
+ position: absolute;
49
+ top: 10px;
50
+ right: 10px;
51
+ /* Adjust the width and height to make the logo smaller */
52
+ }
53
+
54
+ .linkedin-icon img {
55
+ width: 50px;
56
+ /* Adjust the width to your desired size */
57
+ height: auto;
58
+ /* Maintain aspect ratio */
59
+ }
60
+
61
+ .scatter-plot-and-text-container {
62
+ display: flex;
63
+ min-height: 89vh;
64
+ }
65
+
66
+ .scatter-plot-container {
67
+ display: flex;
68
+ width: 100%;
69
+ }
70
+
71
+ svg {
72
+ border: none;
73
+ }
74
+
75
+ .scatter-plot-container svg {
76
+ background-color: #f7f7f7;
77
+ border: 1px solid #ddd;
78
+ cursor: grab;
79
+ }
80
+
81
+ .scatter-plot-container svg .tick text {
82
+ font-size: 1.4em;
83
+ }
84
+
85
+ .text-container {
86
+ width: 35%;
87
+ min-width: 100px;
88
+ font-size: 30px;
89
+ align-items: center;
90
+ justify-content: center;
91
+ background-color: #fff;
92
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
93
+ border-radius: 5px;
94
+ }
95
+
96
+ /* Style for the title */
97
+ h2 {
98
+ font-size: 50px;
99
+ /* Specify the unit, e.g., 'px' */
100
+ margin: 10px 0;
101
+ color: rgb(24, 113, 222);
102
+ align-items: center;
103
+ }
104
+
105
+ /* Style for the text content within the text container */
106
+ .text-container p {
107
+ margin: 10px;
108
+ padding: 2px 0;
109
+ }
110
+
111
+ /* Style for the scatter plot circles */
112
+ circle {
113
+ cursor: pointer;
114
+ transition: fill 0.3s;
115
+ }
116
+
117
+ /* Style for clicked circles */
118
+ circle.clicked {
119
+ fill: pink;
120
+ }
121
+
122
+ /* Style for the contour lines */
123
+ path.contour {
124
+ fill: none;
125
+ stroke: black;
126
+ stroke-width: 1;
127
+ }
128
+
129
+ .box {
130
+ border: 3px solid #ccc;
131
+ padding: 10px;
132
+ background-color: white;
133
+ cursor: pointer;
134
+ margin: 30px;
135
+ /* Add margin to create space between each box */
136
+ }
137
+
138
+ .box.clicked {
139
+ background-color: lightgray;
140
+ }
141
+
142
+ /* Style for the fixed header container */
143
+ .topic-container {
144
+ display: flex;
145
+ flex-direction: column;
146
+ }
147
+
148
+ /* Style for the topic title inside the title box */
149
+ .topic-title {
150
+ margin: 0;
151
+ }
152
+
153
+ /* Style for the main content box */
154
+ .topic-content {
155
+ padding-top: 40px;
156
+ /* Adjust as needed to provide space for the fixed title */
157
+ }
158
+
159
+ /* Style for the content container */
160
+ .content-container {
161
+ display: flex;
162
+ }
163
+
164
+ /* Style for the topic title inside the title box */
165
+ .topic-title {
166
+ margin: 0;
167
+ }
168
+
169
+ .csv-upload-container {
170
+ margin-top: 50px;
171
+ flex: 1;
172
+ align-items: left;
173
+ margin-left: 0;
174
+ /* Remove margin from the left side */
175
+
176
+ /* Adjust the margin as needed */
177
+ }
178
+
179
+
180
+ .csv-upload-input {
181
+ margin-top: 50px;
182
+ flex: 1;
183
+ align-items: left;
184
+ margin-left: 0;
185
+ /* Remove margin from the left side */
186
+
187
+ /* Adjust the margin as needed */
188
+ }
189
+
190
+ .topic-box {
191
+ /* Adjust the maximum height as needed */
192
+ overflow-y: auto;
193
+ display: flex;
194
+ flex-direction: column;
195
+ }
196
+
197
+ .topic-box h2 {
198
+ position: sticky;
199
+ top: 0;
200
+ background-color: white;
201
+ align-self: center;
202
+ /* Set the background color to your desired value */
203
+ }
204
+
205
+ .content-container {
206
+ /* Adjust these styles as needed */
207
+ flex: 1;
208
+ overflow-y: auto;
209
+ }
src/index.jsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import ReactDOM from "react-dom/client";
3
+ import "./index.css";
4
+ import App from "./App";
5
+
6
+ const root = ReactDOM.createRoot(document.getElementById("root"));
7
+ root.render(
8
+ <React.StrictMode>
9
+ <App />
10
+ </React.StrictMode>,
11
+ );
src/logo.svg ADDED
src/react-app-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="react-scripts" />
src/setupTests.js ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ // jest-dom adds custom jest matchers for asserting on DOM nodes.
2
+ // allows you to do things like:
3
+ // expect(element).toHaveTextContent(/react/i)
4
+ // learn more: https://github.com/testing-library/jest-dom
5
+ import "@testing-library/jest-dom";