@cyvest/cyvest-vis 4.0.0 โ†’ 4.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Hook for computing force-directed layout using d3-force.
3
- * Uses an iterative simulation that updates on each tick.
3
+ * Uses a stable simulation that persists across renders.
4
4
  */
5
5
 
6
6
  import { useEffect, useRef, useCallback, useMemo } from "react";
@@ -46,10 +46,12 @@ interface SimLink extends SimulationLinkDatum<SimNode> {
46
46
  }
47
47
 
48
48
  /**
49
- * Selector to check if nodes are initialized
49
+ * Selector to get node IDs for change detection
50
50
  */
51
- const nodeCountSelector = (state: { nodeLookup: Map<string, Node> }) =>
52
- state.nodeLookup.size;
51
+ const nodeIdsSelector = (state: { nodeLookup: Map<string, Node> }) => {
52
+ const ids = Array.from(state.nodeLookup.keys()).sort();
53
+ return ids.join(",");
54
+ };
53
55
 
54
56
  /**
55
57
  * Hook that applies iterative force-directed layout to React Flow nodes.
@@ -61,7 +63,7 @@ export function useForceLayout(
61
63
  ) {
62
64
  const { getNodes, getEdges, setNodes } = useReactFlow();
63
65
  const nodesInitialized = useNodesInitialized();
64
- const nodeCount = useStore(nodeCountSelector);
66
+ const nodeIds = useStore(nodeIdsSelector);
65
67
 
66
68
  // Merge config with defaults
67
69
  const forceConfig = useMemo(
@@ -72,29 +74,57 @@ export function useForceLayout(
72
74
  // Store the simulation reference
73
75
  const simulationRef = useRef<Simulation<SimNode, SimLink> | null>(null);
74
76
 
75
- // Store dragging state
76
- const draggingNodeRef = useRef<string | null>(null);
77
+ // Track dragging state with ref for immediate access
78
+ const draggingRef = useRef<{
79
+ nodeId: string | null;
80
+ active: boolean;
81
+ }>({ nodeId: null, active: false });
82
+
83
+ // Store node positions in a ref to avoid React state conflicts during drag
84
+ const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(
85
+ new Map()
86
+ );
87
+
88
+ // Animation frame ref for smooth updates
89
+ const rafRef = useRef<number | null>(null);
77
90
 
78
91
  // Initialize and run the simulation
79
92
  useEffect(() => {
80
- if (!nodesInitialized || nodeCount === 0) {
93
+ if (!nodesInitialized || !nodeIds) {
81
94
  return;
82
95
  }
83
96
 
84
97
  const nodes = getNodes();
85
98
  const edges = getEdges();
86
99
 
100
+ if (nodes.length === 0) {
101
+ return;
102
+ }
103
+
87
104
  // Create simulation nodes from React Flow nodes
88
105
  const simNodes: SimNode[] = nodes.map((node) => {
89
106
  // Check if this node already exists in the simulation
90
- const existingNode = simulationRef.current?.nodes().find((n) => n.id === node.id);
107
+ const existingNode = simulationRef.current
108
+ ?.nodes()
109
+ .find((n) => n.id === node.id);
110
+
111
+ // Use existing position if available, otherwise use node position
112
+ const x =
113
+ existingNode?.x ??
114
+ nodePositionsRef.current.get(node.id)?.x ??
115
+ node.position.x ??
116
+ Math.random() * 500 - 250;
117
+ const y =
118
+ existingNode?.y ??
119
+ nodePositionsRef.current.get(node.id)?.y ??
120
+ node.position.y ??
121
+ Math.random() * 500 - 250;
91
122
 
92
123
  return {
93
124
  id: node.id,
94
- // Use existing simulation position or node position
95
- x: existingNode?.x ?? node.position.x ?? Math.random() * 500 - 250,
96
- y: existingNode?.y ?? node.position.y ?? Math.random() * 500 - 250,
97
- // Preserve fixed positions for dragged nodes
125
+ x,
126
+ y,
127
+ // Preserve fixed positions for dragged nodes or root
98
128
  fx: existingNode?.fx ?? null,
99
129
  fy: existingNode?.fy ?? null,
100
130
  };
@@ -122,6 +152,12 @@ export function useForceLayout(
122
152
  simulationRef.current.stop();
123
153
  }
124
154
 
155
+ // Cancel any pending animation frame
156
+ if (rafRef.current) {
157
+ cancelAnimationFrame(rafRef.current);
158
+ rafRef.current = null;
159
+ }
160
+
125
161
  // Create the force simulation
126
162
  const simulation = forceSimulation<SimNode>(simNodes)
127
163
  .force(
@@ -129,38 +165,45 @@ export function useForceLayout(
129
165
  forceLink<SimNode, SimLink>(simLinks)
130
166
  .id((d) => d.id)
131
167
  .distance(forceConfig.linkDistance)
132
- .strength(0.5)
168
+ .strength(0.4)
133
169
  )
134
170
  .force(
135
171
  "charge",
136
172
  forceManyBody<SimNode>().strength(forceConfig.chargeStrength)
137
173
  )
138
- .force(
139
- "center",
140
- forceCenter(0, 0).strength(forceConfig.centerStrength)
141
- )
142
- .force(
143
- "collision",
144
- forceCollide<SimNode>(forceConfig.collisionRadius)
145
- )
146
- .force(
147
- "x",
148
- forceX<SimNode>(0).strength(0.01)
149
- )
150
- .force(
151
- "y",
152
- forceY<SimNode>(0).strength(0.01)
153
- )
174
+ .force("center", forceCenter(0, 0).strength(forceConfig.centerStrength))
175
+ .force("collision", forceCollide<SimNode>(forceConfig.collisionRadius))
176
+ .force("x", forceX<SimNode>(0).strength(0.008))
177
+ .force("y", forceY<SimNode>(0).strength(0.008))
154
178
  .alphaDecay(0.02)
155
- .velocityDecay(0.4);
179
+ .velocityDecay(0.35);
180
+
181
+ // Batch updates using requestAnimationFrame for smoother rendering
182
+ const updateNodes = () => {
183
+ if (draggingRef.current.active) {
184
+ // Don't update node positions while actively dragging
185
+ rafRef.current = requestAnimationFrame(updateNodes);
186
+ return;
187
+ }
156
188
 
157
- // Update React Flow nodes on each tick
158
- simulation.on("tick", () => {
189
+ const simNodes = simulation.nodes();
190
+
191
+ // Update position cache
192
+ for (const simNode of simNodes) {
193
+ nodePositionsRef.current.set(simNode.id, { x: simNode.x, y: simNode.y });
194
+ }
195
+
196
+ // Batch update React Flow nodes
159
197
  setNodes((currentNodes) =>
160
198
  currentNodes.map((node) => {
161
- const simNode = simulation.nodes().find((n) => n.id === node.id);
199
+ const simNode = simNodes.find((n) => n.id === node.id);
162
200
  if (!simNode) return node;
163
201
 
202
+ // Skip update if position hasn't changed significantly
203
+ const dx = Math.abs(node.position.x - simNode.x);
204
+ const dy = Math.abs(node.position.y - simNode.y);
205
+ if (dx < 0.1 && dy < 0.1) return node;
206
+
164
207
  return {
165
208
  ...node,
166
209
  position: {
@@ -170,6 +213,16 @@ export function useForceLayout(
170
213
  };
171
214
  })
172
215
  );
216
+
217
+ if (simulation.alpha() > 0.001) {
218
+ rafRef.current = requestAnimationFrame(updateNodes);
219
+ }
220
+ };
221
+
222
+ simulation.on("tick", () => {
223
+ if (rafRef.current === null && simulation.alpha() > 0.001) {
224
+ rafRef.current = requestAnimationFrame(updateNodes);
225
+ }
173
226
  });
174
227
 
175
228
  simulationRef.current = simulation;
@@ -177,10 +230,14 @@ export function useForceLayout(
177
230
  // Cleanup: stop simulation when unmounting or dependencies change
178
231
  return () => {
179
232
  simulation.stop();
233
+ if (rafRef.current) {
234
+ cancelAnimationFrame(rafRef.current);
235
+ rafRef.current = null;
236
+ }
180
237
  };
181
238
  }, [
182
239
  nodesInitialized,
183
- nodeCount,
240
+ nodeIds,
184
241
  getNodes,
185
242
  getEdges,
186
243
  setNodes,
@@ -189,44 +246,47 @@ export function useForceLayout(
189
246
  ]);
190
247
 
191
248
  /**
192
- * Handle drag start - fix the node position and reheat simulation
249
+ * Handle drag start - fix the node position
193
250
  */
194
251
  const onNodeDragStart = useCallback(
195
252
  (_: React.MouseEvent, node: Node) => {
196
253
  const simulation = simulationRef.current;
197
254
  if (!simulation) return;
198
255
 
199
- draggingNodeRef.current = node.id;
200
-
201
- // Reheat the simulation
202
- simulation.alphaTarget(0.3).restart();
256
+ // Mark as dragging immediately
257
+ draggingRef.current = { nodeId: node.id, active: true };
203
258
 
204
- // Fix the node position
259
+ // Find and fix the simulation node
205
260
  const simNode = simulation.nodes().find((n) => n.id === node.id);
206
261
  if (simNode) {
207
- simNode.fx = simNode.x;
208
- simNode.fy = simNode.y;
262
+ simNode.fx = node.position.x;
263
+ simNode.fy = node.position.y;
209
264
  }
265
+
266
+ // Gently reheat the simulation
267
+ simulation.alphaTarget(0.1).restart();
210
268
  },
211
269
  []
212
270
  );
213
271
 
214
272
  /**
215
- * Handle drag - update the fixed position
273
+ * Handle drag - update the fixed position without triggering React updates
216
274
  */
217
- const onNodeDrag = useCallback(
218
- (_: React.MouseEvent, node: Node) => {
219
- const simulation = simulationRef.current;
220
- if (!simulation) return;
275
+ const onNodeDrag = useCallback((_: React.MouseEvent, node: Node) => {
276
+ const simulation = simulationRef.current;
277
+ if (!simulation) return;
221
278
 
222
- const simNode = simulation.nodes().find((n) => n.id === node.id);
223
- if (simNode) {
224
- simNode.fx = node.position.x;
225
- simNode.fy = node.position.y;
226
- }
227
- },
228
- []
229
- );
279
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
280
+ if (simNode) {
281
+ simNode.fx = node.position.x;
282
+ simNode.fy = node.position.y;
283
+ // Update cache
284
+ nodePositionsRef.current.set(node.id, {
285
+ x: node.position.x,
286
+ y: node.position.y,
287
+ });
288
+ }
289
+ }, []);
230
290
 
231
291
  /**
232
292
  * Handle drag end - unfix the node and let simulation cool down
@@ -234,11 +294,13 @@ export function useForceLayout(
234
294
  const onNodeDragStop = useCallback(
235
295
  (_: React.MouseEvent, node: Node) => {
236
296
  const simulation = simulationRef.current;
237
- if (!simulation) return;
238
297
 
239
- draggingNodeRef.current = null;
298
+ // Clear dragging state first
299
+ draggingRef.current = { nodeId: null, active: false };
240
300
 
241
- // Let simulation cool down
301
+ if (!simulation) return;
302
+
303
+ // Let simulation cool down gradually
242
304
  simulation.alphaTarget(0);
243
305
 
244
306
  // Unfix the node (unless it's the root)
@@ -249,6 +311,13 @@ export function useForceLayout(
249
311
  simNode.fy = null;
250
312
  }
251
313
  }
314
+
315
+ // Schedule a gentle restart to let the graph settle
316
+ setTimeout(() => {
317
+ if (simulationRef.current && !draggingRef.current.active) {
318
+ simulationRef.current.alpha(0.1).restart();
319
+ }
320
+ }, 50);
252
321
  },
253
322
  [rootNodeId]
254
323
  );
@@ -269,7 +338,9 @@ export function useForceLayout(
269
338
  }
270
339
 
271
340
  if (updates.linkDistance !== undefined) {
272
- const linkForce = simulation.force("link") as ReturnType<typeof forceLink<SimNode, SimLink>> | undefined;
341
+ const linkForce = simulation.force("link") as
342
+ | ReturnType<typeof forceLink<SimNode, SimLink>>
343
+ | undefined;
273
344
  if (linkForce) {
274
345
  linkForce.distance(updates.linkDistance);
275
346
  }
@@ -283,7 +354,7 @@ export function useForceLayout(
283
354
  }
284
355
 
285
356
  // Reheat simulation to apply changes
286
- simulation.alpha(0.5).restart();
357
+ simulation.alpha(0.3).restart();
287
358
  },
288
359
  []
289
360
  );
@@ -358,7 +429,10 @@ export function computeForceLayout(
358
429
  .distance(config.linkDistance)
359
430
  )
360
431
  .force("charge", forceManyBody().strength(config.chargeStrength))
361
- .force("center", forceCenter(centerX, centerY).strength(config.centerStrength))
432
+ .force(
433
+ "center",
434
+ forceCenter(centerX, centerY).strength(config.centerStrength)
435
+ )
362
436
  .force("collision", forceCollide(config.collisionRadius))
363
437
  .stop();
364
438
 
package/src/index.ts CHANGED
@@ -6,8 +6,31 @@
6
6
  * @packageDocumentation
7
7
  */
8
8
 
9
- // Main component export
9
+ // Main component exports
10
10
  export { CyvestGraph } from "./components/CyvestGraph";
11
+ export { ObservablesGraph } from "./components/ObservablesGraph";
12
+ export { InvestigationGraph } from "./components/InvestigationGraph";
13
+
14
+ // Icon exports for customization
15
+ export {
16
+ getObservableIcon,
17
+ getInvestigationIcon,
18
+ OBSERVABLE_ICON_MAP,
19
+ INVESTIGATION_ICON_MAP,
20
+ type IconProps,
21
+ } from "./components/Icons";
11
22
 
12
23
  // Re-export types for consumers
13
- export type { CyvestGraphProps, ForceLayoutConfig } from "./types";
24
+ export type {
25
+ CyvestGraphProps,
26
+ ObservablesGraphProps,
27
+ InvestigationGraphProps,
28
+ ForceLayoutConfig,
29
+ ObservableNodeData,
30
+ ObservableEdgeData,
31
+ InvestigationNodeData,
32
+ InvestigationNodeType,
33
+ ObservableShape,
34
+ } from "./types";
35
+
36
+ export { DEFAULT_FORCE_CONFIG } from "./types";
package/src/types.ts CHANGED
@@ -11,8 +11,9 @@ import type { Level } from "@cyvest/cyvest-js";
11
11
 
12
12
  /**
13
13
  * Shape types for observable nodes.
14
+ * In the current design, all non-root nodes are circles.
14
15
  */
15
- export type ObservableShape = "square" | "circle" | "rectangle" | "triangle";
16
+ export type ObservableShape = "circle" | "rectangle";
16
17
 
17
18
  /**
18
19
  * Data attached to observable graph nodes.
@@ -28,8 +29,6 @@ export interface ObservableNodeData extends Record<string, unknown> {
28
29
  level: Level;
29
30
  /** Numeric score */
30
31
  score: number;
31
- /** Emoji representing the observable type */
32
- emoji: string;
33
32
  /** Shape for this node type */
34
33
  shape: ObservableShape;
35
34
  /** Whether this is the root observable */
@@ -85,8 +84,6 @@ export interface InvestigationNodeData extends Record<string, unknown> {
85
84
  description?: string;
86
85
  /** Path (for containers) */
87
86
  path?: string;
88
- /** Emoji for the node */
89
- emoji: string;
90
87
  }
91
88
 
92
89
  /**
@@ -107,26 +104,27 @@ export type InvestigationEdge = Edge;
107
104
  * Configuration options for d3-force layout.
108
105
  */
109
106
  export interface ForceLayoutConfig {
110
- /** Strength of the charge force (repulsion). Default: -300 */
107
+ /** Strength of the charge force (repulsion). Default: -200 */
111
108
  chargeStrength: number;
112
- /** Target distance between linked nodes. Default: 100 */
109
+ /** Target distance between linked nodes. Default: 80 */
113
110
  linkDistance: number;
114
- /** Strength of the centering force. Default: 0.1 */
111
+ /** Strength of the centering force. Default: 0.05 */
115
112
  centerStrength: number;
116
- /** Strength of the collision force. Default: 30 */
113
+ /** Radius for collision detection. Default: 40 */
117
114
  collisionRadius: number;
118
- /** Number of simulation iterations. Default: 300 */
115
+ /** Number of simulation iterations (for static layout). Default: 300 */
119
116
  iterations: number;
120
117
  }
121
118
 
122
119
  /**
123
120
  * Default force layout configuration.
121
+ * Tuned for good visual separation and smooth animations.
124
122
  */
125
123
  export const DEFAULT_FORCE_CONFIG: ForceLayoutConfig = {
126
124
  chargeStrength: -200,
127
125
  linkDistance: 80,
128
126
  centerStrength: 0.05,
129
- collisionRadius: 40,
127
+ collisionRadius: 45,
130
128
  iterations: 300,
131
129
  };
132
130
 
@@ -5,95 +5,20 @@
5
5
  import { getColorForLevel, type Level } from "@cyvest/cyvest-js";
6
6
  import type { ObservableShape } from "../types";
7
7
 
8
- /**
9
- * Map observable types to emojis.
10
- */
11
- const OBSERVABLE_EMOJI_MAP: Record<string, string> = {
12
- // Network
13
- "ipv4-addr": "๐ŸŒ",
14
- "ipv6-addr": "๐ŸŒ",
15
- "domain-name": "๐Ÿ ",
16
- url: "๐Ÿ”—",
17
- "autonomous-system": "๐ŸŒ",
18
- "mac-addr": "๐Ÿ“ถ",
19
-
20
- // Email
21
- "email-addr": "๐Ÿ“ง",
22
- "email-message": "โœ‰๏ธ",
23
-
24
- // File
25
- file: "๐Ÿ“„",
26
- "file-hash": "๐Ÿ”",
27
- "file:hash:md5": "๐Ÿ”",
28
- "file:hash:sha1": "๐Ÿ”",
29
- "file:hash:sha256": "๐Ÿ”",
30
-
31
- // User/Identity
32
- user: "๐Ÿ‘ค",
33
- "user-account": "๐Ÿ‘ค",
34
- identity: "๐Ÿชช",
35
-
36
- // Process/System
37
- process: "โš™๏ธ",
38
- software: "๐Ÿ’ฟ",
39
- "windows-registry-key": "๐Ÿ“",
40
-
41
- // Threat Intelligence
42
- "threat-actor": "๐Ÿ‘น",
43
- malware: "๐Ÿฆ ",
44
- "attack-pattern": "โš”๏ธ",
45
- campaign: "๐ŸŽฏ",
46
- indicator: "๐Ÿšจ",
47
-
48
- // Artifacts
49
- artifact: "๐Ÿงช",
50
- certificate: "๐Ÿ“œ",
51
- "x509-certificate": "๐Ÿ“œ",
52
-
53
- // Default
54
- unknown: "โ“",
55
- };
56
-
57
- /**
58
- * Get the emoji for an observable type.
59
- * Falls back to a generic icon if type is unknown.
60
- */
61
- export function getObservableEmoji(observableType: string): string {
62
- const normalized = observableType.toLowerCase().trim();
63
- return OBSERVABLE_EMOJI_MAP[normalized] ?? OBSERVABLE_EMOJI_MAP.unknown;
64
- }
65
-
66
- /**
67
- * Map observable types to shapes.
68
- */
69
- const OBSERVABLE_SHAPE_MAP: Record<string, ObservableShape> = {
70
- // Domains get squares
71
- "domain-name": "square",
72
-
73
- // URLs get circles
74
- url: "circle",
75
-
76
- // IPs get triangles
77
- "ipv4-addr": "triangle",
78
- "ipv6-addr": "triangle",
79
-
80
- // Root/files get rectangles (default for root)
81
- file: "rectangle",
82
- "email-message": "rectangle",
83
- };
84
-
85
8
  /**
86
9
  * Get the shape for an observable type.
10
+ * All non-root nodes are circles for a cleaner look.
87
11
  */
88
12
  export function getObservableShape(
89
- observableType: string,
13
+ _observableType: string,
90
14
  isRoot: boolean
91
15
  ): ObservableShape {
16
+ // Root nodes get a rounded rectangle (pill shape)
92
17
  if (isRoot) {
93
18
  return "rectangle";
94
19
  }
95
- const normalized = observableType.toLowerCase().trim();
96
- return OBSERVABLE_SHAPE_MAP[normalized] ?? "circle";
20
+ // All other nodes are circles
21
+ return "circle";
97
22
  }
98
23
 
99
24
  /**
@@ -123,6 +48,9 @@ export function getLevelColor(level: Level): string {
123
48
  return getColorForLevel(level);
124
49
  }
125
50
 
51
+ /**
52
+ * Lighten a hex color by a percentage.
53
+ */
126
54
  function lightenHexColor(hex: string, amount: number): string {
127
55
  const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
128
56
  if (normalized.length !== 6) {
@@ -145,21 +73,5 @@ function lightenHexColor(hex: string, amount: number): string {
145
73
  * Get background color for security level (lighter version).
146
74
  */
147
75
  export function getLevelBackgroundColor(level: Level): string {
148
- return lightenHexColor(getLevelColor(level), 0.85);
149
- }
150
-
151
- /**
152
- * Emoji map for investigation node types.
153
- */
154
- const INVESTIGATION_NODE_EMOJI: Record<string, string> = {
155
- root: "๐ŸŽฏ",
156
- check: "โœ…",
157
- container: "๐Ÿ“ฆ",
158
- };
159
-
160
- /**
161
- * Get emoji for investigation node type.
162
- */
163
- export function getInvestigationNodeEmoji(nodeType: string): string {
164
- return INVESTIGATION_NODE_EMOJI[nodeType] ?? "โ“";
76
+ return lightenHexColor(getLevelColor(level), 0.88);
165
77
  }
@@ -2,26 +2,21 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { LEVEL_COLORS } from "@cyvest/cyvest-js";
4
4
  import {
5
- getInvestigationNodeEmoji,
6
5
  getLevelBackgroundColor,
7
6
  getLevelColor,
8
- getObservableEmoji,
9
7
  getObservableShape,
10
8
  truncateLabel,
11
9
  } from "../src/utils/observables";
12
10
 
13
11
  describe("observables utils", () => {
14
- it("returns emojis for known and unknown observable types", () => {
15
- expect(getObservableEmoji("domain-name")).toBe("๐Ÿ ");
16
- expect(getObservableEmoji("IPv4-Addr")).toBe("๐ŸŒ");
17
- expect(getObservableEmoji("unmapped")).toBe("โ“");
18
- });
19
-
20
- it("returns shapes based on type and root flag", () => {
21
- expect(getObservableShape("domain-name", false)).toBe("square");
22
- expect(getObservableShape("ipv6-addr", false)).toBe("triangle");
12
+ it("returns shapes based on root flag (all non-root nodes are circles)", () => {
13
+ // All non-root nodes are now circles for a cleaner design
14
+ expect(getObservableShape("domain-name", false)).toBe("circle");
15
+ expect(getObservableShape("ipv6-addr", false)).toBe("circle");
23
16
  expect(getObservableShape("anything-else", false)).toBe("circle");
17
+ // Root nodes get a rectangle (pill shape)
24
18
  expect(getObservableShape("anything-else", true)).toBe("rectangle");
19
+ expect(getObservableShape("domain-name", true)).toBe("rectangle");
25
20
  });
26
21
 
27
22
  it("truncates long labels in the middle by default", () => {
@@ -32,11 +27,9 @@ describe("observables utils", () => {
32
27
 
33
28
  it("maps levels to colors", () => {
34
29
  expect(getLevelColor("SUSPICIOUS")).toBe(LEVEL_COLORS.SUSPICIOUS);
35
- expect(getLevelBackgroundColor("SUSPICIOUS")).toBe("#feeadc");
36
- });
37
-
38
- it("returns investigation node emoji with fallback", () => {
39
- expect(getInvestigationNodeEmoji("root")).toBe("๐ŸŽฏ");
40
- expect(getInvestigationNodeEmoji("missing")).toBe("โ“");
30
+ // Background color is the level color lightened by 88%
31
+ const bgColor = getLevelBackgroundColor("SUSPICIOUS");
32
+ // Just verify it's a valid hex color that's lighter than the original
33
+ expect(bgColor).toMatch(/^#[0-9a-f]{6}$/i);
41
34
  });
42
35
  });