File size: 2,839 Bytes
d9bb6b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
/**
 * Renders flyyt-backend's shared VDA5050 map (nodes/edges) in the 3D scene.
 * Ported from nova-assets' Vda5050GraphView.tsx (same react-three-fiber/
 * drei/three stack flyyt already uses) at flyyt's mm scale instead of
 * meter-scale warehouse robots, and without zones (flyyt-backend's map
 * format has none -- see map_store.py). Same (x,y) -> (x, height, -y)
 * coordinate convention used everywhere else VDA5050 positions land in this
 * app (useVda5050Nats.ts, useFlyLayout.ts's roster fetch).
 */

import { Line } from "@react-three/drei";
import { useEffect, useState } from "react";
import { apiPath } from "./env";
import { FLY_EXTENT_MM } from "./useFlyLayout";

const NODE_COLOR = "#8e56fc";
const EDGE_COLOR = "#22d3ee";
const NODE_RADIUS_MM = FLY_EXTENT_MM[0] * 0.25;
const HEIGHT_MM = 0.5;

interface MapNode {
  id: string;
  x: number;
  y: number;
}

interface MapEdge {
  id: string;
  from: string;
  to: string;
}

interface MapData {
  mapId: string;
  mapVersion: string;
  nodes: MapNode[];
  edges: MapEdge[];
}

function toWorld(x: number, y: number, height: number): [number, number, number] {
  return [x, height, -y];
}

async function fetchMap(): Promise<MapData | null> {
  try {
    const res = await fetch(apiPath("map"), { signal: AbortSignal.timeout(1500) });
    if (!res.ok) return null;
    return await res.json();
  } catch {
    return null;
  }
}

export default function MapView() {
  const [map, setMap] = useState<MapData | null>(null);

  useEffect(() => {
    let cancelled = false;
    const poll = () => {
      fetchMap().then((data) => {
        if (!cancelled && data) setMap(data);
      });
    };
    poll();
    // Polled, not push -- simplest way to pick up a map pushed via POST /map
    // from anywhere (another tab, a script, nova-nav-style tooling) without
    // adding a dedicated NATS subject for map-content itself.
    const interval = setInterval(poll, 5000);
    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, []);

  if (!map || map.nodes.length === 0) return null;

  const nodeById = new Map(map.nodes.map((n) => [n.id, n]));

  return (
    <group>
      {map.edges.map((e) => {
        const a = nodeById.get(e.from);
        const b = nodeById.get(e.to);
        if (!a || !b) return null;
        return (
          <Line
            key={e.id}
            points={[toWorld(a.x, a.y, HEIGHT_MM), toWorld(b.x, b.y, HEIGHT_MM)]}
            color={EDGE_COLOR}
            lineWidth={2}
          />
        );
      })}
      {map.nodes.map((n) => (
        <mesh key={n.id} position={toWorld(n.x, n.y, HEIGHT_MM)}>
          <sphereGeometry args={[NODE_RADIUS_MM, 16, 16]} />
          <meshStandardMaterial color={NODE_COLOR} emissive={NODE_COLOR} emissiveIntensity={0.35} />
        </mesh>
      ))}
    </group>
  );
}