sanity-plugin-graph-view
5.0.135.0.14
dist/_chunks-es/GraphView.js+
dist/_chunks-es/GraphView.jsNew file+286
Index: package/dist/_chunks-es/GraphView.js
===================================================================
--- package/dist/_chunks-es/GraphView.js
+++ package/dist/_chunks-es/GraphView.js
@@ -0,0 +1,286 @@
+import { useEffect, useState } from "react";
+import { useClient, useUserColorManager } from "sanity";
+import { useRouter } from "sanity/router";
+import { jsx, jsxs } from "react/jsx-runtime";
+import { COLOR_HUES, black, gray, hues, white } from "@sanity/color";
+import { useTheme } from "@sanity/ui";
+import BezierEasing from "bezier-easing";
+import deepEqual from "deep-equal";
+import { rgba } from "polished";
+import ForceGraph2D from "react-force-graph-2d";
+import { v4 } from "uuid";
+import { styled } from "styled-components";
+const GraphRoot = styled.div.withConfig({
+ displayName: "GraphRoot",
+ componentId: "sc-kbb958-0"
+})`font-family:${({ theme }) => theme.fonts.text.family};position:absolute;top:0;left:0;width:100%;height:100%;background:${black.hex};`, GraphWrapper = styled.div.withConfig({
+ displayName: "GraphWrapper",
+ componentId: "sc-kbb958-1"
+})`position:relative;width:100%;height:100%;`, HoverNode = styled.div.withConfig({
+ displayName: "HoverNode",
+ componentId: "sc-kbb958-2"
+})`font-family:${({ theme }) => theme.fonts.text.family};display:none;position:absolute;bottom:${({ theme }) => theme.space[0]}px;left:50%;transform:translate3d(-50%,0,0);background:var(--component-bg);border-radius:${({ theme }) => theme.radius[2]}px;padding:${({ theme }) => theme.space[2]}px;z-index:1000;&:empty{display:none;}`, Legend = styled.div.withConfig({
+ displayName: "Legend",
+ componentId: "sc-kbb958-3"
+})`color:#ccc;position:absolute;top:${({ theme }) => theme.space[4]}px;left:${({ theme }) => theme.space[4]}px;& > div{margin:5px 0;}`, LegendRow = styled.div.withConfig({
+ displayName: "LegendRow",
+ componentId: "sc-kbb958-4"
+})`display:flex;`, LegendBadge = styled.div.withConfig({
+ displayName: "LegendBadge",
+ componentId: "sc-kbb958-5"
+})`width:1.25em;height:1.25em;background:currentColor;border-radius:50%;margin-right:${({ theme }) => theme.space[2]}px;`;
+function sizeOf(value) {
+ return value === null ? 0 : typeof value == "object" ? Object.entries(value).reduce((total, [k, v]) => total + sizeOf(k) + sizeOf(v), 0) : Array.isArray(value) ? Object.entries(value).reduce((total, v) => total + sizeOf(v), 0) : typeof value == "string" ? value.length : 1;
+}
+function loadImage(url, w, h) {
+ return new Promise((resolve) => {
+ let img = new Image(w, h);
+ img.onload = () => {
+ resolve(img);
+ }, img.onerror = (event) => {
+ console.error("Image error", event), resolve(null);
+ }, img.src = url;
+ });
+}
+function sortBy(array, f) {
+ return array.sort((a, b) => {
+ let va = f(a), vb = f(b);
+ return va < vb ? -1 : +(va > vb);
+ });
+}
+function truncate(s, limit) {
+ return s.length > limit ? `${s.substring(0, limit)}…` : s;
+}
+const fadeEasing = BezierEasing(0, .9, 1, 1), softEasing = BezierEasing(.25, .1, 0, 1), idleTimeout = 1e4;
+function getTopDocTypes(counts) {
+ return sortBy(Object.keys(counts), (docType) => counts[docType] || 0).reverse().slice(0, 10);
+}
+function formatDocType(docType) {
+ return (docType.substring(0, 1).toUpperCase() + docType.substring(1)).replace(/\./g, " ").replace(/[A-Z]/g, " $&").trim();
+}
+function getDocTypeCounts(docs) {
+ let types = {};
+ for (let doc of docs) types[doc._type] = (types[doc._type] || 0) + 1;
+ return types;
+}
+function labelFor(doc) {
+ return `${doc.title || doc.name || doc._id}`.trim();
+}
+function valueFor(doc, maxSize) {
+ return 5 + 100 * (sizeOf(doc) / maxSize);
+}
+function findRefs(obj, dest = []) {
+ if (obj !== null) {
+ if (typeof obj == "object") for (let [k, v] of Object.entries(obj)) k === "_ref" && typeof v == "string" && v.length > 0 && dest.push(stripDraftId(v)), findRefs(v, dest);
+ else if (Array.isArray(obj)) for (let v of obj) findRefs(v, dest);
+ }
+ return dest;
+}
+function stripDraftId(id) {
+ return id.replace(/^drafts\./, "");
+}
+function deduplicateDrafts(docs) {
+ let deduped = {};
+ for (let doc of docs) doc._id.startsWith("drafts.") || (deduped[doc._id] = doc);
+ for (let doc of docs) if (doc._id.startsWith("drafts.")) {
+ let id = stripDraftId(doc._id);
+ deduped[id] = Object.assign(doc, { _id: id });
+ }
+ return Object.values(deduped);
+}
+var Users = class {
+ _users = [];
+ async getById(id, client) {
+ let user = this._users.find((u) => u._id === id);
+ return user || (user = await client.users.getById(id), this._users.push(user), user.image = await loadImage(user.imageUrl || "https://raw.githubusercontent.com/sanity-io/sanity-plugin-graph-view/main/assets/head-silhouette.jpg", 40, 40)), user;
+ }
+}, Session = class {
+ id = null;
+ user = null;
+ doc = null;
+ lastActive = 0;
+ startTime = 0;
+ angle = 0;
+}, GraphData = class GraphData {
+ sessions = [];
+ data;
+ constructor(docs = []) {
+ let docsById = {};
+ for (let doc of docs) docsById[doc._id] = doc;
+ this.data = {
+ nodes: docs.map((d) => Object.assign({
+ id: d._id,
+ type: "document",
+ doc: d
+ })),
+ links: docs.flatMap((doc) => findRefs(doc).map((ref) => ({
+ source: doc._id,
+ target: ref
+ }))).filter((link) => docsById[link.source] && docsById[link.target])
+ };
+ }
+ setSession(user, docNode) {
+ let session = this.sessions.find((s) => s.user.id === user.id && s.doc?._id === docNode.doc._id);
+ session || (session = new Session(), session.id = v4(), session.user = user, session.startTime = Date.now(), session.doc = docNode.doc, session.angle = Math.random() * 2 * Math.PI, this.sessions.push(session)), session.lastActive = Date.now();
+ }
+ reapSessions() {
+ for (let i = 0; i < this.sessions.length; i++) {
+ let session = this.sessions[i];
+ Date.now() - session.lastActive > idleTimeout && (this.sessions = [...this.sessions.slice(0, i), ...this.sessions.slice(i + 1)], i--);
+ }
+ }
+ clone() {
+ let copy = new GraphData();
+ return Object.assign(copy, this), copy.data = {
+ nodes: [...this.data.nodes],
+ links: [...this.data.links]
+ }, copy;
+ }
+};
+const users = new Users();
+function GraphView({ tool: { options: props } }) {
+ let query = props.query || "\n *[\n !(_id in path(\"_.*\")) &&\n !(_type match \"system.*\") &&\n !(_type match \"sanity.*\")\n ]\n", apiVersion = props.apiVersion ?? "2022-09-01", userColorManager = useUserColorManager(), [maxSize, setMaxSize] = useState(0), [hoverNode, setHoverNode] = useState(null), [documents, setDocuments] = useState([]), [docTypes, setDocTypes] = useState({}), [graph, setGraph] = useState(() => new GraphData()), router = useRouter(), client = useClient({ apiVersion });
+ useEffect(() => {
+ client.fetch(query).then((_docs) => {
+ let docs = deduplicateDrafts(_docs);
+ setMaxSize(Math.max(...docs.map(sizeOf))), setDocuments(docs), setDocTypes(getDocTypeCounts(docs)), setGraph(new GraphData(docs));
+ });
+ }, [client, query]), useEffect(() => {
+ let subscription = client.listen(query, {}, {}).subscribe(async (update) => {
+ if (update.type !== "mutation") return;
+ let doc = update.result;
+ if (doc) {
+ doc._id = stripDraftId(doc._id);
+ let docsById = {};
+ for (let d of documents) docsById[d._id] = d;
+ let oldDoc, docs_0 = [...documents], idx = documents.findIndex((d_0) => d_0._id === doc._id);
+ idx >= 0 ? (oldDoc = docs_0[idx], docs_0[idx] = doc) : docs_0.push(doc), setDocuments(docs_0), setDocTypes(getDocTypeCounts(docs_0)), setMaxSize(Math.max(...docs_0.map(sizeOf)));
+ let newGraph = graph.clone(), oldRefs = findRefs(oldDoc || {}).filter((id) => id === doc._id || docsById[id] !== null), newRefs = findRefs(doc).filter((id_0) => id_0 === doc._id || docsById[id_0] !== null), graphChanged = !deepEqual(oldRefs, newRefs);
+ graphChanged && (newGraph.data.links = newGraph.data.links.filter((l) => l.source.id !== doc._id).concat(newRefs.map((ref) => ({
+ source: doc._id,
+ target: ref
+ }))));
+ let docNode, nodeIdx = graph.data.nodes.findIndex((n) => n.doc && n.doc._id === doc._id);
+ nodeIdx >= 0 ? (docNode = graph.data.nodes[nodeIdx], docNode.doc = doc) : (docNode = {
+ id: doc._id,
+ type: "document",
+ doc
+ }, newGraph.data.nodes.push(docNode), graphChanged = !0), graphChanged && setGraph(newGraph);
+ let user = await users.getById(update.identity, client);
+ graph.setSession(user, docNode);
+ } else if (update.transition === "disappear") {
+ let docId = stripDraftId(update.documentId), docs_1 = documents.filter((d_1) => d_1._id !== docId);
+ setDocuments(docs_1), setDocTypes(getDocTypeCounts(docs_1)), setMaxSize(Math.max(...docs_1.map(sizeOf)));
+ let newGraph_0 = graph.clone();
+ newGraph_0.data.links = newGraph_0.data.links.filter((l_0) => l_0.source.id !== docId && l_0.target.id !== docId), newGraph_0.data.nodes = newGraph_0.data.nodes.filter((n_0) => n_0.id !== docId), setGraph(newGraph_0);
+ }
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [
+ client,
+ query,
+ documents,
+ graph
+ ]), useEffect(() => {
+ let interval = setInterval(() => graph.reapSessions(), 1e3);
+ return () => clearInterval(interval);
+ }, [graph]);
+ let theme = useTheme().sanity;
+ return /* @__PURE__ */ jsx(GraphWrapper, {
+ theme,
+ children: /* @__PURE__ */ jsxs(GraphRoot, {
+ theme,
+ children: [
+ /* @__PURE__ */ jsx(Legend, {
+ theme,
+ children: getTopDocTypes(docTypes).map((docType) => /* @__PURE__ */ jsxs(LegendRow, {
+ className: "legend__row",
+ style: { color: getDocTypeColor(docType).fill },
+ children: [/* @__PURE__ */ jsx(LegendBadge, { theme }), /* @__PURE__ */ jsx("div", { children: formatDocType(docType) })]
+ }, docType))
+ }),
+ hoverNode && /* @__PURE__ */ jsx(HoverNode, {
+ theme,
+ children: labelFor(hoverNode.doc)
+ }),
+ /* @__PURE__ */ jsx(ForceGraph2D, {
+ graphData: graph.data,
+ nodeAutoColorBy: "group",
+ enableNodeDrag: !1,
+ onNodeHover: (node) => setHoverNode(node),
+ onNodeClick: (node_0) => {
+ router.navigateIntent("edit", {
+ id: node_0.doc._id,
+ documentType: node_0.doc._type
+ });
+ },
+ linkColor: () => rgba(gray[500].hex, .25),
+ nodeLabel: () => "",
+ nodeRelSize: 1,
+ nodeVal: (node_1) => valueFor(node_1.doc, maxSize),
+ onRenderFramePost: (ctx, globalScale) => {
+ for (let session of graph.sessions) {
+ let node_2 = graph.data.nodes.find((n_1) => n_1.doc && n_1.doc._id === session?.doc?._id);
+ if (node_2) {
+ let idleFactorRange = idleTimeout, angle = session.angle, radius = Math.sqrt(valueFor(node_2.doc, maxSize)), image = session.user.image, userColor = userColorManager.get(session.user.displayName).tints[400].hex, distance = radius * globalScale + 40, imgW = image ? image.width : 0, imgH = image ? image.height : 0, x = node_2.x + Math.sin(angle) * distance / globalScale, y = node_2.y + Math.cos(angle) * distance / globalScale;
+ ctx.save();
+ try {
+ if (ctx.globalAlpha = fadeEasing(1 - Math.min(idleFactorRange, Date.now() - session.lastActive) / idleFactorRange), ctx.font = `bold ${Math.round(12 / globalScale)}px sans-serif`, ctx.beginPath(), ctx.strokeStyle = rgba(white.hex, 1), ctx.lineWidth = 2 / globalScale, ctx.moveTo(node_2.x + Math.sin(angle) * (distance - imgW / 2) / globalScale, node_2.y + Math.cos(angle) * (distance - imgH / 2) / globalScale), ctx.lineTo(node_2.x + Math.sin(angle) * radius, node_2.y + Math.cos(angle) * radius), ctx.stroke(), ctx.beginPath(), ctx.strokeStyle = rgba(white.hex, 1), ctx.lineWidth = 2 / globalScale, ctx.arc(node_2.x, node_2.y, radius, 0, 2 * Math.PI, !1), ctx.stroke(), image) {
+ ctx.save();
+ try {
+ let f = softEasing(Math.max(0, (700 - (Date.now() - session.startTime)) / 700));
+ f > 0 && (ctx.beginPath(), ctx.fillStyle = rgba(userColor, f), ctx.arc(x, y, (imgW / 2 + 10) / globalScale, 0, 2 * Math.PI, !1), ctx.fill()), ctx.beginPath(), ctx.fillStyle = rgba(white.hex, 1), ctx.arc(x, y, imgW / globalScale / 2, 0, 2 * Math.PI, !1), ctx.clip(), ctx.drawImage(image, x - imgW / globalScale / 2, y - imgH / globalScale / 2, imgW / globalScale, imgH / globalScale), ctx.strokeStyle = black.hex, ctx.lineWidth = 6 / globalScale, ctx.stroke(), ctx.strokeStyle = userColor, ctx.lineWidth = 4 / globalScale, ctx.stroke();
+ } finally {
+ ctx.restore();
+ }
+ }
+ ctx.beginPath(), ctx.strokeStyle = rgba(black.hex, 1), ctx.lineWidth = .5 / globalScale, ctx.arc(x, y, imgW / globalScale / 2, 0, 2 * Math.PI, !1), ctx.stroke();
+ let above = angle >= Math.PI / 2 && angle < Math.PI * 1.5, textY = above ? y - (imgH / 2 + 5) / globalScale : y + (imgH / 2 + 5) / globalScale;
+ ctx.fillStyle = rgba(white.hex, 1), ctx.textAlign = "center", ctx.textBaseline = above ? "bottom" : "top", ctx.fillText(session.user.displayName, x, textY);
+ } finally {
+ ctx.restore();
+ }
+ }
+ }
+ },
+ nodeCanvasObject: (node_3, ctx_0, globalScale_0) => {
+ switch (node_3.type) {
+ case "document": {
+ let nodeColor = getDocTypeColor(node_3.doc._type), radius_0 = Math.sqrt(valueFor(node_3.doc, maxSize)), fontSize = Math.min(100, 10 / globalScale_0);
+ if (ctx_0.beginPath(), ctx_0.fillStyle = hoverNode !== null && node_3.doc._id === hoverNode.doc._id ? rgba(gray[500].hex, .8) : nodeColor.fill, ctx_0.strokeStyle = nodeColor.border, ctx_0.lineWidth = .5, ctx_0.arc(node_3.x, node_3.y, radius_0, 0, 2 * Math.PI, !1), ctx_0.stroke(), ctx_0.fill(), radius_0 * globalScale_0 > 10) {
+ ctx_0.font = `${fontSize}px sans-serif`;
+ let w = radius_0 * 2 + 30 / globalScale_0;
+ for (let len = 50; len >= 5; len /= 1.2) {
+ let label = truncate(labelFor(node_3.doc), Math.round(len));
+ if (ctx_0.measureText(label).width < w) {
+ ctx_0.textAlign = "center", ctx_0.textBaseline = "top", ctx_0.strokeStyle = rgba(black.hex, .5), ctx_0.lineWidth = 2 / globalScale_0, ctx_0.strokeText(label, node_3.x, node_3.y + radius_0 + 5 / globalScale_0), ctx_0.fillText(label, node_3.x, node_3.y + radius_0 + 5 / globalScale_0);
+ break;
+ }
+ }
+ }
+ }
+ }
+ },
+ linkCanvasObject: (link, ctx_1, globalScale_1) => {
+ ctx_1.beginPath(), ctx_1.strokeStyle = rgba(gray[500].hex, .125), ctx_1.lineWidth = 2 / globalScale_1, ctx_1.moveTo(link.source.x, link.source.y), ctx_1.lineTo(link.target.x, link.target.y), ctx_1.stroke();
+ }
+ })
+ ]
+ })
+ });
+}
+const colorCache = {};
+let typeColorNum = 0;
+function getDocTypeColor(docType) {
+ if (colorCache[docType]) return colorCache[docType];
+ let hue = COLOR_HUES[typeColorNum % COLOR_HUES.length];
+ return typeColorNum += 1, colorCache[docType] = {
+ fill: hues[hue][400].hex,
+ border: rgba(black.hex, .5)
+ }, colorCache[docType];
+}
+export { GraphView as default };
+
+//# sourceMappingURL=GraphView.js.map
\ No newline at end of file