@aiready/visualizer 0.1.13 → 0.1.18
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/package.json +9 -3
- package/web/dist/assets/{index-Bu-j1wcp.js → index-R1Ga3mzd.js} +10 -10
- package/web/dist/assets/{index-B7g4wBah.css → index-cSvqzd3J.css} +1 -1
- package/web/dist/index.html +2 -2
- package/web/index.html +12 -0
- package/web/package.json +30 -0
- package/web/public/logo-transparent-bg.png +0 -0
- package/web/public/report-data.json +5827 -0
- package/web/src/App.tsx +192 -0
- package/web/src/components/ErrorDisplay.tsx +39 -0
- package/web/src/components/GraphCanvas.tsx +205 -0
- package/web/src/components/LegendPanel.tsx +284 -0
- package/web/src/components/LoadingSpinner.tsx +19 -0
- package/web/src/components/Navbar.tsx +169 -0
- package/web/src/components/NodeDetails.tsx +210 -0
- package/web/src/components/index.ts +6 -0
- package/web/src/constants.ts +99 -0
- package/web/src/css.d.ts +4 -0
- package/web/src/hooks/useDimensions.ts +28 -0
- package/web/src/hooks/useTheme.ts +27 -0
- package/web/src/main.tsx +11 -0
- package/web/src/style.css +2 -0
- package/web/src/styles/index.css +42 -0
- package/web/src/types.ts +78 -0
- package/web/src/utils.ts +184 -0
- package/web/tsconfig.json +22 -0
- package/web/vite.config.ts +46 -0
package/web/src/App.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo } from 'react';
|
|
2
|
+
import { GraphData, FileNode, SeverityLevel, EdgeType } from './types';
|
|
3
|
+
import { themeConfig } from './constants';
|
|
4
|
+
import { transformReportToGraph, loadReportData } from './utils';
|
|
5
|
+
import { useDimensions } from './hooks/useDimensions';
|
|
6
|
+
import { useTheme } from './hooks/useTheme';
|
|
7
|
+
import {
|
|
8
|
+
LoadingSpinner,
|
|
9
|
+
ErrorDisplay,
|
|
10
|
+
Navbar,
|
|
11
|
+
LegendPanel,
|
|
12
|
+
NodeDetails,
|
|
13
|
+
GraphCanvas,
|
|
14
|
+
} from './components';
|
|
15
|
+
|
|
16
|
+
// All available severity levels
|
|
17
|
+
const ALL_SEVERITIES: SeverityLevel[] = ['critical', 'major', 'minor', 'info'];
|
|
18
|
+
|
|
19
|
+
// All available edge types (excluding 'default' and 'reference' as they're filtered out in UI)
|
|
20
|
+
const ALL_EDGE_TYPES: EdgeType[] = ['similarity', 'dependency', 'related'];
|
|
21
|
+
|
|
22
|
+
function App() {
|
|
23
|
+
const [data, setData] = useState<GraphData | null>(null);
|
|
24
|
+
const [selectedNode, setSelectedNode] = useState<FileNode | null>(null);
|
|
25
|
+
const [loading, setLoading] = useState(true);
|
|
26
|
+
const [error, setError] = useState<string | null>(null);
|
|
27
|
+
|
|
28
|
+
// Filter state - start with all visible
|
|
29
|
+
const [visibleSeverities, setVisibleSeverities] = useState<Set<SeverityLevel>>(
|
|
30
|
+
new Set(ALL_SEVERITIES)
|
|
31
|
+
);
|
|
32
|
+
const [visibleEdgeTypes, setVisibleEdgeTypes] = useState<Set<EdgeType>>(
|
|
33
|
+
new Set(ALL_EDGE_TYPES)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const { containerRef, dimensions } = useDimensions();
|
|
37
|
+
const { theme, setTheme, effectiveTheme } = useTheme();
|
|
38
|
+
|
|
39
|
+
const colors = themeConfig[effectiveTheme];
|
|
40
|
+
|
|
41
|
+
// Load report data
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const loadData = async () => {
|
|
44
|
+
const reportData = await loadReportData();
|
|
45
|
+
|
|
46
|
+
if (!reportData) {
|
|
47
|
+
setError(
|
|
48
|
+
'No scan data found. Run "pnpm aiready scan ." then copy to public/report-data.json'
|
|
49
|
+
);
|
|
50
|
+
setLoading(false);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setData(transformReportToGraph(reportData));
|
|
55
|
+
setLoading(false);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
loadData();
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// Toggle severity visibility
|
|
62
|
+
const handleToggleSeverity = (severity: SeverityLevel) => {
|
|
63
|
+
setVisibleSeverities(prev => {
|
|
64
|
+
const next = new Set(prev);
|
|
65
|
+
if (next.has(severity)) {
|
|
66
|
+
next.delete(severity);
|
|
67
|
+
} else {
|
|
68
|
+
next.add(severity);
|
|
69
|
+
}
|
|
70
|
+
return next;
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Toggle edge type visibility
|
|
75
|
+
const handleToggleEdgeType = (edgeType: EdgeType) => {
|
|
76
|
+
setVisibleEdgeTypes(prev => {
|
|
77
|
+
const next = new Set(prev);
|
|
78
|
+
if (next.has(edgeType)) {
|
|
79
|
+
next.delete(edgeType);
|
|
80
|
+
} else {
|
|
81
|
+
next.add(edgeType);
|
|
82
|
+
}
|
|
83
|
+
return next;
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Filter data based on visible severities and edge types
|
|
88
|
+
// Also hides edges connected to hidden nodes
|
|
89
|
+
const filteredData = useMemo(() => {
|
|
90
|
+
if (!data) return null;
|
|
91
|
+
|
|
92
|
+
// Get set of visible node IDs
|
|
93
|
+
const visibleNodeIds = new Set(
|
|
94
|
+
data.nodes
|
|
95
|
+
.filter(node => {
|
|
96
|
+
const severity = (node.severity || 'default') as SeverityLevel;
|
|
97
|
+
return visibleSeverities.has(severity);
|
|
98
|
+
})
|
|
99
|
+
.map(node => node.id)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Filter nodes: keep if severity is visible
|
|
103
|
+
const filteredNodes = data.nodes.filter(node => {
|
|
104
|
+
const severity = (node.severity || 'default') as SeverityLevel;
|
|
105
|
+
return visibleSeverities.has(severity);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Filter edges: keep if:
|
|
109
|
+
// 1. Edge type is visible AND
|
|
110
|
+
// 2. Both source and target nodes are visible
|
|
111
|
+
const filteredEdges = data.edges.filter(edge => {
|
|
112
|
+
// Check edge type visibility
|
|
113
|
+
const edgeType = (edge.type || 'default') as EdgeType;
|
|
114
|
+
if (!visibleEdgeTypes.has(edgeType)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
// Check that both connected nodes are visible
|
|
118
|
+
const sourceId = typeof edge.source === 'string' ? edge.source : edge.source.id;
|
|
119
|
+
const targetId = typeof edge.target === 'string' ? edge.target : edge.target.id;
|
|
120
|
+
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
nodes: filteredNodes,
|
|
125
|
+
edges: filteredEdges,
|
|
126
|
+
};
|
|
127
|
+
}, [data, visibleSeverities, visibleEdgeTypes]);
|
|
128
|
+
|
|
129
|
+
// Handle loading state
|
|
130
|
+
if (loading) {
|
|
131
|
+
return <LoadingSpinner colors={colors} />;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Handle error state
|
|
135
|
+
if (error) {
|
|
136
|
+
return <ErrorDisplay colors={colors} error={error} />;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div
|
|
141
|
+
className="flex flex-col h-screen font-sans"
|
|
142
|
+
style={{ backgroundColor: colors.bg, color: colors.text }}
|
|
143
|
+
>
|
|
144
|
+
<Navbar
|
|
145
|
+
colors={colors}
|
|
146
|
+
theme={theme}
|
|
147
|
+
setTheme={setTheme}
|
|
148
|
+
data={filteredData}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<div className="flex flex-1 overflow-hidden">
|
|
152
|
+
<div ref={containerRef} className="flex-1 relative">
|
|
153
|
+
{filteredData && (
|
|
154
|
+
<GraphCanvas
|
|
155
|
+
data={filteredData}
|
|
156
|
+
dimensions={dimensions}
|
|
157
|
+
colors={colors}
|
|
158
|
+
effectiveTheme={effectiveTheme}
|
|
159
|
+
onNodeClick={setSelectedNode}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
{/* Right panel: Legend OR NodeDetails */}
|
|
165
|
+
<div
|
|
166
|
+
className="w-80 border-l flex flex-col h-full"
|
|
167
|
+
style={{ backgroundColor: colors.panel, borderColor: colors.panelBorder }}
|
|
168
|
+
>
|
|
169
|
+
{selectedNode ? (
|
|
170
|
+
<NodeDetails
|
|
171
|
+
colors={colors}
|
|
172
|
+
selectedNode={selectedNode}
|
|
173
|
+
onClose={() => setSelectedNode(null)}
|
|
174
|
+
/>
|
|
175
|
+
) : (
|
|
176
|
+
<div className="flex-1 overflow-y-auto">
|
|
177
|
+
<LegendPanel
|
|
178
|
+
colors={colors}
|
|
179
|
+
visibleSeverities={visibleSeverities}
|
|
180
|
+
visibleEdgeTypes={visibleEdgeTypes}
|
|
181
|
+
onToggleSeverity={handleToggleSeverity}
|
|
182
|
+
onToggleEdgeType={handleToggleEdgeType}
|
|
183
|
+
/>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export default App;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { ThemeColors } from '../types';
|
|
2
|
+
|
|
3
|
+
interface ErrorDisplayProps {
|
|
4
|
+
colors: ThemeColors;
|
|
5
|
+
error: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ErrorDisplay({ colors, error }: ErrorDisplayProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="flex h-screen items-center justify-center" style={{ backgroundColor: colors.bg, color: colors.text }}>
|
|
11
|
+
<div className="text-center max-w-md p-6">
|
|
12
|
+
<svg
|
|
13
|
+
className="w-16 h-16 mx-auto text-amber-500 mb-4"
|
|
14
|
+
fill="none"
|
|
15
|
+
viewBox="0 0 24 24"
|
|
16
|
+
stroke="currentColor"
|
|
17
|
+
>
|
|
18
|
+
<path
|
|
19
|
+
strokeLinecap="round"
|
|
20
|
+
strokeLinejoin="round"
|
|
21
|
+
strokeWidth={2}
|
|
22
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
23
|
+
/>
|
|
24
|
+
</svg>
|
|
25
|
+
<h2 className="text-xl font-semibold mb-2 text-amber-400">No Scan Data Found</h2>
|
|
26
|
+
<p className="mb-4" style={{ color: colors.textMuted }}>{error}</p>
|
|
27
|
+
<div
|
|
28
|
+
className="text-left p-4 rounded-lg text-sm font-mono"
|
|
29
|
+
style={{ backgroundColor: colors.cardBg }}
|
|
30
|
+
>
|
|
31
|
+
<p className="text-cyan-400"># Step 1: Run aiready scan</p>
|
|
32
|
+
<p className="mb-2" style={{ color: colors.textMuted }}>aiready scan . --output json</p>
|
|
33
|
+
<p className="text-cyan-400"># Step 2: Copy latest report to visualizer</p>
|
|
34
|
+
<p style={{ color: colors.textMuted }}>cp .aiready/aiready-report-*.json packages/visualizer/web/public/report-data.json</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import * as d3 from 'd3';
|
|
3
|
+
import { FileNode, GraphData, ThemeColors, EffectiveTheme } from '../types';
|
|
4
|
+
import { severityColors, edgeColors, GRAPH_CONFIG } from '../constants';
|
|
5
|
+
import { getEdgeDistance, getEdgeStrength, getEdgeOpacity, getEdgeStrokeWidth } from '../utils';
|
|
6
|
+
|
|
7
|
+
interface GraphCanvasProps {
|
|
8
|
+
data: GraphData;
|
|
9
|
+
dimensions: { width: number; height: number };
|
|
10
|
+
colors: ThemeColors;
|
|
11
|
+
effectiveTheme: EffectiveTheme;
|
|
12
|
+
onNodeClick: (node: FileNode | null) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function GraphCanvas({
|
|
16
|
+
data,
|
|
17
|
+
dimensions,
|
|
18
|
+
colors,
|
|
19
|
+
effectiveTheme,
|
|
20
|
+
onNodeClick,
|
|
21
|
+
}: GraphCanvasProps) {
|
|
22
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
23
|
+
const zoomTransformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!data || !svgRef.current || !data.nodes.length) return;
|
|
27
|
+
|
|
28
|
+
const svg = d3.select(svgRef.current);
|
|
29
|
+
|
|
30
|
+
// Get actual SVG dimensions from the DOM element
|
|
31
|
+
const svgRect = svgRef.current.getBoundingClientRect();
|
|
32
|
+
const width = svgRect.width;
|
|
33
|
+
const height = svgRect.height;
|
|
34
|
+
|
|
35
|
+
svg.selectAll('*').remove();
|
|
36
|
+
|
|
37
|
+
const g = svg.append('g');
|
|
38
|
+
|
|
39
|
+
// Setup zoom
|
|
40
|
+
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
|
41
|
+
.scaleExtent([GRAPH_CONFIG.zoomMin, GRAPH_CONFIG.zoomMax])
|
|
42
|
+
.on('zoom', (event) => {
|
|
43
|
+
g.attr('transform', event.transform);
|
|
44
|
+
zoomTransformRef.current = event.transform;
|
|
45
|
+
});
|
|
46
|
+
svg.call(zoom);
|
|
47
|
+
svg.call(zoom.transform, zoomTransformRef.current);
|
|
48
|
+
|
|
49
|
+
// Prepare nodes and links
|
|
50
|
+
const nodes = data.nodes.map((d, i) => {
|
|
51
|
+
// Initialize nodes in a circle around center with slight random spread
|
|
52
|
+
const angle = (i / data.nodes.length) * Math.PI * 2;
|
|
53
|
+
const radius = 50 + Math.random() * 30;
|
|
54
|
+
return {
|
|
55
|
+
...d,
|
|
56
|
+
x: width / 2 + Math.cos(angle) * radius,
|
|
57
|
+
y: height / 2 + Math.sin(angle) * radius,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
const links = data.edges.map(d => ({ ...d }));
|
|
61
|
+
|
|
62
|
+
// Create force simulation
|
|
63
|
+
const simulation = d3.forceSimulation(nodes as d3.SimulationNodeDatum[])
|
|
64
|
+
.force(
|
|
65
|
+
'link',
|
|
66
|
+
d3.forceLink(links)
|
|
67
|
+
.id((d: unknown) => (d as { id: string }).id)
|
|
68
|
+
.distance((d: unknown) => getEdgeDistance((d as { type: string }).type))
|
|
69
|
+
.strength((d: unknown) => getEdgeStrength((d as { type: string }).type))
|
|
70
|
+
)
|
|
71
|
+
.force('charge', d3.forceManyBody().strength(GRAPH_CONFIG.simulation.chargeStrength))
|
|
72
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
73
|
+
.force('collision', d3.forceCollide().radius(GRAPH_CONFIG.collisionRadius))
|
|
74
|
+
.force('x', d3.forceX(width / 2).strength(GRAPH_CONFIG.simulation.centerStrength))
|
|
75
|
+
.force('y', d3.forceY(height / 2).strength(GRAPH_CONFIG.simulation.centerStrength));
|
|
76
|
+
|
|
77
|
+
// Create link group
|
|
78
|
+
const linkGroup = g.append('g').attr('class', 'links');
|
|
79
|
+
const link = linkGroup
|
|
80
|
+
.selectAll('line')
|
|
81
|
+
.data(links)
|
|
82
|
+
.enter()
|
|
83
|
+
.append('line')
|
|
84
|
+
.attr('stroke', (d: unknown) => edgeColors[(d as { type: string }).type] || edgeColors.default)
|
|
85
|
+
.attr('stroke-opacity', (d: unknown) => getEdgeOpacity((d as { type: string }).type))
|
|
86
|
+
.attr('stroke-width', (d: unknown) => getEdgeStrokeWidth((d as { type: string }).type));
|
|
87
|
+
|
|
88
|
+
// Create node group
|
|
89
|
+
const nodeGroup = g.append('g').attr('class', 'nodes');
|
|
90
|
+
|
|
91
|
+
const node = nodeGroup
|
|
92
|
+
.selectAll('g')
|
|
93
|
+
.data(nodes)
|
|
94
|
+
.enter()
|
|
95
|
+
.append('g')
|
|
96
|
+
.attr('cursor', 'pointer')
|
|
97
|
+
.call(
|
|
98
|
+
d3.drag<SVGGElement, any>()
|
|
99
|
+
.on('start', dragstarted)
|
|
100
|
+
.on('drag', dragged)
|
|
101
|
+
.on('end', dragended) as any
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Add circles to nodes
|
|
105
|
+
node
|
|
106
|
+
.append('circle')
|
|
107
|
+
.attr('r', (d: unknown) => Math.sqrt((d as { value: number }).value || 10) + GRAPH_CONFIG.nodeBaseRadius)
|
|
108
|
+
.attr('fill', (d: unknown) => (d as { color: string }).color || severityColors.default)
|
|
109
|
+
.attr('stroke', effectiveTheme === 'dark' ? '#fff' : '#000')
|
|
110
|
+
.attr('stroke-width', 1.5);
|
|
111
|
+
|
|
112
|
+
// Add labels to nodes
|
|
113
|
+
node
|
|
114
|
+
.append('text')
|
|
115
|
+
.text((d: unknown) => ((d as { label: string }).label.split('/').pop() || (d as { label: string }).label))
|
|
116
|
+
.attr('x', 0)
|
|
117
|
+
.attr('y', (d: unknown) => Math.sqrt((d as { value: number }).value || 10) + 12)
|
|
118
|
+
.attr('text-anchor', 'middle')
|
|
119
|
+
.attr('fill', effectiveTheme === 'dark' ? '#e2e8f0' : '#1e293b')
|
|
120
|
+
.attr('font-size', '9px')
|
|
121
|
+
.attr('font-family', 'system-ui, sans-serif')
|
|
122
|
+
.attr('pointer-events', 'none');
|
|
123
|
+
|
|
124
|
+
// Add tooltips
|
|
125
|
+
node.append('title').text((d: unknown) => (d as { title: string }).title);
|
|
126
|
+
|
|
127
|
+
// Event handlers
|
|
128
|
+
node.on('click', (event: unknown, d: unknown) => {
|
|
129
|
+
(event as Event).stopPropagation();
|
|
130
|
+
onNodeClick(d as FileNode);
|
|
131
|
+
});
|
|
132
|
+
svg.on('click', () => onNodeClick(null));
|
|
133
|
+
|
|
134
|
+
// Simulation tick
|
|
135
|
+
simulation.on('tick', () => {
|
|
136
|
+
link
|
|
137
|
+
.attr('x1', (d: unknown) => (d as { source: { x: number } }).source.x)
|
|
138
|
+
.attr('y1', (d: unknown) => (d as { source: { y: number } }).source.y)
|
|
139
|
+
.attr('x2', (d: unknown) => (d as { target: { x: number } }).target.x)
|
|
140
|
+
.attr('y2', (d: unknown) => (d as { target: { y: number } }).target.y);
|
|
141
|
+
|
|
142
|
+
node.attr('transform', (d: unknown) => `translate(${(d as { x: number }).x},${(d as { y: number }).y})`);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Drag functions
|
|
146
|
+
function dragstarted(event: unknown, d: unknown) {
|
|
147
|
+
if (!(event as { active: boolean }).active) simulation.alphaTarget(0.3).restart();
|
|
148
|
+
(d as { fx: number | null }).fx = (d as { x: number }).x;
|
|
149
|
+
(d as { fy: number | null }).fy = (d as { y: number }).y;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function dragged(event: unknown, d: unknown) {
|
|
153
|
+
(d as { fx: number }).fx = (event as { x: number }).x;
|
|
154
|
+
(d as { fy: number }).fy = (event as { y: number }).y;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function dragended(event: unknown, d: unknown) {
|
|
158
|
+
if (!(event as { active: boolean }).active) simulation.alphaTarget(0);
|
|
159
|
+
(d as { fx: null }).fx = null;
|
|
160
|
+
(d as { fy: null }).fy = null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return () => {
|
|
164
|
+
simulation.stop();
|
|
165
|
+
};
|
|
166
|
+
}, [data, dimensions, effectiveTheme, onNodeClick]);
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="flex-1 relative w-full h-full">
|
|
170
|
+
<div
|
|
171
|
+
className="absolute inset-0 pointer-events-none z-0"
|
|
172
|
+
style={{
|
|
173
|
+
backgroundImage: `linear-gradient(${colors.grid} 1px, transparent 1px), linear-gradient(90deg, ${colors.grid} 1px, transparent 1px)`,
|
|
174
|
+
backgroundSize: '40px 40px',
|
|
175
|
+
}}
|
|
176
|
+
/>
|
|
177
|
+
<svg
|
|
178
|
+
ref={svgRef}
|
|
179
|
+
width="100%"
|
|
180
|
+
height="100%"
|
|
181
|
+
style={{ display: 'block', backgroundColor: 'transparent', zIndex: 10, position: 'absolute', top: 0, left: 0 }}
|
|
182
|
+
/>
|
|
183
|
+
<div className="absolute bottom-6 left-6 z-20">
|
|
184
|
+
<div
|
|
185
|
+
className="px-4 py-2.5 rounded-lg backdrop-blur-sm border text-xs flex items-center gap-2"
|
|
186
|
+
style={{
|
|
187
|
+
backgroundColor: `${colors.panel}ee`,
|
|
188
|
+
borderColor: colors.panelBorder,
|
|
189
|
+
color: colors.textMuted,
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
193
|
+
<path
|
|
194
|
+
strokeLinecap="round"
|
|
195
|
+
strokeLinejoin="round"
|
|
196
|
+
strokeWidth={2}
|
|
197
|
+
d="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-3V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"
|
|
198
|
+
/>
|
|
199
|
+
</svg>
|
|
200
|
+
<span>Drag to move • Scroll to zoom • Click for details</span>
|
|
201
|
+
</div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|