sanity-plugin-graph-view 1.0.7 → 3.0.0-studio-v3.0

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.
@@ -0,0 +1,487 @@
1
+ import {SanityClient, useUserColorManager} from 'sanity/_unstable'
2
+ import {rgba} from 'polished'
3
+ import deepEqual from 'deep-equal'
4
+ import React, {useCallback, useEffect, useState} from 'react'
5
+ import {ForceGraph2D} from 'react-force-graph'
6
+ import {v4 as uuidv4} from 'uuid'
7
+ import BezierEasing from 'bezier-easing'
8
+ import {useFetchDocuments, useListen} from './hooks'
9
+ import {sortBy, loadImage, sizeOf, truncate} from './utils'
10
+ import {SanityDocument} from '@sanity/client'
11
+ import {useRouter} from 'sanity/_unstable'
12
+ import {useClient} from 'sanity'
13
+ import {GraphRoot, GraphWrapper, HoverNode, Legend, LegendBadge, LegendRow} from './GraphViewStyle'
14
+ import {useTheme} from '@sanity/ui'
15
+ import {black, COLOR_HUES, gray, white, hues} from '@sanity/color'
16
+
17
+ const DEFAULT_QUERY = `
18
+ *[
19
+ !(_id in path("_.*")) &&
20
+ !(_type match "system.*") &&
21
+ !(_type match "sanity.*")
22
+ ]
23
+ `
24
+
25
+ const fadeEasing = BezierEasing(0, 0.9, 1, 1)
26
+ const softEasing = BezierEasing(0.25, 0.1, 0.0, 1.0)
27
+ const idleTimeout = 10000
28
+ const imageSize = 40
29
+
30
+ function getTopDocTypes(counts: Record<string, number>) {
31
+ return sortBy(Object.keys(counts), (docType) => counts[docType] || 0)
32
+ .reverse()
33
+ .slice(0, 10)
34
+ }
35
+
36
+ function formatDocType(docType: string) {
37
+ return (docType.substring(0, 1).toUpperCase() + docType.substring(1))
38
+ .replace(/\./g, ' ')
39
+ .replace(/[A-Z]/g, ' $&')
40
+ .trim()
41
+ }
42
+
43
+ function getDocTypeCounts(docs: SanityDocument[]) {
44
+ const types: Record<string, number> = {}
45
+ for (const doc of docs) {
46
+ types[doc._type] = (types[doc._type] || 0) + 1
47
+ }
48
+ return types
49
+ }
50
+
51
+ function labelFor(doc: SanityDocument) {
52
+ return `${doc.title || doc.name || doc._id}`.trim()
53
+ }
54
+
55
+ function valueFor(doc: any, maxSize: number) {
56
+ return 5 + 100 * (sizeOf(doc) / maxSize)
57
+ }
58
+
59
+ function findRefs(obj: any, dest: any[] = []) {
60
+ if (obj !== null) {
61
+ if (typeof obj === 'object') {
62
+ for (const [k, v] of Object.entries(obj)) {
63
+ if (k === '_ref' && typeof v === 'string' && v.length > 0) {
64
+ dest.push(stripDraftId(v))
65
+ }
66
+ findRefs(v, dest)
67
+ }
68
+ } else if (Array.isArray(obj)) {
69
+ for (const v of obj) {
70
+ findRefs(v, dest)
71
+ }
72
+ }
73
+ }
74
+ return dest
75
+ }
76
+
77
+ function stripDraftId(id: string) {
78
+ return id.replace(/^drafts\./, '')
79
+ }
80
+
81
+ function deduplicateDrafts(docs: SanityDocument[]) {
82
+ const deduped: Record<string, SanityDocument> = {}
83
+ for (const doc of docs) {
84
+ if (!/^drafts\./.test(doc._id)) {
85
+ deduped[doc._id] = doc
86
+ }
87
+ }
88
+ for (const doc of docs) {
89
+ if (/^drafts\./.test(doc._id)) {
90
+ const id = stripDraftId(doc._id)
91
+ deduped[id] = Object.assign(doc, {_id: id})
92
+ }
93
+ }
94
+ return Object.values(deduped)
95
+ }
96
+
97
+ class Users {
98
+ _users: any[] = []
99
+
100
+ async getById(id: string, client: SanityClient) {
101
+ let user = this._users.find((u) => u._id === id)
102
+ if (!user) {
103
+ user = await client.users.getById(id)
104
+ this._users.push(user)
105
+ user.image = await loadImage(
106
+ user.imageUrl ||
107
+ 'https://raw.githubusercontent.com/sanity-io/sanity-plugin-graph-view/main/assets/head-silhouette.jpg',
108
+ imageSize,
109
+ imageSize
110
+ )
111
+ }
112
+ return user
113
+ }
114
+ }
115
+
116
+ class Session {
117
+ id: string | null = null
118
+ user: any = null
119
+ doc: SanityDocument | null = null
120
+ lastActive: number = 0
121
+ startTime: number = 0
122
+ angle: number = 0
123
+ }
124
+
125
+ class GraphData {
126
+ sessions: Session[] = []
127
+ data: any
128
+
129
+ constructor(docs: SanityDocument[] = []) {
130
+ const docsById: Record<string, SanityDocument> = {}
131
+ for (const doc of docs) {
132
+ docsById[doc._id] = doc
133
+ }
134
+
135
+ this.data = {
136
+ nodes: docs.map((d) => Object.assign({id: d._id, type: 'document', doc: d})),
137
+ links: docs
138
+ .flatMap((doc) => findRefs(doc).map((ref) => ({source: doc._id, target: ref})))
139
+ .filter((link) => docsById[link.source] && docsById[link.target]),
140
+ }
141
+ }
142
+
143
+ setSession(user, docNode) {
144
+ let session = this.sessions.find((s) => s.user.id === user.id && s.doc?._id === docNode.doc._id)
145
+ if (!session) {
146
+ session = new Session()
147
+ session.id = uuidv4()
148
+ session.user = user
149
+ session.startTime = Date.now()
150
+ session.doc = docNode.doc
151
+ session.angle = Math.random() * 2 * Math.PI
152
+ this.sessions.push(session)
153
+ }
154
+ session.lastActive = Date.now()
155
+ }
156
+
157
+ reapSessions() {
158
+ for (let i = 0; i < this.sessions.length; i++) {
159
+ const session = this.sessions[i]
160
+ if (Date.now() - session.lastActive > idleTimeout) {
161
+ this.sessions = [...this.sessions.slice(0, i), ...this.sessions.slice(i + 1)]
162
+ i--
163
+ }
164
+ }
165
+ }
166
+
167
+ clone() {
168
+ const copy = new GraphData()
169
+ Object.assign(copy, this)
170
+ copy.data = {
171
+ nodes: [...this.data.nodes],
172
+ links: [...this.data.links],
173
+ }
174
+ return copy
175
+ }
176
+ }
177
+
178
+ const users = new Users()
179
+
180
+ interface GraphViewConfig {
181
+ query?: string
182
+ }
183
+
184
+ export function GraphView(props: GraphViewConfig) {
185
+ const query = props.query || DEFAULT_QUERY
186
+
187
+ const userColorManager = useUserColorManager()
188
+ const [maxSize, setMaxSize] = useState(0)
189
+ const [hoverNode, setHoverNode] = useState<any>(null)
190
+ const [documents, setDocuments] = useState<SanityDocument[]>([])
191
+ const [docTypes, setDocTypes] = useState<Record<string, number>>({})
192
+ const [graph, setGraph] = useState(() => new GraphData())
193
+ const router = useRouter()
194
+ const client = useClient()
195
+
196
+ const fetchCallback = useCallback((_docs) => {
197
+ const docs = deduplicateDrafts(_docs)
198
+ setMaxSize(Math.max(...docs.map(sizeOf)))
199
+ setDocuments(docs)
200
+ setDocTypes(getDocTypeCounts(docs))
201
+ setGraph(new GraphData(docs))
202
+ }, [])
203
+
204
+ const listenCallback = useCallback(
205
+ async (update) => {
206
+ const doc = update.result
207
+ if (doc) {
208
+ doc._id = stripDraftId(doc._id)
209
+
210
+ const docsById: Record<string, SanityDocument> = {}
211
+ for (const d of documents) {
212
+ docsById[d._id] = d
213
+ }
214
+
215
+ let oldDoc
216
+ const docs = [...documents]
217
+ const idx = documents.findIndex((d) => d._id === doc._id)
218
+ if (idx >= 0) {
219
+ oldDoc = docs[idx]
220
+ docs[idx] = doc
221
+ } else {
222
+ docs.push(doc)
223
+ }
224
+ setDocuments(docs)
225
+ setDocTypes(getDocTypeCounts(docs))
226
+ setMaxSize(Math.max(...docs.map(sizeOf)))
227
+
228
+ const newGraph = graph.clone()
229
+
230
+ const oldRefs = findRefs(oldDoc || {}).filter(
231
+ (id) => id === doc._id || docsById[id] !== null
232
+ )
233
+ const newRefs = findRefs(doc).filter((id) => id === doc._id || docsById[id] !== null)
234
+
235
+ let graphChanged = !deepEqual(oldRefs, newRefs)
236
+ if (graphChanged) {
237
+ newGraph.data.links = newGraph.data.links
238
+ .filter((l) => l.source.id !== doc._id)
239
+ .concat(newRefs.map((ref) => ({source: doc._id, target: ref})))
240
+ }
241
+
242
+ let docNode
243
+ const nodeIdx = graph.data.nodes.findIndex((n) => n.doc && n.doc._id === doc._id)
244
+ if (nodeIdx >= 0) {
245
+ docNode = graph.data.nodes[nodeIdx]
246
+ docNode.doc = doc
247
+ } else {
248
+ docNode = {id: doc._id, type: 'document', doc: doc}
249
+ newGraph.data.nodes.push(docNode)
250
+ graphChanged = true
251
+ }
252
+ if (graphChanged) {
253
+ setGraph(newGraph)
254
+ }
255
+
256
+ const user = await users.getById(update.identity, client)
257
+ graph.setSession(user, docNode)
258
+ } else if (update.transition === 'disappear') {
259
+ const docId = stripDraftId(update.documentId)
260
+
261
+ const docs = documents.filter((d) => d._id !== docId)
262
+ setDocuments(docs)
263
+ setDocTypes(getDocTypeCounts(docs))
264
+ setMaxSize(Math.max(...docs.map(sizeOf)))
265
+
266
+ const newGraph = graph.clone()
267
+ newGraph.data.links = newGraph.data.links.filter(
268
+ (l) => l.source.id !== docId && l.target.id !== docId
269
+ )
270
+ newGraph.data.nodes = newGraph.data.nodes.filter((n) => n.id !== docId)
271
+ setGraph(newGraph)
272
+ }
273
+ },
274
+ [documents, graph, client]
275
+ )
276
+ useFetchDocuments(query, fetchCallback, [])
277
+ useListen(query, {}, {}, listenCallback, [documents, graph])
278
+ useEffect(() => {
279
+ const interval = setInterval(() => graph.reapSessions(), 1000)
280
+ return () => clearInterval(interval)
281
+ }, [graph])
282
+
283
+ const theme = useTheme().sanity
284
+
285
+ return (
286
+ <GraphWrapper theme={theme}>
287
+ <GraphRoot theme={theme}>
288
+ <Legend theme={theme}>
289
+ {getTopDocTypes(docTypes).map((docType) => (
290
+ <LegendRow
291
+ className={'legend__row'}
292
+ key={docType}
293
+ style={{color: getDocTypeColor(docType).fill}}
294
+ >
295
+ <LegendBadge theme={theme} />
296
+ <div>{formatDocType(docType)}</div>
297
+ </LegendRow>
298
+ ))}
299
+ </Legend>
300
+ {hoverNode && <HoverNode theme={theme}>{labelFor(hoverNode.doc)}</HoverNode>}
301
+
302
+ <ForceGraph2D
303
+ graphData={graph.data}
304
+ nodeAutoColorBy="group"
305
+ enableNodeDrag={false}
306
+ onNodeHover={(node: any) => setHoverNode(node)}
307
+ onNodeClick={(node: any) => {
308
+ router.navigateIntent('edit', {id: node.doc._id, documentType: node.doc._type})
309
+ }}
310
+ linkColor={() => rgba(gray[500].hex, 0.25)}
311
+ nodeLabel={() => ''}
312
+ nodeRelSize={1}
313
+ nodeVal={(node: any) => valueFor(node.doc, maxSize)}
314
+ onRenderFramePost={(ctx, globalScale) => {
315
+ for (const session of graph.sessions) {
316
+ const node = graph.data.nodes.find((n) => n.doc && n.doc._id === session?.doc?._id)
317
+ if (node) {
318
+ const idleFactorRange = idleTimeout
319
+ const angle = session.angle
320
+ const radius = Math.sqrt(valueFor(node.doc, maxSize))
321
+ const image = session.user.image
322
+ const userColor = userColorManager.get(session.user.displayName).tints[400].hex
323
+ const distance = radius * globalScale + 40
324
+ const imgW = image ? image.width : 0
325
+ const imgH = image ? image.height : 0
326
+ const x = node.x + (Math.sin(angle) * distance) / globalScale
327
+ const y = node.y + (Math.cos(angle) * distance) / globalScale
328
+
329
+ ctx.save()
330
+ try {
331
+ ctx.globalAlpha = fadeEasing(
332
+ 1 - Math.min(idleFactorRange, Date.now() - session.lastActive) / idleFactorRange
333
+ )
334
+ ctx.font = `bold ${Math.round(12 / globalScale)}px sans-serif`
335
+
336
+ ctx.beginPath()
337
+ ctx.strokeStyle = rgba(white.hex, 1.0)
338
+ ctx.lineWidth = 2 / globalScale
339
+ ctx.moveTo(
340
+ node.x + (Math.sin(angle) * (distance - imgW / 2)) / globalScale,
341
+ node.y + (Math.cos(angle) * (distance - imgH / 2)) / globalScale
342
+ )
343
+ ctx.lineTo(node.x + Math.sin(angle) * radius, node.y + Math.cos(angle) * radius)
344
+ ctx.stroke()
345
+
346
+ ctx.beginPath()
347
+ ctx.strokeStyle = rgba(white.hex, 1.0)
348
+ ctx.lineWidth = 2 / globalScale
349
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false)
350
+ ctx.stroke()
351
+
352
+ if (image) {
353
+ ctx.save()
354
+ // eslint-disable-next-line max-depth
355
+ try {
356
+ const dur = 700
357
+ const f = softEasing(
358
+ Math.max(0, (dur - (Date.now() - session.startTime)) / dur)
359
+ )
360
+ // eslint-disable-next-line max-depth
361
+ if (f > 0) {
362
+ ctx.beginPath()
363
+ ctx.fillStyle = rgba(userColor, f)
364
+ ctx.arc(x, y, (imgW / 2 + 10) / globalScale, 0, 2 * Math.PI, false)
365
+ ctx.fill()
366
+ }
367
+
368
+ ctx.beginPath()
369
+ ctx.fillStyle = rgba(white.hex, 1.0)
370
+ ctx.arc(x, y, imgW / globalScale / 2, 0, 2 * Math.PI, false)
371
+ ctx.clip()
372
+
373
+ ctx.drawImage(
374
+ image,
375
+ x - imgW / globalScale / 2,
376
+ y - imgH / globalScale / 2,
377
+ imgW / globalScale,
378
+ imgH / globalScale
379
+ )
380
+
381
+ ctx.strokeStyle = black.hex
382
+ ctx.lineWidth = 6 / globalScale
383
+ ctx.stroke()
384
+
385
+ ctx.strokeStyle = userColor
386
+ ctx.lineWidth = 4 / globalScale
387
+ ctx.stroke()
388
+ } finally {
389
+ ctx.restore()
390
+ }
391
+ }
392
+
393
+ ctx.beginPath()
394
+ ctx.strokeStyle = rgba(black.hex, 1)
395
+ ctx.lineWidth = 0.5 / globalScale
396
+ ctx.arc(x, y, imgW / globalScale / 2, 0, 2 * Math.PI, false)
397
+ ctx.stroke()
398
+
399
+ const above = angle >= Math.PI / 2 && angle < Math.PI * 1.5
400
+ const textY = above
401
+ ? y - (imgH / 2 + 5) / globalScale
402
+ : y + (imgH / 2 + 5) / globalScale
403
+ ctx.fillStyle = rgba(white.hex, 1.0)
404
+ ctx.textAlign = 'center'
405
+ ctx.textBaseline = above ? 'bottom' : 'top'
406
+ ctx.fillText(session.user.displayName, x, textY)
407
+ } finally {
408
+ ctx.restore()
409
+ }
410
+ }
411
+ }
412
+ }}
413
+ nodeCanvasObject={(node: any, ctx, globalScale) => {
414
+ // eslint-disable-next-line default-case
415
+ switch (node.type) {
416
+ case 'document': {
417
+ const nodeColor = getDocTypeColor(node.doc._type)
418
+ const radius = Math.sqrt(valueFor(node.doc, maxSize))
419
+ const fontSize = Math.min(100, 10.0 / globalScale)
420
+
421
+ ctx.beginPath()
422
+ ctx.fillStyle =
423
+ hoverNode !== null && node.doc._id === hoverNode.doc._id
424
+ ? rgba(gray[500].hex, 0.8)
425
+ : nodeColor.fill
426
+ ctx.strokeStyle = nodeColor.border
427
+ ctx.lineWidth = 0.5
428
+ ctx.arc(node.x, node.y, radius, 0, 2 * Math.PI, false)
429
+ ctx.stroke()
430
+ ctx.fill()
431
+
432
+ if (radius * globalScale > 10) {
433
+ ctx.font = `${fontSize}px sans-serif`
434
+ const w = radius * 2 + 30 / globalScale
435
+ for (let len = 50; len >= 5; len /= 1.2) {
436
+ const label = truncate(labelFor(node.doc), Math.round(len))
437
+ const textMetrics = ctx.measureText(label)
438
+ if (textMetrics.width < w) {
439
+ // ctx.fillStyle = rgba(color.white.hex, 1.0)
440
+ ctx.textAlign = 'center'
441
+ ctx.textBaseline = 'top'
442
+
443
+ ctx.strokeStyle = rgba(black.hex, 0.5)
444
+ ctx.lineWidth = 2 / globalScale
445
+ ctx.strokeText(label, node.x, node.y + radius + 5 / globalScale)
446
+
447
+ ctx.fillText(label, node.x, node.y + radius + 5 / globalScale)
448
+ break
449
+ }
450
+ }
451
+ }
452
+ }
453
+ }
454
+ }}
455
+ linkCanvasObject={(link: any, ctx, globalScale) => {
456
+ ctx.beginPath()
457
+ ctx.strokeStyle = rgba(gray[500].hex, 0.125)
458
+ ctx.lineWidth = 2 / globalScale
459
+ ctx.moveTo(link.source.x, link.source.y)
460
+ ctx.lineTo(link.target.x, link.target.y)
461
+ ctx.stroke()
462
+ }}
463
+ />
464
+ </GraphRoot>
465
+ </GraphWrapper>
466
+ )
467
+ }
468
+
469
+ const colorCache = {}
470
+ let typeColorNum = 0
471
+
472
+ function getDocTypeColor(docType) {
473
+ if (colorCache[docType]) {
474
+ return colorCache[docType]
475
+ }
476
+
477
+ const hue = COLOR_HUES[typeColorNum % COLOR_HUES.length]
478
+
479
+ typeColorNum += 1
480
+
481
+ colorCache[docType] = {
482
+ fill: hues[hue][400].hex,
483
+ border: rgba(black.hex, 0.5),
484
+ }
485
+
486
+ return colorCache[docType]
487
+ }
@@ -1,13 +1,4 @@
1
- 'use strict'
2
-
3
- var _interopRequireDefault = require('@babel/runtime/helpers/interopRequireDefault')
4
-
5
- Object.defineProperty(exports, '__esModule', {
6
- value: true,
7
- })
8
- exports.GraphViewIcon = void 0
9
-
10
- var _react = _interopRequireDefault(require('react'))
1
+ import React from 'react'
11
2
 
12
3
  /**
13
4
  * Couple of things to note:
@@ -17,21 +8,11 @@ var _react = _interopRequireDefault(require('react'))
17
8
  * would like here, but for plugins that are to be used in more than one
18
9
  * studio, we suggest these rules are followed
19
10
  **/
20
- var GraphViewIcon = function GraphViewIcon() {
21
- return /*#__PURE__*/ _react['default'].createElement(
22
- 'svg',
23
- {
24
- xmlns: 'http://www.w3.org/2000/svg',
25
- width: '1em',
26
- height: '1em',
27
- viewBox: '0 0 46.063 46.063',
28
- },
29
- /*#__PURE__*/ _react['default'].createElement('path', {
30
- fill: 'currentColor',
31
- d:
32
- 'M18.022 38.676v2.813h-1.21q-4.864 0-6.525-1.447-1.64-1.445-1.64-5.762v-4.666q0-2.95-1.055-4.083-1.055-1.13-3.828-1.13h-1.19v-2.794h1.19q2.793 0 3.828-1.114 1.055-1.132 1.055-4.043V11.76q0-4.316 1.64-5.742 1.66-1.446 6.524-1.446h1.212v2.793h-1.328q-2.754 0-3.594.86-.84.86-.84 3.613v4.844q0 3.066-.898 4.453-.88 1.387-3.028 1.875 2.168.527 3.047 1.914.88 1.387.88 4.434v4.843q0 2.754.84 3.614.84.86 3.594.86h1.328zM28.04 38.676h1.368q2.735 0 3.555-.84.84-.84.84-3.633V29.36q0-3.047.88-4.434.878-1.387 3.046-1.914-2.17-.488-3.048-1.875-.88-1.387-.88-4.453V11.84q0-2.773-.84-3.613-.82-.86-3.554-.86H28.04V4.574h1.232q4.863 0 6.484 1.446 1.64 1.426 1.64 5.742v4.687q0 2.91 1.055 4.042 1.056 1.114 3.83 1.114h1.21V24.4h-1.21q-2.774 0-3.83 1.13-1.053 1.134-1.053 4.084v4.667q0 4.318-1.64 5.763-1.622 1.446-6.485 1.446h-1.23v-2.814z',
33
- })
34
- )
35
- }
36
-
37
- exports.GraphViewIcon = GraphViewIcon
11
+ export const GraphViewIcon = () => (
12
+ <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 46.063 46.063">
13
+ <path
14
+ fill="currentColor"
15
+ d="M18.022 38.676v2.813h-1.21q-4.864 0-6.525-1.447-1.64-1.445-1.64-5.762v-4.666q0-2.95-1.055-4.083-1.055-1.13-3.828-1.13h-1.19v-2.794h1.19q2.793 0 3.828-1.114 1.055-1.132 1.055-4.043V11.76q0-4.316 1.64-5.742 1.66-1.446 6.524-1.446h1.212v2.793h-1.328q-2.754 0-3.594.86-.84.86-.84 3.613v4.844q0 3.066-.898 4.453-.88 1.387-3.028 1.875 2.168.527 3.047 1.914.88 1.387.88 4.434v4.843q0 2.754.84 3.614.84.86 3.594.86h1.328zM28.04 38.676h1.368q2.735 0 3.555-.84.84-.84.84-3.633V29.36q0-3.047.88-4.434.878-1.387 3.046-1.914-2.17-.488-3.048-1.875-.88-1.387-.88-4.453V11.84q0-2.773-.84-3.613-.82-.86-3.554-.86H28.04V4.574h1.232q4.863 0 6.484 1.446 1.64 1.426 1.64 5.742v4.687q0 2.91 1.055 4.042 1.056 1.114 3.83 1.114h1.21V24.4h-1.21q-2.774 0-3.83 1.13-1.053 1.134-1.053 4.084v4.667q0 4.318-1.64 5.763-1.622 1.446-6.485 1.446h-1.23v-2.814z"
16
+ />
17
+ </svg>
18
+ )
@@ -0,0 +1,63 @@
1
+ import styled from 'styled-components'
2
+ import {Theme} from '@sanity/ui'
3
+ import {black} from '@sanity/color'
4
+ import React, {PropsWithChildren} from 'react'
5
+
6
+ type Style = PropsWithChildren<{theme: SanityTheme}>
7
+ type SanityTheme = Theme['sanity']
8
+
9
+ export const GraphRoot: React.FC<Style> = styled.div`
10
+ font-family: ${({theme}: Style) => theme.fonts.text.family};
11
+ position: absolute;
12
+ top: 0;
13
+ left: 0;
14
+ width: 100%;
15
+ height: 100%;
16
+ background: ${black.hex};
17
+ `
18
+
19
+ export const GraphWrapper: React.FC<Style> = styled.div`
20
+ position: relative;
21
+ width: 100%;
22
+ height: 100%;
23
+ ` as React.FC<Style>
24
+
25
+ export const HoverNode: React.FC<Style> = styled.div`
26
+ font-family: ${({theme}: Style) => theme.fonts.text.family};
27
+ display: none;
28
+ position: absolute;
29
+ bottom: ${({theme}: Style) => theme.space[0]}px;
30
+ left: 50%;
31
+ transform: translate3d(-50%, 0, 0);
32
+ background: var(--component-bg);
33
+ border-radius: ${({theme}: Style) => theme.radius[2]}px;
34
+ padding: ${({theme}: Style) => theme.space[2]}px;
35
+ z-index: 1000;
36
+
37
+ &:empty {
38
+ display: none;
39
+ }
40
+ `
41
+
42
+ export const Legend: React.FC<Style> = styled.div`
43
+ color: #ccc;
44
+ position: absolute;
45
+ top: ${({theme}: Style) => theme.space[4]}px;
46
+ left: ${({theme}: Style) => theme.space[4]}px;
47
+
48
+ & > div {
49
+ margin: 5px 0;
50
+ }
51
+ `
52
+
53
+ export const LegendRow = styled.div`
54
+ display: flex;
55
+ `
56
+
57
+ export const LegendBadge: React.FC<Style> = styled.div`
58
+ width: 1.25em;
59
+ height: 1.25em;
60
+ background: currentColor;
61
+ border-radius: 50%;
62
+ margin-right: ${({theme}: Style) => theme.space[2]}px;
63
+ `
@@ -0,0 +1,34 @@
1
+ import {useEffect} from 'react'
2
+ import {useClient} from 'sanity'
3
+ import {ListenEvent, ListenOptions} from '@sanity/client'
4
+
5
+ export function useListen(
6
+ query: string,
7
+ params: {[key: string]: any},
8
+ options: ListenOptions,
9
+ onUpdate: (event: ListenEvent<any>) => void,
10
+ dependencies: unknown[]
11
+ ): void {
12
+ const client = useClient()
13
+ useEffect(() => {
14
+ const subscription = client.listen(query, params, options).subscribe((update) => {
15
+ onUpdate(update)
16
+ })
17
+ return () => {
18
+ subscription.unsubscribe()
19
+ }
20
+ }, dependencies)
21
+ }
22
+
23
+ export function useFetchDocuments(
24
+ query: string,
25
+ onFetch: (event: ListenEvent<any>) => void,
26
+ dependencies: unknown[]
27
+ ): void {
28
+ const client = useClient()
29
+ useEffect(() => {
30
+ client.fetch(query).then((result) => {
31
+ onFetch(result)
32
+ })
33
+ }, dependencies)
34
+ }
@@ -0,0 +1,51 @@
1
+ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
2
+ export function sizeOf(value: any): number {
3
+ if (value === null) {
4
+ return 0
5
+ }
6
+
7
+ if (typeof value === 'object') {
8
+ return Object.entries(value).reduce((total, [k, v]) => total + sizeOf(k) + sizeOf(v), 0)
9
+ }
10
+
11
+ if (Array.isArray(value)) {
12
+ return Object.entries(value).reduce((total, v) => total + sizeOf(v), 0)
13
+ }
14
+
15
+ if (typeof value === 'string') {
16
+ return value.length
17
+ }
18
+
19
+ return 1
20
+ }
21
+
22
+ export function loadImage(url: string, w: number, h: number): Promise<HTMLImageElement | null> {
23
+ return new Promise((resolve) => {
24
+ const img = new Image(w, h)
25
+ img.onload = () => {
26
+ resolve(img)
27
+ }
28
+ img.onerror = (event) => {
29
+ // eslint-disable-next-line no-console
30
+ console.log('Image error', event)
31
+ resolve(null)
32
+ }
33
+ img.src = url
34
+ })
35
+ }
36
+
37
+ export function sortBy<T>(array: T[], f: (t: T) => number): T[] {
38
+ return array.sort((a, b) => {
39
+ const va = f(a)
40
+ const vb = f(b)
41
+ // eslint-disable-next-line no-nested-ternary
42
+ return va < vb ? -1 : va > vb ? 1 : 0
43
+ })
44
+ }
45
+
46
+ export function truncate(s: string, limit: number): string {
47
+ if (s.length > limit) {
48
+ return `${s.substring(0, limit)}…`
49
+ }
50
+ return s
51
+ }
package/.editorconfig DELETED
@@ -1,16 +0,0 @@
1
- ; editorconfig.org
2
- root = true
3
- charset= utf8
4
-
5
- [*]
6
- end_of_line = lf
7
- insert_final_newline = true
8
- trim_trailing_whitespace = true
9
- indent_style = space
10
- indent_size = 2
11
-
12
- [*.md]
13
- trim_trailing_whitespace = false
14
-
15
- [*.snap]
16
- trim_trailing_whitespace = false
package/.eslintignore DELETED
@@ -1 +0,0 @@
1
- /lib