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 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
+ [![CI](https://github.com/dkirkby/nnet-svg/actions/workflows/ci.yml/badge.svg)](https://github.com/dkirkby/nnet-svg/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/nnet-svg)](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
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export { layoutDenseNetwork } from "./layout.js";
3
+ export { createDenseNetworkSvg, renderDenseNetworkSvg } from "./renderSvg.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./types.js";
2
+ export { layoutDenseNetwork } from "./layout.js";
3
+ export { createDenseNetworkSvg, renderDenseNetworkSvg } from "./renderSvg.js";
@@ -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
+ }
@@ -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,2 @@
1
+ // Public type definitions (DESIGN_SPEC.md §12, §13, §25).
2
+ export {};
@@ -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;
@@ -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
+ }