prism-viz-engine 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.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Layer 01 — archify IR → JSON Canvas.
3
+ *
4
+ * GROUNDED, not recalled. Every field below was read out of the real schemas in
5
+ * `GriotSandbox/viz-generate/archify/archify/schemas/` on 2026-09-11. Full harvest:
6
+ * `.prism/shared/research/2026-09-11-archify.md`.
7
+ *
8
+ * WHY ARCHIFY IS THE GRAFT AND LANSHU WAS NOT
9
+ * -------------------------------------------
10
+ * Both were shelved as layer-01 generators. Only one has an IR.
11
+ *
12
+ * Lanshu — no node/edge graph at all. A content-slot form for one hardcoded picture;
13
+ * every coordinate is a source literal, arrows carry startBinding = None.
14
+ * It serialises ink, not a model. (research/2026-09-11-lanshu.md)
15
+ * archify — `package.json:6` calls the package "JSON-IR diagram renderers". Five JSON
16
+ * Schema draft-2020-12 documents; NL never reaches a renderer, the model
17
+ * reads SKILL.md and emits IR. There is no parser and no prompt engine.
18
+ *
19
+ * And the IR is already positional, which is the part that makes this nearly free:
20
+ *
21
+ * archify JSON Canvas
22
+ * ─────────────────────────────────────────────── ──────────────────────────────
23
+ * component.pos [x, y] (:117 -> $defs/point) node.x / node.y
24
+ * component.size [w, h] (:118-125) node.width / node.height
25
+ * connection.fromSide/toSide (:161-162) edge.fromSide / edge.toSide
26
+ * common $defs.side enum CanvasSide
27
+ * ["left","right","top","bottom"] (:22-24) "top"|"right"|"bottom"|"left"
28
+ *
29
+ * The side enum is the SAME SET. Not similar — the same four strings. So edge anchoring
30
+ * survives the conversion untouched, which is the single thing that usually doesn't.
31
+ *
32
+ * CORRECTIONS carried from the harvest, so nobody re-learns them here:
33
+ * - "single-file, zero-dep" is half wrong: zero RUNTIME deps is real (no `dependencies`
34
+ * key), but the repo is 513 files and ajv is standalone-compiled into committed
35
+ * source (`generated-validators.mjs`, 421 KB). Zero-dep is an artefact of the build.
36
+ * - the top-level arrays are `components` and `connections` — NOT `nodes`/`edges`.
37
+ * - layout is mostly authored, not solved: `grid.mjs:1` says "Not auto-layout — fixed
38
+ * cell math only." Only workflow v2 has a solver, and it solves X only. So when a
39
+ * component gives `row`/`col` instead of `pos`, WE do the cell math here, from the
40
+ * IR's own `layout` block — we do not invent a layout engine.
41
+ * - licence, as a fact: `spdx:MIT`, two holders (tt-a1i 2026; Cocoon AI 2025) — archify
42
+ * is itself derived from `Cocoon-AI/architecture-diagram-generator`.
43
+ */
44
+
45
+ import type { JSONCanvas, CanvasNode, CanvasEdge, CanvasSide } from "../../core/json-canvas"
46
+
47
+ // ── the IR, transcribed from the schemas ───────────────────────────────────────
48
+ /** `common.schema.json:22-24` — identical set to CanvasSide. */
49
+ export type ArchifySide = "left" | "right" | "top" | "bottom"
50
+ /** `common.schema.json $defs.point` — a fixed 2-tuple, `items:false`. */
51
+ export type ArchifyPoint = [number, number]
52
+
53
+ /** `architecture.schema.json:85` — required `id`, `type`, `label`. */
54
+ export interface ArchifyComponent {
55
+ id: string
56
+ type: string
57
+ label: string
58
+ sublabel?: string
59
+ tag?: string
60
+ brand?: string
61
+ sources?: unknown
62
+ row?: number
63
+ col?: number
64
+ pos?: ArchifyPoint
65
+ size?: ArchifyPoint
66
+ }
67
+
68
+ /** `architecture.schema.json:149` — required `from`, `to`. */
69
+ export interface ArchifyConnection {
70
+ id?: string
71
+ from: string
72
+ to: string
73
+ label?: string
74
+ variant?: string
75
+ fromSide?: ArchifySide
76
+ toSide?: ArchifySide
77
+ route?: "auto" | "straight" | "orthogonal-h" | "orthogonal-v"
78
+ via?: ArchifyPoint[]
79
+ labelAt?: ArchifyPoint
80
+ labelDx?: number
81
+ labelDy?: number
82
+ labelSegment?: number
83
+ width?: number
84
+ }
85
+
86
+ /** Required `kind`, `label`, `wraps` — `wraps` is a list of component ids. */
87
+ export interface ArchifyBoundary {
88
+ kind: string
89
+ label: string
90
+ wraps: string[]
91
+ pad?: number
92
+ }
93
+
94
+ /** Required `mode`. The cell math we apply when a component has no `pos`. */
95
+ export interface ArchifyLayout {
96
+ mode: string
97
+ origin?: ArchifyPoint
98
+ cols?: number
99
+ gapX?: number
100
+ gapY?: number
101
+ cellW?: number
102
+ cellH?: number
103
+ }
104
+
105
+ /** `architecture.schema.json:7` — required `schema_version`, `diagram_type`, `meta`, `components`. */
106
+ export interface ArchifyIR {
107
+ schema_version: string | number
108
+ diagram_type: string
109
+ meta: Record<string, unknown>
110
+ layout?: ArchifyLayout
111
+ components: ArchifyComponent[]
112
+ boundaries?: ArchifyBoundary[]
113
+ connections?: ArchifyConnection[]
114
+ cards?: unknown
115
+ }
116
+
117
+ // ── defaults, named so they are obviously ours and not archify's ───────────────
118
+ const FALLBACK = { cellW: 180, cellH: 96, gapX: 40, gapY: 40, origin: [0, 0] as ArchifyPoint }
119
+
120
+ /**
121
+ * `grid.mjs:1` — "Not auto-layout — fixed cell math only." We reproduce exactly that:
122
+ * row/col times cell plus gap, offset by origin. We do NOT substitute dagre or a force
123
+ * pass, because that would silently move authored diagrams.
124
+ */
125
+ function placeFromGrid(c: ArchifyComponent, L: ArchifyLayout | undefined) {
126
+ const cellW = L?.cellW ?? FALLBACK.cellW
127
+ const cellH = L?.cellH ?? FALLBACK.cellH
128
+ const gapX = L?.gapX ?? FALLBACK.gapX
129
+ const gapY = L?.gapY ?? FALLBACK.gapY
130
+ const [ox, oy] = L?.origin ?? FALLBACK.origin
131
+ return {
132
+ x: ox + (c.col ?? 0) * (cellW + gapX),
133
+ y: oy + (c.row ?? 0) * (cellH + gapY),
134
+ width: cellW,
135
+ height: cellH,
136
+ }
137
+ }
138
+
139
+ export interface AdaptOptions {
140
+ /** Stamped onto every emitted node so provenance survives the conversion. */
141
+ harvestedBy?: string
142
+ harvestedAt?: string
143
+ }
144
+
145
+ /**
146
+ * The conversion. Positional fields pass through; grid-placed components get the IR's
147
+ * own cell math; boundaries become JSON Canvas `group` nodes sized to their members.
148
+ *
149
+ * Anything archify carries that JSON Canvas has no home for — `route`, `via`, `labelAt`,
150
+ * `variant`, `width`, `sublabel`, `tag`, `brand` — is preserved under `griot.code` /
151
+ * node extras rather than dropped, so a round trip back to archify stays possible.
152
+ */
153
+ export function archifyToCanvas(ir: ArchifyIR, opts: AdaptOptions = {}): JSONCanvas {
154
+ const nodes: CanvasNode[] = []
155
+ const edges: CanvasEdge[] = []
156
+ const box = new Map<string, { x: number; y: number; width: number; height: number }>()
157
+
158
+ for (const c of ir.components) {
159
+ const geom = c.pos
160
+ ? {
161
+ x: c.pos[0],
162
+ y: c.pos[1],
163
+ width: c.size?.[0] ?? FALLBACK.cellW,
164
+ height: c.size?.[1] ?? FALLBACK.cellH,
165
+ }
166
+ : placeFromGrid(c, ir.layout)
167
+
168
+ box.set(c.id, geom)
169
+ const node = {
170
+ id: c.id,
171
+ type: "text",
172
+ text: c.sublabel ? `${c.label}\n${c.sublabel}` : c.label,
173
+ ...geom,
174
+ griot: {
175
+ layer: "unplaceable" as const, // routed by a human on the canvas, never guessed here
176
+ provenance: {
177
+ harvestedBy: opts.harvestedBy ?? "archify-ir",
178
+ harvestedAt: opts.harvestedAt ?? new Date().toISOString().slice(0, 10),
179
+ },
180
+ code: { licence: "spdx:MIT", capability: c.type },
181
+ },
182
+ } as unknown as CanvasNode
183
+ // keep the archify-only fields so the round trip is lossless
184
+ ;(node as any).archify = { type: c.type, tag: c.tag, brand: c.brand, sublabel: c.sublabel }
185
+ ;(node as any).label = c.label
186
+ nodes.push(node)
187
+ }
188
+
189
+ // Boundaries -> group nodes, sized to the bounding box of what they wrap + pad.
190
+ for (const [i, b] of (ir.boundaries ?? []).entries()) {
191
+ const members = b.wraps.map((id) => box.get(id)).filter(Boolean) as Array<{
192
+ x: number
193
+ y: number
194
+ width: number
195
+ height: number
196
+ }>
197
+ if (!members.length) continue // a boundary wrapping nothing is dropped, not faked
198
+ const pad = b.pad ?? 16
199
+ const minX = Math.min(...members.map((m) => m.x)) - pad
200
+ const minY = Math.min(...members.map((m) => m.y)) - pad
201
+ const maxX = Math.max(...members.map((m) => m.x + m.width)) + pad
202
+ const maxY = Math.max(...members.map((m) => m.y + m.height)) + pad
203
+ const group = {
204
+ id: `boundary-${i}-${b.kind}`,
205
+ type: "group",
206
+ label: b.label,
207
+ x: minX,
208
+ y: minY,
209
+ width: maxX - minX,
210
+ height: maxY - minY,
211
+ } as CanvasNode
212
+ // `kind` (region | security-group | …) is archify's own topology declaration. An
213
+ // earlier pass kept only `label` and the router then had to guess from prose — which
214
+ // is how a real deployment diagram scored as an app architecture.
215
+ ;(group as any).archify = { kind: b.kind, wraps: b.wraps }
216
+ nodes.unshift(group)
217
+ }
218
+
219
+ // Connections -> edges. fromSide/toSide pass through UNTRANSLATED — same enum.
220
+ for (const [i, c] of (ir.connections ?? []).entries()) {
221
+ const edge: CanvasEdge = {
222
+ id: c.id ?? `${c.from}->${c.to}-${i}`,
223
+ fromNode: c.from,
224
+ toNode: c.to,
225
+ ...(c.fromSide ? { fromSide: c.fromSide as CanvasSide } : {}),
226
+ ...(c.toSide ? { toSide: c.toSide as CanvasSide } : {}),
227
+ ...(c.label ? { label: c.label } : {}),
228
+ }
229
+ ;(edge as any).archify = { route: c.route, via: c.via, variant: c.variant, width: c.width }
230
+ edges.push(edge)
231
+ }
232
+
233
+ return { nodes, edges }
234
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Layer 02 — the canvas. xyflow, because the decision was already made.
3
+ *
4
+ * The djeli-uxui-harvest stage contract, decision 4: "CANVAS is xyflow (layer 02)
5
+ * because nodes are a plain JSON array an agent reads and writes directly, with no
6
+ * canvas-widget indirection." The shelf agrees — xyflow is "the node-graph engine under
7
+ * Langflow/Flowise", MIT, Fragment's visual-builder base.
8
+ *
9
+ * The load-bearing word in that decision is WRITES. So this canvas is not a rendering
10
+ * of a harvest, it is the instrument that edits one:
11
+ *
12
+ * - drag a card in from the palette -> a component is placed
13
+ * - drag a node across a lane -> its layer role is REASSIGNED
14
+ * - drag handle to handle -> an edge is written
15
+ * - every mutation calls onChange -> the host persists; the engine never writes
16
+ *
17
+ * Lanes are the eleven verbatim roles, drawn as background bands. Dropping into a band
18
+ * IS the routing act — which is why the routing view and the editing view are the same
19
+ * screen rather than a table you read and a canvas you look at.
20
+ */
21
+
22
+ import { useCallback, useMemo, useRef } from "react"
23
+ import {
24
+ ReactFlow,
25
+ Background,
26
+ BackgroundVariant,
27
+ Controls,
28
+ MiniMap,
29
+ ReactFlowProvider,
30
+ ViewportPortal,
31
+ useReactFlow,
32
+ useViewport,
33
+ applyNodeChanges,
34
+ applyEdgeChanges,
35
+ addEdge,
36
+ type Node,
37
+ type Edge,
38
+ type NodeChange,
39
+ type EdgeChange,
40
+ type Connection,
41
+ type ReactFlowInstance,
42
+ type OnNodeDrag,
43
+ } from "@xyflow/react"
44
+ import "@xyflow/react/dist/style.css"
45
+
46
+ import { ComponentNode, type ComponentNodeData } from "./ComponentNode"
47
+ import { ALL_SLOTS, ROLE_EMBER, ROLE_EQUIV, type LayerSlot } from "../../core/layer-roles"
48
+
49
+ export const LANE_H = 168
50
+
51
+ const nodeTypes = { griot: ComponentNode }
52
+
53
+ export interface CanvasProps {
54
+ nodes: Node<ComponentNodeData>[]
55
+ edges: Edge[]
56
+ onNodesChange: (c: NodeChange[]) => void
57
+ onEdgesChange: (c: EdgeChange[]) => void
58
+ onConnect: (c: Connection) => void
59
+ /** Fired when a node's y puts it in a different lane — the re-route. */
60
+ onReroute: (nodeId: string, layer: LayerSlot) => void
61
+ /** Fired when a palette card is dropped onto the canvas. */
62
+ onDropComponent: (payload: string, position: { x: number; y: number }, layer: LayerSlot) => void
63
+ onSelect: (nodeId: string | null) => void
64
+ }
65
+
66
+ export const laneOf = (y: number): LayerSlot =>
67
+ ALL_SLOTS[Math.max(0, Math.min(ALL_SLOTS.length - 1, Math.floor(y / LANE_H)))]
68
+
69
+ /**
70
+ * The lanes live in FLOW space, not screen space.
71
+ *
72
+ * They were rendered as a plain child of <ReactFlow>, which put them in the viewport's
73
+ * own coordinates — so the graph panned and zoomed underneath them and the labels
74
+ * stopped describing the rows they sat on. A lane that does not move with its nodes is
75
+ * worse than no lane: it asserts a role for whatever happens to be under it.
76
+ *
77
+ * <ViewportPortal> renders into the transformed pane, so a lane is pinned to the same
78
+ * coordinates as the nodes it contains and survives any camera move.
79
+ */
80
+ function LaneBands({ width }: { width: number }) {
81
+ return (
82
+ <ViewportPortal>
83
+ {ALL_SLOTS.map((role, i) => (
84
+ <div
85
+ key={role}
86
+ className="vz-lane"
87
+ style={{ top: i * LANE_H, height: LANE_H, width, ["--ember" as string]: ROLE_EMBER[role] }}
88
+ />
89
+ ))}
90
+ </ViewportPortal>
91
+ )
92
+ }
93
+
94
+ /**
95
+ * The label rail — screen space, but tracking the bands.
96
+ *
97
+ * Bands belong in flow space or a node drifts out of the role it is sitting in. Labels
98
+ * do NOT: send them through the same transform and the legend slides off the left edge
99
+ * the moment you pan right, which is the context loss this is fixing, just rotated 90
100
+ * degrees.
101
+ *
102
+ * So the rail is pinned to the viewport and reads `useViewport()` to place each label at
103
+ * its band's PROJECTED y. Bands move with the graph; the legend never leaves. Labels
104
+ * fade out when their band is too short to read at the current zoom rather than
105
+ * overlapping their neighbours — the same instinct as archify's Reading Depth, where
106
+ * detail drops out by scale but nothing moves to make room.
107
+ */
108
+ function LaneRail() {
109
+ const { y, zoom } = useViewport()
110
+ const h = LANE_H * zoom
111
+ const readable = h > 26
112
+ return (
113
+ <div className="vz-rail" aria-hidden={!readable}>
114
+ {ALL_SLOTS.map((role, i) => {
115
+ const top = y + i * h
116
+ return (
117
+ <div
118
+ key={role}
119
+ className="vz-rail-row"
120
+ style={{
121
+ top,
122
+ height: h,
123
+ opacity: readable ? 1 : 0,
124
+ ["--ember" as string]: ROLE_EMBER[role],
125
+ }}
126
+ >
127
+ <span className="vz-lane-name">{role}</span>
128
+ {ROLE_EQUIV[role] && h > 48 && <span className="vz-lane-eq">{ROLE_EQUIV[role]}</span>}
129
+ </div>
130
+ )
131
+ })}
132
+ </div>
133
+ )
134
+ }
135
+
136
+ function CanvasInner(props: CanvasProps) {
137
+ const { screenToFlowPosition } = useReactFlow()
138
+ const wrap = useRef<HTMLDivElement>(null)
139
+ const laneWidth = useMemo(() => 4000, [])
140
+
141
+ const onDragOver = useCallback((e: React.DragEvent) => {
142
+ e.preventDefault()
143
+ e.dataTransfer.dropEffect = "copy"
144
+ }, [])
145
+
146
+ const onDrop = useCallback(
147
+ (e: React.DragEvent) => {
148
+ e.preventDefault()
149
+ const payload = e.dataTransfer.getData("application/griot-node")
150
+ if (!payload) return
151
+ const position = screenToFlowPosition({ x: e.clientX, y: e.clientY })
152
+ props.onDropComponent(payload, position, laneOf(position.y))
153
+ },
154
+ [screenToFlowPosition, props]
155
+ )
156
+
157
+ // xyflow v12 types this handler's event as MouseEvent | TouchEvent (not React's
158
+ // synthetic MouseEvent) because a drag can end from touch. We never read the event.
159
+ const onNodeDragStop: OnNodeDrag<Node<ComponentNodeData>> = useCallback(
160
+ (_e, node) => {
161
+ const target = laneOf(node.position.y + (node.measured?.height ?? 96) / 2)
162
+ const current = node.data.griot.layer
163
+ if (target !== current) props.onReroute(node.id, target)
164
+ },
165
+ [props]
166
+ )
167
+
168
+ return (
169
+ <div className="vz-canvas" ref={wrap} onDragOver={onDragOver} onDrop={onDrop}>
170
+ <ReactFlow
171
+ nodes={props.nodes}
172
+ edges={props.edges}
173
+ nodeTypes={nodeTypes}
174
+ onNodesChange={props.onNodesChange}
175
+ onEdgesChange={props.onEdgesChange}
176
+ onConnect={props.onConnect}
177
+ onNodeDragStop={onNodeDragStop}
178
+ onPaneClick={() => props.onSelect(null)}
179
+ onNodeClick={(_e, n) => props.onSelect(n.id)}
180
+ minZoom={0.2}
181
+ maxZoom={2.2}
182
+ fitView
183
+ proOptions={{ hideAttribution: false }}
184
+ >
185
+ <LaneBands width={laneWidth} />
186
+ <LaneRail />
187
+ <Background variant={BackgroundVariant.Dots} gap={22} size={1} color="var(--grid)" />
188
+ <Controls position="bottom-right" />
189
+ <MiniMap
190
+ pannable
191
+ zoomable
192
+ bgColor="#0d1116"
193
+ maskStrokeColor="rgba(255,255,255,.12)"
194
+ nodeColor={(n) => ROLE_EMBER[(n.data as ComponentNodeData).griot?.layer as LayerSlot] ?? "#555"}
195
+ maskColor="rgba(0,0,0,.55)"
196
+ />
197
+ </ReactFlow>
198
+ </div>
199
+ )
200
+ }
201
+
202
+ export function Canvas(props: CanvasProps) {
203
+ return (
204
+ <ReactFlowProvider>
205
+ <CanvasInner {...props} />
206
+ </ReactFlowProvider>
207
+ )
208
+ }
209
+
210
+ export { applyNodeChanges, applyEdgeChanges, addEdge }
211
+ export type { Node, Edge, NodeChange, EdgeChange, Connection, ReactFlowInstance }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Layer 02 — the node. A Waku box: every box IS a real module file.
3
+ *
4
+ * The prism-viz-engine thesis ends "...ships them as interactive, source-wired surfaces
5
+ * in the Waku mold — not static SVGs." So this component refuses to be a label:
6
+ *
7
+ * Wiring A — click selects it and the shell reveals its detail (hash-deep-linkable)
8
+ * Wiring B — the ⟨source⟩ control opens the REAL file at the REAL line
9
+ * Wiring C — `data-node` id glows when a live trace event names it
10
+ *
11
+ * It carries BOTH harvest halves on one card, because a decision about the future of
12
+ * the tooling needs the picture and the function together: the UI half gives the
13
+ * file:line and mount point, the code half gives licence, fit and the standing ruling.
14
+ */
15
+
16
+ import { memo, useCallback } from "react"
17
+ import { Handle, Position, type NodeProps } from "@xyflow/react"
18
+ import { ROLE_EMBER, type LayerSlot } from "../../core/layer-roles"
19
+ import type { GriotNodeMeta } from "../../core/json-canvas"
20
+
21
+ export interface ComponentNodeData extends Record<string, unknown> {
22
+ label: string
23
+ griot: GriotNodeMeta
24
+ tracing?: boolean
25
+ reveal?: (origin: { repo: string; file: string; line: number }) => void | Promise<void>
26
+ }
27
+
28
+ const DECISION_TONE: Record<string, string> = {
29
+ adopt: "var(--ok)",
30
+ trial: "var(--sky)",
31
+ defer: "var(--amber)",
32
+ pass: "var(--dim)",
33
+ undecided: "var(--dim)",
34
+ }
35
+
36
+ function ComponentNodeImpl({ data, selected, id }: NodeProps) {
37
+ const d = data as ComponentNodeData
38
+ const g = d.griot
39
+ const ember = ROLE_EMBER[g.layer as LayerSlot] ?? "#6b7385"
40
+ const ui = g.ui
41
+ const code = g.code
42
+
43
+ const onReveal = useCallback(
44
+ (e: React.MouseEvent) => {
45
+ e.stopPropagation()
46
+ if (ui?.origin && d.reveal) void d.reveal(ui.origin)
47
+ },
48
+ [ui, d]
49
+ )
50
+
51
+ return (
52
+ <div
53
+ className={`vz-node${selected ? " sel" : ""}${d.tracing ? " tracing" : ""}`}
54
+ data-node={id}
55
+ style={{ ["--ember" as string]: ember }}
56
+ title={ui?.mountPoint}
57
+ >
58
+ <Handle type="target" position={Position.Top} className="vz-handle" />
59
+
60
+ <div className="vz-node-top">
61
+ {ui && <span className="vz-repo">{ui.origin.repo}</span>}
62
+ {g.walkLevel && <span className="vz-kind">{g.walkLevel}</span>}
63
+ {code?.decision && code.decision !== "undecided" && (
64
+ <span className="vz-dec" style={{ color: DECISION_TONE[code.decision] }}>
65
+ {code.decision}
66
+ </span>
67
+ )}
68
+ {!!ui?.notCopy?.length && (
69
+ <span className="vz-warn" title={ui.notCopy.join("\n")}>
70
+ ⚠{ui.notCopy.length}
71
+ </span>
72
+ )}
73
+ </div>
74
+
75
+ <div className="vz-label">{d.label}</div>
76
+
77
+ {/* The evidence, always visible. A card without file:line is not a finding. */}
78
+ {ui && (
79
+ <button className="vz-src" onClick={onReveal} disabled={!d.reveal} title={d.reveal ? "open the real source" : "no reveal on this host"}>
80
+ <span className="vz-src-file">{ui.origin.file}</span>
81
+ <span className="vz-src-line">:{ui.origin.line}</span>
82
+ </button>
83
+ )}
84
+
85
+ {/* The code half — licence is a FACT for a field, never a verdict. */}
86
+ {code && (
87
+ <div className="vz-code">
88
+ <span className="vz-lic">{code.licence}</span>
89
+ {code.fit && (
90
+ <span className="vz-fit" title={code.fit.why}>
91
+ {code.fit.app}
92
+ <b>{"·".repeat(code.fit.strength)}</b>
93
+ </span>
94
+ )}
95
+ </div>
96
+ )}
97
+
98
+ <Handle type="source" position={Position.Bottom} className="vz-handle" />
99
+ </div>
100
+ )
101
+ }
102
+
103
+ export const ComponentNode = memo(ComponentNodeImpl)