Spaces:
Sleeping
Sleeping
Deploy Reverse Etymology Atlas
Browse files- frontend/src/App.tsx +66 -14
- frontend/src/styles.css +108 -5
- 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 |
-
|
| 273 |
-
if (!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
| 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" : ""}
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
.
|
| 275 |
display: block;
|
| 276 |
position: absolute;
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
z-index: 30;
|
| 279 |
-
height: min(
|
|
|
|
| 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 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 147 |
}
|
| 148 |
this.dirty = true;
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 182 |
-
|
| 183 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
| 201 |
-
if (this.pointers.size === 1)
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
};
|
| 208 |
|
| 209 |
private onMove = (e: PointerEvent) => {
|
| 210 |
-
if (this.pointers.has(e.pointerId))
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
const pts = [...this.pointers.values()];
|
| 213 |
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
|
| 214 |
-
|
|
|
|
| 215 |
const factor = dist / this.lastPinch;
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
}
|
| 218 |
this.lastPinch = dist;
|
|
|
|
|
|
|
| 219 |
return;
|
| 220 |
}
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
|
|
|
| 233 |
}
|
| 234 |
};
|
| 235 |
|
| 236 |
private onUp = (e: PointerEvent) => {
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
| 238 |
this.pointers.delete(e.pointerId);
|
| 239 |
-
|
| 240 |
-
this.
|
| 241 |
-
|
| 242 |
-
const
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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 (
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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);
|