@fortemi/graph 2026.7.2 → 2026.7.4

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