import { Explosion } from "@thorium/cards/Legacy/SensorGrid/Explosion"; import { q } from "@thorium/context/AppContext"; import { useCardContext } from "@thorium/context/CardContext"; import useAnimationFrame from "@thorium/hooks/useAnimationFrame"; import { useStation } from "@thorium/routes/station/useStation"; import { SVGImageLoader } from "@thorium/ui/SVGImageLoader"; import { cn } from "@thorium/utils/cn"; import { useLiveQuery } from "@thorium/utils/live-query/client"; import chroma from "chroma-js"; import { Suspense, useEffect, useRef, useState, type DetailedHTMLProps, type HTMLAttributes, type PointerEvent, type RefObject, } from "react"; import { Menu, MenuItem, Popover, Separator } from "react-aria-components"; import maskUrl from "./mask.svg?url"; import { sensorsSpeeds, useSensorsStore } from "./useSensorsStore"; import "./style.css"; export function SensorGrid({ gridRef, draggingRef, dragging, className, onContactHover, onGridHover, }: { className?: string; dragging?: number | "planet" | "border" | "ping" | null; gridRef: RefObject; draggingRef?: RefObject; onGridHover?: () => void; onContactHover?: (name: string, picture: string | null) => void; }) { const { station, shipId } = useStation(); const [armyContacts] = q.legacy.sensorGrid.armyContacts.useNetRequest({ shipId, }); const [sensors] = q.legacy.sensorGrid.sensors.useNetRequest({ shipId }); const contactsRef = useRef(null); q.legacy.sensorGrid.sonarPing.useNetRequest( { shipId }, { callback(data) { if (!data) return; gridRef.current?.classList.remove("ping"); contactsRef.current?.classList.add("can-hover"); requestAnimationFrame(() => { gridRef.current?.classList.add("ping"); }); setTimeout(() => { contactsRef.current?.classList.remove("can-hover"); }, 5200); }, }, ); q.legacy.sensorGrid.stream.useDataStream({ shipId }); const sensorsStore = useSensorsStore(); const isCore = station.name === "Flight Director"; const draggingContact = !dragging ? null : typeof dragging === "number" ? armyContacts.find((c) => c.id === dragging) : sensorsStore[dragging]; return (
useSensorsStore.setState({ selectedContact: null })} >
{dragging && draggingContact ? ( ) : null}
); } export function Interference({ interference }: { interference: number }) { const { cardLoaded } = useCardContext(); const { station } = useStation(); const isCore = station.name === "Flight Director"; const ref = useRef(null); const ctx = ref.current?.getContext("2d"); useEffect(() => { if (ref.current) { const dims = ref.current.getBoundingClientRect(); ref.current.width = dims.width; ref.current.height = dims.height; } }, []); useAnimationFrame( () => { if (!ctx) return; const w = ctx.canvas.width; const h = ctx.canvas.height; const image = ctx.createImageData(w, h); for (let i = 0; i < image.data.length; i += 4) { const val = 255 * Math.random(); image.data[i] = val; image.data[i + 1] = val; image.data[i + 2] = val; image.data[i + 3] = 255; } ctx.putImageData(image, 0, 0); }, cardLoaded && interference > 0, ); return ( ); } const destinationMap = new Map(); export function SensorContacts({ gridRef, onContactHover, }: { gridRef: RefObject; onContactHover?: (name: string, picture: string | null) => void; }) { const { shipId } = useStation(); const [contacts] = q.legacy.sensorGrid.sensorContacts.useNetRequest({ shipId, }); q.legacy.sensorGrid.sensorContactsDestination.useNetSubscribe( { shipId, }, (data) => { destinationMap.clear(); for (const [id, destination] of data) { destinationMap.set(id, destination); } }, ); return contacts.map((c) => ( { if (onContactHover) { event.stopPropagation(); } onContactHover?.(c.name, c.picture); }} /> )); } function SensorContact({ id, name, position, destination, size, gridRef, destroyed, frozenState, onPointerMove, ...props }: { id: number; name: string; type: "contact" | "border" | "planet" | "ping" | "projectile"; color: string; size: number; icon: string; position: { x: number; y: number }; destination: { x: number; y: number }; disabled: boolean; hostile: boolean; destroyed: boolean; frozenState: any; gridRef: RefObject; onPointerMove?: (event: PointerEvent) => void; }) { const { station, shipId } = useStation(); const isCore = station.name === "Flight Director"; const iconRef = useRef(null); const contactRef = useRef(null); const triggerRef = useRef(null); const draggingRef = useRef(false); const { interpolate } = useLiveQuery(); const { cardLoaded } = useCardContext(); const [pickingSpeed, setPickingSpeed] = useState<{ x: number; y: number; } | null>(null); const sensorsStore = useSensorsStore(); const [sensors] = q.legacy.sensorGrid.sensors.useNetRequest({ shipId }); useAnimationFrame(() => { const position = interpolate(id); if (iconRef.current && !draggingRef.current) { const dest = destinationMap.get(id); iconRef.current.style.transform = `translate(${(frozenState?.destination?.x ?? dest?.x ?? destination.x) * 100}%, ${(frozenState?.destination?.y ?? dest?.y ?? destination.y) * 100}%)`; iconRef.current.style.opacity = "1"; } if (!position || !contactRef.current) return; contactRef.current.style.transform = `translate(${position.x * 100}%, ${position.y * 100}%)`; contactRef.current.style.opacity = "1"; }, cardLoaded); async function pickSpeed(speed: number) { if (pickingSpeed) { await q.legacy.sensorGrid.updateContact.netSend({ contactId: id, destination: pickingSpeed, speed, }); } setPickingSpeed(null); draggingRef.current = false; } function handleDrag(event: PointerEvent) { event.stopPropagation(); const iconDimensions = iconRef.current?.getBoundingClientRect(); if (!iconDimensions) return; const offset = [iconDimensions.left - event.clientX, iconDimensions.top - event.clientY]; const abortController = new AbortController(); const dimensions = gridRef.current?.getBoundingClientRect(); if (!dimensions) return; document.addEventListener( "pointermove", (moveEvent) => { if (Math.hypot(moveEvent.clientX - event.clientX, moveEvent.clientY - event.clientY) < 2) { return; } draggingRef.current = true; const x = (moveEvent.clientX + offset[0] - dimensions.left) / dimensions.width; const y = (moveEvent.clientY + offset[1] - dimensions.top) / dimensions.height; if (iconRef.current) { iconRef.current.style.transform = `translate(${x * 100}%, ${y * 100}%)`; } }, { signal: abortController.signal }, ); document.addEventListener( "pointerup", async (upEvent) => { abortController.abort(); if (Math.hypot(upEvent.clientX - event.clientX, upEvent.clientY - event.clientY) < 2) { useSensorsStore.setState({ selectedContact: id }); draggingRef.current = false; return; } const x = (upEvent.clientX + offset[0] - dimensions.left) / dimensions.width; const y = (upEvent.clientY + offset[1] - dimensions.top) / dimensions.height; // Check if the contact is within the sensor grid area const gridParentDimensions = gridRef.current?.parentElement?.getBoundingClientRect(); const draggingDimensions = iconRef.current?.children[0]?.getBoundingClientRect(); if ( draggingDimensions && gridParentDimensions && (draggingDimensions.left > gridParentDimensions.right || draggingDimensions.right < gridParentDimensions.left || draggingDimensions.top > gridParentDimensions.bottom || draggingDimensions.bottom < gridParentDimensions.top) ) { await q.legacy.sensorGrid.updateContact.netSend({ contactId: id, destination: { x, y }, speed: sensors.defaultSpeed, }); q.legacy.sensorGrid.removeContact.netSend({ contactId: id, }); draggingRef.current = false; return; } if (sensorsStore.askForSpeed) { setPickingSpeed({ x, y }); } else { await q.legacy.sensorGrid.updateContact.netSend({ contactId: id, destination: { x, y }, speed: sensors.defaultSpeed, }); draggingRef.current = false; } }, { once: true }, ); } return ( <> {frozenState?.new ? null : (
{destroyed ? ( ) : ( )}
)} {isCore && !destroyed ? (
{sensorsStore.showContactLabels && (

{frozenState?.name ?? name}

)} {sensorsStore.selectedContact === id ? (
) : null} { setPickingSpeed(null); draggingRef.current = false; }} crossOffset={100} > {sensorsSpeeds.map((speed) => ( pickSpeed(speed.id)}> {speed.label} ))} { setPickingSpeed(null); draggingRef.current = false; }} > Cancel { q.legacy.sensorGrid.removeContact.netSend({ contactId: id }); setPickingSpeed(null); }} > Remove { await q.legacy.sensorGrid.updateContact.netSend({ contactId: id, destroyed: true, }); setPickingSpeed(null); draggingRef.current = false; }} > Destroy
) : null} ); } export function ContactImage({ type, icon, color, disabled, size, hostile, ref, isGhost, ...props }: { type: "contact" | "border" | "ping" | "planet" | "projectile"; icon: string; color: string; size: number; hostile?: boolean; disabled?: boolean; ref?: RefObject; isGhost?: boolean; dimmed?: boolean; } & DetailedHTMLProps, HTMLDivElement>) { const { station } = useStation(); const isCore = station.name === "Flight Director"; if (type === "contact") { return ( {}} className={cn("w-[5%] h-[5%] object-contain cursor-pointer pointer-events-auto", { "opacity-50": isGhost, "drop-shadow-[0_0_3px_red]": hostile, })} style={{ color: color, transform: `translate(-50%, -50%) scale(${size})`, ...(disabled ? { maskImage: `url("${maskUrl}")`, maskSize: `${2 / size}px` } : {}), }} /> ); } if (type === "planet") { return (
); } if (type === "border") { return (
); } if (type === "ping") { return ; } return null; } function SensorPing({ color, size }: { color: string; size: number }) { return (
); } export function GridLines({ rings = 3, lines = 12, aligned = false, }: { rings?: number; lines?: number; aligned?: boolean; }) { return ( <> {Array(rings) .fill(0) .map((_, i, array) => (
))} {Array(lines) .fill(0) .map((_, i, array) => (
))} ); } function polarToCartesian( centerX: number, centerY: number, radius: number, angleInDegrees: number, ) { const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; return { x: centerX + radius * Math.cos(angleInRadians), y: centerY + radius * Math.sin(angleInRadians), }; } function describeArc( x: number, y: number, innerRadius: number, outerRadius: number, startAngle: number, endAngle: number, ) { const innerStart = polarToCartesian(x, y, innerRadius, endAngle); const innerEnd = polarToCartesian(x, y, innerRadius, startAngle); const outerStart = polarToCartesian(x, y, outerRadius, endAngle); const outerEnd = polarToCartesian(x, y, outerRadius, startAngle); const d = [ "M", outerStart.x, outerStart.y, "A", outerRadius, outerRadius, 0, 0, 0, outerEnd.x, outerEnd.y, "L", innerEnd.x, innerEnd.y, "A", innerRadius, innerRadius, 0, 0, 1, innerStart.x, innerStart.y, "L", outerStart.x, outerStart.y, ].join(" "); return d; } export function GridSegments({ rings = 3, lines = 12, aligned = false, }: { rings?: number; lines?: number; aligned?: boolean; }) { const { shipId, station } = useStation(); const isCore = station.name === "Flight Director"; const [sensors] = q.legacy.sensorGrid.sensors.useNetRequest({ shipId }); const segments = sensors.segments; return ( {Array.from({ length: rings }).map((_, i) => Array.from({ length: lines }).map((_, ii) => ( { if (!isCore) return; if (event.altKey) { q.legacy.sensorGrid.setSegment.netSend({ shipId, ring: i, line: ii, blocked: !segments[`${i}-${ii}`], }); } }} className={cn("opacity-0 pointer-events-none", { "opacity-100": segments[`${i}-${ii}`], "pointer-events-auto": isCore, })} d={describeArc( 50, 50, (50 / rings) * i, (50 / rings) * (1 + i), ((ii - (aligned ? 0 : 0.5)) * 360) / lines, ((ii + 1 - (aligned ? 0 : 0.5)) * 360) / lines, )} /> )), )} ); }