Add the crate dependency graph visualization to the contributor guide (#3907)

* Add the crate dependency graph visualization to the contributor guide

* Code review fixes

* And more
This commit is contained in:
Keavon Chambers
2026-03-17 01:35:56 -07:00
committed by GitHub
parent df8001fca8
commit d9214c7292
21 changed files with 497 additions and 349 deletions

View File

@@ -0,0 +1,19 @@
/* eslint-disable no-console */
import fs from "fs";
import { instance } from "@viz-js/viz";
const [inputFile, outputFile] = process.argv.slice(2);
if (!inputFile || !outputFile) {
console.error("Usage: node generate-crate-hierarchy.ts <input.dot> <output.svg>");
process.exit(1);
}
const dot = fs.readFileSync(inputFile, "utf-8");
const viz = await instance();
const svg = viz.renderString(dot, { format: "svg" });
fs.writeFileSync(outputFile, svg);
console.log(`SVG output written to: ${outputFile}`);

View File

@@ -5,8 +5,8 @@ page_template = "book.html"
[extra]
order = 2 # Chapter number
js = ["/js/component/youtube-embed.js"]
css = ["/component/youtube-embed.css"]
js = ["/js/component/youtube-embed.js", "/js/page/contributor-guide/crate-hierarchy.js"]
css = ["/component/youtube-embed.css", "/page/contributor-guide/crate-hierarchy.css"]
+++
The best introduction for getting up-to-speed with Graphite contribution comes from watching this webcast recording. Before asking questions in Discord, please watch the full video because it gives a comprehensive overview of most things you will need to know.
@@ -37,6 +37,16 @@ The frontend is the GUI for Graphite which users see and interact with. It is bu
[Graphene](../graphene/) is the node graph engine which manages and renders the documents. It is itself a programming language, where Graphene programs are compiled while being edited live by the user, and where executing the program renders the document.
## Crate dependency graph
This diagram shows the structure of the crates that comprise the Graphite codebase and how they depend on each other. Every Arrow points from a crate to another which it depends on.
<div class="crate-hierarchy">
<!-- replacements::crate_hierarchy() -->
</div>
## Frontend/backend communication
Frontend-to-backend communication is achieved through a thin Rust translation layer in [`/frontend/wasm/src/editor_api.rs`](https://github.com/GraphiteEditor/Graphite/tree/master/frontend/wasm/src/editor_api.rs) which wraps the editor backend's Rust-based message system API and provides the TypeScript-compatible API of callable functions. These wrapper functions are compiled by [wasm-bindgen](https://github.com/rustwasm/wasm-bindgen) into autogenerated TS functions that serve as an entry point from TS into the Wasm binary.

View File

@@ -16,6 +16,7 @@
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.9",
"@viz-js/viz": "^3.25.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
@@ -1232,6 +1233,13 @@
"win32"
]
},
"node_modules/@viz-js/viz": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/@viz-js/viz/-/viz-3.25.0.tgz",
"integrity": "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",

View File

@@ -13,6 +13,7 @@
"scripts": {
"postinstall": "node .build-scripts/install.ts",
"generate-editor-structure": "node .build-scripts/generate-editor-structure.ts generated/hierarchical_message_system_tree.txt generated/hierarchical_message_system_tree.html",
"generate-crate-hierarchy": "node .build-scripts/generate-crate-hierarchy.ts generated/crate_hierarchy.dot generated/crate_hierarchy.svg",
"check": "tsc --noEmit && eslint",
"fix": "eslint --fix"
},
@@ -21,11 +22,12 @@
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.9",
"@viz-js/viz": "^3.25.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^5.5.5",
"eslint": "^9.39.2",
"prettier": "^3.8.0",
"sass": "1.97.2",
"tar": "^7.5.6",

View File

@@ -0,0 +1,73 @@
.crate-hierarchy {
position: relative;
margin-top: 20px;
.crate-hierarchy-controls {
position: absolute;
display: flex;
flex-direction: column;
gap: 4px;
top: 8px;
right: 8px;
z-index: 1;
button {
position: relative;
border: none;
border-radius: 2px;
width: 32px;
height: 32px;
background: var(--color-navy);
user-select: none;
&:hover:not(:disabled) {
opacity: 0.5;
}
&:disabled {
opacity: 0.25;
}
// + and - icon geometry
&::before,
&.zoom-in::after {
content: "";
background: white;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
}
&::before {
width: 14px;
height: 2px;
}
&.zoom-in::after {
width: 2px;
height: 14px;
}
}
}
.crate-hierarchy-viewport {
touch-action: none;
overflow: hidden;
cursor: grab;
svg {
display: block;
transform-origin: 0 0;
width: 100%;
height: auto;
pointer-events: none;
user-select: none;
text {
font-family: inherit;
font-size: 0.7em;
}
}
}
}

View File

@@ -0,0 +1,181 @@
document.addEventListener("DOMContentLoaded", () => {
const container = document.querySelector(".crate-hierarchy");
if (!container) return;
const svg = container.querySelector("svg");
if (!svg) return;
// Wrap SVG in a viewport container
const viewport = document.createElement("div");
viewport.className = "crate-hierarchy-viewport";
svg?.parentNode?.insertBefore(viewport, svg);
viewport.appendChild(svg);
// Remove any width/height attributes so CSS controls sizing
svg.removeAttribute("width");
svg.removeAttribute("height");
// Create zoom controls
const controls = document.createElement("div");
controls.className = "crate-hierarchy-controls";
controls.innerHTML = `<button class="zoom-in"></button><button class="zoom-out"></button>`;
container.insertBefore(controls, viewport);
const zoomInBtn = controls.querySelector(".zoom-in");
const zoomOutBtn = controls.querySelector(".zoom-out");
if (!(zoomInBtn instanceof HTMLButtonElement) || !(zoomOutBtn instanceof HTMLButtonElement)) return;
// Lock the viewport height to the SVG's natural rendered height (ignoring any zoom transform)
const updateViewportHeight = () => {
const prevTransform = svg.style.transform;
svg.style.transform = "";
viewport.style.height = `${svg.getBoundingClientRect().height}px`;
svg.style.transform = prevTransform;
};
updateViewportHeight();
window.addEventListener("resize", () => {
updateViewportHeight();
applyTransform();
});
const MIN_SCALE = 1;
const MAX_SCALE = 4;
const ZOOM_STEP = 0.15;
const BUTTON_ZOOM_STEP = 0.5;
const ANIMATION_DURATION = 200;
let scale = MIN_SCALE;
let panX = 0;
let panY = 0;
let animationFrameId = 0;
let isDragging = false;
let dragStartX = 0;
let dragStartY = 0;
let panStartX = 0;
let panStartY = 0;
function clampPan() {
const viewportRect = viewport.getBoundingClientRect();
const viewportW = viewportRect.width;
const viewportH = viewportRect.height;
// The SVG is scaled to fill the viewport width at scale=1
const scaledW = viewportW * scale;
const scaledH = svg?.getBoundingClientRect()?.height || 0;
// How much overflow exists on each axis
const overflowX = Math.max(0, scaledW - viewportW);
const overflowY = Math.max(0, scaledH - viewportH);
// Pan is constrained so scaled content edges don't pull away from viewport edges
panX = Math.min(0, Math.max(-overflowX, panX));
panY = Math.min(0, Math.max(-overflowY, panY));
}
function updateButtons() {
if (zoomInBtn instanceof HTMLButtonElement) zoomInBtn.disabled = scale >= MAX_SCALE;
if (zoomOutBtn instanceof HTMLButtonElement) zoomOutBtn.disabled = scale <= MIN_SCALE;
}
function applyTransform() {
clampPan();
if (svg) svg.style.transform = `translate(${panX}px, ${panY}px) scale(${scale})`;
updateButtons();
}
function zoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newScale) {
const viewportRect = viewport.getBoundingClientRect();
// Point in viewport-local coordinates
const pointX = clientX - viewportRect.left;
const pointY = clientY - viewportRect.top;
// Where this point maps in the pre-zoom content
const contentX = (pointX - panX) / scale;
const contentY = (pointY - panY) / scale;
scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale));
// Adjust pan so the same content point stays under the cursor
panX = pointX - contentX * scale;
panY = pointY - contentY * scale;
applyTransform();
}
function animateZoomAt(/** @type {number} */ clientX, /** @type {number} */ clientY, /** @type {number} */ newTargetScale) {
cancelAnimationFrame(animationFrameId);
const targetScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newTargetScale));
const startScale = scale;
const startPanX = panX;
const startPanY = panY;
const viewportRect = viewport.getBoundingClientRect();
const pointX = clientX - viewportRect.left;
const pointY = clientY - viewportRect.top;
const contentX = (pointX - panX) / scale;
const contentY = (pointY - panY) / scale;
const targetPanX = pointX - contentX * targetScale;
const targetPanY = pointY - contentY * targetScale;
const startTime = performance.now();
const step = (/** @type {number} */ now) => {
const t = Math.min(1, (now - startTime) / ANIMATION_DURATION);
const ease = t * (2 - t); // ease-out quadratic
scale = startScale + (targetScale - startScale) * ease;
panX = startPanX + (targetPanX - startPanX) * ease;
panY = startPanY + (targetPanY - startPanY) * ease;
applyTransform();
if (t < 1) animationFrameId = requestAnimationFrame(step);
};
animationFrameId = requestAnimationFrame(step);
}
// Scroll wheel zoom
viewport.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
zoomAt(e.clientX, e.clientY, scale + delta);
},
{ passive: false },
);
// Button zoom (animated, zoom toward center of viewport)
zoomInBtn?.addEventListener("click", () => {
const rect = viewport.getBoundingClientRect();
animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale + BUTTON_ZOOM_STEP);
});
zoomOutBtn?.addEventListener("click", () => {
const rect = viewport.getBoundingClientRect();
animateZoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, scale - BUTTON_ZOOM_STEP);
});
// Click-drag to pan
viewport.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panStartX = panX;
panStartY = panY;
viewport.setPointerCapture(e.pointerId);
viewport.style.cursor = "grabbing";
});
window.addEventListener("pointermove", (e) => {
if (!isDragging) return;
panX = panStartX + (e.clientX - dragStartX);
panY = panStartY + (e.clientY - dragStartY);
applyTransform();
});
window.addEventListener("pointerup", () => {
if (!isDragging) return;
isDragging = false;
viewport.style.cursor = "";
});
applyTransform();
});

View File

@@ -133,10 +133,12 @@
{%- filter replace(from = "<!-- replacements::blog_posts(count = 2) -->", to = replacements::blog_posts(count = 2)) -%}
{%- filter replace(from = "<!-- replacements::text_balancer() -->", to = replacements::text_balancer()) -%}
{%- filter replace(from = "<!-- replacements::hierarchical_message_system_tree() -->", to = replacements::hierarchical_message_system_tree()) -%}
{%- filter replace(from = "<!-- replacements::crate_hierarchy() -->", to = replacements::crate_hierarchy()) -%}
{%- block content -%}{%- endblock -%}
{%- endfilter -%}
{%- endfilter -%}
{%- endfilter -%}
{%- endfilter -%}
{# This is a comment. It exists to prevent the {%- -%} on the lines above from removing the line break between the `content` block and `</main>` #}
</main>
<footer>

View File

@@ -45,9 +45,20 @@
TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
cd tools/editor-message-tree
cargo run
cd ../../website
cargo run -p editor-message-tree -- website/generated/hierarchical_message_system_tree.txt
cd website
npm run generate-editor-structure</pre>" -%}
{{ content | default(value = fallback) | safe }}
{% endmacro hierarchical_message_system_tree %}
{% macro crate_hierarchy() %}
{%- set content = load_data(path = "../generated/crate_hierarchy.svg", format = "plain", required = false) -%}
{%- set fallback = "<pre>THIS CONTENT IS FILLED IN WHEN CI BUILDS THE WEBSITE.
TO TEST IT LOCALLY, FROM THE ROOT OF THE PROJECT, RUN:
cargo run -p crate-hierarchy-viz -- website/generated/crate_hierarchy.dot
cd website
npm run generate-crate-hierarchy</pre>" -%}
{{ content | default(value = fallback) | safe }}
{% endmacro crate_hierarchy %}