Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Semantic Visualizer

A React + Vite bundle that draws a semantic model (concepts, properties, metrics, groups and the
relationships between them) with React Flow. Built into a single `dist/assets/index.js` and
`dist/assets/index.css` for embedding.

```bash
npm ci
npm run dev # http://localhost:5173 renders public/sample.json
# http://localhost:5173/?sample=review renders public/sample-review.json with the diff UI
npm run build # dist/assets/index.{js,css}
```

## Mounting

### Auto-mount (container attributes)

Every `.semantic-visualizer` element with a `data-json-url` is mounted on `DOMContentLoaded` and on
each `htmx:load`.

| Attribute | Default | Meaning |
|---|---|---|
| `data-json-url` | required | URL returning the graph JSON below (fetched with `credentials: same-origin`) |
| `data-height` | `400px` | CSS height applied to the container |
| `data-layout` | `force` | `force` (d3-force) or `tree` (hierarchy, groups off) |
| `data-show-minimap` | `false` | `true` shows the React Flow minimap |
| `data-locale` | detected | `en` or `de`; otherwise resolved from `?lang`, localStorage, navigator |
| `data-changes-only` | `false` | `true` opens on the elements that carry a diff (no effect when nothing does) |

### `init(options)`

For a host that has more to supply than a URL. Exported from the bundle:

```js
import { init } from '/assets/semantic-visualizer/index.js';

const handle = init({
container: '#graph', // selector or element
graphData, // { nodes, edges } — see the contract below
height: '340px',
locale: 'en',
layout: 'force', // or 'tree'
changesOnly: true, // open on what carries a diff
focus: 'customer', // externalId to centre and select on mount
onSelect: (externalId, node) => true, // return true to claim the click (see below)
storageKey: 'my-key', // optional; localStorage key for positions and toggles
showMiniMap: false,
});

handle.update({ graphData: nextGraph, focus: 'order', changesOnly: false });
```

`onSelect` lets the host own what a click means. Returning `true` claims the click and withholds
the built-in detail panel for that element (the host is showing it itself); any other return hands
the click back and the panel opens. The element passed as `focus` is treated as claimed on mount.

`update(next)` merges `graphData`, `focus`, `changesOnly` and `onSelect` into the stored options and
re-renders. `container`, `height`, `locale`, `layout`, `storageKey` and `showMiniMap` are fixed at
mount. `init` returns `null` when the container is not found.

## Graph JSON contract

```json
{ "nodes": [ ... ], "edges": [ ... ] }
```

### Node

| Key | Type | Meaning |
|---|---|---|
| `id` | string | Unique within the graph; edges reference it |
| `type` | string | `entity`, `metric`, `property`, `shared_property`, `group` |
| `parentId` | string? | `id` of the enclosing group |
| `data` | object | See below |

`data`:

| Key | Type | Meaning |
|---|---|---|
| `label` | string | Display name |
| `externalId` | string? | Stable id; what `focus`, `onSelect` and the review correlate on |
| `link` | string? | Href for the label and the "open details" link in the panel |
| `description` | string? | Shown in the detail panel |
| `foreignNamespace` | string? | Set when the concept lives in another namespace; badged, and hidden past a threshold |
| `highlight` | boolean? | Search hit; also switches on the entity-relationship view when a property matches |
| `searchMatch` | boolean? | `false` marks a 1-hop neighbour included only as context |
| `properties` | Property[] | Rendered as rows in the entity-relationship view and the panel |
| `diff` | `add` \| `modify` \| `remove`? | What a pending change request does to this element; ring and badge on the node |
| `diffDetail` | DiffDetail? | What changed, for the panel |
| `changedPropertyCount` | number? | How many of its properties carry a diff when the concept itself does not; keeps it in the changes-only view |
| `evidence` | Evidence[]? | What the change cites |
| `evidenceMissing` | boolean? | Changed but cites nothing; marked on the node and in the panel |
| `overlaps` | Overlap[]? | Other pending change requests editing this element |
| `consumers` | Consumers? | What breaks if a removal goes ahead |
| `unresolved` | boolean? | Named by a relationship but present nowhere; drawn dashed and red |
| `removedBy` | `{ link }`? | With `unresolved`: the change request that removed it |
| `dimmed` | boolean? | Rendered faded. Set by the visualizer for context nodes; accepted as input too |

### Property (`data.properties[]`)

| Key | Type | Meaning |
|---|---|---|
| `name` | string | |
| `externalId` | string? | Stable id |
| `type` | string? | Data type, shown beside the name |
| `description` | string? | |
| `primaryKey` | boolean? | Key icon |
| `inherited` | boolean? | Listed separately, italic |
| `shared` | boolean? | Carried by several concepts; badged in the panel |
| `highlight` | boolean? | Search hit; the row is accented and auto-expanded |
| `diff` | `add` \| `modify` \| `remove`? | Rail, badge and strike-through on the row |
| `diffDetail` | DiffDetail? | Its `fields` render on the row in the panel |

### Edge

| Key | Type | Meaning |
|---|---|---|
| `id` | string | |
| `source`, `target` | string | Node ids |
| `label` | string | Drawn on the edge |
| `type` | string | Relationship type, e.g. `hasProperty`, `relatedTo`, `isA` |
| `externalId` | string? | Stable id |
| `diff` | `add` \| `modify` \| `remove`? | Coloured stroke; `remove` is dashed. Both ends count as changed for the changes-only view |
| `diffDetail` | DiffDetail? | Shown when the edge is clicked |
| `evidence`, `evidenceMissing` | | As on nodes |

### DiffDetail

```json
{
"op": "add | modify | remove",
"impact": "structural | descriptive | cosmetic",
"fields": [
{ "field": "description", "before": "…", "after": "…", "base": "…", "impact": "descriptive" }
]
}
```

`before`/`after` are rendered by shape: a boolean, a value from a small vocabulary (`status`, `kind`,
`element_type`, `better_when`) or a code-like field (`pattern`, `formula`, `iri`, `data_type`,
`extends`, `unit`) as a chip transition; a list as a set difference; a map (e.g. translations keyed
by locale) key by key; anything else as side-by-side prose. `base` is optional and, when present,
adds a third column showing the value the author wrote against (a conflict). Any value may arrive
decorated as `{ "display": "badge", "label", "color", "icon", "href" }` and is drawn as a badge;
`color` is a palette token (`red`, `orange`, `amber`, `yellow`, `green`, `teal`, `blue`, `indigo`,
`purple`, `pink`, `gray`), `icon` inline SVG, `href` a link back to the thing named.

### Evidence, Overlap, Consumers

```json
"evidence": [ { "quote": "…", "label": "docs/glossary.md", "resolvable": true } ],
"overlaps": [ { "changeRequestExternalId": "cr-17", "link": "/…", "teamName": "Fulfillment" } ],
"consumers": {
"total": 2,
"relationships": [ { "label": "reports", "namespace": "reporting", "link": "/…" } ],
"dataProducts": [ { "label": "Finance KPIs", "link": "/…" } ],
"dataContracts": [ { "label": "…", "link": "/…" } ]
}
```

`public/sample-review.json` exercises every key above.
10 changes: 10 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@
<div class="semantic-visualizer"
data-json-url="/sample.json"
data-height="600px"></div>
<script>
// `?sample=review` swaps in the branch-diff fixture, opened on the changes, so the diff UI can be
// seen on `npm run dev`. Runs before the module below, which reads the attributes on mount.
const sample = new URLSearchParams(location.search).get('sample');
if (sample) {
const container = document.querySelector('.semantic-visualizer');
container.dataset.jsonUrl = `/sample-${sample}.json`;
container.dataset.changesOnly = 'true';
}
</script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
116 changes: 116 additions & 0 deletions public/sample-review.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
{
"nodes": [
{
"id": "1",
"type": "entity",
"data": {
"label": "Customer",
"externalId": "customer",
"link": "#",
"changedPropertyCount": 2,
"evidence": [
{ "quote": "Customers are segmented into loyalty tiers based on rolling 12-month spend.", "label": "docs/glossary.md" }
],
"properties": [
{ "name": "customer_id", "externalId": "customer-id", "type": "string", "primaryKey": true },
{ "name": "email", "externalId": "customer-email", "type": "string", "shared": true },
{
"name": "loyalty_tier",
"externalId": "loyalty-tier",
"type": "string",
"diff": "modify",
"diffDetail": {
"op": "modify",
"impact": "descriptive",
"fields": [
{ "field": "description", "before": "Loyalty tier.", "after": "Loyalty program segment assigned based on rolling 12-month spend.", "impact": "descriptive" },
{ "field": "status", "before": "draft", "after": "active", "impact": "cosmetic" }
]
}
},
{ "name": "fax_number", "externalId": "fax-number", "type": "string", "diff": "remove", "diffDetail": { "op": "remove", "impact": "structural", "fields": [] } }
]
}
},
{
"id": "2",
"type": "entity",
"data": {
"label": "Order",
"externalId": "order",
"link": "#",
"overlaps": [ { "changeRequestExternalId": "cr-17", "link": "#", "teamName": "Fulfillment" } ],
"properties": [
{ "name": "order_id", "externalId": "order-id", "type": "string", "primaryKey": true },
{ "name": "order_date", "externalId": "order-date", "type": "date" }
]
}
},
{
"id": "3",
"type": "entity",
"data": {
"label": "Invoice",
"externalId": "invoice",
"link": "#",
"diff": "add",
"evidenceMissing": true,
"diffDetail": {
"op": "add",
"impact": "structural",
"fields": [
{ "field": "description", "before": null, "after": "A request for payment issued for one or more orders.", "impact": "descriptive" },
{ "field": "classification", "before": null, "after": { "display": "badge", "label": "Confidential", "color": "red" }, "impact": "structural" }
]
},
"properties": [
{ "name": "invoice_id", "externalId": "invoice-id", "type": "string", "primaryKey": true, "diff": "add" },
{ "name": "total", "externalId": "invoice-total", "type": "decimal", "diff": "add" }
]
}
},
{
"id": "4",
"type": "metric",
"data": {
"label": "Revenue",
"externalId": "revenue",
"link": "#",
"diff": "remove",
"diffDetail": { "op": "remove", "impact": "structural", "fields": [] },
"consumers": {
"total": 2,
"dataProducts": [ { "label": "Finance KPIs", "link": "#" } ],
"relationships": [ { "label": "reports", "namespace": "reporting" } ]
},
"properties": [ { "name": "amount", "externalId": "revenue-amount", "type": "decimal" } ]
}
},
{
"id": "5",
"type": "entity",
"data": {
"label": "Product",
"externalId": "product",
"link": "#",
"foreignNamespace": "catalog",
"properties": [ { "name": "product_id", "externalId": "product-id", "type": "string", "primaryKey": true } ]
}
},
{
"id": "6",
"type": "entity",
"data": { "label": "Warehouse", "externalId": "warehouse", "unresolved": true, "removedBy": { "link": "#" }, "properties": [] }
}
],
"edges": [
{ "id": "e1", "source": "1", "target": "2", "label": "places", "type": "relatedTo" },
{ "id": "e2", "source": "2", "target": "4", "label": "measures", "type": "relatedTo", "diff": "remove", "diffDetail": { "op": "remove", "impact": "structural", "fields": [] } },
{ "id": "e3", "source": "2", "target": "5", "label": "orderedProduct", "type": "relatedTo" },
{
"id": "e4", "source": "3", "target": "2", "label": "billedFor", "type": "relatedTo", "diff": "add",
"diffDetail": { "op": "add", "impact": "structural", "fields": [ { "field": "description", "before": null, "after": "The orders an invoice settles.", "impact": "descriptive" } ] }
},
{ "id": "e5", "source": "2", "target": "6", "label": "shipsFrom", "type": "relatedTo", "diff": "add", "diffDetail": { "op": "add", "impact": "structural", "fields": [] } }
]
}
Loading