Compare commits
29 Commits
f2ccb6b840
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
349495019f
|
|||
|
bb31650718
|
|||
|
ce9ed1f950
|
|||
| f314b960e1 | |||
|
4f97e26295
|
|||
|
a2fe2780cf
|
|||
|
3c16148bb7
|
|||
|
9a8e97885c
|
|||
|
46feef3a48
|
|||
| 6e7dd7a560 | |||
| 7a347ed246 | |||
|
4f5b301e7d
|
|||
|
8fedbc34fa
|
|||
| 47d4c7926e | |||
|
3fe9ecbbb8
|
|||
|
7435e91b07
|
|||
|
e0b685bfda
|
|||
|
7498d9066e
|
|||
| b974ac8936 | |||
|
02526f340a
|
|||
| d7da674267 | |||
| 9029739657 | |||
| 27eadaeca4 | |||
| 7bd76c1d4a | |||
| 62a656cfda | |||
| 6e3a97da5f | |||
| 724a423ce8 | |||
|
2ae20cdac1
|
|||
|
81fb8beece
|
@@ -31,7 +31,7 @@ RUN cp /psn-py/psn$(python3-config --extension-suffix) /output
|
||||
|
||||
FROM alpine:3.20 AS final
|
||||
|
||||
RUN apk add --no-cache python3 py3-aiohttp
|
||||
RUN apk add --no-cache python3 py3-aiohttp py3-python-osc
|
||||
|
||||
|
||||
COPY --from=backend /output /backend/psn.so
|
||||
|
||||
26
README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# followspot-psn
|
||||
|
||||
Solution for controlling followspots, and sending the position data to ma3 over
|
||||
[posistagenet (PSN)](https://posistage.net/). It uses
|
||||
[psn-py](https://github.com/vyv/psn-py) to do PSN stuff.
|
||||
|
||||
### Deployment
|
||||
|
||||
**TLDR**: `docker compose up --build` on a Linux machine.
|
||||
|
||||
> [!NOTE]
|
||||
> Why Linux? This is because PSN uses multicast to transmit positional data to
|
||||
> ma3. On linux this is done using host networking driver. However the host
|
||||
> networking driver on macOS or windows will only allow broadcasting on the
|
||||
> docker linux VM. It could probably be done using some fancy routing/bridge
|
||||
> setup with the docker VM.
|
||||
|
||||
This opens a webserver on port 8000 where you can control the PSN trackers. PSN
|
||||
data is multicasted on `236.10.10.10:56565`. Tracker positions can also be
|
||||
updated using OSC, by default OSC listens on port 9000. The OSC endpoint expects
|
||||
data on `/Tracker/{trackerid}` with three floats specifying x, y and z value.
|
||||
|
||||
Currently all configuration is done in the source files, most notably in
|
||||
[psn_server.py](backend/psn_server.py).
|
||||
|
||||
The rest of the functionality is documented in the code.
|
||||
@@ -1,18 +1,69 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
import weakref
|
||||
from dataclasses import dataclass
|
||||
|
||||
import psn
|
||||
from aiohttp import web
|
||||
from aiohttp import WSCloseCode, web
|
||||
from pythonosc.dispatcher import Dispatcher
|
||||
from pythonosc.osc_server import AsyncIOOSCUDPServer
|
||||
|
||||
PSN_DEFAULT_UDP_PORT = 56565
|
||||
PSN_DEFAULT_UDP_MCAST_ADDRESS = "236.10.10.10"
|
||||
PORT = 8000
|
||||
PSN_DEFAULT_UDP_PORT = os.getenv("PSN_DEFAULT_UDP_PORT", 56565)
|
||||
PSN_DEFAULT_UDP_MCAST_ADDRESS = os.getenv(
|
||||
"PSN_DEFAULT_UDP_MCAST_ADDRESS", "236.10.10.10"
|
||||
)
|
||||
WEB_SERVER_PORT = int(os.getenv("WEB_SERVER_PORT", 8000))
|
||||
IP = "0.0.0.0"
|
||||
NUM_TRACKERS = 3
|
||||
OSC_SERVER_PORT = int(os.getenv("OSC_SERVER_PORT", 9000))
|
||||
NUM_TRACKERS = int(os.getenv("NUM_TRACKERS", 3))
|
||||
|
||||
|
||||
class SceneDimensions:
|
||||
x_min: float
|
||||
x_max: float
|
||||
y_min: float
|
||||
y_max: float
|
||||
z_min: float
|
||||
z_max: float
|
||||
|
||||
dimension_name: str
|
||||
|
||||
dimension_map = {
|
||||
"scene_only": (-13 / 2, 13 / 2, 0, 6.3, 0, 4),
|
||||
"full_arena": (-13 / 2, 13 / 2, -9.7, 6.3, 0, 4),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.set_scene_only_dimensions()
|
||||
|
||||
def _set_new_dimensions(self, x_min, x_max, y_min, y_max, z_min, z_max):
|
||||
self.x_min = x_min
|
||||
self.x_max = x_max
|
||||
self.y_min = y_min
|
||||
self.y_max = y_max
|
||||
self.z_min = z_min
|
||||
self.z_max = z_max
|
||||
|
||||
def set_scene_only_dimensions(self):
|
||||
self._set_new_dimensions(*SceneDimensions.dimension_map["scene_only"])
|
||||
self.dimension_name = "scene_only"
|
||||
|
||||
def set_full_arena_dimensions(self):
|
||||
self._set_new_dimensions(*SceneDimensions.dimension_map["full_arena"])
|
||||
self.dimension_name = "full_arena"
|
||||
|
||||
|
||||
START_POSITION_INTERNAL = (0.5, 0.5, 2)
|
||||
|
||||
|
||||
def map_range(
|
||||
value: float, in_min: float, in_max: float, out_min: float, out_max: float
|
||||
) -> float:
|
||||
return (value - in_min) * (out_max - out_min) / (in_max - in_min) + out_min
|
||||
|
||||
|
||||
# Internal state is a list of TrackerData objects
|
||||
@@ -21,23 +72,37 @@ class TrackerData:
|
||||
id: int
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
# Convert from internal coordinates to actual scene coordinates
|
||||
# Internal representation is float values from 0 to 1 for x and y
|
||||
# Thus we need to know the max size and aspect ratio of the scene
|
||||
# x is the width of the scene, y is the depth, z is the height
|
||||
def pic_to_scene_coords(x, y):
|
||||
scene_width = 8.0
|
||||
scene_depth = 6.3
|
||||
tracker_height = 0.8 + 1.5 # Scene height + height of person
|
||||
@staticmethod
|
||||
def internal_to_scene_coords_3d(
|
||||
x: float, y: float, z: float, dim: SceneDimensions
|
||||
) -> tuple[float, float, float]:
|
||||
"""
|
||||
Convert internal coordinates to scene coordinates
|
||||
|
||||
x_val = x * scene_width - (scene_width / 2)
|
||||
y_val = y * scene_depth
|
||||
return x_val, y_val, tracker_height
|
||||
Internal coordinates are in the range [0, 1] for x and y with (0,0) at the top left
|
||||
Scene coordinates are in the range [X_MIN, X_MAX] for x and [Y_MIN, YMAX] for y
|
||||
"""
|
||||
x_val = map_range(x, 0, 1, dim.x_min, dim.x_max)
|
||||
y_val = map_range(y, 0, 1, dim.y_max, dim.y_min) # Invert y axis
|
||||
z_val = z
|
||||
|
||||
def to_tracker(self):
|
||||
return x_val, y_val, z_val
|
||||
|
||||
@staticmethod
|
||||
def scene_to_internal_coords_3d(
|
||||
x: float, y: float, z: float, dim: SceneDimensions
|
||||
) -> tuple[float, float, float]:
|
||||
x_val = map_range(x, dim.x_min, dim.x_max, 0, 1)
|
||||
y_val = map_range(y, dim.y_max, dim.y_min, 0, 1) # Invert y axis
|
||||
z_val = z
|
||||
|
||||
return x_val, y_val, z_val
|
||||
|
||||
def to_tracker(self, dim: SceneDimensions) -> psn.Tracker:
|
||||
tracker = psn.Tracker(self.id, f"Tracker {self.id}")
|
||||
x, y, z = self.pic_to_scene_coords(self.x, self.y)
|
||||
x, y, z = TrackerData.internal_to_scene_coords_3d(self.x, self.y, self.z, dim)
|
||||
tracker.set_pos(psn.Float3(x, y, z))
|
||||
return tracker
|
||||
|
||||
@@ -62,13 +127,25 @@ def get_elapsed_time_ms():
|
||||
return get_time_ms() - START_TIME
|
||||
|
||||
|
||||
async def update_all_clients(app: web.Application, ws: web.WebSocketResponse = None):
|
||||
async def update_all_other_clients(
|
||||
app: web.Application, ws: web.WebSocketResponse = None
|
||||
):
|
||||
for ws_send in app["ws_clients"]:
|
||||
if ws_send == ws:
|
||||
continue
|
||||
await ws_send.send_str(trackers_to_json(app))
|
||||
|
||||
|
||||
async def update_all_clients(app: web.Application):
|
||||
for ws in app["ws_clients"]:
|
||||
await ws.send_str(trackers_to_json(app))
|
||||
|
||||
|
||||
async def update_all_clients_bg(app: web.Application):
|
||||
for ws in app["ws_clients"]:
|
||||
await ws.send_str(json.dumps({"refresh": True}))
|
||||
|
||||
|
||||
async def handle_websocket(request):
|
||||
ws = web.WebSocketResponse()
|
||||
logging.debug("Websocket connection starting")
|
||||
@@ -83,34 +160,68 @@ async def handle_websocket(request):
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
# Each message is a single tracker object
|
||||
logging.debug(f"Received ws update: {msg.data}")
|
||||
update_tracker(msg.data, request.app)
|
||||
await update_all_clients(request.app, ws)
|
||||
await update_all_other_clients(request.app, ws)
|
||||
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
logging.error("ws connection closed with exception %s" % ws.exception())
|
||||
print("ws connection closed with exception %s" % ws.exception())
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Websocket exception: {e}")
|
||||
|
||||
finally:
|
||||
logging.debug("Websocket connection closing")
|
||||
await ws.close()
|
||||
|
||||
request.app["ws_clients"].remove(ws)
|
||||
request.app["ws_clients"].discard(ws)
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
async def on_shutdown(app):
|
||||
for ws in set(app["ws_clients"]):
|
||||
await ws.close(code=WSCloseCode.GOING_AWAY, message="Server shutdown")
|
||||
|
||||
|
||||
async def handle_root(request):
|
||||
return web.FileResponse("./static/index.html")
|
||||
|
||||
|
||||
async def handle_background_image(request):
|
||||
if request.app["scene_dimensions"].dimension_name == "full_arena":
|
||||
return web.FileResponse("./static/scene_and_crowd.png")
|
||||
elif request.app["scene_dimensions"].dimension_name == "scene_only":
|
||||
return web.FileResponse("./static/scene_only.png")
|
||||
return web.Response(text="Incorrect server scende dimension state", status=500)
|
||||
|
||||
|
||||
async def handle_set_mode(request):
|
||||
try:
|
||||
data = request.data = await request.json()
|
||||
mode = data["mode"]
|
||||
if mode == "full_arena":
|
||||
request.app["scene_dimensions"].set_full_arena_dimensions()
|
||||
elif mode == "scene_only":
|
||||
request.app["scene_dimensions"].set_scene_only_dimensions()
|
||||
else:
|
||||
return web.Response(text="Invalid mode", status=400)
|
||||
|
||||
await update_all_clients_bg(request.app)
|
||||
|
||||
return web.Response(text="OK")
|
||||
except Exception as e:
|
||||
return web.Response(text=f"Error: {e}", status=400)
|
||||
|
||||
|
||||
async def handlet_get_mode(request):
|
||||
return web.json_response({"mode": request.app["scene_dimensions"].dimension_name})
|
||||
|
||||
|
||||
async def broadcast_psn_data(app):
|
||||
encoder = psn.Encoder("Server 1")
|
||||
while True:
|
||||
trackers = {}
|
||||
for tracker_data in app["trackers"].values():
|
||||
trackers[tracker_data.id] = tracker_data.to_tracker()
|
||||
trackers[tracker_data.id] = tracker_data.to_tracker(app["scene_dimensions"])
|
||||
packets = encoder.encode_data(trackers, get_elapsed_time_ms())
|
||||
for packet in packets:
|
||||
app["sock"].sendto(
|
||||
@@ -126,21 +237,89 @@ async def background_tasks(app: web.Application):
|
||||
await app["broadcast_psn_data"]
|
||||
|
||||
|
||||
def osc_tracker_updater(address, fixed_args, *args) -> None:
|
||||
app = fixed_args[0]
|
||||
tracker_id = int(address.split("/")[-1])
|
||||
x, y, z = TrackerData.scene_to_internal_coords_3d(*args, app["scene_dimensions"])
|
||||
logging.debug(f"OSC received: id: {tracker_id} at {x}, {y}, {z}")
|
||||
tracker = TrackerData(tracker_id, x, y, z)
|
||||
app["trackers"][tracker_id] = tracker
|
||||
|
||||
asyncio.ensure_future(update_all_clients(app))
|
||||
|
||||
async def handle_add_tracker(request):
|
||||
# Add tracker with id from request
|
||||
try:
|
||||
trackers = request.app["trackers"]
|
||||
request_data = await request.json()
|
||||
tracker_id = request_data["id"]
|
||||
if tracker_id in trackers:
|
||||
return web.Response(text="Tracker already exists", status=400)
|
||||
|
||||
trackers[tracker_id] = TrackerData(tracker_id, *START_POSITION_INTERNAL)
|
||||
await update_all_clients(request.app)
|
||||
|
||||
return web.Response(text="OK", status=200)
|
||||
except Exception as e:
|
||||
return web.Response(text=f"Error: {e}", status=400)
|
||||
|
||||
|
||||
|
||||
async def handler_delete_tracker(request):
|
||||
# Delete tracker with id from request
|
||||
try:
|
||||
trackers = request.app["trackers"]
|
||||
request_data = await request.json()
|
||||
tracker_id = request_data["id"]
|
||||
if tracker_id not in trackers:
|
||||
return web.Response(text="Tracker does not exist", status=400)
|
||||
|
||||
del trackers[tracker_id]
|
||||
await update_all_clients(request.app)
|
||||
|
||||
return web.Response(text="OK", status=200)
|
||||
except Exception as e:
|
||||
return web.Response(text=f"Error: {e}", status=400)
|
||||
|
||||
async def receive_osc_data(app):
|
||||
dispatcher = Dispatcher()
|
||||
dispatcher.map("/Tracker*", osc_tracker_updater, app)
|
||||
server = AsyncIOOSCUDPServer(
|
||||
(IP, OSC_SERVER_PORT), dispatcher, asyncio.get_event_loop()
|
||||
)
|
||||
transport, protocol = await server.create_serve_endpoint()
|
||||
app["osc_transport"] = transport
|
||||
yield
|
||||
await transport.close()
|
||||
|
||||
|
||||
def create_app():
|
||||
app = web.Application()
|
||||
app.router.add_get("/", handle_root)
|
||||
app.router.add_get("/ws", handle_websocket)
|
||||
app.router.add_get("/background_image", handle_background_image)
|
||||
|
||||
app.router.add_post("/mode", handle_set_mode)
|
||||
app.router.add_get("/mode", handlet_get_mode)
|
||||
|
||||
app.router.add_post("/tracker", handle_add_tracker)
|
||||
app.router.add_delete("/tracker", handler_delete_tracker)
|
||||
|
||||
app.router.add_static("/", "./static")
|
||||
|
||||
# Setup app state
|
||||
app["ws_clients"] = set()
|
||||
# All app state needs to be mutable as changing state variables directly while running is not supported
|
||||
app["ws_clients"] = weakref.WeakSet()
|
||||
app["trackers"] = {}
|
||||
app["sock"] = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
app["scene_dimensions"] = SceneDimensions()
|
||||
|
||||
for i in range(NUM_TRACKERS):
|
||||
app["trackers"][i] = TrackerData(i, 0, 0)
|
||||
app["trackers"][i] = TrackerData(i, *START_POSITION_INTERNAL)
|
||||
|
||||
app.on_shutdown.append(on_shutdown)
|
||||
app.cleanup_ctx.append(background_tasks)
|
||||
app.cleanup_ctx.append(receive_osc_data)
|
||||
|
||||
return app
|
||||
|
||||
@@ -148,4 +327,4 @@ def create_app():
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
app = create_app()
|
||||
web.run_app(app, host=IP, port=PORT)
|
||||
web.run_app(app, host=IP, port=WEB_SERVER_PORT)
|
||||
|
||||
|
Before Width: | Height: | Size: 138 KiB |
11
compose.yaml
@@ -2,3 +2,14 @@ services:
|
||||
followspot-psn:
|
||||
build: .
|
||||
network_mode: host
|
||||
environment:
|
||||
PSN_DEFAULT_UDP_PORT: 56565
|
||||
PSN_DEFAULT_UDP_MCAST_ADDRESS: "236.10.10.10"
|
||||
WEB_SERVER_PORT: 8000
|
||||
OSC_SERVER_PORT: 9000
|
||||
NUM_TRACKERS: 0
|
||||
volumes:
|
||||
- spot-data:/data
|
||||
|
||||
volumes:
|
||||
spot-data:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-astro","prettier-plugin-svelte","prettier-plugin-tailwindcss"]
|
||||
"plugins": [
|
||||
"prettier-plugin-astro",
|
||||
"prettier-plugin-svelte",
|
||||
"prettier-plugin-tailwindcss"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// @ts-check
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
import svelte from '@astrojs/svelte';
|
||||
import svelte from "@astrojs/svelte";
|
||||
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [svelte(), tailwind()]
|
||||
integrations: [
|
||||
svelte(),
|
||||
tailwind({
|
||||
applyBaseStyles: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
17
frontend/components.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.mjs",
|
||||
"css": "src/styles/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://next.shadcn-svelte.com/registry"
|
||||
}
|
||||
@@ -6,21 +6,35 @@
|
||||
"dev": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"format": "prettier --write .",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/svelte": "^7.0.3",
|
||||
"@astrojs/tailwind": "^5.1.4",
|
||||
"astro": "^5.1.4",
|
||||
"svelte": "^5.17.2",
|
||||
"@astrojs/svelte": "^7.0.5",
|
||||
"@astrojs/tailwind": "^5.1.5",
|
||||
"astro": "^5.4.2",
|
||||
"svelte": "^5.22.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.4.2",
|
||||
"bits-ui": "0.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"mode-watcher": "^0.5.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-svelte": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9"
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"svelte-sonner": "^0.3.28",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul-svelte": "^0.3.2"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
1817
frontend/pnpm-lock.yaml
generated
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 749 B |
BIN
frontend/public/scene_and_crowd.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 138 KiB |
BIN
frontend/public/scene_only.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
@@ -2,30 +2,41 @@
|
||||
import { onMount } from "svelte";
|
||||
import type { TrackerData } from "../utils/types";
|
||||
import Drag from "./Drag.svelte";
|
||||
import Slider from "./Slider.svelte";
|
||||
import Settings from "./Settings.svelte";
|
||||
|
||||
let ws: WebSocket | null = $state(null);
|
||||
let image: HTMLImageElement | null = $state(null);
|
||||
|
||||
let trackers: TrackerData[] = $state([
|
||||
{ id: 1, x: 0, y: 0 },
|
||||
{ id: 2, x: 0, y: 0 },
|
||||
]);
|
||||
let trackers: TrackerData[] = $state([]);
|
||||
|
||||
let width = $state(0);
|
||||
let height = $state(0);
|
||||
let selected = $state(0);
|
||||
|
||||
let debounce: number | null = $state(null);
|
||||
|
||||
const connect = () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
ws = new WebSocket("/ws");
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
for (const tracker of data) {
|
||||
tracker.x *= width;
|
||||
tracker.y *= height;
|
||||
console.log(data);
|
||||
|
||||
if (data.refresh && image) {
|
||||
image.src = `/background_image?${Math.random()}`;
|
||||
return;
|
||||
} else {
|
||||
for (const tracker of data) {
|
||||
tracker.x *= width;
|
||||
tracker.y *= height;
|
||||
}
|
||||
trackers = data;
|
||||
}
|
||||
trackers = data;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -51,24 +62,33 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={resize} />
|
||||
<div class="relative">
|
||||
<img
|
||||
src="/scene_drawing.png"
|
||||
alt=""
|
||||
bind:this={image}
|
||||
class="max-w-screen max-h-screen"
|
||||
onload={resize}
|
||||
/>
|
||||
<div class="absolute inset-0 border border-red-500">
|
||||
{#each trackers as tracker}
|
||||
<Drag
|
||||
bind:id={tracker.id}
|
||||
bind:x={tracker.x}
|
||||
bind:y={tracker.y}
|
||||
bind:width
|
||||
bind:height
|
||||
{ws}
|
||||
/>
|
||||
{/each}
|
||||
<div class="flex h-screen w-screen items-center justify-center gap-2 py-2">
|
||||
<div class="mr-auto w-20">
|
||||
<Settings />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<img
|
||||
src="/background_image?342038402"
|
||||
alt=""
|
||||
bind:this={image}
|
||||
class="max-w-screen max-h-screen"
|
||||
onload={resize}
|
||||
/>
|
||||
<div class="absolute inset-0 border border-red-500">
|
||||
{#each trackers as tracker}
|
||||
<Drag
|
||||
bind:id={tracker.id}
|
||||
bind:x={tracker.x}
|
||||
bind:y={tracker.y}
|
||||
bind:z={tracker.z}
|
||||
bind:width
|
||||
bind:height
|
||||
bind:selected
|
||||
{ws}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider bind:trackers {ws} bind:height bind:width bind:selected />
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
type Props = {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ws: WebSocket | null;
|
||||
selected: number;
|
||||
};
|
||||
|
||||
let {
|
||||
id = $bindable(),
|
||||
x = $bindable(),
|
||||
y = $bindable(),
|
||||
z = $bindable(),
|
||||
ws = $bindable(),
|
||||
width = $bindable(),
|
||||
height = $bindable(),
|
||||
selected = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let element: HTMLElement;
|
||||
@@ -33,6 +39,7 @@
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
element!.setPointerCapture(e.pointerId);
|
||||
selected = id;
|
||||
capturedPointerId = e.pointerId;
|
||||
}
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
@@ -58,6 +65,7 @@
|
||||
id: id,
|
||||
x: x_send,
|
||||
y: y_send,
|
||||
z: z,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -69,7 +77,12 @@
|
||||
onpointerup={onPointerUp}
|
||||
onpointermove={onPointerMove}
|
||||
style={`transform: translate(${vis_x}px, ${vis_y}px)`}
|
||||
class="absolute flex h-32 w-32 touch-none select-none items-center justify-center rounded-full border-red-400 bg-red-400"
|
||||
class={cn(
|
||||
"absolute flex h-24 w-24 touch-none select-none items-center justify-center rounded-full",
|
||||
selected === id
|
||||
? "z-50 border-green-400 bg-green-400"
|
||||
: "border-red-400 bg-red-400",
|
||||
)}
|
||||
>
|
||||
Tracker {id}
|
||||
</div>
|
||||
|
||||
38
frontend/src/components/Modal.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
action: () => void;
|
||||
};
|
||||
|
||||
let { action }: Props = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
const openModal = () => {
|
||||
dialog.showModal();
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class="mb-24 rounded-md bg-red-500 p-2" onclick={openModal}>
|
||||
Change mode
|
||||
</button>
|
||||
|
||||
<dialog
|
||||
open={false}
|
||||
bind:this={dialog}
|
||||
class="absolute left-0 top-0 z-50 m-0 h-full max-h-full w-full max-w-full p-0 open:bg-black/80"
|
||||
>
|
||||
<div class="flex h-full w-full flex-col items-center justify-center">
|
||||
<h1 class="text-center text-8xl text-red-600">THIS GONNA FUCK THINGS UP</h1>
|
||||
<div class="flex items-center justify-center gap-6">
|
||||
<button class="rounded-md bg-gray-400 p-4" onclick={() => dialog.close()}
|
||||
>Close</button
|
||||
>
|
||||
<button
|
||||
class="rounded-md bg-red-500 p-4"
|
||||
onclick={() => {
|
||||
action();
|
||||
dialog.close();
|
||||
}}>Change mode</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
59
frontend/src/components/Settings.svelte
Normal file
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import * as Drawer from "$lib/components/ui/drawer/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import TrackerSetting from "./TrackerSetting.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
|
||||
const addTracker = async (arg: number) => {
|
||||
const response = await fetch("/tracker", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: Number(arg),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Could not add tracker");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTracker = async (arg: number) => {
|
||||
const response = await fetch("/tracker", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: Number(arg),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
toast.error("Could not delete tracker");
|
||||
}
|
||||
};
|
||||
|
||||
let id = $state(0);
|
||||
</script>
|
||||
|
||||
<Drawer.Root>
|
||||
<Drawer.Trigger asChild let:builder>
|
||||
<Button builders={[builder]} variant="secondary">Settings</Button>
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header>
|
||||
<Drawer.Title>Are you sure absolutely sure?</Drawer.Title>
|
||||
<Drawer.Description>This action cannot be undone.</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
<div class="flex items-center justify-evenly">
|
||||
<TrackerSetting bind:value={id} action={addTracker} text="Add Tracker" />
|
||||
<TrackerSetting
|
||||
bind:value={id}
|
||||
action={deleteTracker}
|
||||
text="Delete Tracker"
|
||||
/>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
113
frontend/src/components/Slider.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import type { TrackerData } from "$lib/utils/types";
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
type Props = {
|
||||
selected: number;
|
||||
trackers: TrackerData[];
|
||||
ws: WebSocket | null;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
let {
|
||||
selected = $bindable(),
|
||||
trackers = $bindable(),
|
||||
ws,
|
||||
width = $bindable(),
|
||||
height = $bindable(),
|
||||
}: Props = $props();
|
||||
|
||||
let arena_state: "full_arena" | "scene_only" = $state("scene_only");
|
||||
|
||||
let tracker = $state<TrackerData | undefined>(undefined);
|
||||
|
||||
$effect(() => {
|
||||
tracker = trackers.find((ball) => ball.id === selected);
|
||||
});
|
||||
|
||||
const onchange = () => {
|
||||
const x_send = tracker!.x / width;
|
||||
const y_send = tracker!.y / height;
|
||||
ws?.send(
|
||||
JSON.stringify({
|
||||
id: tracker!.id,
|
||||
x: x_send,
|
||||
y: y_send,
|
||||
z: tracker!.z,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
type arenaResponse = {
|
||||
mode: "full_arena" | "scene_only";
|
||||
};
|
||||
|
||||
const buttonAction = async () => {
|
||||
await fetch("/mode")
|
||||
.then((res) => res.json())
|
||||
.then((data: arenaResponse) => {
|
||||
arena_state = data.mode;
|
||||
});
|
||||
|
||||
await fetch("/mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mode: arena_state === "full_arena" ? "scene_only" : "full_arena",
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
let z_viz = $derived(tracker?.z.toFixed(2));
|
||||
</script>
|
||||
|
||||
<div class="slider-container ml-auto w-20">
|
||||
<Modal action={buttonAction} />
|
||||
{#if tracker}
|
||||
<input
|
||||
type="range"
|
||||
id="z"
|
||||
min="0"
|
||||
max="4"
|
||||
step="0.01"
|
||||
oninput={onchange}
|
||||
bind:value={tracker.z}
|
||||
/>
|
||||
<output for="z">{z_viz} m</output>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.slider-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
input {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: none;
|
||||
width: 120px;
|
||||
height: 50%;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
/* WebKit Browsers (Chrome, Safari, Edge) */
|
||||
input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 50px; /* Larger size */
|
||||
height: 50px;
|
||||
background: red; /* Customize color */
|
||||
margin-left: -22px; /* Center the thumb */
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input::-webkit-slider-runnable-track {
|
||||
background: lightgray; /* Customize track */
|
||||
width: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
18
frontend/src/components/TrackerSetting.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import Button from "./ui/button/button.svelte";
|
||||
import Input from "./ui/input/input.svelte";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
action: (arg: number) => void;
|
||||
value: number;
|
||||
};
|
||||
|
||||
let { action, text, value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2>{text}</h2>
|
||||
<Input placeholder={"1"} bind:value required />
|
||||
<Button onclick={() => action(value)}>Submit</Button>
|
||||
</div>
|
||||
25
frontend/src/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Button as ButtonPrimitive } from "bits-ui";
|
||||
import { type Events, type Props, buttonVariants } from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = Props;
|
||||
type $$Events = Events;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let variant: $$Props["variant"] = "default";
|
||||
export let size: $$Props["size"] = "default";
|
||||
export let builders: $$Props["builders"] = [];
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<ButtonPrimitive.Root
|
||||
{builders}
|
||||
class={cn(buttonVariants({ variant, size, className }))}
|
||||
type="button"
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
>
|
||||
<slot />
|
||||
</ButtonPrimitive.Root>
|
||||
49
frontend/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
import type { Button as ButtonPrimitive } from "bits-ui";
|
||||
import Root from "./button.svelte";
|
||||
|
||||
const buttonVariants = tv({
|
||||
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
type Variant = VariantProps<typeof buttonVariants>["variant"];
|
||||
type Size = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
type Props = ButtonPrimitive.Props & {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
type Events = ButtonPrimitive.Events;
|
||||
|
||||
export {
|
||||
Root,
|
||||
type Props,
|
||||
type Events,
|
||||
//
|
||||
Root as Button,
|
||||
type Props as ButtonProps,
|
||||
type Events as ButtonEvents,
|
||||
buttonVariants,
|
||||
};
|
||||
24
frontend/src/components/ui/drawer/drawer-content.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import DrawerOverlay from "./drawer-overlay.svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DrawerPrimitive.ContentProps;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Portal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
class={cn(
|
||||
"bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border",
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<div class="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full"></div>
|
||||
<slot />
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPrimitive.Portal>
|
||||
18
frontend/src/components/ui/drawer/drawer-description.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DrawerPrimitive.DescriptionProps;
|
||||
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Description
|
||||
bind:el
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</DrawerPrimitive.Description>
|
||||
16
frontend/src/components/ui/drawer/drawer-footer.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
||||
el?: HTMLDivElement;
|
||||
};
|
||||
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div bind:this={el} class={cn("mt-auto flex flex-col gap-2 p-4", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
19
frontend/src/components/ui/drawer/drawer-header.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
||||
el?: HTMLDivElement;
|
||||
};
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={el}
|
||||
class={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
12
frontend/src/components/ui/drawer/drawer-nested.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
type $$Props = DrawerPrimitive.Props;
|
||||
export let shouldScaleBackground: $$Props["shouldScaleBackground"] = true;
|
||||
export let open: $$Props["open"] = false;
|
||||
export let activeSnapPoint: $$Props["activeSnapPoint"] = undefined;
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.NestedRoot {shouldScaleBackground} bind:open bind:activeSnapPoint {...$$restProps}>
|
||||
<slot />
|
||||
</DrawerPrimitive.NestedRoot>
|
||||
18
frontend/src/components/ui/drawer/drawer-overlay.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DrawerPrimitive.OverlayProps;
|
||||
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Overlay
|
||||
bind:el
|
||||
class={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</DrawerPrimitive.Overlay>
|
||||
18
frontend/src/components/ui/drawer/drawer-title.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = DrawerPrimitive.TitleProps;
|
||||
|
||||
export let el: $$Props["el"] = undefined;
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Title
|
||||
bind:el
|
||||
class={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</DrawerPrimitive.Title>
|
||||
12
frontend/src/components/ui/drawer/drawer.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
type $$Props = DrawerPrimitive.Props;
|
||||
export let shouldScaleBackground: $$Props["shouldScaleBackground"] = true;
|
||||
export let open: $$Props["open"] = false;
|
||||
export let activeSnapPoint: $$Props["activeSnapPoint"] = undefined;
|
||||
</script>
|
||||
|
||||
<DrawerPrimitive.Root {shouldScaleBackground} bind:open bind:activeSnapPoint {...$$restProps}>
|
||||
<slot />
|
||||
</DrawerPrimitive.Root>
|
||||
41
frontend/src/components/ui/drawer/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Drawer as DrawerPrimitive } from "vaul-svelte";
|
||||
|
||||
import Root from "./drawer.svelte";
|
||||
import Content from "./drawer-content.svelte";
|
||||
import Description from "./drawer-description.svelte";
|
||||
import Overlay from "./drawer-overlay.svelte";
|
||||
import Footer from "./drawer-footer.svelte";
|
||||
import Header from "./drawer-header.svelte";
|
||||
import Title from "./drawer-title.svelte";
|
||||
import NestedRoot from "./drawer-nested.svelte";
|
||||
|
||||
const Trigger = DrawerPrimitive.Trigger;
|
||||
const Portal = DrawerPrimitive.Portal;
|
||||
const Close = DrawerPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
NestedRoot,
|
||||
Content,
|
||||
Description,
|
||||
Overlay,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Trigger,
|
||||
Portal,
|
||||
Close,
|
||||
|
||||
//
|
||||
Root as Drawer,
|
||||
NestedRoot as DrawerNestedRoot,
|
||||
Content as DrawerContent,
|
||||
Description as DrawerDescription,
|
||||
Overlay as DrawerOverlay,
|
||||
Footer as DrawerFooter,
|
||||
Header as DrawerHeader,
|
||||
Title as DrawerTitle,
|
||||
Trigger as DrawerTrigger,
|
||||
Portal as DrawerPortal,
|
||||
Close as DrawerClose,
|
||||
};
|
||||
29
frontend/src/components/ui/input/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export type FormInputEvent<T extends Event = Event> = T & {
|
||||
currentTarget: EventTarget & HTMLInputElement;
|
||||
};
|
||||
export type InputEvents = {
|
||||
blur: FormInputEvent<FocusEvent>;
|
||||
change: FormInputEvent<Event>;
|
||||
click: FormInputEvent<MouseEvent>;
|
||||
focus: FormInputEvent<FocusEvent>;
|
||||
focusin: FormInputEvent<FocusEvent>;
|
||||
focusout: FormInputEvent<FocusEvent>;
|
||||
keydown: FormInputEvent<KeyboardEvent>;
|
||||
keypress: FormInputEvent<KeyboardEvent>;
|
||||
keyup: FormInputEvent<KeyboardEvent>;
|
||||
mouseover: FormInputEvent<MouseEvent>;
|
||||
mouseenter: FormInputEvent<MouseEvent>;
|
||||
mouseleave: FormInputEvent<MouseEvent>;
|
||||
mousemove: FormInputEvent<MouseEvent>;
|
||||
paste: FormInputEvent<ClipboardEvent>;
|
||||
input: FormInputEvent<InputEvent>;
|
||||
wheel: FormInputEvent<WheelEvent>;
|
||||
};
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
43
frontend/src/components/ui/input/input.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLInputAttributes } from "svelte/elements";
|
||||
import type { InputEvents } from "./index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
type $$Props = HTMLInputAttributes;
|
||||
type $$Events = InputEvents;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let value: $$Props["value"] = undefined;
|
||||
export { className as class };
|
||||
|
||||
// Workaround for https://github.com/sveltejs/svelte/issues/9305
|
||||
// Fixed in Svelte 5, but not backported to 4.x.
|
||||
export let readonly: $$Props["readonly"] = undefined;
|
||||
</script>
|
||||
|
||||
<input
|
||||
class={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
bind:value
|
||||
{readonly}
|
||||
type="tel"
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:focusin
|
||||
on:focusout
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:mousemove
|
||||
on:paste
|
||||
on:input
|
||||
on:wheel|passive
|
||||
{...$$restProps}
|
||||
/>
|
||||
1
frontend/src/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
20
frontend/src/components/ui/sonner/sonner.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
|
||||
import { mode } from "mode-watcher";
|
||||
|
||||
let restProps: SonnerProps = $props();
|
||||
</script>
|
||||
|
||||
<Sonner
|
||||
theme={$mode}
|
||||
class="toaster group"
|
||||
toastOptions={{
|
||||
classes: {
|
||||
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -1,19 +1,22 @@
|
||||
---
|
||||
import Container from "../components/Container.svelte";
|
||||
import "$lib/styles/app.css";
|
||||
import { Toaster } from "$lib/components/ui/sonner/index.js";
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
/>
|
||||
<title>Astro</title>
|
||||
<title>Followspot</title>
|
||||
</head>
|
||||
<body class="flex h-dvh w-screen items-center justify-center overflow-hidden">
|
||||
<Toaster client:load />
|
||||
<Container client:load />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
76
frontend/src/styles/app.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 72.2% 50.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/src/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export interface TrackerData {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { vitePreprocess } from '@astrojs/svelte';
|
||||
import { vitePreprocess } from "@astrojs/svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
}
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,96 @@
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: ["./src/**/*.{html,js,svelte,ts,astro}"],
|
||||
safelist: ["dark"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border) / <alpha-value>)",
|
||||
input: "hsl(var(--input) / <alpha-value>)",
|
||||
ring: "hsl(var(--ring) / <alpha-value>)",
|
||||
background: "hsl(var(--background) / <alpha-value>)",
|
||||
foreground: "hsl(var(--foreground) / <alpha-value>)",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--primary-foreground) / <alpha-value>)",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
|
||||
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
|
||||
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
|
||||
foreground: "hsl(var(--muted-foreground) / <alpha-value>)",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
|
||||
foreground: "hsl(var(--accent-foreground) / <alpha-value>)",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
|
||||
foreground: "hsl(var(--popover-foreground) / <alpha-value>)",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card) / <alpha-value>)",
|
||||
foreground: "hsl(var(--card-foreground) / <alpha-value>)",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
xl: "calc(var(--radius) + 4px)",
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...fontFamily.sans],
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
to: { height: "var(--bits-accordion-content-height)" },
|
||||
},
|
||||
"accordion-up": {
|
||||
from: { height: "var(--bits-accordion-content-height)" },
|
||||
to: { height: "0" },
|
||||
},
|
||||
"caret-blink": {
|
||||
"0%,70%,100%": { opacity: "1" },
|
||||
"20%,50%": { opacity: "0" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"caret-blink": "caret-blink 1.25s ease-out infinite",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [tailwindcssAnimate],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
"$lib/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
|
||||