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