@twango/twiki 0.0.0-stage → 0.1.1

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.
Files changed (225) hide show
  1. package/.github/actions/product-cache/action.yml +32 -0
  2. package/.github/actions/product-cache/index.mjs +450 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/bun.lock +1701 -0
  6. package/cli/runtime.test.ts +420 -0
  7. package/cli/runtime.ts +385 -0
  8. package/cli/twiki.ts +305 -0
  9. package/package.json +138 -4
  10. package/site/_headers +7 -0
  11. package/site/assets/fonts/OFL.txt +93 -0
  12. package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  13. package/site/assets/social-background.svg +38 -0
  14. package/site/bun.lock +1701 -0
  15. package/site/database/index.ts +7 -0
  16. package/site/database/schema.ts +95 -0
  17. package/site/drizzle.config.ts +7 -0
  18. package/site/migrations/0001_auth.sql +14 -0
  19. package/site/migrations/0002_site_access.sql +5 -0
  20. package/site/migrations/0003_github_username.sql +1 -0
  21. package/site/migrations/meta/0002_snapshot.json +432 -0
  22. package/site/migrations/meta/0003_snapshot.json +439 -0
  23. package/site/migrations/meta/_journal.json +27 -0
  24. package/site/package.json +120 -0
  25. package/site/src/app.d.ts +14 -0
  26. package/site/src/app.html +11 -0
  27. package/site/src/hooks.server.ts +12 -0
  28. package/site/src/lib/analytics-config.json +1 -0
  29. package/site/src/lib/analytics.ts +40 -0
  30. package/site/src/lib/auth-config.json +3 -0
  31. package/site/src/lib/components/SEO.svelte +113 -0
  32. package/site/src/lib/components/doc/AccessLink.svelte +27 -0
  33. package/site/src/lib/components/doc/Backlinks.svelte +59 -0
  34. package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
  35. package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
  36. package/site/src/lib/components/doc/DocPager.svelte +91 -0
  37. package/site/src/lib/components/doc/DocShell.svelte +115 -0
  38. package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
  39. package/site/src/lib/components/doc/LockedContent.svelte +29 -0
  40. package/site/src/lib/components/doc/NavTree.svelte +158 -0
  41. package/site/src/lib/components/doc/NoteActions.svelte +139 -0
  42. package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
  43. package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
  44. package/site/src/lib/components/doc/Sidebar.svelte +351 -0
  45. package/site/src/lib/components/doc/Toc.svelte +339 -0
  46. package/site/src/lib/components/doc/enhancements.ts +146 -0
  47. package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
  48. package/site/src/lib/components/doc/image-preview.ts +114 -0
  49. package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
  50. package/site/src/lib/components/embeds/mermaid.ts +353 -0
  51. package/site/src/lib/components/files/FileCode.svelte +81 -0
  52. package/site/src/lib/components/files/FileExplorer.svelte +74 -0
  53. package/site/src/lib/components/files/FileHistory.svelte +170 -0
  54. package/site/src/lib/components/files/FileIcon.svelte +28 -0
  55. package/site/src/lib/components/files/FileRmd.svelte +209 -0
  56. package/site/src/lib/components/files/FileTabs.svelte +180 -0
  57. package/site/src/lib/components/files/FileTree.svelte +67 -0
  58. package/site/src/lib/components/files/blame-gutter.ts +45 -0
  59. package/site/src/lib/components/files/code-view.ts +98 -0
  60. package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
  61. package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
  62. package/site/src/lib/components/graph/GraphView.svelte +100 -0
  63. package/site/src/lib/components/graph/graph-data.ts +137 -0
  64. package/site/src/lib/components/graph/graph-model.ts +103 -0
  65. package/site/src/lib/components/graph/index.ts +3 -0
  66. package/site/src/lib/components/graph/render-graph.ts +626 -0
  67. package/site/src/lib/components/popover/index.ts +1 -0
  68. package/site/src/lib/components/popover/link-popovers.ts +233 -0
  69. package/site/src/lib/components/popover/popover.css +82 -0
  70. package/site/src/lib/components/search/SearchButton.svelte +57 -0
  71. package/site/src/lib/components/search/SearchPalette.svelte +189 -0
  72. package/site/src/lib/components/search/index.ts +3 -0
  73. package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
  74. package/site/src/lib/components/search/pagefind-query.ts +22 -0
  75. package/site/src/lib/components/search/pagefind-url.ts +12 -0
  76. package/site/src/lib/components/search/search-state.svelte.ts +22 -0
  77. package/site/src/lib/components/ui/IconButton.svelte +30 -0
  78. package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
  79. package/site/src/lib/components/ui/index.ts +1 -0
  80. package/site/src/lib/config.ts +9 -0
  81. package/site/src/lib/content-license.ts +7 -0
  82. package/site/src/lib/file-history.ts +37 -0
  83. package/site/src/lib/file-icons.ts +27 -0
  84. package/site/src/lib/file-tabs.ts +72 -0
  85. package/site/src/lib/files.ts +73 -0
  86. package/site/src/lib/github-user.ts +70 -0
  87. package/site/src/lib/metadata.ts +166 -0
  88. package/site/src/lib/notebook-nav.ts +63 -0
  89. package/site/src/lib/publication.ts +1 -0
  90. package/site/src/lib/rmd.ts +156 -0
  91. package/site/src/lib/server/access-admin.ts +33 -0
  92. package/site/src/lib/server/content-model.ts +253 -0
  93. package/site/src/lib/server/content-provider.ts +266 -0
  94. package/site/src/lib/server/content.ts +277 -0
  95. package/site/src/lib/server/runtime-content.ts +34 -0
  96. package/site/src/lib/site-config.json +9 -0
  97. package/site/src/lib/social-image.ts +13 -0
  98. package/site/src/lib/styles/article.css +607 -0
  99. package/site/src/lib/styles/foundation.css +138 -0
  100. package/site/src/lib/styles/print.css +115 -0
  101. package/site/src/lib/styles/shell.css +182 -0
  102. package/site/src/lib/text-exports.ts +5 -0
  103. package/site/src/lib/types.ts +140 -0
  104. package/site/src/lib/utils.ts +6 -0
  105. package/site/src/routes/+error.svelte +16 -0
  106. package/site/src/routes/+layout.server.ts +10 -0
  107. package/site/src/routes/+layout.svelte +72 -0
  108. package/site/src/routes/+layout.ts +3 -0
  109. package/site/src/routes/404/+page.svelte +13 -0
  110. package/site/src/routes/[...slug]/+page.server.ts +24 -0
  111. package/site/src/routes/[...slug]/+page.svelte +180 -0
  112. package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
  113. package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
  114. package/site/src/routes/admin/access/+page.server.ts +53 -0
  115. package/site/src/routes/admin/access/+page.svelte +118 -0
  116. package/site/src/routes/graph.json/+server.ts +7 -0
  117. package/site/src/routes/index.xml/+server.ts +7 -0
  118. package/site/src/routes/layout.css +10 -0
  119. package/site/src/routes/robots.txt/+server.ts +9 -0
  120. package/site/src/routes/sitemap.xml/+server.ts +7 -0
  121. package/site/src/routes/tags/+page.server.ts +5 -0
  122. package/site/src/routes/tags/+page.svelte +38 -0
  123. package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
  124. package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
  125. package/site/static/favicon.png +0 -0
  126. package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
  127. package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
  128. package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
  129. package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
  130. package/site/static/fonts/Virgil.woff2 +0 -0
  131. package/site/svelte.config.js +71 -0
  132. package/site/tooling/LICENSE-quartz +25 -0
  133. package/site/tooling/adapter.js +55 -0
  134. package/site/tooling/assets.ts +323 -0
  135. package/site/tooling/benchmark.ts +191 -0
  136. package/site/tooling/build.ts +407 -0
  137. package/site/tooling/cache.ts +133 -0
  138. package/site/tooling/check-diagrams.ts +164 -0
  139. package/site/tooling/check-links.ts +86 -0
  140. package/site/tooling/check-targets.ts +139 -0
  141. package/site/tooling/compiler.ts +1386 -0
  142. package/site/tooling/deploy.ts +112 -0
  143. package/site/tooling/fingerprint.ts +81 -0
  144. package/site/tooling/lib/app-version.js +92 -0
  145. package/site/tooling/lib/application-asset-urls.ts +27 -0
  146. package/site/tooling/lib/application-cache.ts +269 -0
  147. package/site/tooling/lib/asset-hash-cache.ts +116 -0
  148. package/site/tooling/lib/autotag.ts +99 -0
  149. package/site/tooling/lib/build-assembly.ts +148 -0
  150. package/site/tooling/lib/build-workspace.ts +386 -0
  151. package/site/tooling/lib/cache-selection.ts +70 -0
  152. package/site/tooling/lib/cloudflare-prewarm.js +40 -0
  153. package/site/tooling/lib/comments.ts +32 -0
  154. package/site/tooling/lib/content-cache.ts +244 -0
  155. package/site/tooling/lib/content-urls.ts +50 -0
  156. package/site/tooling/lib/course-cache.ts +298 -0
  157. package/site/tooling/lib/course-files.ts +281 -0
  158. package/site/tooling/lib/currency.ts +33 -0
  159. package/site/tooling/lib/deployment-config.ts +222 -0
  160. package/site/tooling/lib/deployment-manifest.ts +274 -0
  161. package/site/tooling/lib/deployment-prepare.ts +325 -0
  162. package/site/tooling/lib/deployment-upload.ts +293 -0
  163. package/site/tooling/lib/dev-git-state.ts +130 -0
  164. package/site/tooling/lib/dev-history.ts +86 -0
  165. package/site/tooling/lib/dev-social-images.ts +38 -0
  166. package/site/tooling/lib/dev-state.ts +107 -0
  167. package/site/tooling/lib/edition-builds.ts +184 -0
  168. package/site/tooling/lib/edition-paths.js +29 -0
  169. package/site/tooling/lib/empty-tag-route.js +23 -0
  170. package/site/tooling/lib/file-history.ts +201 -0
  171. package/site/tooling/lib/file-icons.ts +93 -0
  172. package/site/tooling/lib/file-policy.ts +38 -0
  173. package/site/tooling/lib/file-search.js +13 -0
  174. package/site/tooling/lib/git-revisions.ts +41 -0
  175. package/site/tooling/lib/history-pool.ts +74 -0
  176. package/site/tooling/lib/history-worker.ts +22 -0
  177. package/site/tooling/lib/knit-rmd.R +38 -0
  178. package/site/tooling/lib/lastmod.ts +201 -0
  179. package/site/tooling/lib/markdown-files.ts +51 -0
  180. package/site/tooling/lib/ofm.ts +634 -0
  181. package/site/tooling/lib/output.ts +60 -0
  182. package/site/tooling/lib/product-cache.ts +1006 -0
  183. package/site/tooling/lib/project-config.ts +288 -0
  184. package/site/tooling/lib/public-assets.ts +38 -0
  185. package/site/tooling/lib/public-links.ts +56 -0
  186. package/site/tooling/lib/publishing.ts +156 -0
  187. package/site/tooling/lib/r-runtime.ts +257 -0
  188. package/site/tooling/lib/rebuild-queue.ts +25 -0
  189. package/site/tooling/lib/rmd-preflight.ts +166 -0
  190. package/site/tooling/lib/rmd-preview-cache.ts +93 -0
  191. package/site/tooling/lib/rmd-previews.ts +129 -0
  192. package/site/tooling/lib/runtime-data-modules.ts +30 -0
  193. package/site/tooling/lib/runtime-packaging.ts +321 -0
  194. package/site/tooling/lib/search-cache.js +165 -0
  195. package/site/tooling/lib/search-inputs.js +129 -0
  196. package/site/tooling/lib/slug.ts +377 -0
  197. package/site/tooling/lib/social-images.ts +210 -0
  198. package/site/tooling/lib/text-exports.ts +51 -0
  199. package/site/tooling/lib/visit-elements.ts +17 -0
  200. package/site/tooling/live-test.ts +138 -0
  201. package/site/tooling/pipeline.ts +103 -0
  202. package/site/tooling/prepare.ts +3 -0
  203. package/site/tooling/publish.ts +28 -0
  204. package/site/tooling/r-cache.ts +45 -0
  205. package/site/tooling/r-render-profile.json +7 -0
  206. package/site/tooling/search.js +94 -0
  207. package/site/tooling/verify-deployment.ts +109 -0
  208. package/site/tooling/vite-content.ts +240 -0
  209. package/site/tooling/wrangler-paths.js +25 -0
  210. package/site/tooling/wrangler.ts +19 -0
  211. package/site/tsconfig.json +16 -0
  212. package/site/vite.config.ts +40 -0
  213. package/site/worker/access.ts +57 -0
  214. package/site/worker/analytics.ts +100 -0
  215. package/site/worker/auth-routes.ts +78 -0
  216. package/site/worker/auth.ts +78 -0
  217. package/site/worker/content-runtime.ts +226 -0
  218. package/site/worker/framing.ts +4 -0
  219. package/site/worker/gate.ts +147 -0
  220. package/site/worker/index.ts +9 -0
  221. package/site/worker/login.ts +62 -0
  222. package/site/worker/publication.ts +44 -0
  223. package/site/worker/runtime.ts +104 -0
  224. package/site/worker/tsconfig.json +13 -0
  225. package/site/wrangler.jsonc +37 -0
@@ -0,0 +1,626 @@
1
+ /**
2
+ * Faithful port of Quartz's graph renderer
3
+ * (quartz/components/scripts/graph.inline.ts @ repo root) to the SvelteKit
4
+ * site: pixi.js draws, d3-force simulates (and keeps simulating — drag
5
+ * reheats it via the alphaTarget pattern), d3-zoom/d3-drag handle
6
+ * interaction, @tweenjs/tween.js animates the hover highlights. Same stack,
7
+ * same parameters, same behavior.
8
+ *
9
+ * This module is only ever loaded via dynamic `import()` from
10
+ * GraphView.svelte, so pixi + d3 + tween stay out of the initial bundle.
11
+ *
12
+ * Intentional deviations from the original (all mechanical, not behavioral):
13
+ * - Data comes from /graph.json (display-form ids) instead of Quartz's
14
+ * contentIndex, and navigation goes through a callback (SvelteKit `goto`)
15
+ * instead of window.spaNavigate.
16
+ * - Link construction and neighbourhood traversal retain Quartz's observable
17
+ * ordering (d3-force seeds nodes by index); only final node lookup uses a Map
18
+ * instead of repeated Array.find calls.
19
+ * - Quartz re-renders on its custom `themechange` event; here GraphView
20
+ * re-runs renderGraph when `:root`'s class list changes (MutationObserver).
21
+ * - Pixi uses its stable WebGL backend instead of preferring experimental
22
+ * WebGPU; the rendered scene is otherwise the same and works on browsers
23
+ * without a usable WebGPU adapter.
24
+ * - The local graph anchors the page being read at the simulation origin so
25
+ * dense two-hop neighborhoods cannot push it outside the preview.
26
+ * - Cleanup also stops the d3 simulation timer (Quartz leaves it to decay).
27
+ */
28
+ import { Group as TweenGroup, Tween as Tweened } from '@tweenjs/tween.js';
29
+ import { drag } from 'd3-drag';
30
+ import {
31
+ forceCenter,
32
+ forceCollide,
33
+ forceLink,
34
+ forceManyBody,
35
+ forceRadial,
36
+ forceSimulation,
37
+ type Simulation,
38
+ type SimulationLinkDatum,
39
+ type SimulationNodeDatum
40
+ } from 'd3-force';
41
+ import { select } from 'd3-selection';
42
+ import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
43
+ import { Application, Circle, Container, Graphics, Text } from 'pixi.js';
44
+ import { getVisited, type GraphConfig, type GraphData } from './graph-data';
45
+ import { canvasPosition, orderedGraphElements } from './graph-model';
46
+
47
+ type GraphicsInfo = {
48
+ color: string;
49
+ gfx: Graphics;
50
+ alpha: number;
51
+ active: boolean;
52
+ };
53
+
54
+ type NodeData = {
55
+ id: string;
56
+ text: string;
57
+ tags: string[];
58
+ } & SimulationNodeDatum;
59
+
60
+ type LinkData = {
61
+ source: NodeData;
62
+ target: NodeData;
63
+ } & SimulationLinkDatum<NodeData>;
64
+
65
+ type LinkRenderData = GraphicsInfo & {
66
+ simulationData: LinkData;
67
+ };
68
+
69
+ type NodeRenderData = GraphicsInfo & {
70
+ simulationData: NodeData;
71
+ label: Text;
72
+ };
73
+
74
+ type TweenNode = {
75
+ update: (time: number) => void;
76
+ stop: () => void;
77
+ };
78
+
79
+ /**
80
+ * Quartz reads its theme palette straight off :root (--secondary, --tertiary,
81
+ * --gray, --light, --lightgray, --dark, --bodyFont). This site's tokens
82
+ * (site/src/routes/layout.css) play the equivalent roles:
83
+ *
84
+ * --secondary (current node) → --color-accent
85
+ * --tertiary (visited node, tag ring) → accent softened toward muted
86
+ * --gray (unvisited node fill) → --color-muted
87
+ * --light (tag node fill) → --color-bg
88
+ * --lightgray (link lines) → --color-subtle
89
+ * --dark (label text) → --color-text
90
+ * --bodyFont → body font-family
91
+ *
92
+ * Colors are resolved through a probe element so any CSS color syntax the
93
+ * tokens use ends up as an rgb() string pixi can parse.
94
+ */
95
+ function resolveThemeColors() {
96
+ const probe = document.createElement('span');
97
+ probe.style.position = 'absolute';
98
+ probe.style.visibility = 'hidden';
99
+ document.body.appendChild(probe);
100
+ const resolve = (expr: string): string => {
101
+ probe.style.color = '';
102
+ probe.style.color = expr;
103
+ return getComputedStyle(probe).color;
104
+ };
105
+ // channel-wise mix of two resolved rgb()/rgba() strings
106
+ const mix = (a: string, b: string, t: number): string => {
107
+ const pa = a.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
108
+ const pb = b.match(/[\d.]+/g)?.map(Number) ?? [0, 0, 0];
109
+ const c = [0, 1, 2].map((i) => Math.round((pa[i] ?? 0) * (1 - t) + (pb[i] ?? 0) * t));
110
+ return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
111
+ };
112
+ const accent = resolve('var(--color-accent)');
113
+ const muted = resolve('var(--color-muted)');
114
+ const map = {
115
+ '--secondary': accent,
116
+ '--tertiary': mix(accent, muted, 0.4),
117
+ '--gray': muted,
118
+ '--light': resolve('var(--color-bg)'),
119
+ '--lightgray': resolve('var(--color-subtle)'),
120
+ '--dark': resolve('var(--color-text)'),
121
+ '--bodyFont': getComputedStyle(document.body).fontFamily
122
+ };
123
+ probe.remove();
124
+ return map;
125
+ }
126
+
127
+ export async function renderGraph(
128
+ graph: HTMLElement,
129
+ fullData: GraphData,
130
+ slug: string | undefined,
131
+ cfg: GraphConfig,
132
+ isGlobalGraph: boolean,
133
+ navigate: (id: string) => void
134
+ ): Promise<() => void> {
135
+ const visited = getVisited();
136
+ graph.replaceChildren();
137
+ graph.dataset.graphReady = 'false';
138
+
139
+ const {
140
+ drag: enableDrag,
141
+ zoom: enableZoom,
142
+ scale,
143
+ repelForce,
144
+ centerForce,
145
+ linkDistance,
146
+ fontSize,
147
+ opacityScale,
148
+ removeTags,
149
+ showTags,
150
+ focusOnHover,
151
+ enableRadial,
152
+ centerCurrentNode
153
+ } = cfg;
154
+ const data = new Map(fullData.nodes.map((n) => [n.id, n]));
155
+ const tweens = new Map<string, TweenNode>();
156
+ const selected = orderedGraphElements(fullData, slug, {
157
+ depth: cfg.depth,
158
+ showTags,
159
+ removeTags
160
+ });
161
+ const nodes: NodeData[] = selected.nodeIds.map((url) => {
162
+ const text = url.startsWith('tags/') ? '#' + url.substring(5) : (data.get(url)?.title ?? url);
163
+ return {
164
+ id: url,
165
+ text,
166
+ tags: data.get(url)?.tags ?? []
167
+ };
168
+ });
169
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
170
+ const graphData: { nodes: NodeData[]; links: LinkData[] } = {
171
+ nodes,
172
+ links: selected.links.map((l) => ({
173
+ source: nodeById.get(l.source)!,
174
+ target: nodeById.get(l.target)!
175
+ }))
176
+ };
177
+ const centeredNode = centerCurrentNode && slug !== undefined ? nodeById.get(slug) : undefined;
178
+ if (centeredNode) {
179
+ centeredNode.fx = 0;
180
+ centeredNode.fy = 0;
181
+ }
182
+
183
+ let width = graph.offsetWidth;
184
+ const previewHeight = Number.parseFloat(
185
+ getComputedStyle(graph).getPropertyValue('--graph-preview-height')
186
+ );
187
+ let height = Math.max(graph.offsetHeight, previewHeight || 1);
188
+
189
+ // we virtualize the simulation and use pixi to actually render it
190
+ const simulation: Simulation<NodeData, LinkData> = forceSimulation<NodeData>(graphData.nodes)
191
+ .force('charge', forceManyBody().strength(-100 * repelForce))
192
+ .force('center', forceCenter().strength(centerForce))
193
+ .force('link', forceLink(graphData.links).distance(linkDistance))
194
+ .force('collide', forceCollide<NodeData>((n) => nodeRadius(n)).iterations(3));
195
+ const radius = (Math.min(width, height) / 2) * 0.8;
196
+ if (enableRadial) simulation.force('radial', forceRadial(radius).strength(0.2));
197
+
198
+ // precompute style prop strings as pixi doesn't support css variables
199
+ const computedStyleMap = resolveThemeColors();
200
+
201
+ // calculate color
202
+ const color = (d: NodeData) => {
203
+ const isCurrent = d.id === slug;
204
+ if (isCurrent) {
205
+ return computedStyleMap['--secondary'];
206
+ } else if (visited.has(d.id) || d.id.startsWith('tags/')) {
207
+ return computedStyleMap['--tertiary'];
208
+ } else {
209
+ return computedStyleMap['--gray'];
210
+ }
211
+ };
212
+
213
+ function nodeRadius(d: NodeData) {
214
+ const numLinks = graphData.links.filter(
215
+ (l) => l.source.id === d.id || l.target.id === d.id
216
+ ).length;
217
+ return 2 + Math.sqrt(numLinks);
218
+ }
219
+
220
+ let hoveredNodeId: string | null = null;
221
+ let hoveredNeighbours: Set<string> = new Set();
222
+ const linkRenderData: LinkRenderData[] = [];
223
+ const nodeRenderData: NodeRenderData[] = [];
224
+ function updateHoverInfo(newHoveredId: string | null) {
225
+ hoveredNodeId = newHoveredId;
226
+
227
+ if (newHoveredId === null) {
228
+ hoveredNeighbours = new Set();
229
+ for (const n of nodeRenderData) {
230
+ n.active = false;
231
+ }
232
+
233
+ for (const l of linkRenderData) {
234
+ l.active = false;
235
+ }
236
+ } else {
237
+ hoveredNeighbours = new Set();
238
+ for (const l of linkRenderData) {
239
+ const linkData = l.simulationData;
240
+ if (linkData.source.id === newHoveredId || linkData.target.id === newHoveredId) {
241
+ hoveredNeighbours.add(linkData.source.id);
242
+ hoveredNeighbours.add(linkData.target.id);
243
+ }
244
+
245
+ l.active = linkData.source.id === newHoveredId || linkData.target.id === newHoveredId;
246
+ }
247
+
248
+ for (const n of nodeRenderData) {
249
+ n.active = hoveredNeighbours.has(n.simulationData.id);
250
+ }
251
+ }
252
+ }
253
+
254
+ let dragStartTime = 0;
255
+ let dragging = false;
256
+
257
+ function renderLinks() {
258
+ tweens.get('link')?.stop();
259
+ const tweenGroup = new TweenGroup();
260
+
261
+ for (const l of linkRenderData) {
262
+ let alpha = 1;
263
+
264
+ // if we are hovering over a node, we want to highlight the immediate neighbours
265
+ // with full alpha and the rest with default alpha
266
+ if (hoveredNodeId) {
267
+ alpha = l.active ? 1 : 0.2;
268
+ }
269
+
270
+ l.color = l.active ? computedStyleMap['--gray'] : computedStyleMap['--lightgray'];
271
+ tweenGroup.add(new Tweened<LinkRenderData>(l).to({ alpha }, 200));
272
+ }
273
+
274
+ tweenGroup.getAll().forEach((tw) => tw.start());
275
+ tweens.set('link', {
276
+ update: tweenGroup.update.bind(tweenGroup),
277
+ stop() {
278
+ tweenGroup.getAll().forEach((tw) => tw.stop());
279
+ }
280
+ });
281
+ }
282
+
283
+ function renderLabels() {
284
+ tweens.get('label')?.stop();
285
+ const tweenGroup = new TweenGroup();
286
+
287
+ const defaultScale = 1 / scale;
288
+ const activeScale = defaultScale * 1.1;
289
+ for (const n of nodeRenderData) {
290
+ const nodeId = n.simulationData.id;
291
+
292
+ if (hoveredNodeId === nodeId) {
293
+ tweenGroup.add(
294
+ new Tweened<Text>(n.label).to(
295
+ {
296
+ alpha: 1,
297
+ scale: { x: activeScale, y: activeScale }
298
+ },
299
+ 100
300
+ )
301
+ );
302
+ } else {
303
+ tweenGroup.add(
304
+ new Tweened<Text>(n.label).to(
305
+ {
306
+ alpha: n.label.alpha,
307
+ scale: { x: defaultScale, y: defaultScale }
308
+ },
309
+ 100
310
+ )
311
+ );
312
+ }
313
+ }
314
+
315
+ tweenGroup.getAll().forEach((tw) => tw.start());
316
+ tweens.set('label', {
317
+ update: tweenGroup.update.bind(tweenGroup),
318
+ stop() {
319
+ tweenGroup.getAll().forEach((tw) => tw.stop());
320
+ }
321
+ });
322
+ }
323
+
324
+ function renderNodes() {
325
+ tweens.get('hover')?.stop();
326
+
327
+ const tweenGroup = new TweenGroup();
328
+ for (const n of nodeRenderData) {
329
+ let alpha = 1;
330
+
331
+ // if we are hovering over a node, we want to highlight the immediate neighbours
332
+ if (hoveredNodeId !== null && focusOnHover) {
333
+ alpha = n.active ? 1 : 0.2;
334
+ }
335
+
336
+ tweenGroup.add(new Tweened<Graphics>(n.gfx, tweenGroup).to({ alpha }, 200));
337
+ }
338
+
339
+ tweenGroup.getAll().forEach((tw) => tw.start());
340
+ tweens.set('hover', {
341
+ update: tweenGroup.update.bind(tweenGroup),
342
+ stop() {
343
+ tweenGroup.getAll().forEach((tw) => tw.stop());
344
+ }
345
+ });
346
+ }
347
+
348
+ function renderPixiFromD3() {
349
+ renderNodes();
350
+ renderLinks();
351
+ renderLabels();
352
+ }
353
+
354
+ tweens.forEach((tween) => tween.stop());
355
+ tweens.clear();
356
+
357
+ const app = new Application();
358
+ await app.init({
359
+ width,
360
+ height,
361
+ antialias: true,
362
+ autoStart: false,
363
+ autoDensity: true,
364
+ backgroundAlpha: 0,
365
+ preference: 'webgl',
366
+ resolution: window.devicePixelRatio,
367
+ eventMode: 'static'
368
+ });
369
+ graph.appendChild(app.canvas);
370
+
371
+ const stage = app.stage;
372
+ stage.interactive = false;
373
+
374
+ const labelsContainer = new Container<Text>({ zIndex: 3, isRenderGroup: true });
375
+ const nodesContainer = new Container<Graphics>({ zIndex: 2, isRenderGroup: true });
376
+ const linkContainer = new Container<Graphics>({ zIndex: 1, isRenderGroup: true });
377
+ stage.addChild(nodesContainer, labelsContainer, linkContainer);
378
+
379
+ for (const n of graphData.nodes) {
380
+ const nodeId = n.id;
381
+
382
+ const label = new Text({
383
+ interactive: false,
384
+ eventMode: 'none',
385
+ text: n.text,
386
+ alpha: 0,
387
+ anchor: { x: 0.5, y: 1.2 },
388
+ style: {
389
+ fontSize: fontSize * 15,
390
+ fill: computedStyleMap['--dark'],
391
+ fontFamily: computedStyleMap['--bodyFont']
392
+ },
393
+ resolution: window.devicePixelRatio * 4
394
+ });
395
+ label.scale.set(1 / scale);
396
+
397
+ let oldLabelOpacity = 0;
398
+ const isTagNode = nodeId.startsWith('tags/');
399
+ const gfx = new Graphics({
400
+ interactive: true,
401
+ label: nodeId,
402
+ eventMode: 'static',
403
+ hitArea: new Circle(0, 0, nodeRadius(n)),
404
+ cursor: 'pointer'
405
+ })
406
+ .circle(0, 0, nodeRadius(n))
407
+ .fill({ color: isTagNode ? computedStyleMap['--light'] : color(n) })
408
+ .on('pointerover', (e) => {
409
+ updateHoverInfo(e.target.label);
410
+ oldLabelOpacity = label.alpha;
411
+ if (!dragging) {
412
+ renderPixiFromD3();
413
+ }
414
+ })
415
+ .on('pointerleave', () => {
416
+ updateHoverInfo(null);
417
+ label.alpha = oldLabelOpacity;
418
+ if (!dragging) {
419
+ renderPixiFromD3();
420
+ }
421
+ });
422
+
423
+ if (isTagNode) {
424
+ gfx.stroke({ width: 2, color: computedStyleMap['--tertiary'] });
425
+ }
426
+
427
+ nodesContainer.addChild(gfx);
428
+ labelsContainer.addChild(label);
429
+
430
+ const nodeRenderDatum: NodeRenderData = {
431
+ simulationData: n,
432
+ gfx,
433
+ label,
434
+ color: color(n),
435
+ alpha: 1,
436
+ active: false
437
+ };
438
+
439
+ nodeRenderData.push(nodeRenderDatum);
440
+ }
441
+
442
+ for (const l of graphData.links) {
443
+ const gfx = new Graphics({ interactive: false, eventMode: 'none' });
444
+ linkContainer.addChild(gfx);
445
+
446
+ const linkRenderDatum: LinkRenderData = {
447
+ simulationData: l,
448
+ gfx,
449
+ color: computedStyleMap['--lightgray'],
450
+ alpha: 1,
451
+ active: false
452
+ };
453
+
454
+ linkRenderData.push(linkRenderDatum);
455
+ }
456
+
457
+ let currentTransform = zoomIdentity;
458
+ if (enableDrag) {
459
+ select<HTMLCanvasElement, NodeData | undefined>(app.canvas).call(
460
+ drag<HTMLCanvasElement, NodeData | undefined>()
461
+ .container(() => app.canvas)
462
+ .subject(() => graphData.nodes.find((n) => n.id === hoveredNodeId))
463
+ .on('start', function dragstarted(event) {
464
+ if (!event.active) simulation.alphaTarget(1).restart();
465
+ event.subject.fx = event.subject.x;
466
+ event.subject.fy = event.subject.y;
467
+ event.subject.__initialDragPos = {
468
+ x: event.subject.x,
469
+ y: event.subject.y,
470
+ fx: event.subject.fx,
471
+ fy: event.subject.fy
472
+ };
473
+ dragStartTime = Date.now();
474
+ dragging = true;
475
+ })
476
+ .on('drag', function dragged(event) {
477
+ const initPos = event.subject.__initialDragPos;
478
+ event.subject.fx = initPos.x + (event.x - initPos.x) / currentTransform.k;
479
+ event.subject.fy = initPos.y + (event.y - initPos.y) / currentTransform.k;
480
+ })
481
+ .on('end', function dragended(event) {
482
+ if (!event.active) simulation.alphaTarget(0);
483
+ if (event.subject === centeredNode) {
484
+ event.subject.fx = 0;
485
+ event.subject.fy = 0;
486
+ } else {
487
+ event.subject.fx = null;
488
+ event.subject.fy = null;
489
+ }
490
+ dragging = false;
491
+
492
+ // if the time between mousedown and mouseup is short, we consider it a click
493
+ if (Date.now() - dragStartTime < 500) {
494
+ const node = graphData.nodes.find((n) => n.id === event.subject.id) as NodeData;
495
+ navigate(node.id);
496
+ }
497
+ })
498
+ );
499
+ } else {
500
+ for (const node of nodeRenderData) {
501
+ node.gfx.on('click', () => {
502
+ navigate(node.simulationData.id);
503
+ });
504
+ }
505
+ }
506
+
507
+ let zoomController: ZoomBehavior<HTMLCanvasElement, NodeData> | undefined;
508
+ if (enableZoom) {
509
+ zoomController = zoom<HTMLCanvasElement, NodeData>()
510
+ .filter((event: Event) => {
511
+ // local graph only wheel-zooms with ctrl/meta held (Quartz checks
512
+ // for the .global-graph-container class; here it's a parameter)
513
+ if (event.type === 'wheel') {
514
+ if (isGlobalGraph || graph.dataset.expanded === 'true') return true;
515
+ const we = event as WheelEvent;
516
+ return we.ctrlKey || we.metaKey;
517
+ }
518
+ return !(event as MouseEvent).button;
519
+ })
520
+ .extent((): [[number, number], [number, number]] => [
521
+ [0, 0],
522
+ [width, height]
523
+ ])
524
+ .scaleExtent([0.25, 4])
525
+ .on('zoom', ({ transform }) => {
526
+ currentTransform = transform;
527
+ stage.scale.set(transform.k, transform.k);
528
+ stage.position.set(transform.x, transform.y);
529
+
530
+ // zoom adjusts opacity of labels too
531
+ const scale = transform.k * opacityScale;
532
+ const scaleOpacity = Math.max((scale - 1) / 3.75, 0);
533
+ const activeNodes = nodeRenderData.filter((n) => n.active).flatMap((n) => n.label);
534
+
535
+ for (const label of labelsContainer.children) {
536
+ if (!activeNodes.includes(label)) {
537
+ label.alpha = scaleOpacity;
538
+ }
539
+ }
540
+ });
541
+ select<HTMLCanvasElement, NodeData>(app.canvas).call(zoomController);
542
+ }
543
+
544
+ // Resize the existing Pixi canvas, keeping the simulation and zoom alive.
545
+ const resizeObserver = new ResizeObserver(() => {
546
+ const nextWidth = graph.clientWidth;
547
+ const nextHeight = graph.clientHeight;
548
+ if (!nextWidth || !nextHeight || (nextWidth === width && nextHeight === height)) return;
549
+ const { x, y, k } = currentTransform;
550
+ const nextTransform = zoomIdentity
551
+ .translate(x + ((nextWidth - width) * (1 - k)) / 2, y + ((nextHeight - height) * (1 - k)) / 2)
552
+ .scale(k);
553
+ width = nextWidth;
554
+ height = nextHeight;
555
+ app.renderer.resize(width, height);
556
+ if (enableRadial) {
557
+ simulation.force('radial', forceRadial((Math.min(width, height) / 2) * 0.8).strength(0.2));
558
+ simulation.alpha(Math.max(simulation.alpha(), 0.15)).restart();
559
+ }
560
+ if (zoomController)
561
+ select<HTMLCanvasElement, NodeData>(app.canvas).call(zoomController.transform, nextTransform);
562
+ });
563
+ resizeObserver.observe(graph);
564
+
565
+ function drawFrame(time: number) {
566
+ for (const n of nodeRenderData) {
567
+ const { x, y } = n.simulationData;
568
+ const position = canvasPosition(x, y, width, height);
569
+ if (!position) continue;
570
+ n.gfx.position.set(position.x, position.y);
571
+ if (n.simulationData === centeredNode) {
572
+ graph.dataset.graphCurrentX = String(position.x);
573
+ graph.dataset.graphCurrentY = String(position.y);
574
+ }
575
+ if (n.label) {
576
+ n.label.position.set(position.x, position.y);
577
+ }
578
+ }
579
+
580
+ for (const l of linkRenderData) {
581
+ const linkData = l.simulationData;
582
+ l.gfx.clear();
583
+ l.gfx.moveTo(linkData.source.x! + width / 2, linkData.source.y! + height / 2);
584
+ l.gfx
585
+ .lineTo(linkData.target.x! + width / 2, linkData.target.y! + height / 2)
586
+ .stroke({ alpha: l.alpha, width: 1, color: l.color });
587
+ }
588
+
589
+ tweens.forEach((t) => t.update(time));
590
+ app.renderer.render(stage);
591
+ }
592
+
593
+ // Draw once immediately. A backgrounded/throttled tab may defer
594
+ // requestAnimationFrame, but its graph preview should never remain blank.
595
+ drawFrame(performance.now());
596
+ graph.dataset.graphReady = 'true';
597
+ graph.dataset.graphNodes = String(nodeRenderData.length);
598
+ graph.dataset.graphLinks = String(linkRenderData.length);
599
+
600
+ let stopAnimation = false;
601
+ let animationFrame = 0;
602
+ function animate(time: number) {
603
+ if (stopAnimation) return;
604
+ drawFrame(time);
605
+ animationFrame = requestAnimationFrame(animate);
606
+ }
607
+
608
+ animationFrame = requestAnimationFrame(animate);
609
+ let destroyed = false;
610
+ return () => {
611
+ if (destroyed) return;
612
+ destroyed = true;
613
+ stopAnimation = true;
614
+ resizeObserver.disconnect();
615
+ cancelAnimationFrame(animationFrame);
616
+ tweens.forEach((t) => t.stop());
617
+ tweens.clear();
618
+ simulation.stop();
619
+ // Pixi treats a boolean `true` renderer option as "remove the canvas AND
620
+ // release global resources". Local and global graphs are separate Pixi
621
+ // applications, so releasing the process-wide text texture pool while
622
+ // the other graph is alive corrupts its atlas. Remove this app's canvas,
623
+ // children and renderer without releasing resources shared by its peer.
624
+ app.destroy({ removeView: true, releaseGlobalResources: false }, { children: true });
625
+ };
626
+ }
@@ -0,0 +1 @@
1
+ export { linkPopovers } from './link-popovers';