mirror of
https://github.com/reconurge/flowsint.git
synced 2026-08-02 21:51:44 -05:00
feat(app): custom icons + graph refactor
This commit is contained in:
+12
-16
@@ -1,29 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" theme="dark">
|
||||
<head>
|
||||
<!-- uncomment for react scan -->
|
||||
<!-- <script
|
||||
crossOrigin="anonymous"
|
||||
src="//unpkg.com/react-scan/dist/auto.global.js"
|
||||
>
|
||||
</script> -->
|
||||
<script crossorigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js"></script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
|
||||
<!-- Google Fonts - Oxanium -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Oxanium:wght@200;300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Oxanium:wght@200;300;400;500;600;700;800&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/icon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icon.png" />
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Graph based investigation manager."
|
||||
/>
|
||||
<meta name="description" content="Graph based investigation manager." />
|
||||
<title>Flowsint</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useLocation, useParams } from '@tanstack/react-router'
|
||||
import InvestigationList from '../investigations/investigation-list'
|
||||
import GraphNavigation from '../sketches/graph-navigation'
|
||||
import GraphNavigation from '../sketches/graph/panels/graph-navigation'
|
||||
import FlowNavigation from '../flows/flow-navigation'
|
||||
import SketchList from '../investigations/sketch-list'
|
||||
import AnalysesList from '../analyses/analyses-list'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Terminal, Unlock } from 'lucide-react'
|
||||
import { ModeToggle } from '../mode-toggle'
|
||||
import { useLayoutStore } from '@/stores/layout-store'
|
||||
import Legend from '../sketches/legend'
|
||||
import Legend from '../sketches/graph/components/legend'
|
||||
import { Link, useParams } from '@tanstack/react-router'
|
||||
import InfoDialog from './info'
|
||||
import { memo } from 'react'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
||||
import { CopyButton } from '@/components/copy'
|
||||
import { Check, Rocket, X, MousePointer } from 'lucide-react'
|
||||
import LaunchFlow from '../launch-enricher'
|
||||
import NodeActions from '../node-actions'
|
||||
import NodeActions from '../graph/actions/node-actions'
|
||||
import { Button } from '../../ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
|
||||
+2
-2
@@ -12,9 +12,9 @@ import { Info, Merge, MinusCircleIcon, PlusCircleIcon } from 'lucide-react'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { GraphNode } from '@/types'
|
||||
import { NodeDisplayCard } from './create-relation'
|
||||
import { NodeDisplayCard } from '../../create-relation'
|
||||
import { RadioGroup } from '@/components/ui/radio-group'
|
||||
import { Alert, AlertDescription, AlertTitle } from '../ui/alert'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { cn, deepObjectDiff } from '@/lib/utils'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import React, { useRef, useCallback } from 'react'
|
||||
import GraphViewer from './graph-viewer'
|
||||
import GraphViewer from '../index'
|
||||
// import WebGLGraphViewer from './webgl'
|
||||
import NodeContextMenu from './node-context-menu'
|
||||
import BackgroundContextMenu from './background-context-menu'
|
||||
import EdgeContextMenu from './edge-context-menu'
|
||||
import NodeContextMenu from '../context-menu/node-context-menu'
|
||||
import BackgroundContextMenu from '../context-menu/background-context-menu'
|
||||
import EdgeContextMenu from '../context-menu/edge-context-menu'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
|
||||
const GraphMain = () => {
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Info } from 'lucide-react'
|
||||
import Lasso from '../selectors/lasso'
|
||||
import Rectangle from '../selectors/rectangle'
|
||||
import Lasso from '../../selectors/lasso'
|
||||
import Rectangle from '../../selectors/rectangle'
|
||||
|
||||
interface GraphSelectorOverlayProps {
|
||||
isActive: boolean
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { Type, Share2 } from 'lucide-react'
|
||||
import { TooltipState } from './types'
|
||||
import { TooltipState } from './utils/types'
|
||||
|
||||
interface GraphTooltipProps {
|
||||
tooltip: TooltipState
|
||||
+2
-2
@@ -15,9 +15,9 @@ import { useParams } from '@tanstack/react-router'
|
||||
import { capitalizeFirstLetter } from '@/lib/utils'
|
||||
import BaseContextMenu from '@/components/xyflow/context-menu'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { CopyButton } from '../copy'
|
||||
import { CopyButton } from '@/components/copy'
|
||||
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
|
||||
import { useConfirm } from '../use-confirm-dialog'
|
||||
import { useConfirm } from '@/components/use-confirm-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
|
||||
+2
-2
@@ -13,10 +13,10 @@ import { useLaunchFlow } from '@/hooks/use-launch-flow'
|
||||
import { useLaunchEnricher } from '@/hooks/use-launch-enricher'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { capitalizeFirstLetter } from '@/lib/utils'
|
||||
import NodeActions from '@/components/sketches/node-actions'
|
||||
import NodeActions from '../actions/node-actions'
|
||||
import BaseContextMenu from '@/components/xyflow/context-menu'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { CopyButton } from '../copy'
|
||||
import { CopyButton } from '@/components/copy'
|
||||
|
||||
interface GraphContextMenuProps {
|
||||
node: GraphNode
|
||||
+7
-19
@@ -1,4 +1,5 @@
|
||||
import { CONSTANTS, GRAPH_COLORS, tempPos, tempDimensions } from './constants'
|
||||
import { CONSTANTS, GRAPH_COLORS, tempPos, tempDimensions } from '../utils/constants'
|
||||
import { calculateNodeSize } from '../utils/utils'
|
||||
|
||||
interface LinkRenderParams {
|
||||
link: any
|
||||
@@ -91,7 +92,6 @@ export const renderLink = ({
|
||||
const isCurrent = currentEdge?.id === link.id
|
||||
const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0
|
||||
let linkWidthBase = forceSettings?.linkWidth?.value ?? 2
|
||||
const nodeSize = forceSettings?.nodeSize?.value ?? 14
|
||||
const shouldRenderDetails = globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD
|
||||
|
||||
const linkWidth = shouldRenderDetails
|
||||
@@ -125,17 +125,9 @@ export const renderLink = ({
|
||||
}
|
||||
|
||||
// Calculate node radii to stop links at node edges
|
||||
const calculateNodeRadius = (node: any) => {
|
||||
const sizeMultiplier = nodeSize / 100 + 0.2
|
||||
const neighborBonus = Math.min(node.neighbors?.length / 5 || 0, 5)
|
||||
const baseSize = (node.nodeSize + neighborBonus) * sizeMultiplier
|
||||
return shouldRenderDetails
|
||||
? baseSize
|
||||
: baseSize * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
|
||||
}
|
||||
|
||||
const startRadius = calculateNodeRadius(start)
|
||||
const endRadius = calculateNodeRadius(end)
|
||||
// Uses the shared calculateNodeSize function to ensure consistency with node-renderer
|
||||
const startRadius = calculateNodeSize(start, forceSettings, shouldRenderDetails, CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER)
|
||||
const endRadius = calculateNodeSize(end, forceSettings, shouldRenderDetails, CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER)
|
||||
|
||||
const arrowLengthSetting = forceSettings?.linkDirectionalArrowLength?.value
|
||||
const arrowLength =
|
||||
@@ -206,12 +198,8 @@ export const renderLink = ({
|
||||
if (arrowRelPos === 1) {
|
||||
const tan = bezierTangent(0.99)
|
||||
const tanLen = Math.hypot(tan.x, tan.y) || 1
|
||||
const sizeMultiplier = nodeSize / 100 + 0.2
|
||||
const neighborBonus = Math.min(end.neighbors?.length / 5 || 0, 5)
|
||||
const baseTargetSize = (end.nodeSize + neighborBonus) * sizeMultiplier
|
||||
const targetNodeSize = globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD
|
||||
? baseTargetSize
|
||||
: baseTargetSize * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
|
||||
// Use the same calculation as above to ensure consistency
|
||||
const targetNodeSize = calculateNodeSize(end, forceSettings, shouldRenderDetails, CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER)
|
||||
arrowX = end.x - (tan.x / tanLen) * targetNodeSize
|
||||
arrowY = end.y - (tan.y / tanLen) * targetNodeSize
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { TooltipState } from './types'
|
||||
import { TooltipState } from '../utils/types'
|
||||
|
||||
export const useTooltip = (graphRef: React.RefObject<any>) => {
|
||||
const [tooltip, setTooltip] = useState<TooltipState>({
|
||||
@@ -7,22 +7,22 @@ import { useGraphStore } from '@/stores/graph-store'
|
||||
import { ItemType, useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { useSaveNodePositions } from '@/hooks/use-save-node-positions'
|
||||
import { CONSTANTS } from './constants'
|
||||
import { GraphViewerProps } from './types'
|
||||
import { preloadImage } from './image-cache'
|
||||
import { renderNode } from './node-renderer'
|
||||
import { renderLink } from './link-renderer'
|
||||
import { useHighlightState } from './use-highlight-state'
|
||||
import { useTooltip } from './use-tooltip'
|
||||
import { transformGraphData } from './graph-data-transformer'
|
||||
import { useGraphLayout } from './use-graph-layout'
|
||||
import { useGraphEvents } from './use-graph-events'
|
||||
import { useGraphInitialization } from './use-graph-initialization'
|
||||
import { GraphEmptyState } from './graph-empty-state'
|
||||
import { GraphTooltip } from './graph-tooltip'
|
||||
import { GraphLoadingOverlay } from './graph-loading-overlay'
|
||||
import { GraphSelectorOverlay } from './graph-selector-overlay'
|
||||
import MinimapCanvas from './minimap'
|
||||
import { CONSTANTS } from './utils/constants'
|
||||
import { GraphViewerProps } from './utils/types'
|
||||
import { preloadImage } from './utils/image-cache'
|
||||
import { renderNode } from './node/node-renderer'
|
||||
import { renderLink } from './edge/link-renderer'
|
||||
import { useHighlightState } from './hooks/use-highlight-state'
|
||||
import { useTooltip } from './hooks/use-tooltip'
|
||||
import { transformGraphData } from './utils/graph-data-transformer'
|
||||
import { useGraphLayout } from './hooks/use-graph-layout'
|
||||
import { useGraphEvents } from './hooks/use-graph-events'
|
||||
import { useGraphInitialization } from './hooks/use-graph-initialization'
|
||||
import { GraphEmptyState } from './components/graph-empty-state'
|
||||
import { GraphTooltip } from './components/graph-tooltip'
|
||||
import { GraphLoadingOverlay } from './components/graph-loading-overlay'
|
||||
import { GraphSelectorOverlay } from './components/graph-selector-overlay'
|
||||
import MinimapCanvas from './components/minimap'
|
||||
|
||||
const GraphViewer: React.FC<GraphViewerProps> = ({
|
||||
nodes,
|
||||
@@ -54,6 +54,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
|
||||
const isSelectorModeActive = useGraphControls((s) => s.isSelectorModeActive)
|
||||
const selectionMode = useGraphControls((s) => s.selectionMode)
|
||||
const nodeColors = useNodesDisplaySettings((s) => s.colors)
|
||||
const customIcons = useNodesDisplaySettings((s) => s.customIcons)
|
||||
const setActions = useGraphControls((s) => s.setActions)
|
||||
const setCurrentLayoutType = useGraphControls((s) => s.setCurrentLayoutType)
|
||||
const autoZoomOnCurrentNode = useGraphSettingsStore((s) =>
|
||||
@@ -127,7 +128,7 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [nodes, showIcons, forceSettings.nodeOutlined?.value])
|
||||
}, [nodes, showIcons, forceSettings.nodeOutlined?.value, customIcons])
|
||||
|
||||
// Container size management
|
||||
useEffect(() => {
|
||||
@@ -431,4 +432,5 @@ const GraphViewer: React.FC<GraphViewerProps> = ({
|
||||
}
|
||||
|
||||
export default GraphViewer
|
||||
export { GRAPH_COLORS } from './constants'
|
||||
export { GRAPH_COLORS } from './utils/constants'
|
||||
export { default as GraphMain } from './components/graph-main'
|
||||
|
||||
+8
-10
@@ -1,6 +1,6 @@
|
||||
import { CONSTANTS, GRAPH_COLORS } from './constants'
|
||||
import { getCachedImage, getCachedFlagImage } from './image-cache'
|
||||
import { truncateText } from './utils'
|
||||
import { CONSTANTS, GRAPH_COLORS } from '../utils/constants'
|
||||
import { getCachedImage, getCachedFlagImage } from '../utils/image-cache'
|
||||
import { truncateText, calculateNodeSize } from '../utils/utils'
|
||||
|
||||
interface NodeRenderParams {
|
||||
node: any
|
||||
@@ -54,14 +54,8 @@ export const renderNode = ({
|
||||
const inViewport = isInViewport(node, ctx)
|
||||
if (!inViewport) return
|
||||
|
||||
const sizeMultiplier = forceSettings.nodeSize.value / 100 + 0.2
|
||||
const neighborBonus =
|
||||
Math.min(node.neighbors.length / 5, 8) * forceSettings.nodeWeightMultiplierSize.value
|
||||
const baseSize = (node.nodeSize + neighborBonus) * sizeMultiplier
|
||||
|
||||
const shouldRenderDetails = globalScale > CONSTANTS.ZOOM_NODE_DETAIL_THRESHOLD
|
||||
|
||||
const size = shouldRenderDetails ? baseSize : baseSize * CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER
|
||||
const size = calculateNodeSize(node, forceSettings, shouldRenderDetails, CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER)
|
||||
|
||||
const isHighlighted = highlightNodes.has(node.id) || isSelected(node.id) || isCurrent(node.id)
|
||||
const hasAnyHighlight = highlightNodes.size > 0 || highlightLinks.size > 0
|
||||
@@ -92,6 +86,10 @@ export const renderNode = ({
|
||||
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI)
|
||||
|
||||
if (isOutlined) {
|
||||
// Fill background: white in light mode, dark in dark mode
|
||||
ctx.fillStyle = theme === 'light' ? '#FFFFFF' : '#1a1a1a'
|
||||
ctx.fill()
|
||||
// Draw colored outline
|
||||
ctx.strokeStyle = nodeColor
|
||||
ctx.lineWidth = Math.max(2, size * 0.15) / globalScale
|
||||
ctx.stroke()
|
||||
+1
-1
@@ -3,7 +3,7 @@ import NodesPanel from './nodes-panel'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { UserPlus, Users } from 'lucide-react'
|
||||
import { ItemsPanel } from './items-panel'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '../ui/resizable'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { useLayoutStore } from '@/stores/layout-store'
|
||||
import SelectedItemsPanel from './selected-items-panel'
|
||||
|
||||
+2
-2
@@ -2,10 +2,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Download, PlusIcon, Search } from 'lucide-react'
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { type ActionItem } from '@/lib/action-items'
|
||||
import { DraggableItem } from './draggable-item'
|
||||
import { DraggableItem } from '../../draggable-item'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useActionItems } from '@/hooks/use-action-items'
|
||||
import { SkeletonList } from '../shared/skeleton-list'
|
||||
import { SkeletonList } from '@/components/shared/skeleton-list'
|
||||
import { useGraphStore } from '@/stores/graph-store'
|
||||
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
|
||||
|
||||
+3
-3
@@ -10,9 +10,9 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { useLayoutStore } from '@/stores/layout-store'
|
||||
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import type { ItemType } from '@/stores/node-display-settings'
|
||||
import { Badge } from '../ui/badge'
|
||||
import LaunchFlow from './launch-enricher'
|
||||
import { TypeBadge } from '../type-badge'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import LaunchFlow from '../../launch-enricher'
|
||||
import { TypeBadge } from '@/components/type-badge'
|
||||
import { useParams } from '@tanstack/react-router'
|
||||
import { useGraphSettingsStore } from '@/stores/graph-settings-store'
|
||||
const SelectedItemsPanel = () => {
|
||||
@@ -1,4 +0,0 @@
|
||||
export function truncateText(text: string, limit: number = 16): string {
|
||||
if (text.length <= limit) return text
|
||||
return text.substring(0, limit) + '...'
|
||||
}
|
||||
+19
-2
@@ -1,4 +1,4 @@
|
||||
import { TYPE_TO_ICON } from '@/config/icon-mapping'
|
||||
import { TYPE_TO_ICON, useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
//@ts-ignore
|
||||
import * as lucideIcons from 'lucide-react/dist/esm/icons'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
@@ -7,7 +7,9 @@ const imageCache = new Map<string, HTMLImageElement>()
|
||||
const imageLoadPromises = new Map<string, Promise<HTMLImageElement>>()
|
||||
|
||||
const createSvgDataUrl = (iconType: string, color: string = '#FFFFFF'): string => {
|
||||
const iconName = TYPE_TO_ICON[iconType] || TYPE_TO_ICON.default
|
||||
// Check custom icons first, then fall back to default mapping
|
||||
const customIcons = useNodesDisplaySettings.getState().customIcons
|
||||
const iconName = customIcons[iconType] || TYPE_TO_ICON[iconType] || TYPE_TO_ICON.default
|
||||
const IconComponent = (lucideIcons as any)[iconName]
|
||||
|
||||
let svgString: string
|
||||
@@ -129,3 +131,18 @@ export const clearImageCache = (): void => {
|
||||
imageLoadPromises.clear()
|
||||
console.log('[image-cache] Cache cleared')
|
||||
}
|
||||
|
||||
export const clearIconTypeCache = (iconType: string): void => {
|
||||
// Clear all cached versions of this icon type (different colors)
|
||||
const keysToDelete: string[] = []
|
||||
imageCache.forEach((_, key) => {
|
||||
if (key.startsWith(`${iconType}-`)) {
|
||||
keysToDelete.push(key)
|
||||
}
|
||||
})
|
||||
keysToDelete.forEach((key) => {
|
||||
imageCache.delete(key)
|
||||
imageLoadPromises.delete(key)
|
||||
})
|
||||
console.log(`[image-cache] Cleared cache for icon type: ${iconType}`)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function truncateText(text: string, limit: number = 16): string {
|
||||
if (text.length <= limit) return text
|
||||
return text.substring(0, limit) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the actual rendered size of a node
|
||||
* This function MUST be used by both node-renderer and link-renderer to ensure consistency
|
||||
*
|
||||
* @param node - The node object with nodeSize and neighbors properties
|
||||
* @param forceSettings - Settings object containing nodeSize and nodeWeightMultiplierSize
|
||||
* @param shouldRenderDetails - Whether we're in detailed view (zoomed in)
|
||||
* @param zoomedOutMultiplier - Multiplier to apply when zoomed out (default from CONSTANTS.ZOOMED_OUT_SIZE_MULTIPLIER)
|
||||
* @returns The calculated node radius
|
||||
*/
|
||||
export function calculateNodeSize(
|
||||
node: any,
|
||||
forceSettings: any,
|
||||
shouldRenderDetails: boolean,
|
||||
zoomedOutMultiplier: number = 0.6
|
||||
): number {
|
||||
const nodeSizeValue = forceSettings?.nodeSize?.value ?? 1
|
||||
const nodeWeightMultiplierSize = forceSettings?.nodeWeightMultiplierSize?.value ?? 1.5
|
||||
|
||||
const sizeMultiplier = nodeSizeValue / 100 + 0.2
|
||||
const neighborBonus = Math.min((node.neighbors?.length || 0) / 5, 8) * nodeWeightMultiplierSize
|
||||
const baseSize = (node.nodeSize + neighborBonus) * sizeMultiplier
|
||||
|
||||
return shouldRenderDetails ? baseSize : baseSize * zoomedOutMultiplier
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Toolbar } from './toolbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowDownToLineIcon } from 'lucide-react'
|
||||
import { CreateRelationDialog } from './create-relation'
|
||||
import GraphLoader from './graph-loader'
|
||||
import GraphLoader from './graph/components/graph-loader'
|
||||
import Loader from '../loader'
|
||||
import { useGraphControls } from '@/stores/graph-controls-store'
|
||||
import { NodeEditorModal } from './details-panel/node-editor-modal'
|
||||
@@ -15,10 +15,10 @@ import { useActionItems } from '@/hooks/use-action-items'
|
||||
import { toast } from 'sonner'
|
||||
import MapPanel from '../map/map-panel'
|
||||
import NewActions from './add-item-dialog'
|
||||
import GraphMain from './graph-main'
|
||||
import Settings, { KeyboardShortcuts } from './settings'
|
||||
import GraphMain from './graph/components/graph-main'
|
||||
import Settings, { KeyboardShortcuts } from './settings/settings'
|
||||
import { type GraphNode, type GraphEdge } from '@/types'
|
||||
import { MergeDialog } from './merge-nodes'
|
||||
import { MergeDialog } from './graph/actions/merge-nodes'
|
||||
import { useGraphRefresh } from '@/hooks/use-graph-refresh'
|
||||
const RelationshipsTable = lazy(() => import('@/components/table/relationships-view'))
|
||||
|
||||
@@ -120,11 +120,13 @@ const GraphPanel = ({ graphData, isLoading }: GraphPanelProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div onDragOver={handleDragOver}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className="h-full w-full flex flex-col relative outline-2 outline-transparent bg-background">
|
||||
className="h-full w-full flex flex-col relative outline-2 outline-transparent bg-background"
|
||||
>
|
||||
<Toolbar isLoading={isLoading} />
|
||||
<Suspense
|
||||
fallback={
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { Dispatch, SetStateAction, useMemo, useRef, useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import { clearIconTypeCache } from '../../graph/utils/image-cache'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Curated list of popular and useful Lucide icons
|
||||
const AVAILABLE_ICONS = [
|
||||
'User',
|
||||
'Users',
|
||||
'UserPlus',
|
||||
'UserMinus',
|
||||
'UserCheck',
|
||||
'UserX',
|
||||
'UserCog',
|
||||
'UserCircle',
|
||||
'UserCircle2',
|
||||
'Phone',
|
||||
'PhoneCall',
|
||||
'PhoneIncoming',
|
||||
'PhoneOutgoing',
|
||||
'Smartphone',
|
||||
'Mail',
|
||||
'MailOpen',
|
||||
'Inbox',
|
||||
'Send',
|
||||
'MapPin',
|
||||
'Map',
|
||||
'Navigation',
|
||||
'Globe',
|
||||
'Globe2',
|
||||
'Network',
|
||||
'Wifi',
|
||||
'WifiOff',
|
||||
'Share2',
|
||||
'Link',
|
||||
'Link2',
|
||||
'Unlink',
|
||||
'Building',
|
||||
'Building2',
|
||||
'Home',
|
||||
'Briefcase',
|
||||
'Landmark',
|
||||
'Store',
|
||||
'Factory',
|
||||
'Warehouse',
|
||||
'Car',
|
||||
'Bike',
|
||||
'Ship',
|
||||
'Plane',
|
||||
'Rocket',
|
||||
'Train',
|
||||
'Bus',
|
||||
'Truck',
|
||||
'FileText',
|
||||
'File',
|
||||
'Files',
|
||||
'FileCode',
|
||||
'FileJson',
|
||||
'FileLock',
|
||||
'FileKey',
|
||||
'Folder',
|
||||
'FolderOpen',
|
||||
'DollarSign',
|
||||
'Wallet',
|
||||
'CreditCard',
|
||||
'Coins',
|
||||
'Banknote',
|
||||
'TrendingUp',
|
||||
'Calendar',
|
||||
'CalendarDays',
|
||||
'Clock',
|
||||
'Timer',
|
||||
'Image',
|
||||
'Images',
|
||||
'Video',
|
||||
'Film',
|
||||
'Music',
|
||||
'Mic',
|
||||
'Camera',
|
||||
'GraduationCap',
|
||||
'BookOpen',
|
||||
'Library',
|
||||
'School',
|
||||
'Activity',
|
||||
'TrendingUp',
|
||||
'BarChart',
|
||||
'LineChart',
|
||||
'PieChart',
|
||||
'Fingerprint',
|
||||
'ScanFace',
|
||||
'Key',
|
||||
'Lock',
|
||||
'Unlock',
|
||||
'Shield',
|
||||
'ShieldAlert',
|
||||
'ShieldCheck',
|
||||
'Bug',
|
||||
'AlertTriangle',
|
||||
'AlertCircle',
|
||||
'AlertOctagon',
|
||||
'Target',
|
||||
'Crosshair',
|
||||
'Database',
|
||||
'Server',
|
||||
'HardDrive',
|
||||
'Cpu',
|
||||
'MessageSquare',
|
||||
'MessageCircle',
|
||||
'Mail',
|
||||
'Sword',
|
||||
'Star',
|
||||
'StarHalf',
|
||||
'Heart',
|
||||
'ThumbsUp',
|
||||
'ThumbsDown',
|
||||
'Circle',
|
||||
'Square',
|
||||
'Triangle',
|
||||
'Hexagon',
|
||||
'Diamond',
|
||||
'Award',
|
||||
'Trophy',
|
||||
'Medal',
|
||||
'Gift',
|
||||
'Package',
|
||||
'Archive',
|
||||
'Plug',
|
||||
'Cable',
|
||||
'Wifi',
|
||||
'Bluetooth',
|
||||
'Usb',
|
||||
'Info',
|
||||
'HelpCircle',
|
||||
'CheckCircle',
|
||||
'XCircle',
|
||||
'Plus',
|
||||
'Minus',
|
||||
'X',
|
||||
'Check',
|
||||
'Search',
|
||||
'Filter',
|
||||
'SlidersHorizontal',
|
||||
'Settings',
|
||||
'Tool',
|
||||
'Wrench',
|
||||
'Hash',
|
||||
'AtSign',
|
||||
'Percent',
|
||||
'Power',
|
||||
'Zap',
|
||||
'Flame',
|
||||
'Droplet',
|
||||
'Wind',
|
||||
'Cloud',
|
||||
'Sun',
|
||||
'Moon',
|
||||
'Eye',
|
||||
'EyeOff',
|
||||
'Download',
|
||||
'Upload',
|
||||
'Shuffle',
|
||||
'Repeat',
|
||||
'RotateCw',
|
||||
'RefreshCw',
|
||||
'Copy',
|
||||
'Clipboard',
|
||||
'Edit',
|
||||
'Edit3',
|
||||
'Trash',
|
||||
'Trash2',
|
||||
'Save',
|
||||
'Bookmark',
|
||||
'Tag',
|
||||
'Tags',
|
||||
'Flag',
|
||||
'Bell',
|
||||
'BellOff',
|
||||
'Volume2',
|
||||
'VolumeX',
|
||||
'Play',
|
||||
'Pause',
|
||||
'StopCircle',
|
||||
'SkipForward',
|
||||
'SkipBack',
|
||||
'FastForward',
|
||||
'Rewind',
|
||||
'Maximize',
|
||||
'Minimize',
|
||||
'Expand',
|
||||
'Shrink',
|
||||
'ArrowRight',
|
||||
'ArrowLeft',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowRightLeft',
|
||||
'ArrowUpDown',
|
||||
'ChevronsRight',
|
||||
'ChevronsLeft',
|
||||
'ChevronsUp',
|
||||
'ChevronsDown',
|
||||
'MoreHorizontal',
|
||||
'MoreVertical',
|
||||
'Footprints',
|
||||
'Smile',
|
||||
'Frown',
|
||||
'Meh',
|
||||
'Code',
|
||||
'Code2',
|
||||
'Terminal',
|
||||
'Command',
|
||||
'Binary',
|
||||
'Braces',
|
||||
'GitBranch',
|
||||
'GitCommit',
|
||||
'GitMerge',
|
||||
'Github',
|
||||
'Chrome',
|
||||
'Facebook',
|
||||
'Twitter',
|
||||
'Instagram',
|
||||
'Linkedin',
|
||||
'Quote',
|
||||
'BookmarkPlus'
|
||||
] as const
|
||||
|
||||
type IconPopoverProps = {
|
||||
iconType: string | null
|
||||
open: boolean
|
||||
setOpen: Dispatch<SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
export default function IconPicker({ iconType, open, setOpen }: IconPopoverProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const { setIcon } = useNodesDisplaySettings()
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
const searchLower = search.toLowerCase()
|
||||
return AVAILABLE_ICONS.filter((icon) => icon.toLowerCase().includes(searchLower))
|
||||
}, [search])
|
||||
|
||||
const COLUMNS = 6
|
||||
const ICON_SIZE = 56
|
||||
|
||||
const rows = Math.ceil(filteredIcons.length / COLUMNS)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => ICON_SIZE,
|
||||
overscan: 5
|
||||
})
|
||||
|
||||
// Reset and initialize when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearch('')
|
||||
setIsReady(false)
|
||||
// Wait for dialog to render and measure
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true)
|
||||
virtualizer.measure()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setIsReady(false)
|
||||
}
|
||||
}, [open, virtualizer])
|
||||
|
||||
const handleIconSelect = (iconName: string) => {
|
||||
if (!iconType) return
|
||||
setIcon(iconType, iconName as keyof typeof LucideIcons)
|
||||
clearIconTypeCache(iconType)
|
||||
setTimeout(() => {
|
||||
const customIcons = useNodesDisplaySettings.getState().customIcons
|
||||
console.log('Updated customIcons:', customIcons)
|
||||
}, 100)
|
||||
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{iconType ? (
|
||||
<>
|
||||
Change icon for <span className="text-primary">{iconType}</span>
|
||||
</>
|
||||
) : (
|
||||
<>Select an icon</>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose from {AVAILABLE_ICONS.length} available Lucide icons
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<div ref={parentRef} className="h-[400px] overflow-auto rounded-md border border-border">
|
||||
{!isReady ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">Loading icons...</div>
|
||||
</div>
|
||||
) : filteredIcons.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
No icons found matching "{search}"
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIdx = virtualRow.index * COLUMNS
|
||||
const rowIcons = filteredIcons.slice(startIdx, startIdx + COLUMNS)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-6 gap-2 p-2">
|
||||
{rowIcons.map((iconName) => {
|
||||
const Icon = (LucideIcons as any)[iconName]
|
||||
|
||||
if (!Icon) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={iconName}
|
||||
onClick={() => handleIconSelect(iconName)}
|
||||
className={cn(
|
||||
'flex h-12 w-full flex-col items-center justify-center gap-1 rounded-md border border-border p-2 transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
)}
|
||||
title={iconName}
|
||||
>
|
||||
<Icon size={24} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useIcon } from '@/hooks/use-icon'
|
||||
|
||||
type NodeIconTriggerProps = {
|
||||
type: string
|
||||
onClick: () => void
|
||||
}
|
||||
export const NodeIconTrigger = ({ type, onClick }: NodeIconTriggerProps) => {
|
||||
const Icon = useIcon(type)
|
||||
return (
|
||||
<Button onClick={onClick} variant={'ghost'} size={'icon'}>
|
||||
<Icon size={16} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+36
-30
@@ -4,7 +4,6 @@ import {
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle
|
||||
} from '@/components/ui/sheet'
|
||||
@@ -31,8 +30,10 @@ import { useParams } from '@tanstack/react-router'
|
||||
import { sketchService } from '@/api/sketch-service'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { queryKeys } from '@/api/query-keys'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useNodesDisplaySettings, ITEM_TYPES, ItemType } from '@/stores/node-display-settings'
|
||||
import { NodeIconTrigger } from './icon-picker/trigger'
|
||||
import IconPicker from './icon-picker/popup'
|
||||
|
||||
// SettingItem Components
|
||||
interface SettingItemProps {
|
||||
@@ -211,8 +212,14 @@ function DynamicSetting({ categoryId, settingKey, setting, onValueChange }: Dyna
|
||||
function NodeColorsSection() {
|
||||
const storeColors = useNodesDisplaySettings((s) => s.colors)
|
||||
const setColor = useNodesDisplaySettings((s) => s.setColor)
|
||||
const resetColors = useNodesDisplaySettings((s) => s.resetColors)
|
||||
const resetSettings = useNodesDisplaySettings((s) => s.resetSettings)
|
||||
const randomizeColors = useNodesDisplaySettings((s) => s.randomizeColors)
|
||||
const [openIconPicker, setOpenIconPicker] = useState(false)
|
||||
const [currentType, setCurrentType] = useState<string | null>(null)
|
||||
const handleOpenIconPicker = useCallback((type: string) => {
|
||||
setOpenIconPicker(true)
|
||||
setCurrentType(type)
|
||||
}, [])
|
||||
|
||||
// Local state for immediate UI updates
|
||||
const [localColors, setLocalColors] = useState(storeColors)
|
||||
@@ -258,8 +265,8 @@ function NodeColorsSection() {
|
||||
debounceTimers.current.forEach((timer) => clearTimeout(timer))
|
||||
debounceTimers.current.clear()
|
||||
// Reset colors in store
|
||||
resetColors()
|
||||
}, [resetColors])
|
||||
resetSettings()
|
||||
}, [resetSettings])
|
||||
|
||||
// Cleanup timers on unmount
|
||||
useEffect(() => {
|
||||
@@ -272,17 +279,22 @@ function NodeColorsSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">Node colors</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground">Node</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Customize the colors for each node type in the graph visualization.
|
||||
Customize the node styles for each type in the graph visualization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b flex items-center gap-2 pb-4">
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="grow shadow-none">
|
||||
Reset to default colors
|
||||
Reset to default
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRandomizeColors} className="grow shadow-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRandomizeColors}
|
||||
className="grow shadow-none"
|
||||
>
|
||||
Randomize
|
||||
</Button>
|
||||
</div>
|
||||
@@ -296,6 +308,7 @@ function NodeColorsSection() {
|
||||
className="w-6 h-6 rounded border border-border shrink-0"
|
||||
style={{ backgroundColor: localColors[itemType] }}
|
||||
/>
|
||||
<NodeIconTrigger onClick={() => handleOpenIconPicker(itemType)} type={itemType} />
|
||||
<Label className="text-sm font-medium text-foreground capitalize">
|
||||
{itemType.replace(/_/g, ' ')}
|
||||
</Label>
|
||||
@@ -319,6 +332,7 @@ function NodeColorsSection() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<IconPicker open={openIconPicker} setOpen={setOpenIconPicker} iconType={currentType} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -390,7 +404,12 @@ function DynamicSection({ categoryId, category, title, description }: DynamicSec
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" size="sm" onClick={() => resetSettings()} className="w-full shadow-none">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetSettings()}
|
||||
className="w-full shadow-none"
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
@@ -642,16 +661,12 @@ export default function GlobalSettings() {
|
||||
<div className="px-6 pb-4 border-b">
|
||||
<TabsList className="w-full h-auto flex">
|
||||
{Object.keys(settings).map((category: string) => (
|
||||
<TabsTrigger
|
||||
key={category}
|
||||
value={category}
|
||||
className="capitalize h-9"
|
||||
>
|
||||
<TabsTrigger key={category} value={category} className="capitalize h-9">
|
||||
{category}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
<TabsTrigger value="nodecolors" className="h-9">
|
||||
Node colors
|
||||
Node
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
@@ -659,18 +674,11 @@ export default function GlobalSettings() {
|
||||
{/* Content panel */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{Object.keys(settings).map((category: string) => (
|
||||
<TabsContent
|
||||
key={category}
|
||||
value={category}
|
||||
className="mt-0 h-full flex flex-col"
|
||||
>
|
||||
<TabsContent key={category} value={category} className="mt-0 h-full flex flex-col">
|
||||
{getSelectedSectionPanel(category)}
|
||||
</TabsContent>
|
||||
))}
|
||||
<TabsContent
|
||||
value="nodecolors"
|
||||
className="mt-0 h-full flex flex-col"
|
||||
>
|
||||
<TabsContent value="nodecolors" className="mt-0 h-full flex flex-col">
|
||||
{getSelectedSectionPanel('nodecolors')}
|
||||
</TabsContent>
|
||||
</div>
|
||||
@@ -797,14 +805,14 @@ export function KeyboardShortcuts() {
|
||||
category: 'Graph',
|
||||
items: [
|
||||
{ key: `S`, description: 'Hold to activate selection' },
|
||||
{ key: `Shift`, description: 'Hold while clicking on nodes to add to selection' },
|
||||
{ key: `Shift`, description: 'Hold while clicking on nodes to add to selection' }
|
||||
]
|
||||
},
|
||||
{
|
||||
category: 'Settings',
|
||||
items: [
|
||||
{ key: `${modKey}+G`, description: 'Toggle graph settings' },
|
||||
{ key: `${modKey}+K`, description: 'Toggle keyboard shortcuts' },
|
||||
{ key: `${modKey}+K`, description: 'Toggle keyboard shortcuts' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -825,9 +833,7 @@ export function KeyboardShortcuts() {
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto py-3">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Keyboard Shortcuts</SheetTitle>
|
||||
<SheetDescription>
|
||||
Here is the list of all available keyboard shortcuts.
|
||||
</SheetDescription>
|
||||
<SheetDescription>Here is the list of all available keyboard shortcuts.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="space-y-6 mt-6 p-4">
|
||||
{shortcuts.map((category) => (
|
||||
@@ -1,58 +0,0 @@
|
||||
import type * as LucideIcons from 'lucide-react'
|
||||
|
||||
export const TYPE_TO_ICON: Record<string, keyof typeof LucideIcons> = {
|
||||
individual: 'User',
|
||||
phone: 'Phone',
|
||||
location: 'MapPin',
|
||||
email: 'Mail',
|
||||
ip: 'Network',
|
||||
port: 'Plug',
|
||||
socialaccount: 'Share2',
|
||||
organization: 'Building2',
|
||||
vehicle: 'Car',
|
||||
car: 'Car',
|
||||
motorcycle: 'Bike',
|
||||
boat: 'Ship',
|
||||
plane: 'Plane',
|
||||
website: 'Globe',
|
||||
domain: 'Globe',
|
||||
document: 'FileText',
|
||||
financial: 'DollarSign',
|
||||
event: 'Calendar',
|
||||
device: 'Smartphone',
|
||||
media: 'Image',
|
||||
education: 'GraduationCap',
|
||||
relationship: 'Users',
|
||||
online_activity: 'Activity',
|
||||
digital_footprint: 'Footprints',
|
||||
biometric: 'Fingerprint',
|
||||
credential: 'Key',
|
||||
username: 'UserPlus',
|
||||
siret: 'Building',
|
||||
siren: 'Building',
|
||||
cryptowallet: 'Wallet',
|
||||
cryptotransaction: 'ArrowRightLeft',
|
||||
cryptonft: 'Image',
|
||||
asn: 'Network',
|
||||
cidr: 'Network',
|
||||
whois: 'Info',
|
||||
gravatar: 'UserCircle',
|
||||
breach: 'ShieldAlert',
|
||||
webtracker: 'Target',
|
||||
session: 'Clock',
|
||||
dnsrecord: 'Database',
|
||||
ssl: 'Lock',
|
||||
message: 'MessageSquare',
|
||||
malware: 'Bug',
|
||||
weapon: 'Sword',
|
||||
script: 'FileCode',
|
||||
reputation: 'Star',
|
||||
risk: 'AlertTriangle',
|
||||
file: 'File',
|
||||
bank: 'Landmark',
|
||||
creditcard: 'CreditCard',
|
||||
alias: 'UserCog',
|
||||
affiliation: 'Link2',
|
||||
phrase: 'Quote',
|
||||
default: 'Circle'
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type JSX, useCallback } from 'react'
|
||||
import { useNodesDisplaySettings } from '@/stores/node-display-settings'
|
||||
import { useNodesDisplaySettings, TYPE_TO_ICON } from '@/stores/node-display-settings'
|
||||
import * as LucideIcons from 'lucide-react'
|
||||
import { TYPE_TO_ICON } from '@/config/icon-mapping'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type IconType = string
|
||||
@@ -23,6 +22,13 @@ const CONTAINER_PADDING = 16
|
||||
const BACKGROUND_PADDING = 8
|
||||
|
||||
export const useIcon = (type: IconType, src?: string | null) => {
|
||||
// Subscribe to store changes
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const customIcons = useNodesDisplaySettings((s) => s.customIcons)
|
||||
|
||||
// Get icon name, checking custom icons first
|
||||
const iconName = customIcons[type] || TYPE_TO_ICON[type] || TYPE_TO_ICON.default
|
||||
|
||||
return useCallback(
|
||||
({
|
||||
className = '',
|
||||
@@ -32,7 +38,6 @@ export const useIcon = (type: IconType, src?: string | null) => {
|
||||
color,
|
||||
iconOnly = false
|
||||
}: IconProps): JSX.Element => {
|
||||
const colors = useNodesDisplaySettings((s) => s.colors)
|
||||
const resolvedColor = color || colors[type as keyof typeof colors] || DEFAULT_COLOR
|
||||
|
||||
// Use full size for src images, scaled size for icons
|
||||
@@ -60,8 +65,7 @@ export const useIcon = (type: IconType, src?: string | null) => {
|
||||
}
|
||||
|
||||
// Utiliser une icône Lucide (PERFORMANT - pas de requête HTTP)
|
||||
const iconName = TYPE_TO_ICON[type] || TYPE_TO_ICON.default
|
||||
const LucideIcon = LucideIcons[iconName] as React.ComponentType<{
|
||||
const LucideIcon = (LucideIcons as any)[iconName] as React.ComponentType<{
|
||||
size?: number
|
||||
fontSize?: number
|
||||
className?: string
|
||||
@@ -122,6 +126,6 @@ export const useIcon = (type: IconType, src?: string | null) => {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[type, src]
|
||||
[type, src, colors, iconName]
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ export const Route = createFileRoute('/_auth/dashboard/investigations/$investiga
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90
|
||||
className="w-full bg-primary text-primary-foreground hover:bg-primary/90
|
||||
px-4 py-2 rounded-md font-medium transition-colors
|
||||
flex items-center justify-center gap-2"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type * as LucideIcons from 'lucide-react'
|
||||
|
||||
export type ItemType =
|
||||
| 'individual'
|
||||
@@ -56,7 +57,6 @@ export type ItemType =
|
||||
| 'affiliation'
|
||||
| 'phrase'
|
||||
|
||||
|
||||
export const ITEM_TYPES: ItemType[] = [
|
||||
'individual',
|
||||
'phone',
|
||||
@@ -113,6 +113,64 @@ export const ITEM_TYPES: ItemType[] = [
|
||||
'phrase'
|
||||
]
|
||||
|
||||
// Icon mapping for each item type (Lucide icons)
|
||||
export const TYPE_TO_ICON: Record<string, keyof typeof LucideIcons> = {
|
||||
individual: 'User',
|
||||
phone: 'Phone',
|
||||
location: 'MapPin',
|
||||
email: 'Mail',
|
||||
ip: 'Network',
|
||||
port: 'Plug',
|
||||
socialaccount: 'Share2',
|
||||
organization: 'Building2',
|
||||
vehicle: 'Car',
|
||||
car: 'Car',
|
||||
motorcycle: 'Bike',
|
||||
boat: 'Ship',
|
||||
plane: 'Plane',
|
||||
website: 'Globe',
|
||||
domain: 'Globe',
|
||||
document: 'FileText',
|
||||
financial: 'DollarSign',
|
||||
event: 'Calendar',
|
||||
device: 'Smartphone',
|
||||
media: 'Image',
|
||||
education: 'GraduationCap',
|
||||
relationship: 'Users',
|
||||
online_activity: 'Activity',
|
||||
digital_footprint: 'Footprints',
|
||||
biometric: 'Fingerprint',
|
||||
credential: 'Key',
|
||||
username: 'UserPlus',
|
||||
siret: 'Building',
|
||||
siren: 'Building',
|
||||
cryptowallet: 'Wallet',
|
||||
cryptotransaction: 'ArrowRightLeft',
|
||||
cryptonft: 'Image',
|
||||
asn: 'Network',
|
||||
cidr: 'Network',
|
||||
whois: 'Info',
|
||||
gravatar: 'UserCircle',
|
||||
breach: 'ShieldAlert',
|
||||
webtracker: 'Target',
|
||||
session: 'Clock',
|
||||
dnsrecord: 'Database',
|
||||
ssl: 'Lock',
|
||||
message: 'MessageSquare',
|
||||
malware: 'Bug',
|
||||
weapon: 'Sword',
|
||||
script: 'FileCode',
|
||||
reputation: 'Star',
|
||||
risk: 'AlertTriangle',
|
||||
file: 'File',
|
||||
bank: 'Landmark',
|
||||
creditcard: 'CreditCard',
|
||||
alias: 'UserCog',
|
||||
affiliation: 'Link2',
|
||||
phrase: 'Quote',
|
||||
default: 'Circle'
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS: Record<ItemType, string> = {
|
||||
individual: '#4C8EDA', // bleu moyen
|
||||
phone: '#4CB5AE', // teal doux
|
||||
@@ -169,132 +227,40 @@ const DEFAULT_COLORS: Record<ItemType, string> = {
|
||||
phrase: '#BFA77A' // beige chaud
|
||||
}
|
||||
|
||||
// Définition des icônes par défaut pour chaque type d'élément
|
||||
const DEFAULT_ICONS: Record<ItemType, string> = {
|
||||
individual: '👤',
|
||||
phone: '📞',
|
||||
location: '🏠',
|
||||
email: '✉️',
|
||||
ip: '🌐',
|
||||
port: '🔌',
|
||||
socialaccount: '📱',
|
||||
organization: '🏢',
|
||||
vehicle: '🚗',
|
||||
car: '🚗',
|
||||
motorcycle: '🏍️',
|
||||
boat: '🚤',
|
||||
plane: '✈️',
|
||||
website: '🔗',
|
||||
domain: '🌍',
|
||||
document: '📄',
|
||||
financial: '💳',
|
||||
event: '📅',
|
||||
device: '📱',
|
||||
media: '🖼️',
|
||||
education: '📚',
|
||||
relationship: '👥',
|
||||
online_activity: '💻',
|
||||
digital_footprint: '👣',
|
||||
biometric: '🧬',
|
||||
credential: '🔑',
|
||||
username: '🧑💻',
|
||||
siret: 'ℹ️',
|
||||
siren: 'ℹ️',
|
||||
cryptowallet: '₿',
|
||||
cryptotransaction: '💱',
|
||||
cryptonft: '🖼️',
|
||||
asn: '🌐',
|
||||
cidr: '📡',
|
||||
whois: '🌐',
|
||||
gravatar: '🖼️',
|
||||
breach: '🔓',
|
||||
webtracker: '🎯',
|
||||
session: '🔐',
|
||||
dnsrecord: '🌐',
|
||||
ssl: '🔒',
|
||||
message: '💬',
|
||||
malware: '🦠',
|
||||
weapon: '⚔️',
|
||||
script: '📜',
|
||||
reputation: '⭐',
|
||||
risk: '⚠️',
|
||||
file: '📁',
|
||||
bank: '🏦',
|
||||
creditcard: '💳',
|
||||
alias: '👤',
|
||||
affiliation: '🤝',
|
||||
phrase: '��'
|
||||
}
|
||||
|
||||
const DEFAULT_SIZES: Record<ItemType, number> = {
|
||||
individual: 24, // Large - key entities
|
||||
phone: 16, // Medium-large
|
||||
location: 14, // Medium
|
||||
email: 14, // Medium
|
||||
ip: 16, // Medium-large
|
||||
port: 14, // Medium
|
||||
socialaccount: 14, // Medium
|
||||
organization: 28, // Very large - important entities
|
||||
vehicle: 12, // Small-medium
|
||||
car: 12, // Small-medium
|
||||
motorcycle: 12, // Small-medium
|
||||
boat: 12, // Small-medium
|
||||
plane: 12, // Small-medium
|
||||
website: 14, // Medium
|
||||
domain: 20, // Large
|
||||
document: 12, // Small-medium
|
||||
financial: 18, // Medium-large
|
||||
event: 12, // Small-medium
|
||||
device: 12, // Small-medium
|
||||
media: 12, // Small-medium
|
||||
education: 12, // Small-medium
|
||||
relationship: 10, // Small
|
||||
online_activity: 10, // Small
|
||||
digital_footprint: 10, // Small
|
||||
biometric: 14, // Medium
|
||||
credential: 16, // Medium-large
|
||||
username: 14, // Medium
|
||||
siret: 8, // Very small
|
||||
siren: 8, // Very small
|
||||
cryptowallet: 18, // Medium-large
|
||||
cryptotransaction: 14, // Medium
|
||||
cryptonft: 14, // Medium
|
||||
asn: 32, // Largest - network infrastructure
|
||||
cidr: 28, // Very large - network ranges
|
||||
whois: 10, // Small
|
||||
gravatar: 10, // Small
|
||||
breach: 20, // Large - security important
|
||||
webtracker: 12, // Small-medium
|
||||
session: 10, // Small
|
||||
dnsrecord: 16, // Medium-large
|
||||
ssl: 16, // Medium-large
|
||||
message: 12, // Small-medium
|
||||
malware: 24, // Large - security critical
|
||||
weapon: 22, // Large - high importance
|
||||
script: 12, // Small-medium
|
||||
reputation: 14, // Medium
|
||||
risk: 20, // Large - important for analysis
|
||||
file: 12, // Small-medium
|
||||
bank: 22, // Large - financial importance
|
||||
creditcard: 18, // Medium-large
|
||||
alias: 12, // Small-medium
|
||||
affiliation: 14, // Medium
|
||||
phrase: 10 // Small
|
||||
}
|
||||
|
||||
const hslToHex = (h: number, s: number, l: number): string => {
|
||||
const s1 = s / 100
|
||||
const l1 = l / 100
|
||||
const c = (1 - Math.abs(2 * l1 - 1)) * s1
|
||||
const hh = h / 60
|
||||
const x = c * (1 - Math.abs((hh % 2) - 1))
|
||||
let r1 = 0, g1 = 0, b1 = 0
|
||||
if (hh >= 0 && hh < 1) { r1 = c; g1 = x; b1 = 0 }
|
||||
else if (hh >= 1 && hh < 2) { r1 = x; g1 = c; b1 = 0 }
|
||||
else if (hh >= 2 && hh < 3) { r1 = 0; g1 = c; b1 = x }
|
||||
else if (hh >= 3 && hh < 4) { r1 = 0; g1 = x; b1 = c }
|
||||
else if (hh >= 4 && hh < 5) { r1 = x; g1 = 0; b1 = c }
|
||||
else { r1 = c; g1 = 0; b1 = x }
|
||||
let r1 = 0,
|
||||
g1 = 0,
|
||||
b1 = 0
|
||||
if (hh >= 0 && hh < 1) {
|
||||
r1 = c
|
||||
g1 = x
|
||||
b1 = 0
|
||||
} else if (hh >= 1 && hh < 2) {
|
||||
r1 = x
|
||||
g1 = c
|
||||
b1 = 0
|
||||
} else if (hh >= 2 && hh < 3) {
|
||||
r1 = 0
|
||||
g1 = c
|
||||
b1 = x
|
||||
} else if (hh >= 3 && hh < 4) {
|
||||
r1 = 0
|
||||
g1 = x
|
||||
b1 = c
|
||||
} else if (hh >= 4 && hh < 5) {
|
||||
r1 = x
|
||||
g1 = 0
|
||||
b1 = c
|
||||
} else {
|
||||
r1 = c
|
||||
g1 = 0
|
||||
b1 = x
|
||||
}
|
||||
const m = l1 - c / 2
|
||||
const r = Math.round((r1 + m) * 255)
|
||||
const g = Math.round((g1 + m) * 255)
|
||||
@@ -317,24 +283,20 @@ const randomizeColors = (colors: Record<ItemType, string>): Record<ItemType, str
|
||||
|
||||
interface NodesDisplaySettingsState {
|
||||
colors: Record<ItemType, string>
|
||||
icons: Record<ItemType, string>
|
||||
sizes: Record<ItemType, number>
|
||||
customIcons: Record<string, keyof typeof LucideIcons>
|
||||
setColor: (itemType: ItemType, color: string) => void
|
||||
setIcon: (itemType: ItemType, iconPath: string) => void
|
||||
resetColors: () => void
|
||||
setIcon: (itemType: string, iconName: keyof typeof LucideIcons) => void
|
||||
resetSettings: () => void
|
||||
randomizeColors: () => void
|
||||
resetIcons: () => void
|
||||
resetAll: () => void
|
||||
getIcon: (itemType: ItemType) => string
|
||||
getSize: (itemType: ItemType) => number
|
||||
getIcon: (itemType: ItemType) => keyof typeof LucideIcons
|
||||
}
|
||||
|
||||
export const useNodesDisplaySettings = create<NodesDisplaySettingsState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
colors: { ...DEFAULT_COLORS },
|
||||
icons: { ...DEFAULT_ICONS },
|
||||
sizes: { ...DEFAULT_SIZES },
|
||||
customIcons: { ...TYPE_TO_ICON },
|
||||
setColor: (itemType, color) =>
|
||||
set((state) => ({
|
||||
colors: {
|
||||
@@ -342,27 +304,24 @@ export const useNodesDisplaySettings = create<NodesDisplaySettingsState>()(
|
||||
[itemType]: color
|
||||
}
|
||||
})),
|
||||
setIcon: (itemType, iconPath) =>
|
||||
setIcon: (itemType, iconName) =>
|
||||
set((state) => ({
|
||||
icons: {
|
||||
...state.icons,
|
||||
[itemType]: iconPath
|
||||
customIcons: {
|
||||
...state.customIcons,
|
||||
[itemType]: iconName
|
||||
}
|
||||
})),
|
||||
resetColors: () => set({ colors: { ...DEFAULT_COLORS } }),
|
||||
resetIcons: () => set({ icons: { ...DEFAULT_ICONS } }),
|
||||
resetSettings: () => set({ colors: { ...DEFAULT_COLORS }, customIcons: TYPE_TO_ICON }),
|
||||
randomizeColors: () => set({ colors: randomizeColors(get().colors) }),
|
||||
resetAll: () =>
|
||||
set({
|
||||
colors: { ...DEFAULT_COLORS },
|
||||
icons: { ...DEFAULT_ICONS },
|
||||
sizes: { ...DEFAULT_SIZES }
|
||||
customIcons: {}
|
||||
}),
|
||||
getIcon: (itemType) => {
|
||||
return get().icons[itemType] || ''
|
||||
},
|
||||
getSize: (itemType) => {
|
||||
return get().sizes[itemType] * 0.4
|
||||
const customIcon = get().customIcons[itemType]
|
||||
if (customIcon) return customIcon
|
||||
return TYPE_TO_ICON[itemType] || TYPE_TO_ICON.default
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user