All files / renderer/village TooltipLayer.tsx

0% Statements 0/154
0% Branches 0/1
0% Functions 0/1
0% Lines 0/154

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231                                                                                                                                                                                                                                                                                                                                                                                                                                                                             
import { useEffect, useRef, useState } from "react";
import { useThree } from "@react-three/fiber";
import * as THREE from "three";
import { ZONES } from "../../shared/zones";
import { useSessions, type TabSession } from "../context/SessionContext";
import { buildAgentLabels, labelFor } from "./agentLabels";
 
type TooltipKind = "zone" | "zone-ground" | "zone-signpost" | "zone-icon" | "character";
 
interface TooltipUserData {
  tooltipKind: TooltipKind;
  zoneId?: string;
  zoneName?: string;
  zoneDescription?: string;
  agentId?: string;
  agentKind?: string;
}
 
interface HoverTarget {
  kind: TooltipKind;
  data: TooltipUserData;
  screen: { x: number; y: number };
}
 
const HOVER_DELAY_MS = 200;
const EVENT_NAME = "village:tooltip-update";
 
/**
 * CustomEvent payload the raycaster component dispatches whenever the
 * hovered target changes. The overlay component (rendered OUTSIDE the
 * Canvas) subscribes and paints the tooltip DOM from this state.
 *
 * We cannot render the DOM directly from inside the Canvas subtree: R3F's
 * reconciler owns that tree and does not know how to mount `<div>` hosts,
 * so `createPortal(<div/>, document.body)` from here silently fails to
 * materialise the element. Splitting raycaster-in-Canvas from overlay-
 * outside-Canvas keeps each piece using the reconciler it fits.
 */
interface TooltipUpdate {
  hover: HoverTarget | null;
}
 
interface TooltipOverlayProps {
  sessionId?: string;
}
 
/**
 * Raycaster half: lives inside the Canvas, listens to pointer events on
 * the WebGL domElement, and dispatches `village:tooltip-update` events
 * whenever the hovered object changes. Renders nothing visible - the
 * paired `<TooltipOverlay>` below (rendered outside the Canvas) handles
 * the DOM.
 */
export function TooltipLayer() {
  const { scene, camera, gl } = useThree();
  const raycaster = useRef(new THREE.Raycaster());
  const pointer = useRef(new THREE.Vector2());
  const lastHoverRef = useRef<HoverTarget | null>(null);
  const timer = useRef<number | null>(null);
 
  useEffect(() => {
    const el = gl.domElement;
    const emit = (hover: HoverTarget | null): void => {
      lastHoverRef.current = hover;
      window.dispatchEvent(new CustomEvent<TooltipUpdate>(EVENT_NAME, { detail: { hover } }));
    };
    const onMove = (e: PointerEvent): void => {
      const rect = el.getBoundingClientRect();
      pointer.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
      pointer.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
      const screenX = e.clientX;
      const screenY = e.clientY;
      if (timer.current !== null) window.clearTimeout(timer.current);
      timer.current = window.setTimeout(() => {
        raycaster.current.setFromCamera(pointer.current, camera);
        const hits = raycaster.current.intersectObjects(scene.children, true);
        for (const hit of hits) {
          const ud = findUserData(hit.object);
          if (!ud) continue;
          emit({ kind: ud.tooltipKind, data: ud, screen: { x: screenX, y: screenY } });
          return;
        }
        emit(null);
      }, HOVER_DELAY_MS);
    };
    const onLeave = (): void => {
      if (timer.current !== null) window.clearTimeout(timer.current);
      emit(null);
    };
    el.addEventListener("pointermove", onMove);
    el.addEventListener("pointerleave", onLeave);
    return () => {
      el.removeEventListener("pointermove", onMove);
      el.removeEventListener("pointerleave", onLeave);
      if (timer.current !== null) window.clearTimeout(timer.current);
      // Clear any hover state the outside overlay is holding when we unmount.
      emit(null);
    };
  }, [camera, gl, scene]);
 
  return null;
}
 
/**
 * Overlay half: a regular DOM component that subscribes to
 * `village:tooltip-update` events and paints the tooltip panel with plain
 * React. Must be rendered OUTSIDE the `<Canvas>` so the host reconciler
 * is react-dom and `<div>` / `position: fixed` work normally.
 *
 * Accepts the same `sessionId` as the raycaster so the panel can show
 * zone occupancy / agent details for the right session.
 */
export function TooltipOverlay({ sessionId }: TooltipOverlayProps): JSX.Element | null {
  const { sessions } = useSessions();
  const [hover, setHover] = useState<HoverTarget | null>(null);
 
  useEffect(() => {
    const handler = (e: Event): void => {
      const detail = (e as CustomEvent<TooltipUpdate>).detail;
      setHover(detail?.hover ?? null);
    };
    window.addEventListener(EVENT_NAME, handler);
    return () => {
      window.removeEventListener(EVENT_NAME, handler);
    };
  }, []);
 
  if (!hover) return null;
  const session = sessionId ? sessions.get(sessionId) : undefined;
  const content = renderContent(hover, session);
  if (!content) return null;
 
  // Position the panel just to the lower-right of the cursor, then clamp
  // so it never runs off-screen near the viewport edges.
  const OFFSET = 14;
  const MARGIN = 8;
  const MAX_W = 320;
  const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
  const vh = typeof window !== "undefined" ? window.innerHeight : 800;
  const maxLeft = Math.max(MARGIN, vw - MAX_W - MARGIN);
  const left = Math.min(hover.screen.x + OFFSET, maxLeft);
  const maxTop = Math.max(MARGIN, vh - 140 - MARGIN);
  const top = Math.min(hover.screen.y + OFFSET, maxTop);
 
  return (
    <div
      data-testid="tooltip-panel"
      style={{
        position: "fixed",
        left,
        top,
        background: "rgba(0,0,0,0.88)",
        color: "#fff",
        padding: "8px 10px",
        borderRadius: 4,
        fontSize: 12,
        lineHeight: 1.35,
        width: "max-content",
        maxWidth: MAX_W,
        pointerEvents: "none",
        zIndex: 1000,
        boxShadow: "0 4px 12px rgba(0,0,0,0.35)"
      }}
    >
      {content}
    </div>
  );
}
 
function findUserData(obj: THREE.Object3D): TooltipUserData | null {
  let o: THREE.Object3D | null = obj;
  while (o) {
    const ud = o.userData as Partial<TooltipUserData> | undefined;
    if (ud && typeof ud.tooltipKind === "string") return ud as TooltipUserData;
    o = o.parent;
  }
  return null;
}
 
function renderContent(hover: HoverTarget, session: TabSession | undefined): JSX.Element | null {
  if (hover.kind.startsWith("zone")) {
    const zoneId = hover.data.zoneId;
    if (!zoneId) return null;
    const meta = ZONES.find((z) => z.id === zoneId);
    if (!meta) return null;
    const occupants = session
      ? Array.from(session.agents.values()).filter((a) => a.currentZone === meta.id)
      : [];
    return (
      <div>
        <div style={{ fontWeight: 600 }}>
          {meta.icon} {meta.name}
        </div>
        <div style={{ opacity: 0.85 }}>{meta.description}</div>
        {occupants.length > 0 && (
          <div style={{ marginTop: 6 }}>
            Here now: {occupants.map((o) => o.id.slice(0, 6)).join(", ")}
          </div>
        )}
      </div>
    );
  }
  if (hover.kind === "character") {
    const agentId = hover.data.agentId;
    if (!agentId) return null;
    const agent = session?.agents.get(agentId);
    if (!agent) return null;
    const labels = session ? buildAgentLabels(session.agents.values()) : new Map();
    const name = labelFor(labels, agent);
    const title = agent.kind === "main" ? `🛡 ${name}` : name;
    return (
      <div>
        <div style={{ fontWeight: 600 }}>{title}</div>
        <div style={{ fontSize: 10, opacity: 0.6, marginTop: 2 }}>{agent.id}</div>
        <div style={{ marginTop: 4 }}>
          Zone: {agent.currentZone} -&gt; {agent.targetZone}
        </div>
        <div style={{ marginTop: 4, opacity: 0.8 }}>
          {agent.recentActions
            .slice(-5)
            .reverse()
            .map((a, i) => (
              <div key={i}>- {a.summary}</div>
            ))}
        </div>
      </div>
    );
  }
  return null;
}