Spaces:
Runtime error
Runtime error
Charles De Dampierre
commited on
Commit
•
beea437
1
Parent(s):
2d2001b
first push
Browse files- .dockerignore +2 -0
- .gitattributes copy +35 -0
- .nvmrc +1 -0
- Dockerfile +29 -0
- Makefile +16 -0
- README.md +56 -6
- biome.json +3 -0
- env.model +3 -0
- nginx-configuration.conf +19 -0
- package-lock.json +3 -0
- package.json +3 -0
- public/android-chrome-192x192.png +0 -0
- public/android-chrome-512x512.png +0 -0
- public/apple-touch-icon.png +0 -0
- public/bunka_docs.json +3 -0
- public/bunka_logo.png +0 -0
- public/bunka_topics.json +3 -0
- public/favicon-16x16.png +0 -0
- public/favicon-32x32.png +0 -0
- public/favicon.ico +0 -0
- public/index.html +43 -0
- public/linkedin_logo.png +0 -0
- public/manifest.json +3 -0
- public/robots.txt +3 -0
- public/site.webmanifest +19 -0
- src/App.css +39 -0
- src/App.jsx +43 -0
- src/App.test.jsx +8 -0
- src/Bourdieu.jsx +486 -0
- src/DocsView.jsx +135 -0
- src/DropdownMenu.jsx +41 -0
- src/Map.jsx +367 -0
- src/Map_original.jsx +334 -0
- src/QueryView.jsx +295 -0
- src/TextContainer.jsx +82 -0
- src/TreemapView.jsx +124 -0
- src/UploadFileContext.css +31 -0
- src/UploadFileContext.jsx +219 -0
- src/index.css +209 -0
- src/index.jsx +11 -0
- src/logo.svg +1 -0
- src/react-app-env.d.ts +1 -0
- src/setupTests.js +5 -0
.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:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
-
|
9 |
---
|
10 |
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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";
|