| import { useState } from 'react'; |
| import type { GraphStatistics, ConnectivityInfo } from '../types'; |
| import { NODE_TYPE_LABELS, NODE_COLORS } from '../constants'; |
|
|
| interface Props { |
| statistics: GraphStatistics; |
| processingTime: number; |
| imageName: string; |
| edited?: boolean; |
| connectivity?: ConnectivityInfo | null; |
| onFocusNode?: (id: string) => void; |
| onToggleBroken?: (show: boolean) => void; |
| } |
|
|
| const MAX_LISTED = 40; |
| const REASON_LABEL: Record<string, string> = { isolated: 'no edges', room: 'room', exit: 'exit' }; |
|
|
| export default function StatsPanel({ |
| statistics, |
| processingTime, |
| imageName, |
| edited, |
| connectivity, |
| onFocusNode, |
| onToggleBroken, |
| }: Props) { |
| const [open, setOpen] = useState(true); |
| const [connOpen, setConnOpen] = useState(false); |
| const { total_nodes, total_edges, node_types, pruning_reduction } = statistics; |
| const broken = connectivity && !connectivity.fullyConnected; |
|
|
| const toggleConn = () => |
| setConnOpen((o) => { |
| const next = !o; |
| onToggleBroken?.(next); |
| return next; |
| }); |
|
|
| return ( |
| <div className="panel"> |
| <button className="panel-collapse-header" onClick={() => setOpen((o) => !o)}> |
| <h3>Statistics{edited ? ' (edited)' : ''}</h3> |
| <span className={`chevron${open ? ' open' : ''}`}>›</span> |
| </button> |
| |
| {open && ( |
| <div className="panel-collapse-body"> |
| <div className="stat-row"> |
| <span className="label">Image</span> |
| <span className="value stat-image" title={imageName}>{imageName}</span> |
| </div> |
| |
| <div className="stat-row"> |
| <span className="label">Processing Time</span> |
| <span className="value"> |
| {processingTime > 0 ? `${processingTime.toFixed(1)}s` : 'Cached'} |
| </span> |
| </div> |
| |
| <hr className="stat-divider" /> |
| |
| <div className="stat-row"> |
| <span className="label">Total Nodes</span> |
| <span className="value">{total_nodes}</span> |
| </div> |
| |
| <div className="stat-row"> |
| <span className="label">Total Edges</span> |
| <span className="value">{total_edges}</span> |
| </div> |
| |
| {connectivity && ( |
| <> |
| <div className="stat-row"> |
| <span className="label">Connectivity</span> |
| <span className="value"> |
| {connectivity.score}%{' '} |
| {broken ? ( |
| <button |
| className="conn-warn" |
| title="Issues found. Click to inspect and highlight on the graph." |
| onClick={toggleConn} |
| > |
| ⚠ |
| </button> |
| ) : ( |
| <span className="conn-ok" title="Rooms and exits fully connected">✓</span> |
| )} |
| </span> |
| </div> |
| |
| {broken && connOpen && ( |
| <div className="conn-list"> |
| <div className="conn-list-title"> |
| {connectivity.isolatedCount > 0 && ( |
| <div className="conn-flag"> |
| {connectivity.isolatedCount} node |
| {connectivity.isolatedCount === 1 ? '' : 's'} with no edges |
| </div> |
| )} |
| {connectivity.roomsDisconnected > 0 && ( |
| <div> |
| {connectivity.roomsDisconnected} room |
| {connectivity.roomsDisconnected === 1 ? '' : 's'} unreachable |
| </div> |
| )} |
| {connectivity.exitsDisconnected > 0 && ( |
| <div> |
| {connectivity.exitsDisconnected} exit door |
| {connectivity.exitsDisconnected === 1 ? '' : 's'} unreachable |
| </div> |
| )} |
| <div className="conn-locate"> |
| The smaller, disconnected subgraph is highlighted in red on |
| the graph. Click a node to zoom to it. |
| </div> |
| </div> |
| {connectivity.offenders.slice(0, MAX_LISTED).map((n) => ( |
| <button key={n.id} className="conn-node" onClick={() => onFocusNode?.(n.id)}> |
| <span |
| className="type-swatch" |
| style={{ backgroundColor: NODE_COLORS[n.type] || '#999' }} |
| /> |
| <span className="conn-node-id">{n.id}</span> |
| <span className={`conn-reason conn-reason-${n.reason}`}> |
| {REASON_LABEL[n.reason]} |
| </span> |
| </button> |
| ))} |
| {connectivity.offenders.length > MAX_LISTED && ( |
| <div className="conn-more"> |
| + {connectivity.offenders.length - MAX_LISTED} more |
| </div> |
| )} |
| </div> |
| )} |
| </> |
| )} |
| |
| {pruning_reduction != null && pruning_reduction > 0 && ( |
| <div className="stat-row"> |
| <span className="label">Pruning Reduction</span> |
| <span className="value">{pruning_reduction}%</span> |
| </div> |
| )} |
| |
| <hr className="stat-divider" /> |
| |
| {Object.entries(node_types).map(([type, count]) => ( |
| <div className="stat-row" key={type}> |
| <span className="label" style={{ display: 'flex', alignItems: 'center', gap: 6 }}> |
| <span |
| className="type-swatch" |
| style={{ backgroundColor: NODE_COLORS[type] || '#999' }} |
| /> |
| {NODE_TYPE_LABELS[type] || type} |
| </span> |
| <span className="value">{count}</span> |
| </div> |
| ))} |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|