feat(app): custom icons + graph refactor

This commit is contained in:
dextmorgn
2026-01-08 19:46:52 +01:00
parent c17a542e07
commit 716ed0bc55
43 changed files with 675 additions and 335 deletions
+12 -16
View File
@@ -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'
@@ -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'
@@ -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 = () => {
@@ -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,5 +1,5 @@
import { Type, Share2 } from 'lucide-react'
import { TooltipState } from './types'
import { TooltipState } from './utils/types'
interface GraphTooltipProps {
tooltip: TooltipState
@@ -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'
@@ -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
@@ -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,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'
@@ -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()
@@ -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,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'
@@ -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) + '...'
}
@@ -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>
)
}
@@ -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) => (
-58
View File
@@ -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'
}
+10 -6
View File
@@ -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]
)
}
@@ -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"
>
+101 -142
View File
@@ -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
}
}),
{