Pivograph
Contents
  1. Overview
    1. Quick start
      1. Mapping an organization
        1. 1. Collect the sources
        2. 2. Decide what is a node
        3. 3. Decide what is an edge
        4. 4. Design the types and tags
        5. 5. Write the document
        6. 6. Validate
        7. Checklist
        8. Prompt template
      2. Document format
        1. meta
        2. nodes
        3. edges
        4. nodeTypes and edgeTypes
        5. tags
        6. sections
        7. arrows
        8. Validation
      3. Examples
        1. Open Contributions Descriptor
          1. Embedding
            1. Hugo
              1. Add it to a Hugo site
              2. A graph from the site's pages
              3. A graph from a data file
              4. Parameters
              5. How it works
              6. Checklist
              7. Prompt for an AI agent
              8. Troubleshooting
            2. Reports
              1. The picture
              2. The report protocol
              3. The PDF layout
              4. From the command line
            3. Using the app
              1. Architecture
                1. Development
                  1. For AI agents

                    Overview

                    Pivograph draws an organization as a graph: its projects, platforms, data sources and partners are nodes, and the ways they depend on, feed or maintain each other are edges. The whole map is one JSON document. You can write it by hand, generate it (an AI agent is a good fit), import it from an organization's published Open Contributions Descriptor, then refine it in the browser.

                    Rendering and interaction come from Pivotick, a graph library by CIRCL. Pivograph adds a document format, forms to edit every node and edge, types, tags, GitHub details, filters, and an embeddable read-only viewer.

                    What a map contains:

                    PartWhat it holds
                    nodesThe things being mapped. Each has an id, a label, and optionally a type, a description, links, a GitHub repository, tags, extra details and its own look.
                    edgesDirected connections between two nodes: a label that says how they are related, an arrow direction, a type and a look.
                    nodeTypes, edgeTypesShared defaults. A node of type project takes the type's colour, shape, image and label style unless it sets its own.
                    tagsHow each #tag is drawn: colour and icon. Tags appear as small pills under nodes and can filter the graph.
                    metaTitle, description and a few document settings.

                    Quick start

                    In the browser. Open the app. It starts with the Rulezet example, locked (it can be explored and exported, not edited). The Graph menu opens a map (or drop a .json file on the page), imports an organization's Open Contributions Descriptor, or starts a new graph from ready-made types. Add creates nodes and edges. Export saves the map as JSON, or as a PDF or Markdown report with a picture of the graph. Changes are also kept in the browser between visits.

                    From the command line.

                    bash
                    git clone https://github.com/ecrou-exact/project-graph.git
                    cd project-graph
                    npm install
                    npm run dev      # http://localhost:5173
                    npm test         # tests for the document format
                    npm run build    # static site in dist/

                    The smallest valid document has one node:

                    json
                    { "nodes": [{ "id": "rulezet" }] }

                    Everything else is optional. A missing label defaults to the id; missing types, tags and styles fall back to built-in defaults.

                    Mapping an organization

                    This section is a procedure. It is written for an AI agent (or a person) asked to "map organization X with Pivograph", and it ends with a document that loads without errors and reads well. Follow the steps in order.

                    1. Collect the sources

                    Gather facts before writing anything, and keep the URL of every fact you use.

                    1. The organization's Open Contributions Descriptor, if it publishes one: https://<domain>/.well-known/open-contributions.json. It lists projects, licenses, repositories, open data, standards and relationships in a structured form. If it exists, load it in the app with Graph → Import an organization… first: it gives you a correct skeleton in one step (see Open Contributions Descriptor).
                    2. The code hosting organization: https://github.com/<org> (or GitLab). List the repositories that are real projects: skip forks, archived experiments and tooling repositories unless they matter to the map. For each project, note the repository description, license, main language and topics. The GitHub API gives them without authentication (https://api.github.com/repos/<owner>/<repo>), at up to 60 requests per hour.
                    3. The website and documentation of the organization and of each project: what the project is for, who uses it, which other projects it talks to.
                    4. The code itself, for relationships: API clients, configuration files, import/export modules, webhooks, submodules, dependency manifests. A relationship you can point to in code is worth more than one inferred from a website.

                    2. Decide what is a node

                    A node is anything with its own identity that a reader would want to locate on the map:

                    Keep the map about one level of granularity: if one node is "MISP", its sub-modules are not separate nodes unless the map is about MISP's internals. When in doubt, a detail belongs in a node's description, details or tags, not in a new node.

                    3. Decide what is an edge

                    An edge says how two nodes are related, and its arrow says which way the relationship goes. Use one convention for the whole map and write it in meta.description. The most readable convention is "the arrow goes from the one who acts to the one acted upon": A → B when A calls, uses, imports from, feeds or maintains B.

                    4. Design the types and tags

                    Types carry the look, so the map stays consistent and the JSON stays short:

                    5. Write the document

                    Follow the document format exactly. Rules that matter:

                    6. Validate

                    Load the file in the app (Graph → Open a file…, or paste it in the JSON tab and press Apply). The app reports errors (the file is refused: duplicate ids, edges pointing to unknown nodes, wrong top-level shapes) and warnings (the file loads, but something was ignored: an unknown shape or direction, a type that is not declared). Fix every error and every warning. You can also check the file against the JSON schema.

                    Then read the map as a newcomer would: every node label is understandable, every arrow reads as a sentence (Flowintel → uses → MISP), nothing important floats unconnected.

                    Checklist

                    Prompt template

                    Give an AI agent this prompt, with the organization filled in:

                    text
                    Map the organization <NAME> (<website>, <code hosting URL>) with Pivograph.
                    Read https://ecrou-exact.github.io/project-graph/llms-full.txt first and follow
                    its "Mapping an organization" procedure and "Document format" exactly.
                    Check https://<domain>/.well-known/open-contributions.json and use it if present.
                    Map <scope: all active projects / the projects around X / …>.
                    Arrow convention: from the one who acts to the one acted upon.
                    Only include relationships you can support with code or documentation, and
                    cite the source in each edge's details. Output one JSON document, nothing else.

                    Document format

                    A Pivograph document is a JSON object. All top-level keys are optional; unknown keys are ignored. The JSON schema describes the same format for validators.

                    json
                    {
                      "version": 1,
                      "meta": { "title": "…", "description": "…" },
                      "nodeTypes": { "project": { … } },
                      "edgeTypes": { "uses": { … } },
                      "tags": { "open-source": { … } },
                      "sections": [ { "title": "…", "x": 0, "y": 0, … } ],
                      "arrows": [ { "from": { "node": "…" }, "to": { "section": "…" }, … } ],
                      "nodes": [ { "id": "…", … } ],
                      "edges": [ { "from": "…", "to": "…", … } ]
                    }

                    Inheritance. For every visual field, the value on the node or edge wins; otherwise its type's value is used; otherwise the built-in default. Leaving a field out is how you say "inherit".

                    meta

                    FieldTypeDescription
                    titlestringShown in the app's header and the browser tab.
                    descriptionstringWhat the map shows, the arrow convention, when facts were checked.
                    linkDistancenumberEdge length in the layout. Default 150; raise it (200–350) for big nodes or long edge labels.
                    fixedLayoutbooleanNodes stay exactly at their x / y: no force layout, as in a drawn diagram. Add → Fixed layout toggles it.
                    readOnlybooleanOpens the map locked: it can be explored and exported, not edited. Set automatically on imported descriptors and on the bundled example. To change a locked map, edit its JSON file.
                    sourceobjectWhere the map came from, e.g. { "format": "ocd", "domain": "misp-project.org" }. Set by imports.

                    nodes

                    Each node is an object. Only id is required.

                    FieldTypeDescription
                    idstring or numberRequired. Unique. Edges refer to it. Use lowercase-with-hyphens.
                    labelstringDisplayed name. Defaults to the id.
                    typestringA key of nodeTypes. Undeclared types load with a warning.
                    descriptionstringOne or two sentences, shown in the details panel, the tooltip and the node list.
                    urlstringThe node's website.
                    graphstringAnother graph that details this node, e.g. examples/circl/misp.json for an organisation and its projects: a Pivograph graph or an OCD file, as a URL or a path relative to the app. The details panel and the right-click menu offer Open its graph; a ← button in the header leads back.
                    githubstringGitHub repository: owner/repo, or any github.com URL of the repository (normalized to owner/repo).
                    githubInfoobjectRepository summary saved by the app when a repository is fetched in the node form: fullName, url, description, homepage, stars, forks, issues, language, license, topics, archived, pushedAt, fetchedAt. Kept only together with github. You can fill it yourself from the GitHub API.
                    linksarrayOther links: [{ "label": "Documentation", "url": "https://…" }]. label is optional.
                    tagsarray or string["threat-intelligence", "python"], or "#threat-intelligence #python". Normalized to lowercase, spaces become hyphens, duplicates removed.
                    detailsobjectExtra facts shown in the details panel: strings, numbers, booleans, lists, or nested objects of those. Nested objects are shown as sub-sections. Empty values are dropped.
                    x, ynumberSaved position. Both are needed. Export keeps positions unless you untick positions.

                    Look (each can also be set on a node type):

                    FieldTypeDefaultDescription
                    colorCSS colour#4f7cffFill colour. transparent works, e.g. for logos.
                    shapestringcirclecircle, square, triangle, hexagon, or card: a rounded box with the image and the label inside (see below).
                    sizenumber14Radius in pixels.
                    imagestringURL, path relative to the app (icons/tool.svg, logos/misp.png), or data: URL.
                    imageFitstringcovercover (fills the shape), contain (whole image inside), icon (a glyph on the node's colour), frame (square shape framing the image).
                    borderColorCSS colour#ffffffOutline colour.
                    borderWidthnumber2Outline width; 0 removes it.
                    hideLabelbooleanfalseHide the label, e.g. when the image already shows the name.
                    labelColorCSS colourLabel text colour.
                    labelBackgroundCSS colour or "none"Label background; "none" for no background.
                    labelSizenumberautoLabel font size in pixels.
                    labelFontstringsans, serif, mono, rounded, condensed, or any CSS font-family.
                    hideBadgesbooleanfalseHide the tag pills under the node.

                    Card nodes (shape: "card") draw the box of a diagram: the image (if any), then the label and an optional subtitle, inside the box; edges and arrows end on its border. color is the background (default white), borderColor / borderWidth the border, labelColor / labelSize / labelFont the text (\n starts a new line). size and imageFit don't apply.

                    FieldTypeDefaultDescription
                    subtitlestringSecond line, under the image and label.
                    width, heightnumberits contentBox size in pixels.
                    paddingnumber16Space between the border and the content: raise it to give logos more room.
                    imageSizenumber56Image height in pixels.
                    imagePositionstringtoptop (image above the label) or left (beside it).

                    labelFont also accepts hand: Excalidraw's hand-drawn font (Virgil, bundled).

                    Bundled images: icons/project.svg, platform, tool, data, organization, format, rule, code (white glyphs for imageFit: "icon").

                    edges

                    Each edge is an object with from and to.

                    FieldTypeDescription
                    from, tostring or numberRequired. Node ids. Both nodes must exist.
                    idstringUnique. Defaults to from-to (with a suffix if needed).
                    labelstringHow the two are related: a short verb phrase shown on the edge.
                    typestringA key of edgeTypes.
                    descriptionstringThe mechanism behind the relationship.
                    detailsobjectExtra facts, as for nodes (API endpoints, authentication, sources…).

                    Look (each can also be set on an edge type):

                    FieldTypeDefaultDescription
                    directionstringforwardforward (arrow at to), backward (arrow at from), both, none.
                    colorCSS colour#8a94a6Line and arrow colour.
                    widthnumber2Line width.
                    dashedbooleanfalseDashed line.
                    curvestringautoauto (straight, curved only between nodes with several edges), straight, curved.
                    animatedbooleanfalseMoving dashes along the edge, showing the flow.
                    hideLabelbooleanfalseHide the label.
                    labelColor, labelBackground, labelSize, labelFontAs for nodes.

                    nodeTypes and edgeTypes

                    Objects keyed by type name. A node type accepts label (display name) and every node look field; an edge type accepts label (the default edge label) and every edge look field.

                    json
                    "nodeTypes": {
                      "project": { "label": "Project", "color": "#3b63f3", "shape": "hexagon", "size": 24,
                                   "image": "icons/project.svg", "imageFit": "icon" },
                      "dataset": { "label": "Open data", "color": "#c2860b", "image": "icons/data.svg", "imageFit": "icon" }
                    },
                    "edgeTypes": {
                      "uses":  { "label": "uses", "color": "#0f9d8a" },
                      "feeds": { "label": "feeds", "color": "#c2860b", "animated": true },
                      "sync":  { "label": "syncs with", "direction": "both", "dashed": true }
                    }

                    tags

                    Tags are declared on nodes. The top-level tags object only sets how a tag is drawn; tags without an entry get a colour derived from their name.

                    FieldTypeDescription
                    colorCSS colourPill colour; the text turns black or white for contrast.
                    iconstringA bundled Font Awesome Free icon name.
                    json
                    "tags": {
                      "open-source": { "color": "#2f9e44", "icon": "github" },
                      "threat-intelligence": { "color": "#d6384b", "icon": "shield-halved" }
                    }

                    Available icons: shield-halved, bug, virus, skull, lock, key, fingerprint, triangle-exclamation, circle-info, circle-question, check, xmark, star, flag, tag, heart, fire, bolt, bell, lightbulb, eye, magnifying-glass, database, server, cloud, network-wired, globe, link, code, terminal, gear, wrench, cube, robot, rocket, chart-line, clock, book, file-lines, graduation-cap, user, users, building, handshake, scale-balanced, money-bill, and the brands github, gitlab, python, js, rust, docker, linux, windows, apple, android, aws, google, slack, discord, mastodon.

                    sections

                    Titled frames drawn behind the graph ("Incident response", "Sensors"…), for the picture only: they hold no nodes and edges don't point to them. Add one with Add → Section; drag its title to move it, its bottom-right corner to resize it, double-click its title to edit it (or use the Sections tab). Pair them with meta.fixedLayout so the nodes stay inside. They are part of the PNG export.

                    FieldTypeDefaultDescription
                    x, ynumberRequired. Top-left corner, in graph coordinates (the same as the nodes' x / y).
                    width, heightnumber400, 240Size.
                    titlestringShown in the top-left corner.
                    colorCSS colourgreyTitle colour, and the line under it.
                    underlinebooleanwhen color is setLine under the title.
                    titleSizenumber22Title font size in pixels.
                    titleFontstringAs labelFont.
                    fillCSS colour or "none"light greyBackground; "none" for a bare title (e.g. the diagram's heading).
                    borderColorCSS colour or "none"greyBorder.
                    idstringfrom the titleUnique.
                    json
                    "meta": { "title": "SOC stack", "fixedLayout": true },
                    "sections": [
                      { "title": "Incident response", "x": 20, "y": 115, "width": 750, "height": 420, "color": "#1971c2" },
                      { "title": "Sensors", "x": 20, "y": 880, "width": 1660, "height": 250 }
                    ]

                    arrows

                    Arrows drawn by Pivograph, like sections, for diagrams: unlike an edge, an arrow can start or end on a section or on a free point, and each end can sit anywhere on its target. Add one with Add → Arrow; click it on the graph to select it, then drag its ends (onto a node, a section or empty space) and its label; Delete removes it, a double-click edits it. Arrows are part of the PNG export, not of the report.

                    FieldTypeDefaultDescription
                    from, toobjectRequired. { "node": "id" }, { "section": "id" } or { "x": 0, "y": 0 }. Add "at": [u, v] to place the end on the target's box: [0, 0] top-left, [1, 1] bottom-right, [0.5, 1] the middle of the bottom side. Without at, the end sits on the side facing the other end (straight up or down from a section).
                    labelstring\n starts a new line.
                    descriptionstringShown when the pointer is over the arrow.
                    directionstringforwardAs for edges: forward, backward, both, none.
                    routestringstraightstraight or elbow (right angles).
                    color, width, dashed#343a40, 2, falseLine.
                    labelColor, labelSize, labelFont, labelBackgroundline colour, 14Label; the background defaults to the page colour, "none" for none.
                    labelOffset[dx, dy][0, 0]Shift of the label from the middle of the arrow (set by dragging it).
                    json
                    "arrows": [
                      { "from": { "node": "misp", "at": [0.5, 1] }, "to": { "section": "pipeline" },
                        "direction": "both", "label": "4) Feed detections\n(IoCs) & sightings", "color": "#1c5cff" }
                    ]

                    Validation

                    Loading a document reports:

                    Examples

                    A compact example with the main features:

                    json
                    {
                      "version": 1,
                      "meta": {
                        "title": "Projects linked to Rulezet",
                        "description": "Arrow = who uses whom. Checked against each project's code, September 2026.",
                        "linkDistance": 260
                      },
                      "nodeTypes": {
                        "project": { "label": "Project", "color": "#3b63f3", "shape": "hexagon", "size": 24,
                                     "image": "icons/project.svg", "imageFit": "icon" }
                      },
                      "edgeTypes": {
                        "uses": { "label": "uses", "color": "#0f9d8a", "direction": "forward" }
                      },
                      "tags": { "threat-intelligence": { "color": "#d6384b", "icon": "shield-halved" } },
                      "nodes": [
                        {
                          "id": "rulezet", "label": "Rulezet", "type": "project",
                          "description": "Community platform for sharing and managing detection rules.",
                          "url": "https://rulezet.org", "github": "rulezet/rulezet-core",
                          "tags": ["threat-intelligence", "yara"]
                        },
                        {
                          "id": "vulnerability-lookup", "label": "Vulnerability-Lookup", "type": "project",
                          "description": "Vulnerability lookup and correlation.",
                          "github": "vulnerability-lookup/vulnerability-lookup",
                          "links": [{ "label": "Public instance", "url": "https://vulnerability.circl.lu" }]
                        }
                      ],
                      "edges": [
                        {
                          "from": "rulezet", "to": "vulnerability-lookup", "type": "uses",
                          "label": "fetches CVE & EPSS data",
                          "description": "Rulezet uses Vulnerability-Lookup as its CVE reference: every CVE shown on a rule links to its vulnerability.circl.lu page.",
                          "details": { "Source": "https://github.com/rulezet/rulezet-core" }
                        }
                      ]
                    }

                    Open Contributions Descriptor

                    The Open Contributions Descriptor (OCD) is a JSON file in which an organization describes its open source projects, open data, participation in open standards and relationships with other organizations. It is published at https://<domain>/.well-known/open-contributions.json. OCD Viewer shows it as cards and, through Pivograph, as a graph.

                    Pivograph recognizes an OCD file wherever a JSON file is accepted (Graph → Open a file…, drag and drop, the JSON tab, Graph → Import an organization…, ?src=, postMessage) and turns it into a map:

                    OCDMap
                    organizationThe central Organization node. Its fields (domain, country, links…), the document's contacts, policies, spec_version, generated_at, extensions and any unknown top-level section go to the node's details.
                    projects[]One Project node each, linked from the organization (edge type maintains, no label). name → label, description → description, tags → tags, repository.url → github when it is a GitHub repository. Everything else (status, repository, links, participate, governance, release, custom fields) is kept as-is in details, in its original order. status: archived / disabled also becomes a tag.
                    open_data[]Open data nodes (edge publishes); fields in details.
                    open_standards[]Open standard nodes, one per body (edge participates in, labelled with the contribution types); fields in details.
                    relationships[]External organization or External project nodes, one per target, linked from the organization with the relationship type as label: maintains, co-maintains (both ways), supports, contributes to, sponsors, upstream (arrow towards the organization), downstream, member of, affiliated with. since, until, evidence, contacts and tags go to the edge's details.

                    Because each item keeps its OCD structure in details, the details panel reads section by section like OCD Viewer, and nothing is lost — unknown or future fields included.

                    An imported descriptor opens read-only: no creation, editing or deletion (Pivotick's Create tools, context-menu entries, double-click, the app's buttons and the JSON editor are off). Browsing, filtering and exporting still work. The lock can't be lifted in the app: the graph stays a faithful view of the descriptor. To build your own map around it, start a New graph, or export the JSON and edit it.

                    Graph → Import an organization… accepts a local file, a domain (misp-project.org becomes https://misp-project.org/.well-known/open-contributions.json), or a URL, and offers the official samples (MISP, AIL, flowintel). A remote file must be served with CORS headers; otherwise download it and open it.

                    To go further than the descriptor, enable editing and add what it does not say: relationships between the organization's own projects, logos, tags with colours and icons.

                    Embedding

                    Pivograph can be shown inside another site with an <iframe>, without its top bar, displaying a map you choose.

                    URL parameterEffect
                    embed=1Hides the top bar (logo and menus). Starts empty and read-only. Never reads or overwrites the visitor's saved map.
                    src=<url>Loads this JSON at start: a Pivograph document or an OCD file. The server must allow CORS.
                    example=<name>Opens a bundled example: rulezet, circl or ngsoti, e.g. ?example=ngsoti. Works without embed too: the address follows the example shown, so it can be shared.
                    sidebar=0Hides the side panel.
                    mode=viewerOnly the graph: pan, zoom and drag, no panels (Pivotick's viewer mode). Pair with sidebar=0.
                    theme=light / theme=darkFollow the host page's theme instead of the visitor's system setting.
                    bg=<colour>Background of the page and the graph (#rrggbb or rgb(…)), e.g. the host page's own background.
                    toolbar=1With embed=1: keeps the top bar (Graph and Add menus), for a host page that lets its users edit the map (see pivograph:changed below).
                    tags=1Starts with the tag pills shown on the graph. They are hidden by default; the # Tags button shows them.

                    The map below is the app embedded in this page, loading the example with ?embed=1&sidebar=0&src=…:

                    html
                    <iframe
                      src="https://ecrou-exact.github.io/project-graph/?embed=1&src=https://example.org/.well-known/open-contributions.json"
                      title="Map of our projects"
                      style="width: 100%; height: 80vh; border: 0"
                      allow="fullscreen"></iframe>

                    The host page can also send the data itself, which works for files that have no URL (for example a file the visitor opened):

                    js
                    const frame = document.querySelector('iframe')
                    
                    window.addEventListener('message', (event) => {
                      if (event.source !== frame.contentWindow) return
                      if (event.data?.type === 'pivograph:ready') {
                        frame.contentWindow.postMessage(
                          { type: 'pivograph:load', data: documentOrOcd, name: 'open-contributions.json' },
                          'https://ecrou-exact.github.io',
                        )
                      }
                    })
                    MessageDirectionMeaning
                    { type: 'pivograph:ready' }app → hostThe app is ready to receive data. Sent once at start.
                    { type: 'pivograph:load', data, name }host → appLoad data (a Pivograph document or an OCD file). Only the embedding page can send it.
                    { type: 'pivograph:loaded', nodes, edges }app → hostLoaded, with the number of nodes and edges.
                    { type: 'pivograph:error', message }app → hostThe data was refused; message says why.
                    { type: 'pivograph:changed', data }app → hostAn editable map (loaded with meta.readOnly: false) was changed; data is the whole document, positions included. Never sent for read-only maps.
                    { type: 'pivograph:get' }host → appAsk for the current document.
                    { type: 'pivograph:snapshot' }host → appAsk for a PNG of the graph as drawn.
                    { type: 'pivograph:snapshot', image }app → hostThe answer: a data:image/png URL, or null when the picture could not be made.
                    { type: 'pivograph:export', format }host → appRun one of the app's exports in the frame: json, pivotick, png, md or pdf (the report).
                    { type: 'pivograph:theme', scheme, background }host → appSwitch to light / dark and/or a background colour, live (when the host page changes theme).
                    { type: 'pivograph:document', data }app → hostThe answer: the whole document, positions included.

                    OCD Viewer uses this for its Graph view.

                    Hugo

                    A site built with Hugo can show a graph with the Pivograph component (hugo/pivograph in the repository). Hugo builds the graph's JSON itself, from the site's pages or from a data file, and every graph works with and without JavaScript:

                    Without JavaScriptWith JavaScript
                    PictureThe graph drawn by Hugo as SVG at build time: nodes with their shape, colour, logo and label; edges with their colour, dashes, arrows and labels. A node is a link to the page of its own graph (graph.graph), else to its entry in the text, and shows its name on hover.The interactive graph (the Pivograph app in an iframe) replaces the picture: zoom, drag, search, filters by tag, type and relationship, details panel. If the app can't load, the picture comes back.
                    TextThe graph written out: every node with its links, GitHub facts, tags and details, and every relationship as a sentence (report protocol). Read by search engines and screen readers.The same text, folded under Read the graph as text.
                    Data/pivograph/<name>.json, linked under the graph.The same file, handed to the app.

                    The same page of the example site, both ways:

                    The Rulezet graph drawn by Hugo as SVG: logos, labels, tag pills and coloured arrows, without JavaScript
                    Without JavaScript: the picture Hugo draws at build time, followed by the text. Open it
                    The same graph in the interactive Pivograph app, with its search, filter and view tools
                    With JavaScript: the interactive graph, with search, filters and zoom. Open it

                    The example's source: hugo/example.

                    text
                    hugo/example/
                      hugo.toml                     theme = "pivograph" (a real site: ["my-theme", "pivograph"])
                      content/
                        projects/
                          _index.md                 graph: title, nodeTypes, edgeTypes · {{< pivograph >}}
                          rulezet/
                            index.md                graph: type, url, github, image, relations
                            logo.png
                          misp/
                            index.md
                            logo.png
                          …                         one bundle per project
                        circl/
                          _index.md                 the CIRCL GitHub organisations · {{< pivograph >}}
                          circl/  misp/  …          one bundle per organisation, with its logo and GitHub facts

                    hugo then writes public/pivograph/projects.json and public/pivograph/circl.json, the documents the pages show. Both sections are generated from the app's examples by node scripts/hugo-example.mjs.

                    Add it to a Hugo site

                    1. Copy the component into the site's themes/ folder:

                      bash
                      git clone --depth 1 https://github.com/ecrou-exact/project-graph /tmp/project-graph
                      cp -r /tmp/project-graph/hugo/pivograph themes/pivograph

                      Or as a Hugo Module: [[module.imports]] path = "github.com/ecrou-exact/project-graph/hugo/pivograph" (needs Go).

                    2. Enable it in hugo.toml, after the site's theme. A single theme = "x" becomes a list:

                      toml
                      theme = ["my-theme", "pivograph"]

                      The component adds a shortcode and partials under pivograph/; it doesn't change any existing page, layout or style.

                    3. Give it data: the site's pages (option A) or a data file (option B).

                    4. Place the graph with the shortcode in a Markdown page, or with the partial in a layout:

                      text
                      {{< pivograph >}}
                      go-html-template
                      {{ partial "pivograph/embed.html" (dict "page" . "section" "/projects") }}
                    5. Check: run hugo. It must print no WARN from pivograph. Then open the page: with JavaScript you get the interactive graph; click Without JavaScript in the switch above the graph (it adds ?pivograph=static to the address), or turn JavaScript off, to see the version without JavaScript, the picture drawn by Hugo and the text; Interactive brings the graph back. That choice holds for the browser tab, and the site's links keep it, until ?pivograph=interactive.

                    Requirements: Hugo 0.130 or later, standard or extended edition. Nothing to install with npm, no build step besides Hugo.

                    A graph from the site's pages

                    Use this when the site already has one page per project, team, product… Every page below the section whose front matter has a graph map becomes a node; its relations are its outgoing edges.

                    yaml
                    # content/projects/rulezet/index.md   (a page bundle: logo.png sits next to it)
                    ---
                    title: Rulezet                        # the node's label
                    description: Community platform for sharing and managing detection rules.
                    tags: [cti, yara]                     # the node's tags (the site's own taxonomy)
                    graph:
                      type: project
                      url: https://rulezet.org
                      github: rulezet/rulezet-core
                      image: logo.png
                      relations:
                        - to: misp                        # id of another node: its file or bundle name
                          label: pushes rules as MISP events
                          type: uses
                          description: Admins register MISP servers in Rulezet…
                        - to: vulnerability-lookup
                          label: fetches CVE data from
                    ---

                    The section's _index.md holds the graph's title and its types, and places it:

                    yaml
                    # content/projects/_index.md
                    ---
                    title: Projects linked to Rulezet
                    description: How Rulezet connects to the other projects of its ecosystem.
                    graph:
                      nodeTypes:
                        project: { label: Project, shape: circle, size: 44, imageFit: contain }
                      edgeTypes:
                        uses: { label: uses, color: "#0f9d8a" }
                    ---
                    
                    {{< pivograph >}}

                    Front matter reference

                    WhereKeyBecomesDefault
                    node pagetitlenode label
                    node pagedescriptionnode description
                    node pagetagsnode tags
                    node pagegraph.idnode idthe file name (misp.md) or bundle name (misp/index.md)
                    node pagegraph.label, graph.description, graph.tagsoverride the three above
                    node pagegraph.imagenode image: a page resource (logo.png), a site path (/images/x.png), a URL, or an icon of the app (icons/tool.svg)
                    node pagegraph.graphnode graph: the path of the page that shows this node's own graph (/circl/misp), written as that page's address. Without JavaScript the node links to that page; with JavaScript, Open its graph in the app goes there. The CIRCL example uses it: each organisation's page shows its projects with {{< pivograph data="circl-<id>" >}}.
                    node pagegraph.x, graph.ythe node's position, used by the app and by the picturecomputed
                    node pagegraph.<field>any other node field: type, url, github, links, details, color, shape, size, imageFit, githubInfo…
                    node pagegraph.relations[]edges from this node: to (required) and any edge field: label, type, direction, description, details, color, dashed…
                    section _index.mdtitle, descriptionmeta.title, meta.description
                    section _index.mdgraph.nodeTypes, graph.edgeTypes, graph.tags, graph.linkDistance, graph.metathe same document keys

                    Rules the component applies:

                    A graph from a data file

                    Use this for a graph made in the app. Export → Graph data (JSON) in Pivograph, save the file as data/pivograph/<name>.json (or write it as .yaml / .toml), and show it anywhere:

                    text
                    {{< pivograph data="ecosystem" >}}

                    Export with positions ticked (JSON tab) and the picture drawn without JavaScript keeps the app's layout. Without positions, Hugo lays the graph out itself: the most connected node in the centre when it links to at least half of the others, the rest on rings around it.

                    Parameters

                    The shortcode and the partial take the same parameters.

                    ParameterDefaultEffect
                    sectionthe current sectionBuild the graph from another section: section="/projects".
                    dataUse data/pivograph/<name>.* instead of pages.
                    appparams.pivograph.app, else https://ecrou-exact.github.io/project-graph/The Pivograph app that draws the interactive graph.
                    height85vhHeight of the interactive graph. The graph takes the width of its container: give it the whole width of the page for big graphs (the example site keeps only its text in a narrow column).
                    sidebarfalseShow the app's side panel (node list, tags, types).
                    tagsfalsetrue shows the tag pills, on the interactive graph and in the picture drawn by Hugo.
                    relationsfalsetrue writes the relationships as sentences in the text, under each node and in a Relationships section. By default the text only counts them.
                    picturetruefalse leaves out the picture drawn by Hugo.
                    imageA picture of your own (e.g. Export → Picture in the app) shown instead of Hugo's.
                    level3Heading level of the text's sections.
                    texttruefalse leaves the text out. Not recommended: without JavaScript, only the picture would remain.

                    Site-wide setting, in hugo.toml:

                    toml
                    [params.pivograph]
                      app = "https://ecrou-exact.github.io/project-graph/"

                    To host everything yourself, copy the app's build (npm run build, the dist/ folder) into static/pivograph/ and set app = "/pivograph/".

                    To use the data in your own templates: {{ $doc := partial "pivograph/document.html" (dict "page" . "section" "/projects") }}, then $doc.nodes, $doc.edges… The picture alone: {{ partial "pivograph/svg.html" (dict "doc" $doc) | safeHTML }}.

                    How it works

                    Checklist

                    Prompt for an AI agent

                    text
                    Add a Pivograph graph to this Hugo site, following
                    https://ecrou-exact.github.io/project-graph/docs/guide.html#hugo exactly.
                    
                    1. Copy hugo/pivograph from https://github.com/ecrou-exact/project-graph into
                       themes/pivograph and add "pivograph" after the current theme in hugo.toml.
                    2. For every page of <section> that describes <what the nodes are>, add a
                       `graph` map to its front matter: type, url, github, image when known, and
                       `relations` to the other pages (to: <file or bundle name>, label: a
                       lowercase verb phrase from this page's point of view).
                    3. In <section>/_index.md, declare nodeTypes and edgeTypes under `graph`
                       and put {{< pivograph >}} where the graph should appear.
                    4. Run `hugo`: fix every "pivograph:" warning. Check that the page works with
                       JavaScript on (interactive graph) and off (SVG picture and text).
                    Do not change the site's theme, layouts or other pages.

                    Troubleshooting

                    SymptomCause and fix
                    I can't see the picture drawn by HugoWith JavaScript on, the interactive graph replaces it. Open the page with ?pivograph=static, or turn JavaScript off, to see it.
                    can't evaluate field Pi, math.Sin not definedHugo is older than 0.130. Update Hugo (distribution packages are often older; use a release).
                    pivograph: … relation to "x" ignoredNo page has the graph id x. Use the target's file or bundle name, or set graph.id on it.
                    The interactive graph stays emptyThe page's Content Security Policy blocks the iframe or the fetch: allow the app's origin in frame-src, and connect-src 'self', img-src 'self' data:.
                    Logos missing in the picturegraph.image must be a page resource (next to index.md), a path under static/ starting with /, or a full URL.
                    The picture is crowdedLay the graph out in the app, export it with positions, and use it as a data file (or copy the x, y into graph).

                    Reports

                    Export offers five formats:

                    Menu entryFileContent
                    Graph data (JSON)<title>.jsonThe document, to open again or edit later.
                    Pivotick data (JSON)<title>.pivotick.jsonThe graph for Pivotick alone, without Pivograph: nodes and edges with their computed styles, positions and embedded images, plus the options they need.
                    Report (PDF)via the print dialogA picture of the graph, then a text written from every field. Choose Save as PDF.
                    Report (Markdown)<title>.mdThe same report as Markdown, with the picture embedded.
                    Picture (PNG)<title>.pngThe whole graph as drawn, whatever the zoom.

                    Pivotick data. The file is self-contained and loads in any page that has Pivotick:

                    js
                    const file = await (await fetch('map.pivotick.json')).json()
                    new Pivotick(container, file, file.options)

                    The file keeps everything Pivotick draws: colours, shapes, sizes, images, borders, line styles, arrows, edge labels. It does not keep what Pivograph draws on top of Pivotick: the tag pills, and the colour, background and font of node and edge labels. Pivotick shows those labels in its default style.

                    The picture

                    The graph is drawn as it looks in the app, with images, labels and tag pills included. The picture covers the whole graph, not just the visible part, at twice the screen resolution, and is at most 4000 pixels on its longest side. Images from another site are left out unless that site allows cross-origin requests.

                    The report protocol

                    The content is decided by reportModel() in src/report.js, and laid out by buildReport() (Markdown) and reportHtml() in src/reportPrint.js (PDF). The rules are fixed, so any graph gives a complete report and the same document always gives the same text:

                    1. Title from meta.title (default Untitled graph), then the picture, then meta.description.
                    2. Overview. The number of nodes, by type; the number of relationships, by edge type when there are several; the three most connected nodes (two relationships or more); the nodes without any relationship; the most used tags.
                    3. Nodes, grouped by type in their order of first appearance, with untyped nodes last under Other. For each node:
                      • "Label is a type." followed by its description;
                      • website, GitHub repository (with the saved githubInfo: description, stars, forks, open issues, language, licence, last update, topics not already in the tags, and the date of the data), other links, tags;
                      • details as a nested list, with keys shown with _ replaced by spaces and URLs as links;
                      • every relationship of the node, as a sentence.
                    4. Relationships, grouped by edge type. A relationship that has a description or details gets its own entry with them. The others are merged when only their object differs: "MISP Project maintains MISP, PyMISP and misp-modules."
                    5. Tags: every tag, with its nodes.
                    6. A footer with the date and the counts.

                    How a relationship becomes a sentence. The subject is the node the arrow starts from. With direction set to forward (the default) or none, that is the source (from); with backward, it is the target. The label is the edge's own label, else its type's label, else its type id with _ and - turned into spaces. A final parenthesis in the label is moved to the end of the sentence. The label is then read by its words:

                    LabelKindSentence
                    uses, depends on, draws graphs withverb, alone or ending with a prepositionA uses B.
                    pushes rules as MISP eventsverb with its own objectA pushes rules as MISP events (→ B).
                    member of, affiliated with, used bystate (ends with a preposition, or starts with a participle)A is member of B.
                    API, upstreamnounA is linked to B (API).
                    none—A is linked to B.

                    A word counts as a verb when it ends in s (uses, pushes), or when it is one of is, are, was, has, have, can, will, may, must, does, use. Verbs joined by or / and read as one verb (manages or co-manages: A manages or co-manages B). A label that starts with a capital letter is read as a noun. With direction: "both", the sentence ends with "in both directions". An edge from a node to itself reads "… itself".

                    Writing labels that read well. Use a lowercase verb phrase from the source's point of view: uses, depends on, fetches data from, is funded by. The same label then reads well on the graph and in the report.

                    The PDF layout

                    The PDF and the Markdown file say the same thing; the PDF lays it out with the graph's own colours:

                    The page has no margin of its own, so the browser prints no header or footer (address, date) over the report. Use Save as PDF with the default options; keep Background graphics on if the browser offers it.

                    From the command line

                    bash
                    npm run report -- examples/rulezet.json report.md
                    npm run report -- open-contributions.json report.md --image graph.png

                    The command runs the same protocol on a Pivograph document or an Open Contributions Descriptor. Making the picture needs a browser, so the command can only link one you pass with --image.

                    Using the app

                    Top bar. Search the docs finds a section of this guide as you type (↑ ↓ and Enter to open it). Add creates a node or an edge. Graph starts a new graph, opens a file (a Pivograph document or an Open Contributions Descriptor), imports an organization from its domain, or loads the Rulezet example. Export saves the graph (see Reports).

                    Side panel (left, resizable by dragging its edge):

                    Editing a node (double-click, right-click → Edit node, or the Create tools on the canvas) opens a form in tabs:

                    TabFields
                    ContentLabel, id, type, description
                    LinksWebsite, GitHub repository, other links
                    DetailsExtra fields, edited by path (repository.url)
                    AppearanceColour, shape, size, image, image fit
                    BorderWidth (0 = none), colour
                    LabelShown or hidden, size, font, text colour, background
                    BadgesTags (existing tags are suggested), show or hide the tag pills

                    Editing an edge (double-click, or right-click → Edit Edge) opens a form with a live preview: Content (source, target, swap, how they are related, type, arrow direction), Line (colour, width, solid or dashed, shape, moving dashes), Label and Details. Drawing a new edge on the canvas (Create → Add edge, then click the source and the target) opens the same form.

                    GitHub details. Entering a repository in a node's Links tab fetches its description, stars, forks, open issues, language, licence, topics and last update from the GitHub API, and saves them with the node (githubInfo). Refresh from GitHub fetches them again; Also add the repository topics as tags copies the topics into the node's tags. Nothing else calls the API: showing or importing a map never does, which keeps within the limit of 60 unauthenticated requests per hour.

                    Filtering. Pivotick's Filter Graph panel filters by tags (every tag is offered), type, label, description and, for OCD projects, status and license; relationship types can be switched on and off. Filters set from the side panel and from Filter Graph are the same; active filters are listed at the top of the side panel with Clear.

                    View. Tags on graph shows or hides every tag pill. Pivotick's View and Physics tools change the layout; the minimap and zoom controls sit on the right.

                    Saving. A map you create is kept automatically in your browser only (local storage): nothing is sent to GitHub or to any server, and other visitors never see it. The Rulezet example and imported descriptors are locked: they can be explored and exported, not edited. Export → Graph data (JSON) downloads it (with positions, unless positions is unticked in the JSON tab). The other Export entries are described in Reports.

                    Architecture

                    Pivograph is a static web app built with Vite, in plain JavaScript modules, with no server.

                    text
                    src/
                      main.js        app shell: state, header menus, side panel, import/export, embedding, autosave
                      graph.js       GraphView: the Pivotick instance, styles, forms wired to Pivotick's tools, filters, tag pills
                      model.js       the document format: parsing and validation, type inheritance, Pivotick styles
                      ocd.js         Open Contributions Descriptor → document
                      icons.js       node icons drawn on the node's colour
                      badgeIcons.js  the bundled Font Awesome Free icons
                      github.js      GitHub API client (cached, only used by the node form)
                      report.js      the report protocol: document → English Markdown (pure, also used by scripts/report.mjs)
                      reportPrint.js the report laid out for print (PDF)
                      snapshot.js    the graph as a PNG picture
                      ui/            forms, modals, tag pills, GitHub card, details rendering, DOM helper
                    examples/        example maps
                    hugo/            the Hugo component (pivograph/: shortcode, partials that build the JSON,
                                     draw the SVG and write the text; script) and its example site (example/)
                    schema/          JSON schema of the format
                    docs/            this documentation (Markdown, built into docs/index.html and llms-full.txt)
                    tests/           Vitest tests

                    Data flow. A JSON file goes through parseDocument() (model.js), which validates it and returns a normalized document with errors and warnings. GraphView.load() turns its nodes and edges into Pivotick elements (toRawNode, toRawEdge); from then on Pivotick holds the nodes and edges, and the app holds the document-level state (meta, nodeTypes, edgeTypes, tags). view.toDocument() reads them back for export and autosave.

                    Styles are derived, never stored. Every visual field lives in a node's or edge's data. nodeStyle() and edgeStyle() compute the Pivotick style from the data and the types, so an edit made anywhere — a form, Pivotick's own tools, a type change — restyles the element.

                    Edits from the app's forms go through GraphView.updateNode() / updateEdge() (Pivotick's updateData()). Pivotick's own create and edit tools are routed to the same forms through its callbacks (onBeforeNodeCreate, onBeforeEdgeCreate, onNodeEdit, onEdgeEdit).

                    Things drawn outside Pivotick's styles:

                    Pivotick 2.0.1 notes, worked around in the code:

                    Development

                    bash
                    npm run dev              # dev server with hot reload: the app, the docs at /docs/, the Hugo example at /hugo-example/ (needs hugo)
                    npm test                 # Vitest: document format, OCD import, icons, reports, and the Hugo component when hugo is installed (or HUGO=/path/to/hugo)
                    npm run report -- map.json report.md   # the text report from the command line
                    npm run hugo:example     # the Hugo example site (needs hugo)
                    npm run build            # static site in dist/: the app, docs/, llms.txt, llms-full.txt, hugo-example/ (needs hugo)
                    npm run update:pivotick  # install the latest Pivotick

                    Every push to main installs Hugo, runs the tests, builds the app, the documentation and the Hugo example site (published at /hugo-example/), and deploys to GitHub Pages. A weekly workflow installs the latest Pivotick, runs the tests and the build, and opens a pull request if the version changed.

                    This documentation is written in docs/content/*.md. The build also writes docs/search.json, one entry per section, which the search box (docs/search.js, on the docs pages and in the app's top bar; / focuses it in the docs) searches in the browser.

                    The build turns the Markdown into docs/index.html (static HTML, readable without JavaScript) and llms-full.txt, and publishes the schema and the examples next to it.

                    For AI agents

                    FileContent
                    /llms.txtShort index of this documentation, in the llms.txt format.
                    /llms-full.txtThis whole page as Markdown. Read it before generating a map.
                    pivograph.schema.jsonJSON schema of the document format.
                    examples/rulezet.jsonA complete, checked map.
                    examples/circl.jsonAn organisation and the 21 GitHub organisations it manages, with logos and GitHub facts.
                    examples/ngsoti-soc-stack.jsonA drawn diagram: the NGSOTI SOC stack with Rulezet, in card nodes, sections and arrows, with a fixed layout.
                    examples/minimal.jsonThe smallest useful map.
                    HugoTo add a graph to a Hugo site: procedure, front-matter reference, checklist and prompt.

                    To produce a map, follow Mapping an organization, write the document per Document format, and check it against the validation rules. Output only the JSON document.