Initial commit

This commit is contained in:
Niels Pauls
2026-04-02 12:37:25 +02:00
commit 6ea843fe66
26 changed files with 9077 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
# files related to poetry
__pycache__/
*.py[cod]
poetry.lock

BIN
data.ods Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

116
ods_to_json.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

33
www/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

54
www/src/App.jsx Normal file
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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

File diff suppressed because it is too large Load Diff

1
www/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

10
www/src/main.jsx Normal file
View 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>,
)

View 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
View 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
}
})