Mongoosetross commited on
Commit
c63b2cb
·
verified ·
1 Parent(s): 3b5acef

Deploy Reverse Etymology Atlas

Browse files
Files changed (3) hide show
  1. frontend/src/App.tsx +66 -14
  2. frontend/src/styles.css +108 -5
  3. frontend/src/viz.ts +170 -53
frontend/src/App.tsx CHANGED
@@ -184,6 +184,7 @@ export default function App() {
184
  const viewRef = useRef<CanvasView | null>(null);
185
  const mapRef = useRef<HTMLDivElement>(null);
186
  const mapObj = useRef<L.Map | null>(null);
 
187
  const dq = useDebounced(q, 80);
188
 
189
  useEffect(() => {
@@ -269,18 +270,42 @@ export default function App() {
269
  }, [query, view, depth]);
270
 
271
  useEffect(() => {
272
- if (!canvasRef.current) return;
273
- if (!viewRef.current) viewRef.current = new CanvasView(canvasRef.current);
 
 
 
 
 
 
 
 
 
 
274
  const cv = viewRef.current;
275
  cv.onSelect = (n) => {
276
  setSelected(n);
277
  setMobilePanel("info");
278
  };
279
- cv.onExpand = (key) => setExpand((e) => [...e, key]);
280
- if (tree?.nodes && (view === "tree" || view === "radial")) {
281
- cv.setData(tree.nodes, view === "radial" ? "radial" : "tree", colorBy, true);
 
 
 
282
  }
283
- }, [tree, view, colorBy]);
 
 
 
 
 
 
 
 
 
 
 
284
 
285
  useEffect(() => {
286
  if (selected?.kind !== "word") {
@@ -372,7 +397,7 @@ export default function App() {
372
  {hits.length > 0 && (
373
  <div className="suggest">
374
  {hits.map((h, i) => (
375
- <button key={`${h.term}-${h.lang}-${i}`} className={i === activeHit ? "active" : ""} onMouseDown={() => pick(h)}>
376
  <span className="term">{h.term}</span>
377
  <span>{h.lang_display || h.lang}</span>
378
  <span className="meta">{h.child_count} derived</span>
@@ -381,7 +406,7 @@ export default function App() {
381
  </div>
382
  )}
383
  </div>
384
- <button className="ghost" onClick={() => setMobilePanel(mobilePanel === "filters" ? "none" : "filters")}>
385
  Filters{chipCount ? ` ${chipCount}` : ""}
386
  </button>
387
  </header>
@@ -426,17 +451,28 @@ export default function App() {
426
  )}
427
  </div>
428
  <div className="workspace">
 
 
 
429
  <aside className={`panel ${mobilePanel === "filters" ? "open-sheet" : ""}`}>
 
 
 
 
 
 
 
 
430
  {meta && (
431
  <>
432
  <div className="tabs">
433
- <button className={filterTab === "graph" ? "on" : ""} onClick={() => setFilterTab("graph")}>
434
  Graph
435
  </button>
436
- <button className={filterTab === "leaf" ? "on" : ""} onClick={() => setFilterTab("leaf")}>
437
  Leaves
438
  </button>
439
- <button className={filterTab === "path" ? "on" : ""} onClick={() => setFilterTab("path")}>
440
  Path
441
  </button>
442
  </div>
@@ -468,7 +504,7 @@ export default function App() {
468
  {filterTab === "leaf" && (
469
  <div>
470
  <p style={{ color: "var(--muted)", fontSize: 13 }}>
471
- Only terminal derived words must match. Ancestors stay visible if they lead to a kept leaf.
472
  </p>
473
  <PredicateEditor meta={meta} value={leaf} onChange={setLeaf} />
474
  </div>
@@ -528,6 +564,7 @@ export default function App() {
528
  )}
529
  </>
530
  )}
 
531
  </aside>
532
  <main className="stage">
533
  {!term && meta && (
@@ -541,7 +578,7 @@ export default function App() {
541
  ))}
542
  </div>
543
  )}
544
- {(view === "tree" || view === "radial") && <canvas ref={canvasRef} />}
545
  {view === "map" && <div className="map" ref={mapRef} />}
546
  {view === "table" && (
547
  <div className="table-wrap">
@@ -558,7 +595,13 @@ export default function App() {
558
  </thead>
559
  <tbody>
560
  {words.map((n) => (
561
- <tr key={String(n.id)} onClick={() => setSelected(n)}>
 
 
 
 
 
 
562
  <td>{n.term}</td>
563
  <td>{n.lang_display || n.lang}</td>
564
  <td>{n.family_name}</td>
@@ -594,6 +637,14 @@ export default function App() {
594
  </div>
595
  </main>
596
  <aside className={`panel right inspector ${mobilePanel === "info" ? "open-sheet" : ""}`}>
 
 
 
 
 
 
 
 
597
  {selected ? (
598
  <>
599
  <h2>{selected.term}</h2>
@@ -661,6 +712,7 @@ export default function App() {
661
  Data: {meta.citation.author}, Etymology Atlas ({meta.citation.license}). {meta.nodes.toLocaleString()} words, {meta.edges.toLocaleString()} relations.
662
  </p>
663
  )}
 
664
  </aside>
665
  </div>
666
  </div>
 
184
  const viewRef = useRef<CanvasView | null>(null);
185
  const mapRef = useRef<HTMLDivElement>(null);
186
  const mapObj = useRef<L.Map | null>(null);
187
+ const cameraKeyRef = useRef<string>("");
188
  const dq = useDebounced(q, 80);
189
 
190
  useEffect(() => {
 
270
  }, [query, view, depth]);
271
 
272
  useEffect(() => {
273
+ const canvas = canvasRef.current;
274
+ if (!canvas || (view !== "tree" && view !== "radial")) {
275
+ if (viewRef.current) {
276
+ viewRef.current.destroy();
277
+ viewRef.current = null;
278
+ }
279
+ return;
280
+ }
281
+ if (viewRef.current?.canvas !== canvas) {
282
+ viewRef.current?.destroy();
283
+ viewRef.current = new CanvasView(canvas);
284
+ }
285
  const cv = viewRef.current;
286
  cv.onSelect = (n) => {
287
  setSelected(n);
288
  setMobilePanel("info");
289
  };
290
+ cv.onExpand = (key) => setExpand((e) => (e.includes(key) ? e : [...e, key]));
291
+ if (tree?.nodes) {
292
+ const camKey = `${term}|${lang}|${view}`;
293
+ const resetCamera = cameraKeyRef.current !== camKey;
294
+ cameraKeyRef.current = camKey;
295
+ cv.setData(tree.nodes, view === "radial" ? "radial" : "tree", colorBy, resetCamera);
296
  }
297
+ }, [tree, view, colorBy, term, lang]);
298
+
299
+ useEffect(() => {
300
+ viewRef.current?.setSelected(selected ? String(selected.id) : null);
301
+ }, [selected]);
302
+
303
+ useEffect(() => {
304
+ return () => {
305
+ viewRef.current?.destroy();
306
+ viewRef.current = null;
307
+ };
308
+ }, []);
309
 
310
  useEffect(() => {
311
  if (selected?.kind !== "word") {
 
397
  {hits.length > 0 && (
398
  <div className="suggest">
399
  {hits.map((h, i) => (
400
+ <button key={`${h.term}-${h.lang}-${i}`} className={i === activeHit ? "active" : ""} type="button" onClick={() => pick(h)}>
401
  <span className="term">{h.term}</span>
402
  <span>{h.lang_display || h.lang}</span>
403
  <span className="meta">{h.child_count} derived</span>
 
406
  </div>
407
  )}
408
  </div>
409
+ <button type="button" className="ghost filters-btn" onClick={() => setMobilePanel(mobilePanel === "filters" ? "none" : "filters")}>
410
  Filters{chipCount ? ` ${chipCount}` : ""}
411
  </button>
412
  </header>
 
451
  )}
452
  </div>
453
  <div className="workspace">
454
+ {mobilePanel !== "none" && (
455
+ <button type="button" className="sheet-backdrop" aria-label="Close panel" onClick={() => setMobilePanel("none")} />
456
+ )}
457
  <aside className={`panel ${mobilePanel === "filters" ? "open-sheet" : ""}`}>
458
+ <div className="sheet-grab">
459
+ <span />
460
+ <strong>Filters</strong>
461
+ <button type="button" className="ghost sheet-close" onClick={() => setMobilePanel("none")}>
462
+ Close
463
+ </button>
464
+ </div>
465
+ <div className="sheet-body">
466
  {meta && (
467
  <>
468
  <div className="tabs">
469
+ <button type="button" className={filterTab === "graph" ? "on" : ""} onClick={() => setFilterTab("graph")}>
470
  Graph
471
  </button>
472
+ <button type="button" className={filterTab === "leaf" ? "on" : ""} onClick={() => setFilterTab("leaf")}>
473
  Leaves
474
  </button>
475
+ <button type="button" className={filterTab === "path" ? "on" : ""} onClick={() => setFilterTab("path")}>
476
  Path
477
  </button>
478
  </div>
 
504
  {filterTab === "leaf" && (
505
  <div>
506
  <p style={{ color: "var(--muted)", fontSize: 13 }}>
507
+ Matching words in the filtered language stay visible even when they have further descendants.
508
  </p>
509
  <PredicateEditor meta={meta} value={leaf} onChange={setLeaf} />
510
  </div>
 
564
  )}
565
  </>
566
  )}
567
+ </div>
568
  </aside>
569
  <main className="stage">
570
  {!term && meta && (
 
578
  ))}
579
  </div>
580
  )}
581
+ {(view === "tree" || view === "radial") && <canvas ref={canvasRef} className="viz-canvas" />}
582
  {view === "map" && <div className="map" ref={mapRef} />}
583
  {view === "table" && (
584
  <div className="table-wrap">
 
595
  </thead>
596
  <tbody>
597
  {words.map((n) => (
598
+ <tr
599
+ key={String(n.id)}
600
+ onClick={() => {
601
+ setSelected(n);
602
+ setMobilePanel("info");
603
+ }}
604
+ >
605
  <td>{n.term}</td>
606
  <td>{n.lang_display || n.lang}</td>
607
  <td>{n.family_name}</td>
 
637
  </div>
638
  </main>
639
  <aside className={`panel right inspector ${mobilePanel === "info" ? "open-sheet" : ""}`}>
640
+ <div className="sheet-grab">
641
+ <span />
642
+ <strong>Inspector</strong>
643
+ <button type="button" className="ghost sheet-close" onClick={() => setMobilePanel("none")}>
644
+ Close
645
+ </button>
646
+ </div>
647
+ <div className="sheet-body">
648
  {selected ? (
649
  <>
650
  <h2>{selected.term}</h2>
 
712
  Data: {meta.citation.author}, Etymology Atlas ({meta.citation.license}). {meta.nodes.toLocaleString()} words, {meta.edges.toLocaleString()} relations.
713
  </p>
714
  )}
715
+ </div>
716
  </aside>
717
  </div>
718
  </div>
frontend/src/styles.css CHANGED
@@ -18,16 +18,24 @@
18
  }
19
 
20
  * { box-sizing: border-box; }
21
- html, body, #root { height: 100%; margin: 0; }
 
 
 
 
 
22
  body {
23
  background: var(--ink);
24
  color: var(--parchment);
25
  font-family: var(--font);
26
  font-size: 15px;
 
 
27
  }
28
 
29
  button, input, select, textarea { font: inherit; color: inherit; }
30
  button { cursor: pointer; }
 
31
 
32
  .app {
33
  height: 100%;
@@ -140,18 +148,23 @@ button { cursor: pointer; }
140
  display: grid;
141
  grid-template-columns: 320px 1fr 320px;
142
  min-height: 0;
 
 
143
  }
144
  .panel {
145
  background: rgba(24, 32, 40, 0.72);
146
  border-right: 1px solid var(--line);
147
  overflow: auto;
148
  padding: 14px;
 
149
  }
150
  .panel.right { border-right: 0; border-left: 1px solid var(--line); }
151
  .stage {
152
  position: relative;
153
  min-width: 0;
154
  min-height: 0;
 
 
155
  }
156
  .stage canvas, .stage .map, .stage .table-wrap, .stage .stats {
157
  position: absolute;
@@ -159,6 +172,19 @@ button { cursor: pointer; }
159
  width: 100%;
160
  height: 100%;
161
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  .tabs { display: flex; gap: 6px; margin-bottom: 12px; }
164
  .tabs button {
@@ -271,19 +297,96 @@ tr:hover td { background: var(--ink-3); cursor: pointer; }
271
  @media (max-width: 840px) {
272
  .workspace { grid-template-columns: 1fr; }
273
  .panel { display: none; }
274
- .panel.open-sheet {
275
  display: block;
276
  position: absolute;
277
- left: 0; right: 0; bottom: 0;
 
 
 
 
 
 
 
 
 
 
 
 
278
  z-index: 30;
279
- height: min(78vh, 640px);
 
280
  border-right: 0;
 
281
  border-top: 1px solid var(--line);
282
  border-radius: 18px 18px 0 0;
283
  background: var(--ink-2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
285
  .brand { min-width: 0; }
286
  .brand span { display: none; }
287
- .topbar { padding: 10px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  .legend { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  }
 
18
  }
19
 
20
  * { box-sizing: border-box; }
21
+ html, body, #root {
22
+ height: 100%;
23
+ height: 100dvh;
24
+ margin: 0;
25
+ overflow: hidden;
26
+ }
27
  body {
28
  background: var(--ink);
29
  color: var(--parchment);
30
  font-family: var(--font);
31
  font-size: 15px;
32
+ -webkit-tap-highlight-color: transparent;
33
+ touch-action: manipulation;
34
  }
35
 
36
  button, input, select, textarea { font: inherit; color: inherit; }
37
  button { cursor: pointer; }
38
+ button, a, input, select, label { touch-action: manipulation; }
39
 
40
  .app {
41
  height: 100%;
 
148
  display: grid;
149
  grid-template-columns: 320px 1fr 320px;
150
  min-height: 0;
151
+ position: relative;
152
+ overflow: hidden;
153
  }
154
  .panel {
155
  background: rgba(24, 32, 40, 0.72);
156
  border-right: 1px solid var(--line);
157
  overflow: auto;
158
  padding: 14px;
159
+ -webkit-overflow-scrolling: touch;
160
  }
161
  .panel.right { border-right: 0; border-left: 1px solid var(--line); }
162
  .stage {
163
  position: relative;
164
  min-width: 0;
165
  min-height: 0;
166
+ overflow: hidden;
167
+ overscroll-behavior: none;
168
  }
169
  .stage canvas, .stage .map, .stage .table-wrap, .stage .stats {
170
  position: absolute;
 
172
  width: 100%;
173
  height: 100%;
174
  }
175
+ .viz-canvas {
176
+ touch-action: none;
177
+ display: block;
178
+ cursor: grab;
179
+ -webkit-user-select: none;
180
+ user-select: none;
181
+ }
182
+ .sheet-grab {
183
+ display: none;
184
+ }
185
+ .sheet-backdrop {
186
+ display: none;
187
+ }
188
 
189
  .tabs { display: flex; gap: 6px; margin-bottom: 12px; }
190
  .tabs button {
 
297
  @media (max-width: 840px) {
298
  .workspace { grid-template-columns: 1fr; }
299
  .panel { display: none; }
300
+ .sheet-backdrop {
301
  display: block;
302
  position: absolute;
303
+ inset: 0;
304
+ z-index: 25;
305
+ border: 0;
306
+ background: rgba(0, 0, 0, 0.45);
307
+ padding: 0;
308
+ }
309
+ .panel.open-sheet {
310
+ display: flex;
311
+ flex-direction: column;
312
+ position: absolute;
313
+ left: 0;
314
+ right: 0;
315
+ bottom: 0;
316
  z-index: 30;
317
+ height: min(78dvh, 640px);
318
+ max-height: calc(100% - 12px);
319
  border-right: 0;
320
+ border-left: 0;
321
  border-top: 1px solid var(--line);
322
  border-radius: 18px 18px 0 0;
323
  background: var(--ink-2);
324
+ padding: 0 14px 18px;
325
+ overflow: hidden;
326
+ box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35);
327
+ }
328
+ .sheet-body {
329
+ overflow: auto;
330
+ -webkit-overflow-scrolling: touch;
331
+ flex: 1;
332
+ min-height: 0;
333
+ }
334
+ .sheet-grab {
335
+ display: grid;
336
+ grid-template-columns: 64px 1fr auto;
337
+ align-items: center;
338
+ gap: 8px;
339
+ padding: 10px 0 8px;
340
+ position: sticky;
341
+ top: 0;
342
+ background: var(--ink-2);
343
+ z-index: 1;
344
+ }
345
+ .sheet-grab > span {
346
+ width: 42px;
347
+ height: 4px;
348
+ border-radius: 99px;
349
+ background: rgba(244, 239, 228, 0.28);
350
+ justify-self: start;
351
+ margin-left: 10px;
352
+ }
353
+ .sheet-grab strong {
354
+ font-size: 14px;
355
+ font-weight: 600;
356
+ }
357
+ .sheet-close {
358
+ padding: 8px 12px;
359
+ min-height: 40px;
360
  }
361
  .brand { min-width: 0; }
362
  .brand span { display: none; }
363
+ .brand strong { font-size: 18px; }
364
+ .topbar { padding: 10px; gap: 8px; }
365
+ .toolbar {
366
+ padding: 8px 10px;
367
+ gap: 6px;
368
+ flex-wrap: nowrap;
369
+ overflow-x: auto;
370
+ -webkit-overflow-scrolling: touch;
371
+ scrollbar-width: none;
372
+ }
373
+ .toolbar::-webkit-scrollbar { display: none; }
374
+ .seg { flex: 0 0 auto; }
375
+ .seg button { padding: 8px 10px; min-height: 36px; }
376
+ .filters-btn { flex: 0 0 auto; min-height: 40px; }
377
  .legend { display: none; }
378
+ .status-line {
379
+ left: 12px;
380
+ right: 12px;
381
+ bottom: 10px;
382
+ text-align: center;
383
+ }
384
+ .search-wrap input {
385
+ padding: 12px 16px 12px 40px;
386
+ font-size: 16px; /* avoid iOS zoom-on-focus */
387
+ }
388
+ .suggest { max-height: min(50dvh, 320px); }
389
+ .suggest button { padding: 12px 14px; min-height: 44px; }
390
+ .checklist { max-height: 180px; }
391
+ .checklist label { padding: 10px 8px; min-height: 40px; }
392
  }
frontend/src/viz.ts CHANGED
@@ -17,14 +17,15 @@ export interface Camera {
17
  k: number;
18
  }
19
 
 
 
 
20
  export function layoutGraph(
21
  nodes: GraphNode[],
22
  mode: "tree" | "radial",
23
  colorBy: string
24
  ): { items: LaidOut[]; edges: { x1: number; y1: number; x2: number; y2: number; color: string }[]; width: number; height: number } {
25
  if (!nodes.length) return { items: [], edges: [], width: 1, height: 1 };
26
- const byId = new Map(nodes.map((n) => [String(n.id), n]));
27
- const maxDepth = Math.max(...nodes.map((n) => n.depth || 0), 1);
28
  let root;
29
  try {
30
  root = stratify<GraphNode>()
@@ -34,6 +35,8 @@ export function layoutGraph(
34
  return { items: [], edges: [], width: 1, height: 1 };
35
  }
36
 
 
 
37
  if (mode === "radial") {
38
  const layout = d3tree<GraphNode>().size([2 * Math.PI, 280 + maxDepth * 70]);
39
  layout(root);
@@ -113,28 +116,48 @@ export class CanvasView {
113
  laid: ReturnType<typeof layoutGraph> | null = null;
114
  hoverId: string | null = null;
115
  selectedId: string | null = null;
116
- private dragging = false;
117
  private pointers = new Map<number, { x: number; y: number }>();
118
  private lastPinch = 0;
 
 
 
 
 
119
  private grid = new Map<number, LaidOut[]>();
120
  private raf = 0;
121
  private dirty = true;
 
122
  onSelect: ((n: GraphNode) => void) | null = null;
123
  onHover: ((n: GraphNode | null) => void) | null = null;
124
  onExpand: ((key: string) => void) | null = null;
125
 
126
  constructor(canvas: HTMLCanvasElement) {
127
  this.canvas = canvas;
 
128
  canvas.addEventListener("pointerdown", this.onDown);
129
  canvas.addEventListener("pointermove", this.onMove);
130
  canvas.addEventListener("pointerup", this.onUp);
131
  canvas.addEventListener("pointercancel", this.onUp);
 
132
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
133
  canvas.addEventListener("dblclick", this.onDbl);
 
 
134
  }
135
 
136
  destroy() {
 
137
  cancelAnimationFrame(this.raf);
 
 
 
 
 
 
 
 
 
 
138
  }
139
 
140
  setData(nodes: GraphNode[], mode: "tree" | "radial", colorBy: string, resetCamera: boolean) {
@@ -143,10 +166,22 @@ export class CanvasView {
143
  if (resetCamera && this.laid.items.length) {
144
  const c = this.canvas.getBoundingClientRect();
145
  const root = this.laid.items.find((i) => i.node.depth === 0) || this.laid.items[0];
146
- this.camera = { k: mode === "radial" ? 0.9 : 1, x: c.width * 0.28 - root.x, y: c.height * 0.5 - root.y };
 
 
 
147
  }
148
  this.dirty = true;
149
- this.loop();
 
 
 
 
 
 
 
 
 
150
  }
151
 
152
  private rebuildGrid() {
@@ -177,14 +212,17 @@ export class CanvasView {
177
  const cell = 48;
178
  const gx = Math.floor(w.x / cell);
179
  const gy = Math.floor(w.y / cell);
 
 
180
  let best: LaidOut | null = null;
181
- let bestD = 18;
182
- for (let dx = -1; dx <= 1; dx++) {
183
- for (let dy = -1; dy <= 1; dy++) {
 
184
  const arr = this.grid.get(((gx + dx) << 16) ^ (gy + dy));
185
  if (!arr) continue;
186
  for (const it of arr) {
187
- const d = Math.hypot(it.x - w.x, it.y - w.y);
188
  if (d < bestD) {
189
  bestD = d;
190
  best = it;
@@ -195,56 +233,127 @@ export class CanvasView {
195
  return best;
196
  }
197
 
 
 
 
 
198
  private onDown = (e: PointerEvent) => {
199
- this.canvas.setPointerCapture(e.pointerId);
 
 
 
 
 
 
200
  this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
201
- if (this.pointers.size === 1) this.dragging = true;
202
- if (this.pointers.size === 2) {
 
 
 
 
203
  const pts = [...this.pointers.values()];
204
  this.lastPinch = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
205
- this.dragging = false;
 
 
206
  }
207
  };
208
 
209
  private onMove = (e: PointerEvent) => {
210
- if (this.pointers.has(e.pointerId)) this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
211
- if (this.pointers.size === 2) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  const pts = [...this.pointers.values()];
213
  const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
214
- if (this.lastPinch) {
 
215
  const factor = dist / this.lastPinch;
216
- this.zoomAt((pts[0].x + pts[1].x) / 2, (pts[0].y + pts[1].y) / 2, factor);
 
 
 
 
 
 
 
217
  }
218
  this.lastPinch = dist;
 
 
219
  return;
220
  }
221
- if (this.dragging) {
222
- this.camera.x += e.movementX;
223
- this.camera.y += e.movementY;
224
- this.dirty = true;
225
- return;
226
- }
227
- const hit = this.hit(e.clientX, e.clientY);
228
- const id = hit?.id || null;
229
- if (id !== this.hoverId) {
230
- this.hoverId = id;
231
- this.onHover?.(hit?.node || null);
232
- this.dirty = true;
 
233
  }
234
  };
235
 
236
  private onUp = (e: PointerEvent) => {
237
- const wasDrag = this.pointers.size === 1 && this.dragging;
 
 
 
238
  this.pointers.delete(e.pointerId);
239
- this.dragging = false;
240
- this.lastPinch = 0;
241
- if (wasDrag && Math.abs(e.movementX) < 4 && Math.abs(e.movementY) < 4) {
242
- const hit = this.hit(e.clientX, e.clientY);
243
- if (hit) {
244
- this.selectedId = hit.id;
245
- if (hit.node.kind === "cluster" && hit.node.expand_key) this.onExpand?.(hit.node.expand_key);
246
- else this.onSelect?.(hit.node);
247
- this.dirty = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  }
249
  }
250
  };
@@ -263,7 +372,7 @@ export class CanvasView {
263
  const r = this.canvas.getBoundingClientRect();
264
  const lx = clientX - r.left;
265
  const ly = clientY - r.top;
266
- const next = Math.min(6, Math.max(0.15, this.camera.k * factor));
267
  const wx = (lx - this.camera.x) / this.camera.k;
268
  const wy = (ly - this.camera.y) / this.camera.k;
269
  this.camera.k = next;
@@ -273,6 +382,7 @@ export class CanvasView {
273
  }
274
 
275
  private loop = () => {
 
276
  this.raf = requestAnimationFrame(this.loop);
277
  if (!this.dirty) return;
278
  this.dirty = false;
@@ -285,9 +395,12 @@ export class CanvasView {
285
  if (!ctx || !this.laid) return;
286
  const dpr = Math.min(2, window.devicePixelRatio || 1);
287
  const rect = canvas.getBoundingClientRect();
288
- if (canvas.width !== Math.floor(rect.width * dpr) || canvas.height !== Math.floor(rect.height * dpr)) {
289
- canvas.width = Math.floor(rect.width * dpr);
290
- canvas.height = Math.floor(rect.height * dpr);
 
 
 
291
  }
292
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
293
  ctx.clearRect(0, 0, rect.width, rect.height);
@@ -296,8 +409,8 @@ export class CanvasView {
296
  ctx.scale(this.camera.k, this.camera.k);
297
 
298
  const path = new Set<string>();
299
- if (this.hoverId) {
300
- let cur = this.laid.items.find((i) => i.id === this.hoverId);
301
  const byId = new Map(this.laid.items.map((i) => [i.id, i]));
302
  while (cur) {
303
  path.add(cur.id);
@@ -314,12 +427,12 @@ export class CanvasView {
314
  const mx = (e.x1 + e.x2) / 2;
315
  ctx.bezierCurveTo(mx, e.y1, mx, e.y2, e.x2, e.y2);
316
  ctx.strokeStyle = e.color + "99";
317
- ctx.lineWidth = 1.2;
318
  ctx.stroke();
319
  }
320
  if (path.size) {
321
  const byId = new Map(this.laid.items.map((i) => [i.id, i]));
322
- ctx.lineWidth = 2.4;
323
  for (const id of path) {
324
  const it = byId.get(id);
325
  if (!it || it.node.parent == null) continue;
@@ -346,7 +459,7 @@ export class CanvasView {
346
  ctx.fillStyle = it.color;
347
  ctx.fill();
348
  if (it.node.depth === 0) {
349
- ctx.lineWidth = 2;
350
  ctx.strokeStyle = "#f4efe4";
351
  ctx.stroke();
352
  }
@@ -354,7 +467,7 @@ export class CanvasView {
354
  if (showLabels || it.id === this.hoverId || it.id === this.selectedId || it.node.depth === 0) {
355
  ctx.font = `${it.node.depth === 0 ? 13 : 11}px "Source Sans 3", "Segoe UI", sans-serif`;
356
  ctx.fillStyle = "#f4efe4";
357
- ctx.globalAlpha = it.id === this.hoverId || it.node.depth < 2 || this.camera.k > 0.9 ? 1 : 0.75;
358
  const label =
359
  it.node.kind === "cluster"
360
  ? it.node.term
@@ -365,14 +478,19 @@ export class CanvasView {
365
  }
366
  ctx.restore();
367
 
368
- // minimap on wide screens
369
  if (rect.width > 720 && this.laid.items.length > 8) {
370
- const mw = 140, mh = 90, mx = rect.width - mw - 16, my = 16;
 
 
 
371
  ctx.fillStyle = "rgba(16,22,28,0.72)";
372
  ctx.strokeStyle = "rgba(232,224,212,0.2)";
373
  ctx.fillRect(mx, my, mw, mh);
374
  ctx.strokeRect(mx, my, mw, mh);
375
- let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
 
 
 
376
  for (const it of this.laid.items) {
377
  minx = Math.min(minx, it.x);
378
  miny = Math.min(miny, it.y);
@@ -386,7 +504,6 @@ export class CanvasView {
386
  ctx.translate(mx + 8, my + 8);
387
  ctx.scale(s, s);
388
  ctx.translate(-minx, -miny);
389
- ctx.fillStyle = "#3cb8c4";
390
  for (const it of this.laid.items) {
391
  ctx.fillStyle = it.color;
392
  ctx.fillRect(it.x, it.y, 8, 8);
 
17
  k: number;
18
  }
19
 
20
+ const TAP_PX = 10;
21
+ const HIT_SCREEN_PX = 28;
22
+
23
  export function layoutGraph(
24
  nodes: GraphNode[],
25
  mode: "tree" | "radial",
26
  colorBy: string
27
  ): { items: LaidOut[]; edges: { x1: number; y1: number; x2: number; y2: number; color: string }[]; width: number; height: number } {
28
  if (!nodes.length) return { items: [], edges: [], width: 1, height: 1 };
 
 
29
  let root;
30
  try {
31
  root = stratify<GraphNode>()
 
35
  return { items: [], edges: [], width: 1, height: 1 };
36
  }
37
 
38
+ const maxDepth = Math.max(...nodes.map((n) => n.depth || 0), 1);
39
+
40
  if (mode === "radial") {
41
  const layout = d3tree<GraphNode>().size([2 * Math.PI, 280 + maxDepth * 70]);
42
  layout(root);
 
116
  laid: ReturnType<typeof layoutGraph> | null = null;
117
  hoverId: string | null = null;
118
  selectedId: string | null = null;
 
119
  private pointers = new Map<number, { x: number; y: number }>();
120
  private lastPinch = 0;
121
+ private pinchMid: { x: number; y: number } | null = null;
122
+ private downPt: { x: number; y: number; id: number } | null = null;
123
+ private panLast: { x: number; y: number } | null = null;
124
+ private movedPx = 0;
125
+ private panning = false;
126
  private grid = new Map<number, LaidOut[]>();
127
  private raf = 0;
128
  private dirty = true;
129
+ private unbound = false;
130
  onSelect: ((n: GraphNode) => void) | null = null;
131
  onHover: ((n: GraphNode | null) => void) | null = null;
132
  onExpand: ((key: string) => void) | null = null;
133
 
134
  constructor(canvas: HTMLCanvasElement) {
135
  this.canvas = canvas;
136
+ canvas.style.touchAction = "none";
137
  canvas.addEventListener("pointerdown", this.onDown);
138
  canvas.addEventListener("pointermove", this.onMove);
139
  canvas.addEventListener("pointerup", this.onUp);
140
  canvas.addEventListener("pointercancel", this.onUp);
141
+ canvas.addEventListener("lostpointercapture", this.onUp);
142
  canvas.addEventListener("wheel", this.onWheel, { passive: false });
143
  canvas.addEventListener("dblclick", this.onDbl);
144
+ canvas.addEventListener("contextmenu", this.onContext);
145
+ this.raf = requestAnimationFrame(this.loop);
146
  }
147
 
148
  destroy() {
149
+ this.unbound = true;
150
  cancelAnimationFrame(this.raf);
151
+ const canvas = this.canvas;
152
+ canvas.removeEventListener("pointerdown", this.onDown);
153
+ canvas.removeEventListener("pointermove", this.onMove);
154
+ canvas.removeEventListener("pointerup", this.onUp);
155
+ canvas.removeEventListener("pointercancel", this.onUp);
156
+ canvas.removeEventListener("lostpointercapture", this.onUp);
157
+ canvas.removeEventListener("wheel", this.onWheel);
158
+ canvas.removeEventListener("dblclick", this.onDbl);
159
+ canvas.removeEventListener("contextmenu", this.onContext);
160
+ this.pointers.clear();
161
  }
162
 
163
  setData(nodes: GraphNode[], mode: "tree" | "radial", colorBy: string, resetCamera: boolean) {
 
166
  if (resetCamera && this.laid.items.length) {
167
  const c = this.canvas.getBoundingClientRect();
168
  const root = this.laid.items.find((i) => i.node.depth === 0) || this.laid.items[0];
169
+ const k = mode === "radial" ? 0.85 : Math.min(1.15, Math.max(0.55, c.width / 720));
170
+ const cx = mode === "radial" ? c.width * 0.5 : c.width * 0.28;
171
+ const cy = c.height * 0.5;
172
+ this.camera = { k, x: cx - root.x * k, y: cy - root.y * k };
173
  }
174
  this.dirty = true;
175
+ }
176
+
177
+ setSelected(id: string | null) {
178
+ if (this.selectedId === id) return;
179
+ this.selectedId = id;
180
+ this.dirty = true;
181
+ }
182
+
183
+ requestDraw() {
184
+ this.dirty = true;
185
  }
186
 
187
  private rebuildGrid() {
 
212
  const cell = 48;
213
  const gx = Math.floor(w.x / cell);
214
  const gy = Math.floor(w.y / cell);
215
+ // Finger-sized target in screen px, converted to world units.
216
+ const hitR = Math.max(14, HIT_SCREEN_PX / this.camera.k);
217
  let best: LaidOut | null = null;
218
+ let bestD = hitR;
219
+ const span = Math.max(1, Math.ceil(hitR / cell));
220
+ for (let dx = -span; dx <= span; dx++) {
221
+ for (let dy = -span; dy <= span; dy++) {
222
  const arr = this.grid.get(((gx + dx) << 16) ^ (gy + dy));
223
  if (!arr) continue;
224
  for (const it of arr) {
225
+ const d = Math.hypot(it.x - w.x, it.y - w.y) - it.r * 0.35;
226
  if (d < bestD) {
227
  bestD = d;
228
  best = it;
 
233
  return best;
234
  }
235
 
236
+ private onContext = (e: Event) => {
237
+ e.preventDefault();
238
+ };
239
+
240
  private onDown = (e: PointerEvent) => {
241
+ if (e.button !== 0 && e.pointerType === "mouse") return;
242
+ e.preventDefault();
243
+ try {
244
+ this.canvas.setPointerCapture(e.pointerId);
245
+ } catch {
246
+ /* ignore */
247
+ }
248
  this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
249
+ if (this.pointers.size === 1) {
250
+ this.downPt = { x: e.clientX, y: e.clientY, id: e.pointerId };
251
+ this.panLast = { x: e.clientX, y: e.clientY };
252
+ this.movedPx = 0;
253
+ this.panning = false;
254
+ } else if (this.pointers.size === 2) {
255
  const pts = [...this.pointers.values()];
256
  this.lastPinch = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
257
+ this.pinchMid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
258
+ this.panning = false;
259
+ this.downPt = null;
260
  }
261
  };
262
 
263
  private onMove = (e: PointerEvent) => {
264
+ if (!this.pointers.has(e.pointerId)) {
265
+ // Hover only for mouse / pen when not interacting.
266
+ if (this.pointers.size === 0 && e.pointerType === "mouse") {
267
+ const hit = this.hit(e.clientX, e.clientY);
268
+ const id = hit?.id || null;
269
+ if (id !== this.hoverId) {
270
+ this.hoverId = id;
271
+ this.onHover?.(hit?.node || null);
272
+ this.dirty = true;
273
+ }
274
+ }
275
+ return;
276
+ }
277
+
278
+ e.preventDefault();
279
+ this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
280
+
281
+ if (this.pointers.size >= 2) {
282
  const pts = [...this.pointers.values()];
283
  const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
284
+ const mid = { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 };
285
+ if (this.lastPinch > 0) {
286
  const factor = dist / this.lastPinch;
287
+ if (Number.isFinite(factor) && factor > 0) {
288
+ this.zoomAt(mid.x, mid.y, factor);
289
+ }
290
+ }
291
+ if (this.pinchMid) {
292
+ this.camera.x += mid.x - this.pinchMid.x;
293
+ this.camera.y += mid.y - this.pinchMid.y;
294
+ this.dirty = true;
295
  }
296
  this.lastPinch = dist;
297
+ this.pinchMid = mid;
298
+ this.movedPx = TAP_PX + 1;
299
  return;
300
  }
301
+
302
+ // Single-finger / mouse pan using client deltas (movementX is unreliable on touch).
303
+ if (this.panLast) {
304
+ const dx = e.clientX - this.panLast.x;
305
+ const dy = e.clientY - this.panLast.y;
306
+ this.movedPx += Math.hypot(dx, dy);
307
+ this.panLast = { x: e.clientX, y: e.clientY };
308
+ if (this.movedPx > TAP_PX || this.panning) {
309
+ this.panning = true;
310
+ this.camera.x += dx;
311
+ this.camera.y += dy;
312
+ this.dirty = true;
313
+ }
314
  }
315
  };
316
 
317
  private onUp = (e: PointerEvent) => {
318
+ if (!this.pointers.has(e.pointerId) && this.downPt?.id !== e.pointerId) return;
319
+ const wasSingle = this.pointers.size === 1;
320
+ const moved = this.movedPx;
321
+ const down = this.downPt;
322
  this.pointers.delete(e.pointerId);
323
+
324
+ if (this.pointers.size === 1) {
325
+ // Resume one-finger pan after pinch without firing a tap.
326
+ const remaining = [...this.pointers.entries()][0];
327
+ this.panLast = { x: remaining[1].x, y: remaining[1].y };
328
+ this.downPt = null;
329
+ this.panning = true;
330
+ this.movedPx = TAP_PX + 1;
331
+ this.lastPinch = 0;
332
+ this.pinchMid = null;
333
+ return;
334
+ }
335
+
336
+ if (this.pointers.size === 0) {
337
+ const tapped = wasSingle && !!down && moved < TAP_PX && !this.panning;
338
+ this.downPt = null;
339
+ this.panLast = null;
340
+ this.panning = false;
341
+ this.movedPx = 0;
342
+ this.lastPinch = 0;
343
+ this.pinchMid = null;
344
+ if (tapped) {
345
+ const hit = this.hit(e.clientX, e.clientY);
346
+ if (hit) {
347
+ this.selectedId = hit.id;
348
+ this.hoverId = hit.id;
349
+ if (hit.node.kind === "cluster" && hit.node.expand_key) this.onExpand?.(hit.node.expand_key);
350
+ else this.onSelect?.(hit.node);
351
+ this.dirty = true;
352
+ } else {
353
+ this.hoverId = null;
354
+ this.onHover?.(null);
355
+ this.dirty = true;
356
+ }
357
  }
358
  }
359
  };
 
372
  const r = this.canvas.getBoundingClientRect();
373
  const lx = clientX - r.left;
374
  const ly = clientY - r.top;
375
+ const next = Math.min(6, Math.max(0.2, this.camera.k * factor));
376
  const wx = (lx - this.camera.x) / this.camera.k;
377
  const wy = (ly - this.camera.y) / this.camera.k;
378
  this.camera.k = next;
 
382
  }
383
 
384
  private loop = () => {
385
+ if (this.unbound) return;
386
  this.raf = requestAnimationFrame(this.loop);
387
  if (!this.dirty) return;
388
  this.dirty = false;
 
395
  if (!ctx || !this.laid) return;
396
  const dpr = Math.min(2, window.devicePixelRatio || 1);
397
  const rect = canvas.getBoundingClientRect();
398
+ if (rect.width < 1 || rect.height < 1) return;
399
+ const w = Math.floor(rect.width * dpr);
400
+ const h = Math.floor(rect.height * dpr);
401
+ if (canvas.width !== w || canvas.height !== h) {
402
+ canvas.width = w;
403
+ canvas.height = h;
404
  }
405
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
406
  ctx.clearRect(0, 0, rect.width, rect.height);
 
409
  ctx.scale(this.camera.k, this.camera.k);
410
 
411
  const path = new Set<string>();
412
+ if (this.hoverId || this.selectedId) {
413
+ let cur = this.laid.items.find((i) => i.id === (this.hoverId || this.selectedId));
414
  const byId = new Map(this.laid.items.map((i) => [i.id, i]));
415
  while (cur) {
416
  path.add(cur.id);
 
427
  const mx = (e.x1 + e.x2) / 2;
428
  ctx.bezierCurveTo(mx, e.y1, mx, e.y2, e.x2, e.y2);
429
  ctx.strokeStyle = e.color + "99";
430
+ ctx.lineWidth = 1.2 / this.camera.k;
431
  ctx.stroke();
432
  }
433
  if (path.size) {
434
  const byId = new Map(this.laid.items.map((i) => [i.id, i]));
435
+ ctx.lineWidth = 2.4 / this.camera.k;
436
  for (const id of path) {
437
  const it = byId.get(id);
438
  if (!it || it.node.parent == null) continue;
 
459
  ctx.fillStyle = it.color;
460
  ctx.fill();
461
  if (it.node.depth === 0) {
462
+ ctx.lineWidth = 2 / this.camera.k;
463
  ctx.strokeStyle = "#f4efe4";
464
  ctx.stroke();
465
  }
 
467
  if (showLabels || it.id === this.hoverId || it.id === this.selectedId || it.node.depth === 0) {
468
  ctx.font = `${it.node.depth === 0 ? 13 : 11}px "Source Sans 3", "Segoe UI", sans-serif`;
469
  ctx.fillStyle = "#f4efe4";
470
+ ctx.globalAlpha = it.id === this.hoverId || it.id === this.selectedId || it.node.depth < 2 || this.camera.k > 0.9 ? 1 : 0.75;
471
  const label =
472
  it.node.kind === "cluster"
473
  ? it.node.term
 
478
  }
479
  ctx.restore();
480
 
 
481
  if (rect.width > 720 && this.laid.items.length > 8) {
482
+ const mw = 140,
483
+ mh = 90,
484
+ mx = rect.width - mw - 16,
485
+ my = 16;
486
  ctx.fillStyle = "rgba(16,22,28,0.72)";
487
  ctx.strokeStyle = "rgba(232,224,212,0.2)";
488
  ctx.fillRect(mx, my, mw, mh);
489
  ctx.strokeRect(mx, my, mw, mh);
490
+ let minx = Infinity,
491
+ miny = Infinity,
492
+ maxx = -Infinity,
493
+ maxy = -Infinity;
494
  for (const it of this.laid.items) {
495
  minx = Math.min(minx, it.x);
496
  miny = Math.min(miny, it.y);
 
504
  ctx.translate(mx + 8, my + 8);
505
  ctx.scale(s, s);
506
  ctx.translate(-minx, -miny);
 
507
  for (const it of this.laid.items) {
508
  ctx.fillStyle = it.color;
509
  ctx.fillRect(it.x, it.y, 8, 8);