nnet-svg 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/layout.d.ts +8 -0
- package/dist/layout.js +150 -0
- package/dist/renderSvg.d.ts +11 -0
- package/dist/renderSvg.js +313 -0
- package/dist/types.d.ts +164 -0
- package/dist/types.js +2 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +108 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 David Kirkby
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# nnet-svg
|
|
2
|
+
|
|
3
|
+
[](https://github.com/dkirkby/nnet-svg/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/nnet-svg)
|
|
5
|
+
|
|
6
|
+
Render a dense feed-forward neural network as a static, accessible, inspectable SVG.
|
|
7
|
+
|
|
8
|
+
- **Framework-independent**: returns a native `SVGSVGElement`; works in vanilla JS,
|
|
9
|
+
Observable notebooks, or wrapped by React/Vue/etc.
|
|
10
|
+
- **Automatic layout**: spacing, node radii, and stroke widths derived from the
|
|
11
|
+
viewBox; large layers truncate around an ellipsis glyph.
|
|
12
|
+
- **Data-driven styling**: attach numeric values to nodes/edges once, then style,
|
|
13
|
+
label, and tooltip them through callbacks.
|
|
14
|
+
- **Deterministic and testable**: predictable classes and `data-*` attributes,
|
|
15
|
+
and a pure layout API with no DOM dependency.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npm install nnet-svg
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import { createDenseNetworkSvg } from "nnet-svg";
|
|
27
|
+
|
|
28
|
+
const svg = createDenseNetworkSvg({
|
|
29
|
+
layers: [4, 16, 8, 2],
|
|
30
|
+
maxDisplayedNodes: 8,
|
|
31
|
+
});
|
|
32
|
+
document.body.append(svg);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Observable
|
|
36
|
+
|
|
37
|
+
In a classic notebook cell, import from a CDN:
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
nn = import("https://esm.sh/nnet-svg@0.1")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
nn.createDenseNetworkSvg({ layers: [4, 16, 8, 2], maxDisplayedNodes: 8 })
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
In Observable Framework or Notebook Kit:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
import { createDenseNetworkSvg } from "npm:nnet-svg";
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Plain HTML
|
|
54
|
+
|
|
55
|
+
```html
|
|
56
|
+
<script type="importmap">
|
|
57
|
+
{ "imports": { "nnet-svg": "https://esm.sh/nnet-svg@0.1" } }
|
|
58
|
+
</script>
|
|
59
|
+
<script type="module">
|
|
60
|
+
import { createDenseNetworkSvg } from "nnet-svg";
|
|
61
|
+
document.body.append(createDenseNetworkSvg({ layers: [3, 8, 4, 1] }));
|
|
62
|
+
</script>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## API
|
|
66
|
+
|
|
67
|
+
### `createDenseNetworkSvg(options): SVGSVGElement`
|
|
68
|
+
|
|
69
|
+
Creates and returns a new SVG element displaying the network. The primary API.
|
|
70
|
+
|
|
71
|
+
### `renderDenseNetworkSvg(container, options): SVGSVGElement`
|
|
72
|
+
|
|
73
|
+
Convenience helper: creates the SVG, replaces `container`'s contents with it,
|
|
74
|
+
and returns it.
|
|
75
|
+
|
|
76
|
+
### `layoutDenseNetwork(options): DenseNetworkLayout`
|
|
77
|
+
|
|
78
|
+
**Advanced.** Computes node/ellipsis placement and edge geometry without
|
|
79
|
+
creating any DOM — useful for testing, debugging, or alternative renderers.
|
|
80
|
+
Accepts the same options object as the SVG APIs (rendering-only options are
|
|
81
|
+
ignored) and returns `{ viewBox, orientation, items, edges, nodeRadius,
|
|
82
|
+
ellipsisDotRadius, nodeStrokeWidth, edgeLabelPos }`, where `items` mixes
|
|
83
|
+
`kind: "node"` and `kind: "ellipsis"` entries in drawing order.
|
|
84
|
+
|
|
85
|
+
## Options
|
|
86
|
+
|
|
87
|
+
### Network and layout
|
|
88
|
+
|
|
89
|
+
| Option | Type | Default | Description |
|
|
90
|
+
| --- | --- | --- | --- |
|
|
91
|
+
| `layers` | `number[]` | required | Layer sizes, e.g. `[3, 8, 4, 1]`; at least 2 positive integers. |
|
|
92
|
+
| `maxDisplayedNodes` | `number` | `12` | Even integer ≥ 2. Larger layers are truncated around a central ellipsis; displayed nodes keep their true indices. |
|
|
93
|
+
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Layer flow direction. |
|
|
94
|
+
| `viewBox` | `{ width, height }` | `640×360` (horizontal), `360×640` (vertical) | Internal coordinate system for layout, radii, and font sizes. |
|
|
95
|
+
| `nodeSpread` | `number` | `0` | Node-stack spacing shape, ≥ −1. `-1` pins outer nodes to the padded axis ends; `+1` makes edge insets equal inter-node gaps. |
|
|
96
|
+
| `layerSpread` | `number` | `0` | Same, for layer placement. |
|
|
97
|
+
| `nodeRadius` | `number` | ⅛ of smallest node gap | Node circle radius. |
|
|
98
|
+
| `ellipsisDotRadius` | `number` | `nodeRadius / 4` | Radius of the three ellipsis dots. |
|
|
99
|
+
| `nodeStrokeWidth` | `number` | `max(nodeRadius / 8, 0.75)` | Node circle stroke width; `0` disables the stroke. |
|
|
100
|
+
| `edgeLabelPos` | `number` | `0.4` | Fractional position of edge labels along the edge from source (0) to target (1). `0.5` is the exact midpoint, where symmetric labels can overlap. |
|
|
101
|
+
|
|
102
|
+
### Rendering
|
|
103
|
+
|
|
104
|
+
| Option | Type | Default | Description |
|
|
105
|
+
| --- | --- | --- | --- |
|
|
106
|
+
| `responsive` | `boolean` | `true` | Scale to container width (`width="100%"`, no fixed height). When `false`, fixed pixel `width`/`height` from the viewBox. |
|
|
107
|
+
| `classPrefix` | `string` | `"dn"` | Prefix for generated CSS classes (`dn-root`, `dn-node`, `dn-edge`, …). |
|
|
108
|
+
| `idPrefix` | `string` | generated | Prefix for the title/desc ids. **If you provide it, you are responsible for uniqueness within the page.** |
|
|
109
|
+
| `strict` | `boolean` | `false` | When `true`, callback errors and invalid callback results throw; otherwise they log to `console.error` and rendering continues. |
|
|
110
|
+
| `title` | `string` | — | Accessible top-level SVG `<title>`. |
|
|
111
|
+
| `desc` | `string` | generated summary | Accessible top-level SVG `<desc>`. |
|
|
112
|
+
| `svgAttrs` | `object` | — | Extra attributes merged onto the root `<svg>`. `class` is appended to the generated class; `viewBox` is **ignored** (it is controlled by `options.viewBox` only). |
|
|
113
|
+
|
|
114
|
+
### Callbacks
|
|
115
|
+
|
|
116
|
+
All callbacks receive semantic datum objects with **true original node indices**
|
|
117
|
+
(never display-slot indices or coordinates). Returning `null`/`undefined` means
|
|
118
|
+
"nothing" everywhere.
|
|
119
|
+
|
|
120
|
+
| Option | Signature | Purpose |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `nodeValue` / `edgeValue` | `(ref) => number \| null \| undefined` | Attach a numeric value. Called **exactly once** per visible node/edge; results are cached and exposed as `datum.value` to all callbacks below. `NaN` and `±Infinity` are preserved; non-numbers are logged and ignored (throw when `strict`). |
|
|
123
|
+
| `nodeAttrs` / `edgeAttrs` | `(datum) => attrs` | SVG attributes merged over the defaults (callback wins; `class` is appended). |
|
|
124
|
+
| `nodeLabel` / `edgeLabel` | `(datum) => string \| number \| null` | Visible label text, converted with `String(...)`. Node labels center in the circle; edge labels sit at `edgeLabelPos` along the edge. |
|
|
125
|
+
| `nodeLabelAttrs` / `edgeLabelAttrs` | `(datum) => attrs` | Attributes for the label `<text>` elements (only called when a label rendered). |
|
|
126
|
+
| `nodeTitle` / `edgeTitle` | `(datum) => string \| null` | Native browser tooltip via a `<title>` child. |
|
|
127
|
+
|
|
128
|
+
### Example
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
const svg = createDenseNetworkSvg({
|
|
132
|
+
layers: [3, 16, 8, 2],
|
|
133
|
+
maxDisplayedNodes: 6,
|
|
134
|
+
title: "Dense neural network",
|
|
135
|
+
nodeValue: (node) => activations[node.layerIndex]?.[node.nodeIndex],
|
|
136
|
+
nodeLabel: (node) => (node.value === undefined ? null : node.value.toFixed(2)),
|
|
137
|
+
nodeAttrs: (node) =>
|
|
138
|
+
node.value === undefined ? null : { fill: node.value > 0 ? "white" : "#eee" },
|
|
139
|
+
edgeValue: (edge) =>
|
|
140
|
+
weights[edge.sourceLayerIndex]?.[edge.sourceNodeIndex]?.[edge.targetNodeIndex],
|
|
141
|
+
edgeAttrs: (edge) =>
|
|
142
|
+
edge.value === undefined
|
|
143
|
+
? null
|
|
144
|
+
: { "stroke-width": Math.min(4, 1 + Math.abs(edge.value)) },
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Generated structure
|
|
149
|
+
|
|
150
|
+
The SVG is grouped in drawing order — edges, then nodes/ellipses, then labels —
|
|
151
|
+
so nodes always sit above edges and labels above both:
|
|
152
|
+
|
|
153
|
+
```html
|
|
154
|
+
<svg class="dn-root" role="img" aria-labelledby="…">
|
|
155
|
+
<title>…</title> <!-- when the title option is given -->
|
|
156
|
+
<desc>…</desc> <!-- always: user-provided or generated -->
|
|
157
|
+
<g class="dn-edges">…</g>
|
|
158
|
+
<g class="dn-items">…</g>
|
|
159
|
+
<g class="dn-labels">…</g>
|
|
160
|
+
</svg>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Every element carries deterministic `data-*` attributes with true indices
|
|
164
|
+
(`data-layer-index`, `data-node-index`, `data-edge-index`,
|
|
165
|
+
`data-omitted-count`, …) for inspection, testing, and post-processing. CSS
|
|
166
|
+
classes are secondary metadata; styling is intended to flow through the attr
|
|
167
|
+
callbacks.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
npm install
|
|
173
|
+
npm test # vitest (layout unit tests + jsdom DOM tests)
|
|
174
|
+
npm run typecheck # tsc --noEmit
|
|
175
|
+
npm run build # emits ESM + .d.ts to dist/
|
|
176
|
+
npm run demo # then open http://localhost:3000/demo/ (build first)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
See [DESIGN_SPEC.md](DESIGN_SPEC.md) for the full specification and
|
|
180
|
+
[RELEASING.md](RELEASING.md) for the release process.
|
|
181
|
+
|
|
182
|
+
## License
|
|
183
|
+
|
|
184
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/layout.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DenseNetworkLayout, DenseNetworkLayoutOptions } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_MAX_DISPLAYED_NODES = 12;
|
|
3
|
+
/**
|
|
4
|
+
* Computes node/ellipsis placement and edge geometry without creating any
|
|
5
|
+
* DOM elements. Accepts the full SVG options object; rendering-only options
|
|
6
|
+
* are ignored (spec §3.3).
|
|
7
|
+
*/
|
|
8
|
+
export declare function layoutDenseNetwork(options: DenseNetworkLayoutOptions): DenseNetworkLayout;
|
package/dist/layout.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Layout computation (DESIGN_SPEC.md §8, §9, §12).
|
|
2
|
+
// This module must stay pure: no DOM access and no d3 imports.
|
|
3
|
+
import { validateLayoutOptions } from "./validate.js";
|
|
4
|
+
export const DEFAULT_MAX_DISPLAYED_NODES = 12;
|
|
5
|
+
// Minimum default node stroke width in viewBox units, so strokes stay
|
|
6
|
+
// visible for small nodes (spec §8).
|
|
7
|
+
const MIN_NODE_STROKE_WIDTH = 0.75;
|
|
8
|
+
// Default fractional edge label position (spec §16): slightly off the
|
|
9
|
+
// midpoint, where symmetric edge labels would overlap.
|
|
10
|
+
const DEFAULT_EDGE_LABEL_POS = 0.4;
|
|
11
|
+
const DEFAULT_VIEWBOX = {
|
|
12
|
+
horizontal: { width: 640, height: 360 },
|
|
13
|
+
vertical: { width: 360, height: 640 },
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Positions of `count` points along an axis of length `axisLength` (spec §8):
|
|
17
|
+
* gap = L/(N+spread), inset = (1+spread)/(N+spread) * (L/2), so that
|
|
18
|
+
* 2*inset + (N-1)*gap = L. A single point is centered without evaluating
|
|
19
|
+
* the gap formula (avoids division by zero when spread is -1).
|
|
20
|
+
*
|
|
21
|
+
* A non-zero `margin` pads both axis ends, computing positions over the
|
|
22
|
+
* reduced length `axisLength - 2*margin` and shifting them by `margin`.
|
|
23
|
+
*/
|
|
24
|
+
function axisPositions(count, axisLength, spread, margin) {
|
|
25
|
+
if (count === 1)
|
|
26
|
+
return [axisLength / 2];
|
|
27
|
+
const usableLength = axisLength - 2 * margin;
|
|
28
|
+
const gap = usableLength / (count + spread);
|
|
29
|
+
const inset = margin + ((1 + spread) / (count + spread)) * (usableLength / 2);
|
|
30
|
+
return Array.from({ length: count }, (_, index) => inset + index * gap);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Visible slots for a layer of `size` nodes (spec §9). Layers larger than
|
|
34
|
+
* maxDisplayedNodes get maxDisplayedNodes+1 slots with the central slot
|
|
35
|
+
* replaced by an ellipsis; displayed nodes keep their true indices.
|
|
36
|
+
*/
|
|
37
|
+
function layerSlots(size, maxDisplayedNodes) {
|
|
38
|
+
if (size <= maxDisplayedNodes) {
|
|
39
|
+
return Array.from({ length: size }, (_, nodeIndex) => ({
|
|
40
|
+
kind: "node",
|
|
41
|
+
nodeIndex,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
const half = maxDisplayedNodes / 2;
|
|
45
|
+
const slots = [];
|
|
46
|
+
for (let nodeIndex = 0; nodeIndex < half; nodeIndex++) {
|
|
47
|
+
slots.push({ kind: "node", nodeIndex });
|
|
48
|
+
}
|
|
49
|
+
slots.push({ kind: "ellipsis", omittedCount: size - maxDisplayedNodes });
|
|
50
|
+
for (let nodeIndex = size - half; nodeIndex < size; nodeIndex++) {
|
|
51
|
+
slots.push({ kind: "node", nodeIndex });
|
|
52
|
+
}
|
|
53
|
+
return slots;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Computes node/ellipsis placement and edge geometry without creating any
|
|
57
|
+
* DOM elements. Accepts the full SVG options object; rendering-only options
|
|
58
|
+
* are ignored (spec §3.3).
|
|
59
|
+
*/
|
|
60
|
+
export function layoutDenseNetwork(options) {
|
|
61
|
+
validateLayoutOptions(options);
|
|
62
|
+
const orientation = options.orientation ?? "horizontal";
|
|
63
|
+
const viewBox = options.viewBox ?? DEFAULT_VIEWBOX[orientation];
|
|
64
|
+
const maxDisplayedNodes = options.maxDisplayedNodes ?? DEFAULT_MAX_DISPLAYED_NODES;
|
|
65
|
+
const nodeSpread = options.nodeSpread ?? 0;
|
|
66
|
+
const layerSpread = options.layerSpread ?? 0;
|
|
67
|
+
const horizontal = orientation === "horizontal";
|
|
68
|
+
const layerAxisLength = horizontal ? viewBox.width : viewBox.height;
|
|
69
|
+
const nodeAxisLength = horizontal ? viewBox.height : viewBox.width;
|
|
70
|
+
const slotsPerLayer = options.layers.map((size) => layerSlots(size, maxDisplayedNodes));
|
|
71
|
+
// Default radius: 1/8 of the smallest displayed inter-node gap; if every
|
|
72
|
+
// layer displays a single node there is no gap, so fall back to L/25.
|
|
73
|
+
// Gaps are measured on the unpadded axis: the spread margin below depends
|
|
74
|
+
// on nodeRadius, so the radius must be resolved first (spec §8).
|
|
75
|
+
let smallestNodeGap = Infinity;
|
|
76
|
+
for (const slots of slotsPerLayer) {
|
|
77
|
+
if (slots.length > 1) {
|
|
78
|
+
smallestNodeGap = Math.min(smallestNodeGap, nodeAxisLength / (slots.length + nodeSpread));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const nodeRadius = options.nodeRadius ??
|
|
82
|
+
(Number.isFinite(smallestNodeGap) ? smallestNodeGap / 8 : nodeAxisLength / 25);
|
|
83
|
+
const ellipsisDotRadius = options.ellipsisDotRadius ?? nodeRadius / 4;
|
|
84
|
+
const nodeStrokeWidth = options.nodeStrokeWidth ?? Math.max(nodeRadius / 8, MIN_NODE_STROKE_WIDTH);
|
|
85
|
+
const edgeLabelPos = options.edgeLabelPos ?? DEFAULT_EDGE_LABEL_POS;
|
|
86
|
+
// A spread of -1 pins outer positions to the axis ends, which would clip
|
|
87
|
+
// node circles at the viewBox boundary; pad that axis by the drawn node
|
|
88
|
+
// extent, radius plus half the (edge-centered) stroke (spec §8).
|
|
89
|
+
const margin = nodeRadius + nodeStrokeWidth / 2;
|
|
90
|
+
const layerMargin = layerSpread === -1 ? margin : 0;
|
|
91
|
+
const nodeMargin = nodeSpread === -1 ? margin : 0;
|
|
92
|
+
const layerPositions = axisPositions(options.layers.length, layerAxisLength, layerSpread, layerMargin);
|
|
93
|
+
const items = [];
|
|
94
|
+
// Displayed real nodes per layer, in visible order, for edge generation.
|
|
95
|
+
const displayedNodes = [];
|
|
96
|
+
slotsPerLayer.forEach((slots, layerIndex) => {
|
|
97
|
+
const nodePositions = axisPositions(slots.length, nodeAxisLength, nodeSpread, nodeMargin);
|
|
98
|
+
const layerNodes = [];
|
|
99
|
+
slots.forEach((slot, visibleIndex) => {
|
|
100
|
+
const layerPosition = layerPositions[layerIndex];
|
|
101
|
+
const nodePosition = nodePositions[visibleIndex];
|
|
102
|
+
const x = horizontal ? layerPosition : nodePosition;
|
|
103
|
+
const y = horizontal ? nodePosition : layerPosition;
|
|
104
|
+
if (slot.kind === "node") {
|
|
105
|
+
items.push({ kind: "node", layerIndex, nodeIndex: slot.nodeIndex, visibleIndex, x, y });
|
|
106
|
+
layerNodes.push({ nodeIndex: slot.nodeIndex, x, y });
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
items.push({
|
|
110
|
+
kind: "ellipsis",
|
|
111
|
+
layerIndex,
|
|
112
|
+
visibleIndex,
|
|
113
|
+
omittedCount: slot.omittedCount,
|
|
114
|
+
x,
|
|
115
|
+
y,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
displayedNodes.push(layerNodes);
|
|
120
|
+
});
|
|
121
|
+
const edges = [];
|
|
122
|
+
let edgeIndex = 0;
|
|
123
|
+
for (let layerIndex = 0; layerIndex + 1 < displayedNodes.length; layerIndex++) {
|
|
124
|
+
for (const source of displayedNodes[layerIndex]) {
|
|
125
|
+
for (const target of displayedNodes[layerIndex + 1]) {
|
|
126
|
+
edges.push({
|
|
127
|
+
sourceLayerIndex: layerIndex,
|
|
128
|
+
sourceNodeIndex: source.nodeIndex,
|
|
129
|
+
targetLayerIndex: layerIndex + 1,
|
|
130
|
+
targetNodeIndex: target.nodeIndex,
|
|
131
|
+
edgeIndex: edgeIndex++,
|
|
132
|
+
x1: source.x,
|
|
133
|
+
y1: source.y,
|
|
134
|
+
x2: target.x,
|
|
135
|
+
y2: target.y,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
viewBox: { width: viewBox.width, height: viewBox.height },
|
|
142
|
+
orientation,
|
|
143
|
+
items,
|
|
144
|
+
edges,
|
|
145
|
+
nodeRadius,
|
|
146
|
+
ellipsisDotRadius,
|
|
147
|
+
nodeStrokeWidth,
|
|
148
|
+
edgeLabelPos,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DenseNetworkSvgOptions } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Creates and returns a new `SVGSVGElement` displaying the network.
|
|
4
|
+
* This is the primary public API (spec §3.1).
|
|
5
|
+
*/
|
|
6
|
+
export declare function createDenseNetworkSvg(options: DenseNetworkSvgOptions): SVGSVGElement;
|
|
7
|
+
/**
|
|
8
|
+
* Creates an SVG with {@link createDenseNetworkSvg}, replaces the container
|
|
9
|
+
* contents with it, and returns the SVG (spec §3.2).
|
|
10
|
+
*/
|
|
11
|
+
export declare function renderDenseNetworkSvg(container: Element, options: DenseNetworkSvgOptions): SVGSVGElement;
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// SVG rendering (DESIGN_SPEC.md §3, §7, §10–§17, §19, §21–§24).
|
|
2
|
+
import { create } from "d3-selection";
|
|
3
|
+
import { DEFAULT_MAX_DISPLAYED_NODES, layoutDenseNetwork } from "./layout.js";
|
|
4
|
+
import { validateSvgOptions } from "./validate.js";
|
|
5
|
+
// Default styling (spec §15): neutral and legible, overridable per element.
|
|
6
|
+
// The node stroke-width is resolved by the layout (spec §8), not fixed here.
|
|
7
|
+
const NODE_DEFAULT_ATTRS = {
|
|
8
|
+
fill: "white",
|
|
9
|
+
stroke: "currentColor",
|
|
10
|
+
};
|
|
11
|
+
const EDGE_DEFAULT_ATTRS = {
|
|
12
|
+
stroke: "currentColor",
|
|
13
|
+
"stroke-opacity": 0.25,
|
|
14
|
+
"stroke-width": 1,
|
|
15
|
+
};
|
|
16
|
+
const ELLIPSIS_DOT_DEFAULT_ATTRS = {
|
|
17
|
+
fill: "currentColor",
|
|
18
|
+
};
|
|
19
|
+
const LABEL_DEFAULT_ATTRS = {
|
|
20
|
+
fill: "currentColor",
|
|
21
|
+
"font-size": 10,
|
|
22
|
+
"text-anchor": "middle",
|
|
23
|
+
"dominant-baseline": "middle",
|
|
24
|
+
"pointer-events": "none",
|
|
25
|
+
};
|
|
26
|
+
// Dot offsets from the slot center in units of the dot radius, so the glyph
|
|
27
|
+
// spans roughly one node diameter (spec §10 fixes only the central dot).
|
|
28
|
+
const ELLIPSIS_DOT_OFFSETS = [-3, 0, 3];
|
|
29
|
+
/**
|
|
30
|
+
* Merges override attributes onto defaults (spec §15/§23): overrides win,
|
|
31
|
+
* except `class`, where the override is appended to the generated class.
|
|
32
|
+
*/
|
|
33
|
+
function mergeAttrs(defaults, overrides) {
|
|
34
|
+
const merged = { ...defaults, ...overrides };
|
|
35
|
+
if (defaults.class != null) {
|
|
36
|
+
merged.class =
|
|
37
|
+
overrides?.class != null ? `${defaults.class} ${overrides.class}` : defaults.class;
|
|
38
|
+
}
|
|
39
|
+
return merged;
|
|
40
|
+
}
|
|
41
|
+
/** Applies attributes to a selection, skipping null/undefined values. */
|
|
42
|
+
function setAttrs(selection, attrs) {
|
|
43
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
44
|
+
if (value !== null && value !== undefined) {
|
|
45
|
+
selection.attr(name, String(value));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wraps user callbacks (spec §24): in strict mode exceptions propagate;
|
|
51
|
+
* otherwise they are logged via console.error and treated as no result.
|
|
52
|
+
* Calling with an undefined callback is a no-op returning undefined.
|
|
53
|
+
*/
|
|
54
|
+
function makeSafeCall(strict) {
|
|
55
|
+
return function safeCall(name, callback, arg) {
|
|
56
|
+
if (!callback)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
return callback(arg);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (strict)
|
|
63
|
+
throw error;
|
|
64
|
+
console.error(`nnet-svg: ${name} callback threw; treating its result as absent.`, error);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** Applies the value rules of spec §14 to a raw nodeValue/edgeValue result. */
|
|
70
|
+
function coerceValue(name, raw, strict) {
|
|
71
|
+
if (raw === null || raw === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
if (typeof raw === "number")
|
|
74
|
+
return raw; // NaN and ±Infinity are preserved
|
|
75
|
+
const message = `nnet-svg: ${name} must return a number, null, or undefined; received ${typeof raw}`;
|
|
76
|
+
if (strict)
|
|
77
|
+
throw new Error(message);
|
|
78
|
+
console.error(message);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const nodeKey = (layerIndex, nodeIndex) => `${layerIndex}:${nodeIndex}`;
|
|
82
|
+
// Generated id prefixes (spec §20): unique per call so multiple independent
|
|
83
|
+
// networks can coexist on one page; the random suffix guards against other
|
|
84
|
+
// copies of this module keeping their own counters.
|
|
85
|
+
let idCounter = 0;
|
|
86
|
+
function generateIdPrefix() {
|
|
87
|
+
idCounter += 1;
|
|
88
|
+
return `dn-${idCounter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
89
|
+
}
|
|
90
|
+
/** Default accessible description summarizing the network (spec §18). */
|
|
91
|
+
function defaultDescription(layers, orientation, maxDisplayedNodes, truncated) {
|
|
92
|
+
let text = `Dense neural network with ${layers.length} layers of sizes ${layers.join(", ")}, ` +
|
|
93
|
+
`drawn in ${orientation} orientation.`;
|
|
94
|
+
if (truncated) {
|
|
95
|
+
text += ` Layers with more than ${maxDisplayedNodes} nodes are truncated around a central ellipsis.`;
|
|
96
|
+
}
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Creates and returns a new `SVGSVGElement` displaying the network.
|
|
101
|
+
* This is the primary public API (spec §3.1).
|
|
102
|
+
*/
|
|
103
|
+
export function createDenseNetworkSvg(options) {
|
|
104
|
+
validateSvgOptions(options);
|
|
105
|
+
const layout = layoutDenseNetwork(options);
|
|
106
|
+
const strict = options.strict ?? false;
|
|
107
|
+
const safeCall = makeSafeCall(strict);
|
|
108
|
+
const classPrefix = options.classPrefix ?? "dn";
|
|
109
|
+
const cls = (name) => `${classPrefix}-${name}`;
|
|
110
|
+
const responsive = options.responsive ?? true;
|
|
111
|
+
const { width, height } = layout.viewBox;
|
|
112
|
+
const horizontal = layout.orientation === "horizontal";
|
|
113
|
+
const nodeItems = layout.items.filter((item) => item.kind === "node");
|
|
114
|
+
// ---- Value pass (spec §14): nodeValue/edgeValue run exactly once per
|
|
115
|
+
// visible node/edge; results are cached and reused by all later callbacks.
|
|
116
|
+
const nodeValues = new Map();
|
|
117
|
+
if (options.nodeValue) {
|
|
118
|
+
for (const item of nodeItems) {
|
|
119
|
+
const ref = {
|
|
120
|
+
kind: "node",
|
|
121
|
+
layerIndex: item.layerIndex,
|
|
122
|
+
nodeIndex: item.nodeIndex,
|
|
123
|
+
};
|
|
124
|
+
const value = coerceValue("nodeValue", safeCall("nodeValue", options.nodeValue, ref), strict);
|
|
125
|
+
if (value !== undefined)
|
|
126
|
+
nodeValues.set(nodeKey(item.layerIndex, item.nodeIndex), value);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const edgeValues = new Map();
|
|
130
|
+
if (options.edgeValue) {
|
|
131
|
+
for (const edge of layout.edges) {
|
|
132
|
+
const ref = {
|
|
133
|
+
kind: "edge",
|
|
134
|
+
sourceLayerIndex: edge.sourceLayerIndex,
|
|
135
|
+
sourceNodeIndex: edge.sourceNodeIndex,
|
|
136
|
+
targetLayerIndex: edge.targetLayerIndex,
|
|
137
|
+
targetNodeIndex: edge.targetNodeIndex,
|
|
138
|
+
edgeIndex: edge.edgeIndex,
|
|
139
|
+
};
|
|
140
|
+
const value = coerceValue("edgeValue", safeCall("edgeValue", options.edgeValue, ref), strict);
|
|
141
|
+
if (value !== undefined)
|
|
142
|
+
edgeValues.set(edge.edgeIndex, value);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Semantic datum objects passed to styling/label/title callbacks (spec §13):
|
|
146
|
+
// true indices plus the cached value, never layout geometry.
|
|
147
|
+
const nodeDatums = new Map();
|
|
148
|
+
for (const item of nodeItems) {
|
|
149
|
+
const datum = {
|
|
150
|
+
kind: "node",
|
|
151
|
+
layerIndex: item.layerIndex,
|
|
152
|
+
nodeIndex: item.nodeIndex,
|
|
153
|
+
};
|
|
154
|
+
const value = nodeValues.get(nodeKey(item.layerIndex, item.nodeIndex));
|
|
155
|
+
if (value !== undefined)
|
|
156
|
+
datum.value = value;
|
|
157
|
+
nodeDatums.set(nodeKey(item.layerIndex, item.nodeIndex), datum);
|
|
158
|
+
}
|
|
159
|
+
const edgeDatums = layout.edges.map((edge) => {
|
|
160
|
+
const datum = {
|
|
161
|
+
kind: "edge",
|
|
162
|
+
sourceLayerIndex: edge.sourceLayerIndex,
|
|
163
|
+
sourceNodeIndex: edge.sourceNodeIndex,
|
|
164
|
+
targetLayerIndex: edge.targetLayerIndex,
|
|
165
|
+
targetNodeIndex: edge.targetNodeIndex,
|
|
166
|
+
edgeIndex: edge.edgeIndex,
|
|
167
|
+
};
|
|
168
|
+
const value = edgeValues.get(edge.edgeIndex);
|
|
169
|
+
if (value !== undefined)
|
|
170
|
+
datum.value = value;
|
|
171
|
+
return datum;
|
|
172
|
+
});
|
|
173
|
+
const datumOfEdge = (edge) => edgeDatums[edge.edgeIndex];
|
|
174
|
+
const datumOfNode = (item) => nodeDatums.get(nodeKey(item.layerIndex, item.nodeIndex));
|
|
175
|
+
// ---- Accessibility metadata (spec §18) and generated ids (spec §20).
|
|
176
|
+
// The description always exists (user-provided or generated); the title
|
|
177
|
+
// only when the option is given. aria-labelledby lists whichever exist.
|
|
178
|
+
const idPrefix = options.idPrefix ?? generateIdPrefix();
|
|
179
|
+
const titleId = options.title === undefined ? undefined : `${idPrefix}-title`;
|
|
180
|
+
const descId = `${idPrefix}-desc`;
|
|
181
|
+
const truncated = layout.items.some((item) => item.kind === "ellipsis");
|
|
182
|
+
const desc = options.desc ??
|
|
183
|
+
defaultDescription(options.layers, layout.orientation, options.maxDisplayedNodes ?? DEFAULT_MAX_DISPLAYED_NODES, truncated);
|
|
184
|
+
// ---- Root <svg> (spec §7.2, §23): defaults and accessibility attrs, then
|
|
185
|
+
// responsive attrs, then user svgAttrs. svgAttrs.viewBox is ignored: the
|
|
186
|
+
// viewBox is controlled exclusively by options.viewBox.
|
|
187
|
+
const { viewBox: _unsupported, ...userSvgAttrs } = options.svgAttrs ?? {};
|
|
188
|
+
const responsiveAttrs = responsive
|
|
189
|
+
? { width: "100%", style: "max-width: 100%; height: auto;" }
|
|
190
|
+
: { width, height };
|
|
191
|
+
const rootAttrs = mergeAttrs({
|
|
192
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
193
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
194
|
+
preserveAspectRatio: "xMidYMid meet",
|
|
195
|
+
class: cls("root"),
|
|
196
|
+
role: "img",
|
|
197
|
+
"aria-labelledby": titleId === undefined ? descId : `${titleId} ${descId}`,
|
|
198
|
+
...responsiveAttrs,
|
|
199
|
+
}, userSvgAttrs);
|
|
200
|
+
const svg = create("svg");
|
|
201
|
+
setAttrs(svg, rootAttrs);
|
|
202
|
+
if (titleId !== undefined) {
|
|
203
|
+
svg.append("title").attr("id", titleId).text(options.title ?? "");
|
|
204
|
+
}
|
|
205
|
+
svg.append("desc").attr("id", descId).text(desc);
|
|
206
|
+
// Drawing order (spec §22): edges behind nodes/ellipses, labels on top.
|
|
207
|
+
const edgesGroup = svg.append("g").attr("class", cls("edges"));
|
|
208
|
+
const itemsGroup = svg.append("g").attr("class", cls("items"));
|
|
209
|
+
const labelsGroup = svg.append("g").attr("class", cls("labels"));
|
|
210
|
+
// ---- Edges (spec §11, §21.3).
|
|
211
|
+
for (const edge of layout.edges) {
|
|
212
|
+
const datum = datumOfEdge(edge);
|
|
213
|
+
const line = edgesGroup
|
|
214
|
+
.append("line")
|
|
215
|
+
.attr("data-kind", "edge")
|
|
216
|
+
.attr("data-source-layer-index", edge.sourceLayerIndex)
|
|
217
|
+
.attr("data-source-node-index", edge.sourceNodeIndex)
|
|
218
|
+
.attr("data-target-layer-index", edge.targetLayerIndex)
|
|
219
|
+
.attr("data-target-node-index", edge.targetNodeIndex)
|
|
220
|
+
.attr("data-edge-index", edge.edgeIndex)
|
|
221
|
+
.attr("x1", edge.x1)
|
|
222
|
+
.attr("y1", edge.y1)
|
|
223
|
+
.attr("x2", edge.x2)
|
|
224
|
+
.attr("y2", edge.y2);
|
|
225
|
+
const userAttrs = safeCall("edgeAttrs", options.edgeAttrs, datum);
|
|
226
|
+
setAttrs(line, mergeAttrs({ ...EDGE_DEFAULT_ATTRS, class: cls("edge") }, userAttrs));
|
|
227
|
+
const title = safeCall("edgeTitle", options.edgeTitle, datum);
|
|
228
|
+
if (title !== null && title !== undefined)
|
|
229
|
+
line.append("title").text(String(title));
|
|
230
|
+
}
|
|
231
|
+
// ---- Nodes and ellipsis glyphs (spec §10, §21.1, §21.2).
|
|
232
|
+
for (const item of layout.items) {
|
|
233
|
+
if (item.kind === "node") {
|
|
234
|
+
const datum = datumOfNode(item);
|
|
235
|
+
const circle = itemsGroup
|
|
236
|
+
.append("circle")
|
|
237
|
+
.attr("data-kind", "node")
|
|
238
|
+
.attr("data-layer-index", item.layerIndex)
|
|
239
|
+
.attr("data-node-index", item.nodeIndex)
|
|
240
|
+
.attr("cx", item.x)
|
|
241
|
+
.attr("cy", item.y)
|
|
242
|
+
.attr("r", layout.nodeRadius);
|
|
243
|
+
const userAttrs = safeCall("nodeAttrs", options.nodeAttrs, datum);
|
|
244
|
+
setAttrs(circle, mergeAttrs({
|
|
245
|
+
...NODE_DEFAULT_ATTRS,
|
|
246
|
+
"stroke-width": layout.nodeStrokeWidth,
|
|
247
|
+
class: cls("node"),
|
|
248
|
+
}, userAttrs));
|
|
249
|
+
const title = safeCall("nodeTitle", options.nodeTitle, datum);
|
|
250
|
+
if (title !== null && title !== undefined)
|
|
251
|
+
circle.append("title").text(String(title));
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
const glyph = itemsGroup
|
|
255
|
+
.append("g")
|
|
256
|
+
.attr("class", cls("ellipsis"))
|
|
257
|
+
.attr("data-kind", "ellipsis")
|
|
258
|
+
.attr("data-layer-index", item.layerIndex)
|
|
259
|
+
.attr("data-omitted-count", item.omittedCount);
|
|
260
|
+
// Dots stack along the node-stack direction (spec §10): vertically for
|
|
261
|
+
// horizontal networks, horizontally for vertical networks.
|
|
262
|
+
for (const step of ELLIPSIS_DOT_OFFSETS) {
|
|
263
|
+
const offset = step * layout.ellipsisDotRadius;
|
|
264
|
+
const dot = glyph
|
|
265
|
+
.append("circle")
|
|
266
|
+
.attr("cx", horizontal ? item.x : item.x + offset)
|
|
267
|
+
.attr("cy", horizontal ? item.y + offset : item.y)
|
|
268
|
+
.attr("r", layout.ellipsisDotRadius);
|
|
269
|
+
setAttrs(dot, { ...ELLIPSIS_DOT_DEFAULT_ATTRS, class: cls("ellipsis-dot") });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// ---- Labels (spec §16): positioned by the renderer, drawn above all
|
|
274
|
+
// other elements. Label attr callbacks run only when a label is rendered.
|
|
275
|
+
for (const edge of layout.edges) {
|
|
276
|
+
const datum = datumOfEdge(edge);
|
|
277
|
+
const raw = safeCall("edgeLabel", options.edgeLabel, datum);
|
|
278
|
+
if (raw === null || raw === undefined)
|
|
279
|
+
continue;
|
|
280
|
+
const text = labelsGroup.append("text").attr("data-edge-index", edge.edgeIndex);
|
|
281
|
+
const userAttrs = safeCall("edgeLabelAttrs", options.edgeLabelAttrs, datum);
|
|
282
|
+
setAttrs(text, mergeAttrs({
|
|
283
|
+
...LABEL_DEFAULT_ATTRS,
|
|
284
|
+
x: edge.x1 + (edge.x2 - edge.x1) * layout.edgeLabelPos,
|
|
285
|
+
y: edge.y1 + (edge.y2 - edge.y1) * layout.edgeLabelPos,
|
|
286
|
+
class: cls("edge-label"),
|
|
287
|
+
}, userAttrs));
|
|
288
|
+
text.text(String(raw));
|
|
289
|
+
}
|
|
290
|
+
for (const item of nodeItems) {
|
|
291
|
+
const datum = datumOfNode(item);
|
|
292
|
+
const raw = safeCall("nodeLabel", options.nodeLabel, datum);
|
|
293
|
+
if (raw === null || raw === undefined)
|
|
294
|
+
continue;
|
|
295
|
+
const text = labelsGroup
|
|
296
|
+
.append("text")
|
|
297
|
+
.attr("data-layer-index", item.layerIndex)
|
|
298
|
+
.attr("data-node-index", item.nodeIndex);
|
|
299
|
+
const userAttrs = safeCall("nodeLabelAttrs", options.nodeLabelAttrs, datum);
|
|
300
|
+
setAttrs(text, mergeAttrs({ ...LABEL_DEFAULT_ATTRS, x: item.x, y: item.y, class: cls("node-label") }, userAttrs));
|
|
301
|
+
text.text(String(raw));
|
|
302
|
+
}
|
|
303
|
+
return svg.node();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Creates an SVG with {@link createDenseNetworkSvg}, replaces the container
|
|
307
|
+
* contents with it, and returns the SVG (spec §3.2).
|
|
308
|
+
*/
|
|
309
|
+
export function renderDenseNetworkSvg(container, options) {
|
|
310
|
+
const svg = createDenseNetworkSvg(options);
|
|
311
|
+
container.replaceChildren(svg);
|
|
312
|
+
return svg;
|
|
313
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export type Orientation = "horizontal" | "vertical";
|
|
2
|
+
/** Internal SVG coordinate system used for layout, radii and font sizes. */
|
|
3
|
+
export type ViewBox = {
|
|
4
|
+
width: number;
|
|
5
|
+
height: number;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* SVG attributes as a plain object, e.g. `{ fill: "red", "stroke-width": 2 }`.
|
|
9
|
+
* `null` and `undefined` values remove/skip the attribute.
|
|
10
|
+
*/
|
|
11
|
+
export type SvgAttrs = Record<string, string | number | boolean | null | undefined>;
|
|
12
|
+
/** Options accepted by {@link layoutDenseNetwork} (and included in the SVG options). */
|
|
13
|
+
export type DenseNetworkLayoutOptions = {
|
|
14
|
+
/** Layer sizes, e.g. `[3, 8, 4, 1]`. Required; at least 2 positive integers. */
|
|
15
|
+
layers: number[];
|
|
16
|
+
/**
|
|
17
|
+
* Layers larger than this are truncated around a central ellipsis.
|
|
18
|
+
* Must be an even integer >= 2. Default: 12.
|
|
19
|
+
*/
|
|
20
|
+
maxDisplayedNodes?: number;
|
|
21
|
+
/** Direction of layer flow. Default: "horizontal" (left to right). */
|
|
22
|
+
orientation?: Orientation;
|
|
23
|
+
/**
|
|
24
|
+
* Internal coordinate system. Defaults: 640x360 (horizontal), 360x640 (vertical).
|
|
25
|
+
*/
|
|
26
|
+
viewBox?: ViewBox;
|
|
27
|
+
/**
|
|
28
|
+
* Controls edge insets of the node stack within a layer. Finite number >= -1;
|
|
29
|
+
* -1 pins outer nodes to the edges, +1 makes edge insets equal inter-node gaps.
|
|
30
|
+
* Default: 0.
|
|
31
|
+
*/
|
|
32
|
+
nodeSpread?: number;
|
|
33
|
+
/** Same as {@link DenseNetworkLayoutOptions.nodeSpread}, for layer placement. Default: 0. */
|
|
34
|
+
layerSpread?: number;
|
|
35
|
+
/** Node circle radius. Default: 1/8 of the smallest inter-node gap. */
|
|
36
|
+
nodeRadius?: number;
|
|
37
|
+
/** Radius of the three ellipsis dots. Default: nodeRadius / 4. */
|
|
38
|
+
ellipsisDotRadius?: number;
|
|
39
|
+
/** Node circle stroke width. Default: nodeRadius / 8, minimum 0.75. */
|
|
40
|
+
nodeStrokeWidth?: number;
|
|
41
|
+
/**
|
|
42
|
+
* Fractional position of edge labels along their edge, measured from the
|
|
43
|
+
* source node (0 = source, 1 = target). Default: 0.4, slightly off the
|
|
44
|
+
* midpoint where symmetric edge labels would overlap.
|
|
45
|
+
*/
|
|
46
|
+
edgeLabelPos?: number;
|
|
47
|
+
};
|
|
48
|
+
/** Reference to a real node, using true original indices. */
|
|
49
|
+
export type NodeRef = {
|
|
50
|
+
kind: "node";
|
|
51
|
+
layerIndex: number;
|
|
52
|
+
nodeIndex: number;
|
|
53
|
+
};
|
|
54
|
+
/** Reference to an edge, using true original node indices. */
|
|
55
|
+
export type EdgeRef = {
|
|
56
|
+
kind: "edge";
|
|
57
|
+
sourceLayerIndex: number;
|
|
58
|
+
sourceNodeIndex: number;
|
|
59
|
+
targetLayerIndex: number;
|
|
60
|
+
targetNodeIndex: number;
|
|
61
|
+
/** Global index across all edges in the network. */
|
|
62
|
+
edgeIndex: number;
|
|
63
|
+
};
|
|
64
|
+
/** Datum passed to node styling/label/title callbacks. */
|
|
65
|
+
export type NodeDatum = NodeRef & {
|
|
66
|
+
/** Cached result of the `nodeValue` callback, if it produced a number. */
|
|
67
|
+
value?: number;
|
|
68
|
+
};
|
|
69
|
+
/** Datum passed to edge styling/label/title callbacks. */
|
|
70
|
+
export type EdgeDatum = EdgeRef & {
|
|
71
|
+
/** Cached result of the `edgeValue` callback, if it produced a number. */
|
|
72
|
+
value?: number;
|
|
73
|
+
};
|
|
74
|
+
/** Options accepted by {@link createDenseNetworkSvg} and {@link renderDenseNetworkSvg}. */
|
|
75
|
+
export type DenseNetworkSvgOptions = DenseNetworkLayoutOptions & {
|
|
76
|
+
/**
|
|
77
|
+
* When true (the default), the SVG scales to its container width;
|
|
78
|
+
* when false, it gets fixed width/height attributes from the viewBox.
|
|
79
|
+
*/
|
|
80
|
+
responsive?: boolean;
|
|
81
|
+
/** Prefix for generated CSS classes. Default: "dn". */
|
|
82
|
+
classPrefix?: string;
|
|
83
|
+
/**
|
|
84
|
+
* Prefix for generated SVG ids (title/desc). Generated uniquely if omitted;
|
|
85
|
+
* if provided, the caller is responsible for uniqueness within the page.
|
|
86
|
+
*/
|
|
87
|
+
idPrefix?: string;
|
|
88
|
+
/**
|
|
89
|
+
* When true, callback errors and invalid callback results throw;
|
|
90
|
+
* when false (the default), they are logged via console.error and ignored.
|
|
91
|
+
*/
|
|
92
|
+
strict?: boolean;
|
|
93
|
+
/** Accessible title; rendered as a top-level SVG <title>. */
|
|
94
|
+
title?: string;
|
|
95
|
+
/** Accessible description; a summary is generated if omitted. */
|
|
96
|
+
desc?: string;
|
|
97
|
+
/** Extra attributes merged onto the root <svg> (viewBox cannot be overridden). */
|
|
98
|
+
svgAttrs?: SvgAttrs;
|
|
99
|
+
nodeValue?: (node: NodeRef) => number | null | undefined;
|
|
100
|
+
edgeValue?: (edge: EdgeRef) => number | null | undefined;
|
|
101
|
+
nodeAttrs?: (node: NodeDatum) => SvgAttrs | null | undefined;
|
|
102
|
+
edgeAttrs?: (edge: EdgeDatum) => SvgAttrs | null | undefined;
|
|
103
|
+
nodeLabel?: (node: NodeDatum) => string | number | null | undefined;
|
|
104
|
+
edgeLabel?: (edge: EdgeDatum) => string | number | null | undefined;
|
|
105
|
+
nodeLabelAttrs?: (node: NodeDatum) => SvgAttrs | null | undefined;
|
|
106
|
+
edgeLabelAttrs?: (edge: EdgeDatum) => SvgAttrs | null | undefined;
|
|
107
|
+
nodeTitle?: (node: NodeDatum) => string | null | undefined;
|
|
108
|
+
edgeTitle?: (edge: EdgeDatum) => string | null | undefined;
|
|
109
|
+
};
|
|
110
|
+
/** A displayed real node with its resolved position. */
|
|
111
|
+
export type LayoutNodeItem = {
|
|
112
|
+
kind: "node";
|
|
113
|
+
layerIndex: number;
|
|
114
|
+
/** True index in the original layer. */
|
|
115
|
+
nodeIndex: number;
|
|
116
|
+
/** Slot index within the visible layer. */
|
|
117
|
+
visibleIndex: number;
|
|
118
|
+
x: number;
|
|
119
|
+
y: number;
|
|
120
|
+
};
|
|
121
|
+
/** An ellipsis glyph standing in for omitted nodes in a truncated layer. */
|
|
122
|
+
export type LayoutEllipsisItem = {
|
|
123
|
+
kind: "ellipsis";
|
|
124
|
+
layerIndex: number;
|
|
125
|
+
/** Slot index within the visible layer. */
|
|
126
|
+
visibleIndex: number;
|
|
127
|
+
/** Number of real nodes hidden by this ellipsis. */
|
|
128
|
+
omittedCount: number;
|
|
129
|
+
x: number;
|
|
130
|
+
y: number;
|
|
131
|
+
};
|
|
132
|
+
export type LayoutItem = LayoutNodeItem | LayoutEllipsisItem;
|
|
133
|
+
/** A straight edge between two displayed real nodes in adjacent layers. */
|
|
134
|
+
export type LayoutEdge = {
|
|
135
|
+
sourceLayerIndex: number;
|
|
136
|
+
/** True source node index. */
|
|
137
|
+
sourceNodeIndex: number;
|
|
138
|
+
targetLayerIndex: number;
|
|
139
|
+
/** True target node index. */
|
|
140
|
+
targetNodeIndex: number;
|
|
141
|
+
/** Global index across all edges in the network. */
|
|
142
|
+
edgeIndex: number;
|
|
143
|
+
x1: number;
|
|
144
|
+
y1: number;
|
|
145
|
+
x2: number;
|
|
146
|
+
y2: number;
|
|
147
|
+
};
|
|
148
|
+
/** Result of {@link layoutDenseNetwork}: resolved geometry without any DOM. */
|
|
149
|
+
export type DenseNetworkLayout = {
|
|
150
|
+
viewBox: ViewBox;
|
|
151
|
+
orientation: Orientation;
|
|
152
|
+
/** Ordered by layer index, then visible slot position within the layer. */
|
|
153
|
+
items: LayoutItem[];
|
|
154
|
+
/** Ordered by layer pair, then source visible order, then target visible order. */
|
|
155
|
+
edges: LayoutEdge[];
|
|
156
|
+
/** Resolved node circle radius (default or from options). */
|
|
157
|
+
nodeRadius: number;
|
|
158
|
+
/** Resolved ellipsis dot radius (default or from options). */
|
|
159
|
+
ellipsisDotRadius: number;
|
|
160
|
+
/** Resolved node circle stroke width (default or from options). */
|
|
161
|
+
nodeStrokeWidth: number;
|
|
162
|
+
/** Resolved fractional edge label position (default or from options). */
|
|
163
|
+
edgeLabelPos: number;
|
|
164
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DenseNetworkLayoutOptions, DenseNetworkSvgOptions } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validates the layout-level options (spec §5). Rendering-only options such as
|
|
4
|
+
* callbacks, labels and `responsive` are ignored here (spec §3.3).
|
|
5
|
+
*/
|
|
6
|
+
export declare function validateLayoutOptions(options: DenseNetworkLayoutOptions): void;
|
|
7
|
+
/** Validates the full SVG options: layout rules plus rendering-only rules (spec §5). */
|
|
8
|
+
export declare function validateSvgOptions(options: DenseNetworkSvgOptions): void;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Input validation (DESIGN_SPEC.md §5).
|
|
2
|
+
// Top-level invalid inputs always throw descriptive errors, regardless of `strict`.
|
|
3
|
+
function fail(message) {
|
|
4
|
+
throw new Error(`nnet-svg: ${message}`);
|
|
5
|
+
}
|
|
6
|
+
function show(value) {
|
|
7
|
+
if (typeof value === "string")
|
|
8
|
+
return JSON.stringify(value);
|
|
9
|
+
if (Array.isArray(value))
|
|
10
|
+
return `[${value.map(show).join(", ")}]`;
|
|
11
|
+
if (typeof value === "function")
|
|
12
|
+
return "a function";
|
|
13
|
+
if (typeof value === "object" && value !== null) {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.stringify(value) ?? String(value);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return String(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// Covers numbers (incl. NaN/Infinity), booleans, null, undefined, symbols, bigints.
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Validates the layout-level options (spec §5). Rendering-only options such as
|
|
26
|
+
* callbacks, labels and `responsive` are ignored here (spec §3.3).
|
|
27
|
+
*/
|
|
28
|
+
export function validateLayoutOptions(options) {
|
|
29
|
+
if (typeof options !== "object" || options === null) {
|
|
30
|
+
fail(`options must be an object; received ${show(options)}`);
|
|
31
|
+
}
|
|
32
|
+
const { layers, maxDisplayedNodes, orientation, viewBox, nodeSpread, layerSpread, nodeRadius, ellipsisDotRadius, nodeStrokeWidth, edgeLabelPos, } = options;
|
|
33
|
+
if (layers === undefined) {
|
|
34
|
+
fail("layers is required");
|
|
35
|
+
}
|
|
36
|
+
if (!Array.isArray(layers)) {
|
|
37
|
+
fail(`layers must be an array of positive integers; received ${show(layers)}`);
|
|
38
|
+
}
|
|
39
|
+
if (layers.length < 2) {
|
|
40
|
+
fail(`layers must contain at least 2 layer sizes; received ${layers.length}`);
|
|
41
|
+
}
|
|
42
|
+
layers.forEach((size, index) => {
|
|
43
|
+
if (typeof size !== "number" || !Number.isInteger(size) || size < 1) {
|
|
44
|
+
fail(`every layer size must be a positive integer; layers[${index}] is ${show(size)}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
if (maxDisplayedNodes !== undefined &&
|
|
48
|
+
(typeof maxDisplayedNodes !== "number" ||
|
|
49
|
+
!Number.isInteger(maxDisplayedNodes) ||
|
|
50
|
+
maxDisplayedNodes < 2 ||
|
|
51
|
+
maxDisplayedNodes % 2 !== 0)) {
|
|
52
|
+
fail(`maxDisplayedNodes must be an even integer >= 2; received ${show(maxDisplayedNodes)}`);
|
|
53
|
+
}
|
|
54
|
+
if (orientation !== undefined && orientation !== "horizontal" && orientation !== "vertical") {
|
|
55
|
+
fail(`orientation must be "horizontal" or "vertical"; received ${show(orientation)}`);
|
|
56
|
+
}
|
|
57
|
+
if (viewBox !== undefined) {
|
|
58
|
+
if (typeof viewBox !== "object" || viewBox === null) {
|
|
59
|
+
fail(`viewBox must be an object with width and height; received ${show(viewBox)}`);
|
|
60
|
+
}
|
|
61
|
+
for (const dimension of ["width", "height"]) {
|
|
62
|
+
const value = viewBox[dimension];
|
|
63
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
64
|
+
fail(`viewBox.${dimension} must be a positive finite number; received ${show(value)}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const [name, value] of [
|
|
69
|
+
["nodeSpread", nodeSpread],
|
|
70
|
+
["layerSpread", layerSpread],
|
|
71
|
+
]) {
|
|
72
|
+
if (value !== undefined &&
|
|
73
|
+
(typeof value !== "number" || !Number.isFinite(value) || value < -1)) {
|
|
74
|
+
fail(`${name} must be a finite number >= -1; received ${show(value)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const [name, value] of [
|
|
78
|
+
["nodeRadius", nodeRadius],
|
|
79
|
+
["ellipsisDotRadius", ellipsisDotRadius],
|
|
80
|
+
]) {
|
|
81
|
+
if (value !== undefined &&
|
|
82
|
+
(typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
|
|
83
|
+
fail(`${name} must be a positive finite number; received ${show(value)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Zero is allowed: it means no visible stroke.
|
|
87
|
+
if (nodeStrokeWidth !== undefined &&
|
|
88
|
+
(typeof nodeStrokeWidth !== "number" || !Number.isFinite(nodeStrokeWidth) || nodeStrokeWidth < 0)) {
|
|
89
|
+
fail(`nodeStrokeWidth must be a non-negative finite number; received ${show(nodeStrokeWidth)}`);
|
|
90
|
+
}
|
|
91
|
+
if (edgeLabelPos !== undefined &&
|
|
92
|
+
(typeof edgeLabelPos !== "number" ||
|
|
93
|
+
!Number.isFinite(edgeLabelPos) ||
|
|
94
|
+
edgeLabelPos < 0 ||
|
|
95
|
+
edgeLabelPos > 1)) {
|
|
96
|
+
fail(`edgeLabelPos must be a number between 0 and 1; received ${show(edgeLabelPos)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Validates the full SVG options: layout rules plus rendering-only rules (spec §5). */
|
|
100
|
+
export function validateSvgOptions(options) {
|
|
101
|
+
validateLayoutOptions(options);
|
|
102
|
+
for (const name of ["responsive", "strict"]) {
|
|
103
|
+
const value = options[name];
|
|
104
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
105
|
+
fail(`${name} must be a boolean; received ${show(value)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nnet-svg",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Render a dense feed-forward neural network as a static SVG",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"neural-network",
|
|
7
|
+
"svg",
|
|
8
|
+
"visualization",
|
|
9
|
+
"d3",
|
|
10
|
+
"observable"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/dkirkby/nnet-svg#readme",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/dkirkby/nnet-svg.git"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/dkirkby/nnet-svg/issues"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "David Kirkby <dkirkby@uci.edu>",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsc",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"demo": "npx -y serve .",
|
|
43
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"d3-selection": "^3.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/d3-selection": "^3.0.11",
|
|
50
|
+
"jsdom": "^29.1.1",
|
|
51
|
+
"typescript": "^7.0.2",
|
|
52
|
+
"vitest": "^4.1.10"
|
|
53
|
+
}
|
|
54
|
+
}
|