@fortemi/graph 2026.7.3 → 2026.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,3 @@
1
- import { GraphRepository, CommunitiesRepository } from '@fortemi/core';
2
-
3
1
  // src/degree.ts
4
2
  function computeDegrees(graph) {
5
3
  const degree = /* @__PURE__ */ new Map();
@@ -77,6 +75,11 @@ function mulberry32(seed) {
77
75
  }
78
76
 
79
77
  // src/layout.ts
78
+ function readPosition(map, id) {
79
+ if (!map) return void 0;
80
+ if (map instanceof Map) return map.get(id);
81
+ return Object.prototype.hasOwnProperty.call(map, id) ? map[id] : void 0;
82
+ }
80
83
  var DEFAULTS = {
81
84
  width: 760,
82
85
  height: 460,
@@ -141,6 +144,29 @@ function layoutCommunityGraph(graph, options = {}) {
141
144
  y[index] = centerY + Math.sin(angle) * (algorithm === "radial" ? radius : localRadius * 0.72);
142
145
  }
143
146
  });
147
+ const pinnedIndex = /* @__PURE__ */ new Map();
148
+ const seededIndex = /* @__PURE__ */ new Set();
149
+ graph.nodes.forEach((node, index) => {
150
+ const seed = readPosition(options.initialPositions, node.id);
151
+ if (seed) {
152
+ x[index] = seed.x;
153
+ y[index] = seed.y;
154
+ seededIndex.add(index);
155
+ }
156
+ const pin = readPosition(options.pinned, node.id);
157
+ if (pin) {
158
+ x[index] = pin.x;
159
+ y[index] = pin.y;
160
+ pinnedIndex.set(index, pin);
161
+ seededIndex.add(index);
162
+ }
163
+ });
164
+ const holdPinned = () => {
165
+ for (const [index, pos] of pinnedIndex) {
166
+ x[index] = pos.x;
167
+ y[index] = pos.y;
168
+ }
169
+ };
144
170
  if (algorithm === "force" && n > 1) {
145
171
  const ticks = Math.max(0, Math.floor(options.ticks ?? DEFAULTS.ticks));
146
172
  const seed = options.seed ?? DEFAULTS.seed;
@@ -151,6 +177,7 @@ function layoutCommunityGraph(graph, options = {}) {
151
177
  const communityStrength = options.communityStrength ?? DEFAULTS.communityStrength;
152
178
  const rng = mulberry32(seed);
153
179
  for (let i = 0; i < n; i++) {
180
+ if (seededIndex.has(i)) continue;
154
181
  x[i] += (rng() - 0.5) * 8;
155
182
  y[i] += (rng() - 0.5) * 8;
156
183
  }
@@ -250,6 +277,7 @@ function layoutCommunityGraph(graph, options = {}) {
250
277
  x[i] = clampToRange(x[i], boundsPadding + r[i], width - boundsPadding - r[i]);
251
278
  y[i] = clampToRange(y[i], boundsPadding + r[i], height - boundsPadding - r[i]);
252
279
  }
280
+ holdPinned();
253
281
  alpha *= cooling;
254
282
  }
255
283
  } else {
@@ -257,6 +285,7 @@ function layoutCommunityGraph(graph, options = {}) {
257
285
  x[i] = clampToRange(x[i], boundsPadding + r[i], width - boundsPadding - r[i]);
258
286
  y[i] = clampToRange(y[i], boundsPadding + r[i], height - boundsPadding - r[i]);
259
287
  }
288
+ holdPinned();
260
289
  }
261
290
  const nodes = graph.nodes.map((node, index) => ({
262
291
  ...node,
@@ -430,187 +459,447 @@ function deserializeGraphSnapshot(input) {
430
459
  }
431
460
  return graph;
432
461
  }
433
- var DEFAULT_LAYOUT = {
434
- algorithm: "force",
435
- preserveViewport: true,
436
- communitySpacing: 1
437
- };
438
- var GraphController = class _GraphController {
439
- graphRepo;
440
- communityRepo;
441
- listeners = /* @__PURE__ */ new Set();
442
- communitySourceId;
443
- state;
444
- /** Build a controller from a database handle (production path). */
445
- static fromDb(db, options = {}) {
446
- return new _GraphController(new GraphRepository(db), new CommunitiesRepository(db), options);
462
+
463
+ // src/render-prep.ts
464
+ var GREYSCALE_COMMUNITY_RAMP = [
465
+ "#2B2824",
466
+ "#43403A",
467
+ "#585149",
468
+ "#6E665A",
469
+ "#837A6B",
470
+ "#968C7C"
471
+ ];
472
+ function positionGetter(positions) {
473
+ if (!positions) return () => void 0;
474
+ if (positions instanceof Map) return (id) => positions.get(id);
475
+ return (id) => Object.prototype.hasOwnProperty.call(positions, id) ? positions[id] : void 0;
476
+ }
477
+ function communityRanks(graph) {
478
+ const rank = /* @__PURE__ */ new Map();
479
+ [...graph.communities].sort((a, b) => b.nodes.length - a.nodes.length || a.id.localeCompare(b.id)).forEach((community, index) => rank.set(community.id, index));
480
+ return rank;
481
+ }
482
+ function colorForRank(communityId, rank, palette) {
483
+ if (palette === "community") return colorForCommunity(communityId);
484
+ const ramp = palette === "greyscale" ? GREYSCALE_COMMUNITY_RAMP : palette;
485
+ if (ramp.length === 0) return UNASSIGNED_COMMUNITY_COLOR;
486
+ if (rank < 0) return ramp[ramp.length - 1];
487
+ return ramp[rank % ramp.length];
488
+ }
489
+ function mapCommunityGraph(graph, options = {}) {
490
+ const palette = options.palette ?? "community";
491
+ const labelFor = options.labelFor ?? ((id) => id);
492
+ const getPos = positionGetter(options.positions);
493
+ const sizeFor = options.sizeFor ?? ((degree) => nodeRadius(degree, options.radius));
494
+ const degrees = computeDegrees(graph);
495
+ const rankByComm = communityRanks(graph);
496
+ const commOfNode = /* @__PURE__ */ new Map();
497
+ for (const community of graph.communities) {
498
+ for (const id of community.nodes) commOfNode.set(id, community.id);
447
499
  }
448
- constructor(graphRepo, communityRepo, options = {}) {
449
- this.graphRepo = graphRepo;
450
- this.communityRepo = communityRepo;
451
- this.communitySourceId = options.initialCommunitySourceId ?? null;
452
- this.state = {
453
- mode: options.initialMode ?? "citations",
454
- graph: null,
455
- graphSource: void 0,
456
- communitySource: void 0,
457
- embeddingSetSelector: options.initialEmbeddingSetSelector,
458
- filters: options.initialFilters,
459
- layout: { ...DEFAULT_LAYOUT, ...options.layout },
460
- status: { loading: false, error: null, freshness: null, cache: null },
461
- transition: void 0
500
+ const nodes = graph.nodes.map((node) => {
501
+ const communityId = commOfNode.get(node.id);
502
+ const rank = communityId != null ? rankByComm.get(communityId) ?? -1 : -1;
503
+ const degree = degrees.get(node.id) ?? 0;
504
+ const pos = getPos(node.id);
505
+ const rendered = {
506
+ id: node.id,
507
+ label: labelFor(node.id),
508
+ size: sizeFor(degree, node.id),
509
+ color: colorForRank(communityId, rank, palette),
510
+ communityRank: rank
462
511
  };
512
+ if (pos) {
513
+ rendered.x = pos.x;
514
+ rendered.y = pos.y;
515
+ }
516
+ return rendered;
517
+ });
518
+ const links = graph.edges.map((edge) => {
519
+ const link = { source: edge.source, target: edge.target, weight: edge.weight };
520
+ if (edge.kind !== void 0) link.kind = edge.kind;
521
+ return link;
522
+ });
523
+ return { nodes, links, clusters: graph.communities.length };
524
+ }
525
+ function bakeRenderGraph(graph, options = {}) {
526
+ const positioned = layoutCommunityGraph(graph, options.layout);
527
+ const positions = /* @__PURE__ */ new Map();
528
+ for (const node of positioned.nodes) positions.set(node.id, { x: node.x, y: node.y });
529
+ const mapOptions = { positions };
530
+ if (options.labelFor) mapOptions.labelFor = options.labelFor;
531
+ if (options.palette) mapOptions.palette = options.palette;
532
+ if (options.radius) mapOptions.radius = options.radius;
533
+ if (options.sizeFor) mapOptions.sizeFor = options.sizeFor;
534
+ return mapCommunityGraph(graph, mapOptions);
535
+ }
536
+ function stringifyRenderGraph(graph) {
537
+ const nodes = [...graph.nodes].sort((a, b) => a.id.localeCompare(b.id));
538
+ const links = [...graph.links].sort(
539
+ (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target) || (a.kind ?? "").localeCompare(b.kind ?? "")
540
+ );
541
+ return JSON.stringify({ nodes, links, clusters: graph.clusters });
542
+ }
543
+ function isRenderGraph(value) {
544
+ if (!value || typeof value !== "object") return false;
545
+ const candidate = value;
546
+ return Array.isArray(candidate.nodes) && candidate.nodes.length > 0 && Array.isArray(candidate.links) && candidate.nodes.every(
547
+ (n) => n && typeof n.id === "string" && typeof n.size === "number" && typeof n.color === "string"
548
+ );
549
+ }
550
+ function hasBakedPositions(graph) {
551
+ return graph.nodes.every((n) => typeof n.x === "number" && typeof n.y === "number");
552
+ }
553
+ async function loadRenderSnapshot(source, options = {}) {
554
+ const requirePositions = options.requirePositions ?? true;
555
+ try {
556
+ let data;
557
+ if (typeof source === "string") {
558
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
559
+ if (!fetchImpl) return null;
560
+ const res = await fetchImpl(source);
561
+ if (!res.ok) return null;
562
+ data = await res.json();
563
+ } else if (typeof source === "function") {
564
+ data = await source();
565
+ } else {
566
+ data = source;
567
+ }
568
+ if (!isRenderGraph(data)) return null;
569
+ if (requirePositions && !hasBakedPositions(data)) return null;
570
+ return data;
571
+ } catch {
572
+ return null;
463
573
  }
464
- getState() {
465
- return this.state;
574
+ }
575
+
576
+ // src/contract.ts
577
+ function applyControlFilters(graph, filters) {
578
+ const base = filterCommunityGraph(graph, filters);
579
+ const minDegree = filters?.minDegree ?? 0;
580
+ if (minDegree <= 0) return base;
581
+ const degree = computeDegrees(base);
582
+ const keep = base.nodes.filter((n) => (degree.get(n.id) ?? 0) >= minDegree).map((n) => n.id);
583
+ if (keep.length === base.nodes.length) return base;
584
+ return filterCommunityGraph(base, { nodeIds: keep });
585
+ }
586
+ function communityLegend(graph, colors) {
587
+ if (!graph) return [];
588
+ return graph.communities.map((c) => ({
589
+ communityId: c.id,
590
+ color: colorForCommunity(c.id, colors),
591
+ count: c.nodes.length
592
+ })).sort((a, b) => b.count - a.count || a.communityId.localeCompare(b.communityId));
593
+ }
594
+
595
+ // src/render-dom.ts
596
+ var SVG_NS = "http://www.w3.org/2000/svg";
597
+ function el(tag, attrs) {
598
+ const node = document.createElementNS(SVG_NS, tag);
599
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
600
+ return node;
601
+ }
602
+ function clamp(v, lo, hi) {
603
+ return Math.max(lo, Math.min(hi, v));
604
+ }
605
+ function renderCommunityGraph(container, graph, options = {}) {
606
+ const width = options.width ?? 760;
607
+ const height = options.height ?? 460;
608
+ const background = options.background ?? "#fafafa";
609
+ const interactive = options.interactive ?? true;
610
+ const minScale = options.minScale ?? 0.4;
611
+ const maxScale = options.maxScale ?? 3;
612
+ let currentGraph = graph;
613
+ let filters = options.filters;
614
+ let algorithm = options.algorithm ?? "force";
615
+ let selectedNodeId = options.selectedNodeId ?? null;
616
+ let labelFor = options.labelFor ?? ((id) => id);
617
+ let visible = applyControlFilters(currentGraph, filters);
618
+ let positioned = layoutCommunityGraph(visible, {
619
+ algorithm,
620
+ width,
621
+ height,
622
+ ...options.layoutOptions
623
+ });
624
+ let adjacency = buildAdjacency(visible);
625
+ const transform = { scale: 1, offsetX: 0, offsetY: 0 };
626
+ if (getComputedPosition(container) === "static") container.style.position = "relative";
627
+ const svg = el("svg", {
628
+ role: "img",
629
+ "aria-label": "Community graph",
630
+ viewBox: `0 0 ${width} ${height}`,
631
+ width: "100%"
632
+ });
633
+ svg.style.display = "block";
634
+ svg.style.background = background;
635
+ svg.style.touchAction = "none";
636
+ svg.style.aspectRatio = `${width} / ${height}`;
637
+ const viewport = el("g", {});
638
+ const edgesLayer = el("g", {});
639
+ const nodesLayer = el("g", {});
640
+ viewport.appendChild(edgesLayer);
641
+ viewport.appendChild(nodesLayer);
642
+ svg.appendChild(viewport);
643
+ container.appendChild(svg);
644
+ const popup = document.createElement("div");
645
+ popup.setAttribute("data-fortemi-graph-popup", "");
646
+ Object.assign(popup.style, {
647
+ position: "absolute",
648
+ display: "none",
649
+ pointerEvents: "none",
650
+ padding: "2px 6px",
651
+ font: "12px system-ui, sans-serif",
652
+ background: "#111",
653
+ color: "#fff",
654
+ borderRadius: "4px",
655
+ transform: "translate(-50%, -130%)",
656
+ whiteSpace: "nowrap",
657
+ zIndex: "2"
658
+ });
659
+ container.appendChild(popup);
660
+ const circles = /* @__PURE__ */ new Map();
661
+ const lines = [];
662
+ function applyTransform() {
663
+ viewport.setAttribute(
664
+ "transform",
665
+ `translate(${transform.offsetX} ${transform.offsetY}) scale(${transform.scale})`
666
+ );
466
667
  }
467
- subscribe(listener) {
468
- this.listeners.add(listener);
469
- return () => {
470
- this.listeners.delete(listener);
471
- };
668
+ function fitToViewport() {
669
+ const bounds = computeGraphBounds(positioned.nodes);
670
+ const fit = fitGraphToViewport(bounds, { width, height }, {
671
+ padding: 24,
672
+ minScale,
673
+ maxScale
674
+ });
675
+ transform.scale = fit.scale;
676
+ transform.offsetX = fit.offsetX;
677
+ transform.offsetY = fit.offsetY;
678
+ applyTransform();
472
679
  }
473
- /** Run the initial load. Call once after construction (the hook calls this on mount). */
474
- async start() {
475
- await this.refresh();
680
+ function styleNode(id) {
681
+ const circle = circles.get(id);
682
+ if (!circle) return;
683
+ const node = positioned.nodeIndex.get(id);
684
+ if (!node) return;
685
+ const selected = id === selectedNodeId;
686
+ circle.setAttribute("r", String(selected ? node.r + 3 : node.r));
687
+ circle.setAttribute("stroke", selected ? "#111" : "#fff");
688
+ circle.setAttribute("stroke-width", selected ? "3" : "1.5");
476
689
  }
477
- setState(patch) {
478
- this.state = { ...this.state, ...patch };
479
- for (const listener of this.listeners) listener(this.state);
690
+ function showPopup(id) {
691
+ const node = positioned.nodeIndex.get(id);
692
+ if (!node) {
693
+ popup.style.display = "none";
694
+ return;
695
+ }
696
+ const px = transform.offsetX + node.x * transform.scale;
697
+ const py = transform.offsetY + node.y * transform.scale;
698
+ const rect = svg.getBoundingClientRect?.();
699
+ const scaleToPx = rect && rect.width ? rect.width / width : 1;
700
+ popup.textContent = labelFor(id);
701
+ popup.style.left = `${px * scaleToPx}px`;
702
+ popup.style.top = `${py * scaleToPx}px`;
703
+ popup.style.display = "block";
480
704
  }
481
- beginTransition(toMode, reason, fromMode = this.state.mode) {
482
- this.setState({ transition: { fromMode, toMode, reason, startedAt: (/* @__PURE__ */ new Date()).toISOString() } });
705
+ function setSelected(id, emit) {
706
+ const prev = selectedNodeId;
707
+ selectedNodeId = id;
708
+ if (prev && prev !== id) styleNode(prev);
709
+ if (id) {
710
+ styleNode(id);
711
+ showPopup(id);
712
+ if (emit) options.onSelectNode?.(id);
713
+ } else {
714
+ popup.style.display = "none";
715
+ }
483
716
  }
484
- async refresh() {
485
- try {
486
- this.setState({ status: { ...this.state.status, loading: true, error: null } });
487
- const sources = await this.communityRepo.listCommunitySources();
488
- const activeCommunity = this.communitySourceId ? sources.find((source) => source.id === this.communitySourceId) : void 0;
489
- this.setState({ communitySource: activeCommunity });
490
- const mode = this.state.mode;
491
- if (mode === "citations") {
492
- const nextGraph = await this.graphRepo.buildLinkGraph();
493
- this.setState({
494
- graph: nextGraph,
495
- graphSource: { id: "citations", name: "Citation graph" },
496
- status: { loading: false, error: null, freshness: "fresh", cache: null }
497
- });
498
- return;
499
- }
500
- if (mode === "topics") {
501
- const selector = this.state.embeddingSetSelector;
502
- if (!selector) throw new Error("topics mode requires an embedding-set selector");
503
- const result = await this.graphRepo.buildOrLoadSimilarityGraph({ selector });
504
- this.setState({
505
- graph: result.graph,
506
- graphSource: result.graphSource,
507
- status: { loading: false, error: null, freshness: result.freshness, cache: result.cache }
508
- });
509
- return;
510
- }
511
- if (mode === "precomputed") {
512
- if (!activeCommunity?.graphSourceId) {
513
- throw new Error("precomputed mode requires a community source with graphSourceId");
514
- }
515
- const assignments = await this.communityRepo.getCommunityAssignments(activeCommunity.id);
516
- const nextGraph = await this.graphRepo.loadGraphArtifact(
517
- activeCommunity.graphSourceId,
518
- assignments.map((assignment) => assignment.noteId)
519
- );
520
- this.setState({
521
- graph: nextGraph,
522
- graphSource: { id: activeCommunity.graphSourceId, name: activeCommunity.name },
523
- status: { loading: false, error: null, freshness: activeCommunity.freshness ?? "unknown", cache: null }
717
+ function setHover(id) {
718
+ if (!interactive) return;
719
+ if (!id) {
720
+ for (const [, c] of circles) c.style.opacity = "1";
721
+ for (const { line } of lines) line.style.opacity = "0.55";
722
+ return;
723
+ }
724
+ const keep = new Set(adjacency.get(id) ?? []);
725
+ keep.add(id);
726
+ for (const [nid, c] of circles) c.style.opacity = keep.has(nid) ? "1" : "0.15";
727
+ for (const { line, source, target } of lines) {
728
+ line.style.opacity = source === id || target === id ? "0.9" : "0.06";
729
+ }
730
+ }
731
+ function build() {
732
+ edgesLayer.replaceChildren();
733
+ nodesLayer.replaceChildren();
734
+ circles.clear();
735
+ lines.length = 0;
736
+ for (const edge of positioned.edges) {
737
+ const s = positioned.nodeIndex.get(edge.source);
738
+ const t = positioned.nodeIndex.get(edge.target);
739
+ if (!s || !t) continue;
740
+ const line = el("line", {
741
+ x1: s.x,
742
+ y1: s.y,
743
+ x2: t.x,
744
+ y2: t.y,
745
+ stroke: "#9aa0a6",
746
+ "stroke-width": clamp(edge.weight, 1, 5),
747
+ opacity: 0.55
748
+ });
749
+ edgesLayer.appendChild(line);
750
+ lines.push({ line, source: edge.source, target: edge.target });
751
+ }
752
+ for (const node of positioned.nodes) {
753
+ const circle = el("circle", {
754
+ cx: node.x,
755
+ cy: node.y,
756
+ r: node.r,
757
+ fill: colorForCommunity(node.communityId, options.colors),
758
+ stroke: "#fff",
759
+ "stroke-width": 1.5,
760
+ tabindex: 0,
761
+ role: "button",
762
+ "data-node-id": node.id,
763
+ "aria-label": `Graph node ${labelFor(node.id)}`
764
+ });
765
+ circle.style.cursor = "pointer";
766
+ circle.style.outline = "none";
767
+ const title = el("title", {});
768
+ title.textContent = labelFor(node.id);
769
+ circle.appendChild(title);
770
+ if (interactive) {
771
+ circle.addEventListener("click", (e) => {
772
+ e.stopPropagation();
773
+ setSelected(node.id, true);
524
774
  });
525
- return;
526
- }
527
- if (mode === "dynamic-search") {
528
- const assignments = await this.communityRepo.previewDynamicCommunity(this.state.filters ?? {});
529
- this.setState({
530
- graph: {
531
- nodes: assignments.map((assignment) => ({ id: assignment.noteId })),
532
- edges: [],
533
- communities: [{ id: "dynamic-preview", nodes: assignments.map((assignment) => assignment.noteId) }]
534
- },
535
- graphSource: { id: "dynamic-preview", name: "Dynamic preview" },
536
- status: { loading: false, error: null, freshness: "unknown", cache: null }
775
+ circle.addEventListener("dblclick", (e) => {
776
+ e.stopPropagation();
777
+ options.onNavigate?.(node.id);
537
778
  });
538
- return;
539
- }
540
- if (mode === "user-authored") {
541
- if (!activeCommunity) throw new Error("user-authored mode requires an active community source");
542
- const assignments = await this.communityRepo.getCommunityAssignments(activeCommunity.id);
543
- this.setState({
544
- graph: {
545
- nodes: assignments.map((assignment) => ({ id: assignment.noteId })),
546
- edges: [],
547
- communities: [{ id: activeCommunity.id, nodes: assignments.map((assignment) => assignment.noteId) }]
548
- },
549
- graphSource: { id: activeCommunity.graphSourceId ?? activeCommunity.id, name: activeCommunity.name },
550
- status: { loading: false, error: null, freshness: activeCommunity.freshness ?? "unknown", cache: null }
779
+ circle.addEventListener("mouseenter", () => setHover(node.id));
780
+ circle.addEventListener("mouseleave", () => setHover(null));
781
+ circle.addEventListener("keydown", (e) => {
782
+ if (e.key === "Enter" || e.key === " ") {
783
+ e.preventDefault();
784
+ if (selectedNodeId === node.id) options.onNavigate?.(node.id);
785
+ else setSelected(node.id, true);
786
+ }
551
787
  });
552
788
  }
553
- } catch (err) {
554
- const e = err instanceof Error ? err : new Error(String(err));
555
- this.setState({ status: { ...this.state.status, loading: false, error: e } });
556
- throw e;
789
+ nodesLayer.appendChild(circle);
790
+ circles.set(node.id, circle);
557
791
  }
558
- }
559
- setMode(nextMode) {
560
- this.beginTransition(nextMode, "mode-change");
561
- this.setState({ mode: nextMode });
562
- void this.refresh();
563
- }
564
- setEmbeddingSetSelector(selector) {
565
- this.beginTransition("topics", "embedding-set-change");
566
- this.setState({ embeddingSetSelector: selector, mode: "topics" });
567
- void this.refresh();
568
- }
569
- setCommunitySource(sourceId) {
570
- this.communitySourceId = sourceId;
571
- this.beginTransition(this.state.mode, "community-source-change");
572
- void this.refresh();
573
- }
574
- /** Apply dynamic-search filters and switch into that mode (state only). */
575
- applyFilters(nextFilters) {
576
- this.beginTransition("dynamic-search", "filter-change");
577
- this.setState({ filters: nextFilters, mode: "dynamic-search" });
578
- }
579
- setFilters(nextFilters) {
580
- this.applyFilters(nextFilters);
581
- void this.refresh();
582
- }
583
- async recompute() {
584
- this.beginTransition(this.state.mode, "recompute");
585
- if (this.state.mode === "topics" && this.state.embeddingSetSelector) {
586
- const result = await this.graphRepo.buildOrLoadSimilarityGraph({
587
- selector: this.state.embeddingSetSelector,
588
- source: "live-only"
589
- });
590
- this.setState({
591
- graph: result.graph,
592
- graphSource: result.graphSource,
593
- status: { loading: false, error: null, freshness: result.freshness, cache: result.cache }
594
- });
595
- return;
792
+ if (selectedNodeId && circles.has(selectedNodeId)) {
793
+ styleNode(selectedNodeId);
794
+ showPopup(selectedNodeId);
795
+ } else {
796
+ selectedNodeId = null;
797
+ popup.style.display = "none";
596
798
  }
597
- await this.refresh();
598
799
  }
599
- async previewDynamicCommunity(nextFilters) {
600
- this.applyFilters(nextFilters);
601
- await this.refresh();
800
+ function relayout() {
801
+ visible = applyControlFilters(currentGraph, filters);
802
+ positioned = layoutCommunityGraph(visible, {
803
+ algorithm,
804
+ width,
805
+ height,
806
+ ...options.layoutOptions
807
+ });
808
+ adjacency = buildAdjacency(visible);
809
+ build();
810
+ fitToViewport();
602
811
  }
603
- async saveCurrentCommunity(input) {
604
- const source = await this.communityRepo.saveCommunity(input);
605
- this.communitySourceId = source.id;
606
- this.setState({ communitySource: source });
607
- return source;
812
+ const onWheel = (e) => {
813
+ e.preventDefault();
814
+ const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
815
+ transform.scale = clamp(transform.scale * factor, minScale, maxScale);
816
+ applyTransform();
817
+ if (selectedNodeId) showPopup(selectedNodeId);
818
+ };
819
+ let panning = null;
820
+ const onDown = (e) => {
821
+ if (e.target === svg || e.target === viewport) {
822
+ panning = { x: e.clientX, y: e.clientY };
823
+ setSelected(null, true);
824
+ }
825
+ };
826
+ const onMove = (e) => {
827
+ if (!panning) return;
828
+ transform.offsetX += e.clientX - panning.x;
829
+ transform.offsetY += e.clientY - panning.y;
830
+ panning = { x: e.clientX, y: e.clientY };
831
+ applyTransform();
832
+ };
833
+ const onUp = () => {
834
+ panning = null;
835
+ };
836
+ if (interactive) {
837
+ svg.addEventListener("wheel", onWheel, { passive: false });
838
+ svg.addEventListener("mousedown", onDown);
839
+ svg.addEventListener("mousemove", onMove);
840
+ svg.addEventListener("mouseup", onUp);
841
+ svg.addEventListener("mouseleave", onUp);
608
842
  }
609
- };
843
+ build();
844
+ fitToViewport();
845
+ return {
846
+ element: svg,
847
+ update(next) {
848
+ let needsRelayout = false;
849
+ if (next.graph && next.graph !== currentGraph) {
850
+ currentGraph = next.graph;
851
+ needsRelayout = true;
852
+ }
853
+ if ("filters" in next) {
854
+ filters = next.filters;
855
+ needsRelayout = true;
856
+ }
857
+ if (next.algorithm && next.algorithm !== algorithm) {
858
+ algorithm = next.algorithm;
859
+ needsRelayout = true;
860
+ }
861
+ if (next.labelFor) labelFor = next.labelFor;
862
+ if (needsRelayout) relayout();
863
+ if ("selectedNodeId" in next) setSelected(next.selectedNodeId ?? null, false);
864
+ },
865
+ focus(nodeId) {
866
+ if (!nodeId) {
867
+ setSelected(null, false);
868
+ return;
869
+ }
870
+ const node = positioned.nodeIndex.get(nodeId);
871
+ if (!node) return;
872
+ transform.offsetX = width / 2 - node.x * transform.scale;
873
+ transform.offsetY = height / 2 - node.y * transform.scale;
874
+ applyTransform();
875
+ setSelected(nodeId, false);
876
+ },
877
+ destroy() {
878
+ if (interactive) {
879
+ svg.removeEventListener("wheel", onWheel);
880
+ svg.removeEventListener("mousedown", onDown);
881
+ svg.removeEventListener("mousemove", onMove);
882
+ svg.removeEventListener("mouseup", onUp);
883
+ svg.removeEventListener("mouseleave", onUp);
884
+ }
885
+ svg.remove();
886
+ popup.remove();
887
+ circles.clear();
888
+ lines.length = 0;
889
+ }
890
+ };
891
+ }
892
+ function getComputedPosition(elm) {
893
+ const inline = elm.style.position;
894
+ if (inline) return inline;
895
+ const view = elm.ownerDocument?.defaultView;
896
+ if (view?.getComputedStyle) return view.getComputedStyle(elm).position || "static";
897
+ return "static";
898
+ }
610
899
 
611
900
  // src/index.ts
612
- var VERSION = "2026.7.3";
901
+ var VERSION = "2026.7.5";
613
902
 
614
- export { COMMUNITY_COLORS, GRAPH_SNAPSHOT_VERSION, GraphController, UNASSIGNED_COMMUNITY_COLOR, VERSION, buildAdjacency, colorForCommunity, computeDegrees, computeGraphBounds, deserializeGraphSnapshot, expandNeighborhood, filterCommunityGraph, fitGraphToViewport, layoutCommunityGraph, neighborhoodSubgraph, neighborsOf, nodeRadius, serializeGraphSnapshot, stringifyGraphSnapshot, subgraphForNodes };
903
+ export { COMMUNITY_COLORS, GRAPH_SNAPSHOT_VERSION, GREYSCALE_COMMUNITY_RAMP, UNASSIGNED_COMMUNITY_COLOR, VERSION, applyControlFilters, bakeRenderGraph, buildAdjacency, colorForCommunity, communityLegend, communityRanks, computeDegrees, computeGraphBounds, deserializeGraphSnapshot, expandNeighborhood, filterCommunityGraph, fitGraphToViewport, hasBakedPositions, isRenderGraph, layoutCommunityGraph, loadRenderSnapshot, mapCommunityGraph, neighborhoodSubgraph, neighborsOf, nodeRadius, renderCommunityGraph, serializeGraphSnapshot, stringifyGraphSnapshot, stringifyRenderGraph, subgraphForNodes };
615
904
  //# sourceMappingURL=index.js.map
616
905
  //# sourceMappingURL=index.js.map