File size: 6,475 Bytes
ecc81b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { Text } from "@react-three/drei";
import { useFrame } from "@react-three/fiber";
import { useMemo, useRef } from "react";
import * as THREE from "three";

import { latticeAxisOf } from "../spec.js";

// How many labels exist at once. Not "how many cells have labels" — every cell
// has one, but only the nearest POOL of them are drawn. A 4-D lattice can hold
// thousands of cells and each label is a text mesh with its own glyph layout;
// building one per cell drops the frame rate to single digits and produces an
// unreadable thicket besides. Distance decides who gets one.
const POOL = 56;
const RECHECK = 0.12; // seconds between reassignments
const CAM_STEP = 0.4; // or immediately, once the camera has moved this far
const MIN_SEP = 0.115; // NDC; closest two labels may sit before one is dropped

const _v = new THREE.Vector3();

export default function CellLabels({ parsed, layout, anim, enabled, cellData }) {
  const refs = useRef([]);
  const lastCam = useRef(new THREE.Vector3(Infinity, 0, 0));
  const since = useRef(0);
  const shown = useRef([]);

  // Axis names line up with `shape`, which is spatial only — `names` carries
  // the time axis in front of them when the lattice has one.
  const names = useMemo(() => {
    const all = parsed.spec.lattice.names ?? [];
    const spatial = all.slice(parsed.spec.lattice.time ? 1 : 0);
    return parsed.shape.map((_, i) => spatial[i] ?? `dim${i}`);
  }, [parsed]);

  const positions = useMemo(
    () => parsed.cells.map((c) => layout.position(c)),
    [parsed, layout],
  );

  const coordText = useMemo(
    () => parsed.cells.map((c) => names.map((n, i) => `${n} ${c[i]}`).join("\n")),
    [parsed, names],
  );

  // With a run attached the label carries what the cell actually holds this
  // step, under its coordinates. `cellData` arrives present-only and in the
  // same flat order parseSpec built `cells`, so the index is shared; a length
  // that disagrees means the run and the spec are out of step, and showing
  // numbers against the wrong cells would be worse than showing none.
  const aligned =
    cellData &&
    cellData.pred &&
    cellData.pred.length === parsed.cells.length;

  useFrame((state, dt) => {
    const pool = refs.current;
    if (!pool.length) return;

    if (!enabled) {
      // Hide once, then stop doing work every frame.
      if (shown.current.length) {
        pool.forEach((t) => t && (t.visible = false));
        shown.current = [];
      }
      return;
    }

    const cam = state.camera;
    since.current += dt;
    const moved = cam.position.distanceTo(lastCam.current);
    const restack = since.current > RECHECK || moved > CAM_STEP;

    if (restack) {
      since.current = 0;
      lastCam.current.copy(cam.position);
      // Nearest first...
      const scored = [];
      for (let i = 0; i < positions.length; i++) {
        const p = positions[i];
        _v.set(p[0], p[1], p[2]);
        scored.push([_v.distanceTo(cam.position), i]);
      }
      scored.sort((a, b) => a[0] - b[0]);

      // ...then thinned in *screen* space. Taking the nearest N by distance
      // alone piles them all into one corner of the frame and the result is a
      // thicket you cannot read a single line of. Skipping any candidate that
      // lands too close to one already placed keeps every drawn label legible,
      // and means zooming in reveals more of them — cells spread apart on
      // screen, so more of them clear the spacing test. Behind the camera is
      // skipped outright: a label back there is one spent on nothing.
      const kept = [];
      for (const [dist, i] of scored) {
        if (kept.length >= POOL) break;
        const p = positions[i];
        _v.set(p[0], p[1], p[2]).project(cam);
        if (_v.z > 1) continue;
        let clear = true;
        for (const k of kept) {
          if (Math.hypot((_v.x - k.x) * 0.55, _v.y - k.y) < MIN_SEP) {
            clear = false;
            break;
          }
        }
        if (clear) kept.push({ x: _v.x, y: _v.y, i, dist });
      }
      shown.current = kept.map((k) => [k.dist, k.i]);
    }

    // The layer's swept axis decides each label's tint, so a label says which
    // side of the wavefront its cell is on rather than only where it sits.
    const { spec } = parsed;
    const layer = spec.layers[anim.current.layer];
    const family = spec.nd_method.family;
    const axis =
      layer && family !== "kernel" && family !== "flatten"
        ? latticeAxisOf(layer, spec)
        : null;
    const size = axis === null ? 0 : parsed.shape[axis];
    const front =
      layer && layer.reverse
        ? (1 - anim.current.progress) * (size - 1)
        : anim.current.progress * (size - 1);

    for (let k = 0; k < POOL; k++) {
      const t = pool[k];
      if (!t) continue;
      const entry = shown.current[k];
      if (!entry) {
        t.visible = false;
        continue;
      }
      const [dist, i] = entry;
      const p = positions[i];
      t.visible = true;
      t.position.set(p[0], p[1] + 0.52, p[2]);
      t.quaternion.copy(cam.quaternion); // face the camera
      const label = aligned
        ? `${coordText[i]}\npred ${cellData.pred[i]}\ntrue ${cellData.true[i]}`
        : coordText[i];
      if (t.text !== label) t.text = label;

      // Fade with distance instead of popping in and out at the pool edge.
      const near = layout.radius * 1.1;
      const far = layout.radius * 2.6;
      t.fillOpacity = 1 - THREE.MathUtils.clamp((dist - near) / (far - near), 0, 0.92);

      if (axis === null) {
        t.color = "#9fb0cc";
      } else {
        const d = parsed.cells[i][axis] - front;
        const behind = layer.reverse ? d > 0 : d < 0;
        t.color = Math.abs(d) < 0.75 ? "#ffd79a" : behind ? "#9fc4ff" : "#7d8aa3";
      }
    }
  });

  // Scaled to the model for the same reason the axis labels are: the camera
  // pulls back in proportion to the lattice, so a fixed world size is not a
  // fixed readable size.
  const fontSize = Math.max(0.15, layout.radius * 0.032);

  return (
    <group>
      {Array.from({ length: POOL }, (_, i) => (
        <Text
          key={i}
          ref={(el) => (refs.current[i] = el)}
          visible={false}
          fontSize={fontSize}
          lineHeight={1.25}
          anchorX="center"
          anchorY="bottom"
          outlineWidth={0.014}
          outlineColor="#080b11"
        >
          {""}
        </Text>
      ))}
    </group>
  );
}