Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
# files related to poetry
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
poetry.lock
|
||||
1366
graph_data__20260402_120952.json
Normal file
1366
graph_data__20260402_120952.json
Normal file
File diff suppressed because it is too large
Load Diff
116
ods_to_json.py
Normal file
116
ods_to_json.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from datetime import datetime
|
||||
import pandas as pd # type: ignore
|
||||
import json
|
||||
import os
|
||||
|
||||
# Load ODS file
|
||||
df = pd.read_excel('data.ods', engine='odf')
|
||||
|
||||
# Create node list (convert each row to a dictionary)
|
||||
nodes = []
|
||||
|
||||
# Function to clean dictionary values
|
||||
def clean_dict(d):
|
||||
"""Remove keys with None values from a dictionary."""
|
||||
# Ensure that we are dealing with single values or lists (no arrays)
|
||||
cleaned_dict = {}
|
||||
for k, v in d.items():
|
||||
if isinstance(v, list): # If the value is a list, check each item for validity
|
||||
v = [item for item in v if pd.notna(item) and item is not None]
|
||||
if v: # Only include non-empty lists
|
||||
cleaned_dict[k] = v
|
||||
elif pd.notna(v) and v is not None:
|
||||
cleaned_dict[k] = v
|
||||
return cleaned_dict
|
||||
|
||||
# Iterate over rows to create nodes
|
||||
for _, row in df.iterrows():
|
||||
# Handle the "image" field to check for multiple images
|
||||
image_value = str(row["image"]).strip() if pd.notna(row["image"]) else None
|
||||
|
||||
if image_value:
|
||||
if ',' in image_value:
|
||||
# Split multiple image paths into a list
|
||||
images = [image.strip() for image in image_value.split(',')]
|
||||
else:
|
||||
# Single image, store as a string
|
||||
images = image_value
|
||||
else:
|
||||
images = None # No images if the field is empty or NaN
|
||||
|
||||
node = {
|
||||
"id": str(row["id"]), # Ensure ID is a string
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"category": row["category"],
|
||||
"location": {
|
||||
"country": row["country"],
|
||||
"state": row["state"],
|
||||
"city": row["city"],
|
||||
},
|
||||
"group": row["country"].split()[0], # Only get the 3 letter code without emoji
|
||||
"image": images, # Store image(s) (single or list)
|
||||
"link": row["link"],
|
||||
}
|
||||
|
||||
# Clean the node dictionary before appending
|
||||
nodes.append(clean_dict(node))
|
||||
|
||||
# Create a dictionary to store nodes by location for linking
|
||||
location_groups = {}
|
||||
|
||||
for node in nodes:
|
||||
country = node["location"]["country"].split()[0] # Only get the 3 letter code without emoji
|
||||
state = node["location"]["state"]
|
||||
city = node["location"]["city"]
|
||||
node_id = node["id"]
|
||||
|
||||
# Create hierarchical keys for grouping
|
||||
country_key = country
|
||||
state_key = f"{country}|{state}"
|
||||
city_key = f"{country}|{state}|{city}"
|
||||
|
||||
# Initialize lists if they don't exist
|
||||
location_groups.setdefault(country_key, []).append(node_id)
|
||||
location_groups.setdefault(state_key, []).append(node_id)
|
||||
location_groups.setdefault(city_key, []).append(node_id)
|
||||
|
||||
# Generate links based on hierarchical grouping
|
||||
links = []
|
||||
|
||||
def create_links(node_list, strength):
|
||||
"""Create links between nodes in the same group"""
|
||||
for i in range(len(node_list)):
|
||||
for j in range(i + 1, len(node_list)):
|
||||
links.append({
|
||||
"source": node_list[i],
|
||||
"target": node_list[j],
|
||||
"strength": strength
|
||||
})
|
||||
|
||||
# Apply links at different levels
|
||||
for city_nodes in location_groups.values():
|
||||
if len(city_nodes) > 1:
|
||||
create_links(city_nodes, strength=3) # Strongest links
|
||||
|
||||
for state_nodes in location_groups.values():
|
||||
if len(state_nodes) > 1:
|
||||
create_links(state_nodes, strength=2) # Medium links
|
||||
|
||||
for country_nodes in location_groups.values():
|
||||
if len(country_nodes) > 1:
|
||||
create_links(country_nodes, strength=1) # Weakest links
|
||||
|
||||
# Final JSON structure
|
||||
graph_data = {"nodes": nodes, "links": links}
|
||||
|
||||
# Save to JSON
|
||||
# Format: graph_data_YYYYMMDD_HHMMSS.json
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
output_file = f"graph_data__{timestamp}.json"
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(graph_data, f, indent=4)
|
||||
|
||||
# Print success message and run macOS sound command when script finishes
|
||||
print(f"\n✅ JSON file '{output_file}' generated successfully!")
|
||||
os.system("afplay /System/Library/Sounds/Glass.aiff")
|
||||
16
pyproject.toml
Normal file
16
pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[tool.poetry]
|
||||
name = "antennas"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["yoNico21 <119441054+yoNico21@users.noreply.github.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.11"
|
||||
pandas = "^2.2.3"
|
||||
odfpy = "^1.4.1"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
24
www/.gitignore
vendored
Normal file
24
www/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
8
www/README.md
Normal file
8
www/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# React + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
38
www/eslint.config.js
Normal file
38
www/eslint.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import react from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
react,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/jsx-no-target-blank': 'off',
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
13
www/index.html
Normal file
13
www/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
www/localhost-key.pem
Normal file
28
www/localhost-key.pem
Normal file
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDtu2TiZRgYzZiT
|
||||
FLwJVG9tfi7lMN7AeeQcLJFSv6rHbPYUYgxT0beOOv7F3/2sr1DUrAgu8G/OdY8j
|
||||
sSa/0sy+igrBdjep+LACYq5JgdSGv8IXi1ajOmvq6xtyuEDey7ZN7EZ25umWE4IR
|
||||
ycJ7UNq32BmJv6E1+d1/i6aLzB2chA6T3LbASyN/uU9wGoClT7lUemGzK1/Zl8Sx
|
||||
P1XZRFLBcsp0P/5iOw5G/XThAek9GlxcusQcctAt1uYHIP7YILEYf9LXJVqaK/VN
|
||||
dkVKReGa3k8bKOT7wUnWsm41KI4B2Cmci55C7Cp1psDJFqVTlOlENI9AlTGsAxf0
|
||||
DwIkRq+rAgMBAAECggEAEODmJ8C/tjsaow6szwjEpIR59eHDXu0IiguMAdED4gbV
|
||||
bdsMPwM7wghkQE5H53PHDGJubk650XG9SO5x7tqbmMeaPeQTzaiPbovtDeD+4tZI
|
||||
hH1rGEm0kchBeaKlUGwsNFobIqQAX5xg9rxZZ4H1FWptXb9SRc/Irx6OI4zJaWw/
|
||||
yA4vXUN3Pmsj328bvoMD6sYpnBYup9/TAscyshzSayEN8j48WkDGEpqWwuNMptZr
|
||||
UIvEe/bjoREBMW44DsDrmWqU2/6XorHfJV689p2BwTF+U0Yui8YJQ6UoS5tHENb3
|
||||
kTn5uLG5aa4e0poXcQQ+HASFVsYK7wYnaj6Oc+cdyQKBgQD6FbyF++nUZ095bg2d
|
||||
3ExHpafOvKzuVzrjGEk4nqX4F7wD1clGNyOEyC/6bo7KGoupxr1x3WavIbhQwSP1
|
||||
RF0EVtZZuG17x6OM8Si8r57r0yVQ5FE9x3OpB0ZSzjdga9rhrfn71Vm+Dg9G8O1G
|
||||
gqlghciIK5LQT+nxBnZWibMOfQKBgQDzWtxiiLHT6nO5JZfaKxG5qIvMqc9VnKKY
|
||||
gAbQS92mpVds0BSItzduBTscEIOK2mywHKtz0nnNiDSvqIDjq879Os1ih+NpAzIz
|
||||
6jOL8NcGk8WQTO6M9opr2F34en9A1flMGkdOw25TWV4eh4JxS9kmr9zLpWlH095C
|
||||
I+al201HRwKBgQCTrjRW9s09lghzj87QsuAEy0lOJ1MDqFVo99V9bwpZeEKaDSw/
|
||||
n54E8maKv7DonkZtaqRC0liqAQKkBXojg0xenJ0V/HgCyYDGYT8KNbdmUZjOrRVg
|
||||
oyCk51va2FYRRX/LF37w/StytUDGRs4Hfm9hRX+HEhwvkZF3uLY3IjevzQKBgAXn
|
||||
cG1bj6TqjUAyr0p1vQpaEno2rHcRQ8ibYo7vKEOAw2w6aCUg/NFIgzSdGfPmdLiZ
|
||||
GXfH5XidE1onpmjfpDf0k0MOtO+5SiCEUBfXEgBw2Vw04Zy95oHTUARVRH0YM+Iq
|
||||
yQwaJbUT9/qZowqIoo4TujGeo71AYKYOJOxlM6zFAoGBALopzmaDlXOFZAdh0pec
|
||||
Bp1j1ep93Vr9R3QPUCtwAvfDxC8MrqEZgYvbcYXJlLxBIOiqkYbhHgNnYhlPlxpA
|
||||
hJnXMXi8AMGZ3Hf3ZztcF/JQGrFj5Eek/JdgXviIQ2pzWJwx63zgK5m6PQ83n+Te
|
||||
ApZfvygHC+cEXSP1Y6DUD0Wj
|
||||
-----END PRIVATE KEY-----
|
||||
26
www/localhost.pem
Normal file
26
www/localhost.pem
Normal file
@@ -0,0 +1,26 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEbTCCAtWgAwIBAgIQaTliK2aobewhy3PwYdUZeDANBgkqhkiG9w0BAQsFADCB
|
||||
nzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTowOAYDVQQLDDFuaWVs
|
||||
c3BhdWxzQE5pZWxzcy1NYWNCb29rLVByby5sb2NhbCAoTmllbHMgUGF1bHMpMUEw
|
||||
PwYDVQQDDDhta2NlcnQgbmllbHNwYXVsc0BOaWVsc3MtTWFjQm9vay1Qcm8ubG9j
|
||||
YWwgKE5pZWxzIFBhdWxzKTAeFw0yNTA0MTQyMzUzNDdaFw0yNzA3MTQyMzUzNDda
|
||||
MGUxJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE6MDgG
|
||||
A1UECwwxbmllbHNwYXVsc0BOaWVsc3MtTWFjQm9vay1Qcm8ubG9jYWwgKE5pZWxz
|
||||
IFBhdWxzKTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO27ZOJlGBjN
|
||||
mJMUvAlUb21+LuUw3sB55BwskVK/qsds9hRiDFPRt446/sXf/ayvUNSsCC7wb851
|
||||
jyOxJr/SzL6KCsF2N6n4sAJirkmB1Ia/wheLVqM6a+rrG3K4QN7Ltk3sRnbm6ZYT
|
||||
ghHJwntQ2rfYGYm/oTX53X+LpovMHZyEDpPctsBLI3+5T3AagKVPuVR6YbMrX9mX
|
||||
xLE/VdlEUsFyynQ//mI7Dkb9dOEB6T0aXFy6xBxy0C3W5gcg/tggsRh/0tclWpor
|
||||
9U12RUpF4ZreTxso5PvBSdaybjUojgHYKZyLnkLsKnWmwMkWpVOU6UQ0j0CVMawD
|
||||
F/QPAiRGr6sCAwEAAaNeMFwwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsG
|
||||
AQUFBwMBMB8GA1UdIwQYMBaAFMKekrp7XcvgTZXjLmq2D+bGAGq6MBQGA1UdEQQN
|
||||
MAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAYEASM0As+9N4PSeBwojhqi/
|
||||
IhSNwPE+qKKzOF0dG+r3yL0suWoUlf7CPKsrgJXTfMks6FROn7uzBavDhf8EZ54L
|
||||
PJWUIvLGWJPhg37Iz7+ir7mMuZeJm7scyH6GwKXaqdZsM+kE+FUi7DWqm5lS6b6b
|
||||
zEiWK9mgmobSsqeTs6IYFS1WtQt/CdsRCmZlFJ/+qknJZy634PotJE5tAhk+odBx
|
||||
Ja2JT+EoVK+FKNa/zj20R7RRRyMD9PdnasJg9oXXBDyxCPFbe4/AT+vPpxMSnPJp
|
||||
Rkg2L2vdUpqGd+UKJjQONEeoNUL0pmPEjXnO5wQVFjPfoy0c3zLO58hAoyXYZyQ9
|
||||
ucxoWZXwlzC9RW1+ez8WfCg46RowogSBFu18koizU0f15qVLpPvA7kVaSd7OYhSv
|
||||
+f6gIWgZoXv2EyzoRD8co4M+hYA+BjnjDf386IVeOzqSXUApgMny5TrDP3a+cF1a
|
||||
0BiWJdvxb7n9xe3Tti0k3xoiEgJEtJIMa/iF6B6zkp25
|
||||
-----END CERTIFICATE-----
|
||||
5514
www/package-lock.json
generated
Normal file
5514
www/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
www/package.json
Normal file
33
www/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "www",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@tailwindcss/vite": "^4.0.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-force-graph-3d": "^1.26.0",
|
||||
"tailwindcss": "^4.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react": "^7.37.4",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
BIN
www/public/IMG_20241112_232748.jpg
Normal file
BIN
www/public/IMG_20241112_232748.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
54
www/src/App.jsx
Normal file
54
www/src/App.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import GraphComponent from "./components/GraphComponent";
|
||||
import CategoryFilter from "./components/CategoryFilter";
|
||||
import ResetButton from "./components/ResetButton";
|
||||
import { startRecording, stopRecording } from "./utils/js/screenRecorder";
|
||||
|
||||
export default function App() {
|
||||
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Function to handle key press for starting/stopping recording
|
||||
const handleKeyPress = (event) => {
|
||||
if (event.key === 'r') {
|
||||
if (isRecording) {
|
||||
stopRecording(); // Stop the recording
|
||||
} else {
|
||||
startRecording(); // Start the recording
|
||||
}
|
||||
setIsRecording(!isRecording); // Toggle recording state
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener for keydown event
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
// Cleanup the event listener on unmount
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [isRecording]); // Re-run effect when isRecording changes
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen flex flex-col bg-gray-900 text-white relative overflow-hidden">
|
||||
|
||||
{/*UI top*/}
|
||||
<div className="absolute top-0 left-0 right-0 z-10">
|
||||
{/* Title */}
|
||||
<h1 className="text-4xl font-bold text-center mt-6 uppercase">Antenna Archive 𐂷</h1>
|
||||
|
||||
{/* Category Filter Bar */}
|
||||
<div className="flex justify-center space-x-4 mt-4">
|
||||
<CategoryFilter selectedCategory={selectedCategory} setSelectedCategory={setSelectedCategory} />
|
||||
<ResetButton selectedCategory={selectedCategory} setSelectedCategory={setSelectedCategory} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Graph */}
|
||||
<div className="flex-grow">
|
||||
<GraphComponent selectedCategory={selectedCategory} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
www/src/components/CategoryFilter.jsx
Normal file
22
www/src/components/CategoryFilter.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function CategoryFilter({ selectedCategory, setSelectedCategory }) {
|
||||
const categories = ["Camouflaged", "Non-Camouflaged", "Large", "Tiny"];
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => {
|
||||
console.log(category);
|
||||
setSelectedCategory(category);
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
selectedCategory === category ? "bg-blue-500" : "bg-gray-700"
|
||||
} hover:bg-blue-400`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
www/src/components/GraphComponent.alt.jsx
Normal file
107
www/src/components/GraphComponent.alt.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import ForceGraph3D from "react-force-graph-3d";
|
||||
import graphData from "../data.json";
|
||||
import SpriteText from "https://esm.sh/three-spritetext";
|
||||
|
||||
export default function GraphComponent({ selectedCategory }) {
|
||||
const fgRef = useRef(null);
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [graphDataState, setGraphDataState] = useState({ nodes: [], links: [] });
|
||||
const nodePositions = useRef({}); // Store node positions across re-renders
|
||||
|
||||
useEffect(() => {
|
||||
const filteredNodes = selectedCategory
|
||||
? graphData.nodes.filter((node) => node.category === selectedCategory)
|
||||
: graphData.nodes;
|
||||
|
||||
const filteredNodeIds = new Set(filteredNodes.map((node) => node.id));
|
||||
const filteredLinks = graphData.links.filter(
|
||||
(link) => filteredNodeIds.has(link.source) && filteredNodeIds.has(link.target)
|
||||
);
|
||||
|
||||
// Preserve node positions when switching categories
|
||||
filteredNodes.forEach((node) => {
|
||||
if (nodePositions.current[node.id]) {
|
||||
node.x = nodePositions.current[node.id].x;
|
||||
node.y = nodePositions.current[node.id].y;
|
||||
node.z = nodePositions.current[node.id].z;
|
||||
}
|
||||
});
|
||||
|
||||
setGraphDataState({ nodes: filteredNodes, links: filteredLinks });
|
||||
}, [selectedCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fgRef.current) return; // ✅ Prevent errors if ref isn't available
|
||||
|
||||
// Wait until ForceGraph3D is fully initialized
|
||||
const timeout = setTimeout(() => {
|
||||
if (!fgRef.current) return;
|
||||
|
||||
fgRef.current.d3Force("charge").strength(-120); // Adjust node clustering
|
||||
|
||||
// Save node positions when the graph stabilizes
|
||||
const handleEngineStop = () => {
|
||||
const nodes = fgRef.current.graphData().nodes;
|
||||
nodes.forEach((node) => {
|
||||
nodePositions.current[node.id] = { x: node.x, y: node.y, z: node.z };
|
||||
});
|
||||
};
|
||||
|
||||
fgRef.current.onEngineStop(handleEngineStop); // ✅ Correct way to use engineStop event
|
||||
}, 500); // Slight delay ensures ref is available
|
||||
|
||||
return () => clearTimeout(timeout); // Cleanup timeout on unmount
|
||||
}, [graphDataState]); // Runs when graph data changes
|
||||
|
||||
const handleNodeClick = useCallback((node) => {
|
||||
setSelectedNode(node);
|
||||
}, []);
|
||||
|
||||
const handleBackgroundClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{selectedNode && (
|
||||
<div
|
||||
className="bg-[rgba(255,255,255,0.1)] rounded-lg shadow-md z-10
|
||||
absolute left-[50%] top-[50%] transform-gpu -translate-x-1/2 -translate-y-1/2 max-h-[70vh] p-4 pb-13"
|
||||
>
|
||||
<h3 className="text-lg font-bold mb-2">{selectedNode.name}</h3>
|
||||
<button
|
||||
className="absolute top-0 right-0 p-2 pr-4 text-[rgba(255,255,255,0.5)] cursor-pointer"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<div className="overflow-y-scroll max-h-[100%] w-[65ch] rounded-sm">
|
||||
{selectedNode.image && (
|
||||
<img
|
||||
className="rounded-sm w-full object-contain mb-4"
|
||||
src={selectedNode.image}
|
||||
alt={selectedNode.name}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm">{selectedNode.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ForceGraph3D
|
||||
ref={fgRef}
|
||||
graphData={graphDataState}
|
||||
nodeAutoColorBy="group"
|
||||
nodeThreeObject={(node) => {
|
||||
const sprite = new SpriteText(node.name);
|
||||
sprite.color = node.color;
|
||||
sprite.textHeight = 8;
|
||||
return sprite;
|
||||
}}
|
||||
onNodeClick={handleNodeClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
177
www/src/components/GraphComponent.jsx
Normal file
177
www/src/components/GraphComponent.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import ForceGraph3D from "react-force-graph-3d";
|
||||
import graphData from "../data.json";
|
||||
import SpriteText from "https://esm.sh/three-spritetext";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faInfoCircle, faExternalLinkAlt, faChevronLeft, faChevronRight } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default function GraphComponent({ selectedCategory }) {
|
||||
const fgRef = useRef();
|
||||
const [selectedNode, setSelectedNode] = useState(null);
|
||||
const [graphDataState, setGraphDataState] = useState({ nodes: [], links: [] });
|
||||
const [imageIndex, setImageIndex] = useState(0); // State to track current image in the carousel
|
||||
|
||||
// Set distance from camera orbit
|
||||
const distance = 400;
|
||||
const speed = 0.3;
|
||||
|
||||
useEffect(() => {
|
||||
// Clone the original graph data to avoid modifying it directly
|
||||
const updatedGraphData = {
|
||||
nodes: graphData.nodes.map(node => ({
|
||||
...node,
|
||||
visible: !selectedCategory || node.category === selectedCategory // Hide nodes not in selected category
|
||||
})),
|
||||
links: graphData.links.map(link => ({
|
||||
...link,
|
||||
visible:
|
||||
(!selectedCategory ||
|
||||
(graphData.nodes.find(n => n.id === link.source)?.category === selectedCategory &&
|
||||
graphData.nodes.find(n => n.id === link.target)?.category === selectedCategory)) // Hide links if either connected node is hidden
|
||||
}))
|
||||
};
|
||||
|
||||
setGraphDataState(updatedGraphData);
|
||||
|
||||
// Camera orbit logic
|
||||
const cameraOrbit = () => {
|
||||
let angle = 0;
|
||||
fgRef.current.cameraPosition({ z: distance });
|
||||
|
||||
// Set interval for camera orbit effect
|
||||
const intervalId = setInterval(() => {
|
||||
fgRef.current.cameraPosition({
|
||||
x: distance * Math.sin(angle),
|
||||
z: distance * Math.cos(angle)
|
||||
});
|
||||
angle += (Math.PI / 300) * speed;
|
||||
}, 10);
|
||||
|
||||
// Clear up the interval when the component is unmounted
|
||||
return () => clearInterval(intervalId);
|
||||
};
|
||||
|
||||
// Call camera orbit after the graph is set
|
||||
const cleanupCameraOrbit = cameraOrbit();
|
||||
|
||||
return cleanupCameraOrbit;
|
||||
}, [selectedCategory]);
|
||||
|
||||
const handleNodeClick = useCallback((node) => {
|
||||
setSelectedNode(node);
|
||||
setImageIndex(0); // Reset the image index when a new node is clicked
|
||||
}, []);
|
||||
|
||||
const handleBackgroundClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
// Carousel navigation functions
|
||||
const prevImage = () => {
|
||||
if (selectedNode && selectedNode.image && Array.isArray(selectedNode.image)) {
|
||||
setImageIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : selectedNode.image.length - 1));
|
||||
}
|
||||
};
|
||||
|
||||
const nextImage = () => {
|
||||
if (selectedNode && selectedNode.image && Array.isArray(selectedNode.image)) {
|
||||
setImageIndex((prevIndex) => (prevIndex < selectedNode.image.length - 1 ? prevIndex + 1 : 0));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Information Window Wrapper */}
|
||||
{selectedNode && (
|
||||
<div className="bg-gray-700 rounded-lg shadow-md z-10
|
||||
absolute left-[50%] top-[50%] transform-gpu -translate-x-1/2 -translate-y-1/2 p-4 h-[max-content] max-h-[80%]">
|
||||
{/* Image and Description */}
|
||||
<div className="rounded-sm flex flex-row h-[100%] w-[100%] space-x-4">
|
||||
{/* Image Section */}
|
||||
{selectedNode.image && (
|
||||
<div className="flex flex-col justify-center items-center relative">
|
||||
{/* If it's an array of images, render carousel */}
|
||||
{Array.isArray(selectedNode.image) ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
className="rounded-sm object-contain w-[100%] h-auto"
|
||||
src={selectedNode.image[imageIndex]}
|
||||
alt={`${selectedNode.name} - Image ${imageIndex + 1}`}
|
||||
/>
|
||||
{/* Left and Right Chevron Buttons */}
|
||||
<button
|
||||
className="absolute left-0 top-1/2 transform -translate-y-1/2 text-white text-xl"
|
||||
onClick={prevImage}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-0 top-1/2 transform -translate-y-1/2 text-white text-xl"
|
||||
onClick={nextImage}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Single image
|
||||
<img
|
||||
className="rounded-sm object-contain w-[100%]"
|
||||
src={selectedNode.image}
|
||||
alt={selectedNode.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Text Content */}
|
||||
<div className="w-[100%] max-w-64ch">
|
||||
<div className="flex flex-row space-x-2">
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-bold">{selectedNode.name}</h3>
|
||||
{/* Info Button fontawesome */}
|
||||
{selectedNode.link && (
|
||||
<div className="flex space-x-2 ml-2">
|
||||
<button className="text-[rgba(255,255,255,0.5)] text-sm">
|
||||
<FontAwesomeIcon icon={faInfoCircle} />
|
||||
</button>
|
||||
<button className="hover:text-white text-[rgba(255,255,255,0.5)] text-sm">
|
||||
<a href={selectedNode.link} target="_blank" rel="noreferrer">
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h6 className="text-xs text-gray-400 mb-2">{selectedNode.location.city} | {selectedNode.location.state} | {selectedNode.location.country}</h6>
|
||||
{/* Description */}
|
||||
<p className="text-sm text-justify">{selectedNode.description}</p>
|
||||
</div>
|
||||
{/* Close button */}
|
||||
<button
|
||||
className="self-start hover:text-white text-[rgba(255,255,255,0.5)] text-sm"
|
||||
onClick={() => setSelectedNode(null)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ForceGraph3D */}
|
||||
<ForceGraph3D
|
||||
ref={fgRef}
|
||||
graphData={graphDataState}
|
||||
nodeAutoColorBy="group"
|
||||
nodeThreeObject={node => {
|
||||
const sprite = new SpriteText(node.name);
|
||||
sprite.color = node.color;
|
||||
sprite.textHeight = 8;
|
||||
return sprite;
|
||||
}}
|
||||
linkVisibility={link => link.visible} // Hide links properly
|
||||
nodeVisibility={node => node.visible} // Hide nodes properly
|
||||
onNodeClick={handleNodeClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
www/src/components/ResetButton.jsx
Normal file
18
www/src/components/ResetButton.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faTimes } from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
export default function ResetButton({ selectedCategory, setSelectedCategory }) {
|
||||
const isActive = selectedCategory !== null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
disabled={!isActive}
|
||||
className={`px-4 py-2 rounded-lg transition ${
|
||||
isActive ? "bg-red-500 hover:bg-red-400" : "bg-gray-700 text-gray-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faTimes} /> Reset
|
||||
</button>
|
||||
);
|
||||
}
|
||||
33
www/src/components/bu.GraphComponent.jsx
Normal file
33
www/src/components/bu.GraphComponent.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import ForceGraph3D from "react-force-graph-3d";
|
||||
import graphData from "../data.json";
|
||||
|
||||
export default function GraphComponent({ selectedCategory }) {
|
||||
const fgRef = useRef();
|
||||
|
||||
// Filter nodes based on category
|
||||
const filteredNodes = selectedCategory
|
||||
? graphData.nodes.map((node) => ({
|
||||
...node,
|
||||
muted: node.category !== selectedCategory,
|
||||
}))
|
||||
: graphData.nodes.map((node) => ({ ...node, muted: false }));
|
||||
|
||||
return (
|
||||
<ForceGraph3D
|
||||
ref={fgRef}
|
||||
graphData={{
|
||||
nodes: filteredNodes,
|
||||
links: graphData.links,
|
||||
}}
|
||||
nodeCanvasObject={(node, ctx) => {
|
||||
const label = node.name;
|
||||
ctx.font = "10px Sans-Serif";
|
||||
ctx.fillStyle = node.muted ? "rgba(255,255,255,0.2)" : "white"; // Dim muted nodes
|
||||
ctx.fillText(label, node.x, node.y);
|
||||
}}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleSpeed={0.005}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1366
www/src/data.json
Normal file
1366
www/src/data.json
Normal file
File diff suppressed because it is too large
Load Diff
1
www/src/index.css
Normal file
1
www/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
10
www/src/main.jsx
Normal file
10
www/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
75
www/src/utils/js/screenRecorder.js
Normal file
75
www/src/utils/js/screenRecorder.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// screenRecorder.js
|
||||
|
||||
let mediaRecorder;
|
||||
let recordedChunks = [];
|
||||
let isRecording = false;
|
||||
let stream;
|
||||
|
||||
// Function to start the screen recording
|
||||
export async function startRecording() {
|
||||
let stream;
|
||||
let mediaRecorder;
|
||||
let recordedChunks = [];
|
||||
|
||||
try {
|
||||
// Get screen capture (user must allow this)
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { mediaSource: "screen" }
|
||||
});
|
||||
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
|
||||
// Collect the video data chunks
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
recordedChunks.push(event.data);
|
||||
};
|
||||
|
||||
// When recording stops, process the chunks into a video file
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(recordedChunks, { type: 'video/webm' });
|
||||
const videoUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = videoUrl;
|
||||
a.download = 'recording.webm';
|
||||
a.click();
|
||||
};
|
||||
|
||||
// Start recording
|
||||
mediaRecorder.start();
|
||||
console.log('Recording started');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error starting recording:', err);
|
||||
if (err.name === 'NotAllowedError') {
|
||||
console.error('User denied screen capture permission.');
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
console.error('No available display media devices found. Is your browser configured for screen capture?');
|
||||
} else if (err.name === 'NotReadableError') {
|
||||
console.error('A camera or microphone is being used or is not accessible.');
|
||||
} else if (err.name === 'AbortError') {
|
||||
console.error('The operation was aborted (usually when the user cancels).');
|
||||
} else {
|
||||
console.error('Unknown error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure to clean up the stream and media recorder when done
|
||||
return () => {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop()); // Stop all media tracks
|
||||
}
|
||||
console.log('Recording stopped and resources cleaned up');
|
||||
};
|
||||
}
|
||||
|
||||
// Function to stop the recording
|
||||
export function stopRecording() {
|
||||
if (mediaRecorder && isRecording) {
|
||||
mediaRecorder.stop();
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
console.log('Recording stopped');
|
||||
}
|
||||
}
|
||||
28
www/vite.config.js
Normal file
28
www/vite.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
],
|
||||
optimizeDeps: {
|
||||
include: ['three'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
three: 'three'
|
||||
}
|
||||
},
|
||||
server: {
|
||||
https: {
|
||||
key: fs.readFileSync(path.resolve(__dirname, 'localhost-key.pem')),
|
||||
cert: fs.readFileSync(path.resolve(__dirname, 'localhost.pem')),
|
||||
},
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user