Initial commit

This commit is contained in:
Niels Pauls
2026-04-02 12:37:25 +02:00
commit 303c522419
24 changed files with 9028 additions and 0 deletions

29
www/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# 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?
# Environment variables
localhost-key.pem
localhost.pem
*.pem

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>

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