diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/horizontal-tabs-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/horizontal-tabs-container.tsx new file mode 100644 index 00000000000..105c6128493 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/components/sub-view/horizontal-tabs-container.tsx @@ -0,0 +1,186 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; + +import { InfoIconTooltip } from "../tooltip"; +import type { SubView } from "./types"; + +const tabsContainerStyle = css({ + display: "flex", + alignItems: "center", + gap: "[4px]", +}); + +const tabButtonStyle = cva({ + base: { + fontSize: "[11px]", + fontWeight: "[500]", + padding: "[4px 10px]", + textTransform: "uppercase", + borderRadius: "[3px]", + border: "none", + cursor: "pointer", + transition: "[all 0.3s ease]", + background: "[transparent]", + }, + variants: { + active: { + true: { + opacity: "[1]", + backgroundColor: "[rgba(0, 0, 0, 0.08)]", + color: "core.gray.90", + }, + false: { + opacity: "[0.6]", + color: "core.gray.60", + _hover: { + opacity: "[1]", + backgroundColor: "[rgba(0, 0, 0, 0.04)]", + color: "core.gray.80", + }, + }, + }, + }, +}); + +const contentStyle = css({ + fontSize: "[12px]", + padding: "[12px 12px]", + flex: "[1]", + overflowY: "auto", +}); + +interface TabButtonProps { + subView: SubView; + isActive: boolean; + onClick: () => void; +} + +const TabButton: React.FC = ({ subView, isActive, onClick }) => { + return ( + + ); +}; + +interface HorizontalTabsContainerProps { + /** Array of subviews to display as tabs */ + subViews: SubView[]; + /** ID of the currently active tab */ + activeTabId: string; + /** Callback when a tab is selected */ + onTabChange: (tabId: string) => void; +} + +/** + * Container that displays subviews as horizontal tabs. + * Used in the BottomPanel for Diagnostics, Parameters, and Simulation Settings. + * + * This component returns both the tabs header and the content area as separate + * parts that can be composed into the parent layout. + */ +export const HorizontalTabsContainer: React.FC = ({ + subViews, + activeTabId, + onTabChange, +}) => { + const activeSubView = subViews.find((sv) => sv.id === activeTabId) ?? subViews[0]; + + if (!activeSubView) { + return null; + } + + const Component = activeSubView.component; + + return ( + <> + {/* Tab Header */} +
+ {subViews.map((subView) => ( + onTabChange(subView.id)} + /> + ))} +
+ + {/* Content */} +
+ +
+ + ); +}; + +/** + * Renders just the tab bar portion of the horizontal tabs. + * Useful when you need to compose the tabs header separately from the content. + */ +export const HorizontalTabsHeader: React.FC<{ + subViews: SubView[]; + activeTabId: string; + onTabChange: (tabId: string) => void; +}> = ({ subViews, activeTabId, onTabChange }) => { + return ( +
+ {subViews.map((subView) => ( + onTabChange(subView.id)} + /> + ))} +
+ ); +}; + +/** + * Returns the header action for the currently active tab. + * Used to render custom actions (like add buttons) in the panel header. + */ +export const HorizontalTabsHeaderAction: React.FC<{ + subViews: SubView[]; + activeTabId: string; +}> = ({ subViews, activeTabId }) => { + const activeSubView = + subViews.find((sv) => sv.id === activeTabId) ?? subViews[0]; + + if (!activeSubView?.renderHeaderAction) { + return null; + } + + return <>{activeSubView.renderHeaderAction()}; +}; + +/** + * Renders just the content portion of the horizontal tabs. + * Useful when you need to compose the content separately from the tabs header. + */ +export const HorizontalTabsContent: React.FC<{ + subViews: SubView[]; + activeTabId: string; +}> = ({ subViews, activeTabId }) => { + const activeSubView = subViews.find((sv) => sv.id === activeTabId) ?? subViews[0]; + + if (!activeSubView) { + return null; + } + + const Component = activeSubView.component; + + return ( +
+ +
+ ); +}; + diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/index.ts b/libs/@hashintel/petrinaut/src/components/sub-view/index.ts new file mode 100644 index 00000000000..d665394075c --- /dev/null +++ b/libs/@hashintel/petrinaut/src/components/sub-view/index.ts @@ -0,0 +1,9 @@ +export type { SubView, SubViewResizeConfig } from "./types"; +export { VerticalSubViewsContainer } from "./vertical-sub-views-container"; +export { + HorizontalTabsContainer, + HorizontalTabsHeader, + HorizontalTabsHeaderAction, + HorizontalTabsContent, +} from "./horizontal-tabs-container"; + diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/types.ts b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts new file mode 100644 index 00000000000..afe0448c7db --- /dev/null +++ b/libs/@hashintel/petrinaut/src/components/sub-view/types.ts @@ -0,0 +1,46 @@ +import type { ComponentType, ReactNode } from "react"; + +/** + * Configuration for resizable subviews. + */ +export interface SubViewResizeConfig { + /** Default height when expanded (in pixels) */ + defaultHeight: number; + /** Minimum height constraint (in pixels) */ + minHeight?: number; + /** Maximum height constraint (in pixels) */ + maxHeight?: number; +} + +/** + * SubView represents a single view that can be displayed in either: + * - A vertical collapsible section (LeftSideBar) + * - A horizontal tab (BottomPanel) + * + * This abstraction allows views to be easily moved between panels. + */ +export interface SubView { + /** Unique identifier for the subview */ + id: string; + /** Title displayed in the section header or tab */ + title: string; + /** Optional tooltip shown when hovering over the title/tab */ + tooltip?: string; + /** The component to render for this subview */ + component: ComponentType; + /** + * Optional render function for the header right side (e.g., add button). + * Only used in vertical (collapsible) layout. + */ + renderHeaderAction?: () => ReactNode; + /** + * Whether this subview should grow to fill available space. + * Only affects vertical layout. Defaults to false. + */ + flexGrow?: boolean; + /** + * Configuration for making the subview resizable when expanded. + * Only affects vertical layout. When set, the section can be resized by dragging its bottom edge. + */ + resizable?: SubViewResizeConfig; +} diff --git a/libs/@hashintel/petrinaut/src/components/sub-view/vertical-sub-views-container.tsx b/libs/@hashintel/petrinaut/src/components/sub-view/vertical-sub-views-container.tsx new file mode 100644 index 00000000000..69754d3c90e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/components/sub-view/vertical-sub-views-container.tsx @@ -0,0 +1,357 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; + +import { InfoIconTooltip } from "../tooltip"; +import type { SubView, SubViewResizeConfig } from "./types"; + +const sectionContainerStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + gap: "[4px]", + }, + variants: { + hasBottomPadding: { + true: { + paddingBottom: "[8px]", + }, + false: { + paddingBottom: "[0]", + }, + }, + flexGrow: { + true: { + flex: "[1]", + minHeight: "[0]", + }, + }, + }, +}); + +const headerRowStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", +}); + +const sectionToggleButtonStyle = css({ + display: "flex", + alignItems: "center", + gap: "[6px]", + fontWeight: 600, + fontSize: "[13px]", + color: "[#333]", + cursor: "pointer", + background: "[transparent]", + border: "none", + padding: "spacing.1", + borderRadius: "radius.4", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + }, +}); + +const contentContainerStyle = cva({ + base: { + display: "flex", + flexDirection: "column", + gap: "[2px]", + overflowY: "auto", + }, + variants: { + flexGrow: { + true: { + flex: "[1]", + }, + false: { + maxHeight: "[200px]", + }, + }, + }, +}); + +const resizableContentStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[2px]", + overflowY: "auto", +}); + +const resizeHandleStyle = cva({ + base: { + height: "[6px]", + cursor: "ns-resize", + backgroundColor: "[transparent]", + border: "none", + padding: "[0]", + borderRadius: "[3px]", + transition: "[background-color 0.15s ease]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.08)]", + }, + }, + variants: { + isResizing: { + true: { + backgroundColor: "[rgba(59, 130, 246, 0.3)]", + }, + }, + position: { + top: { + marginBottom: "[2px]", + }, + bottom: { + marginTop: "[2px]", + }, + }, + }, +}); + +/** + * Custom hook for resize logic that can be used at any component level. + */ +const useResizable = ( + config: SubViewResizeConfig, + handlePosition: "top" | "bottom" +) => { + const [height, setHeight] = useState(config.defaultHeight); + const [isResizing, setIsResizing] = useState(false); + const resizeStartY = useRef(0); + const resizeStartHeight = useRef(0); + + const handleResizeStart = useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + setIsResizing(true); + resizeStartY.current = event.clientY; + resizeStartHeight.current = height; + }, + [height] + ); + + const handleResizeMove = useCallback( + (event: MouseEvent) => { + if (!isResizing) { + return; + } + + const delta = event.clientY - resizeStartY.current; + // When handle is at top, invert the delta: dragging up should increase height + const effectiveDelta = handlePosition === "top" ? -delta : delta; + const newHeight = resizeStartHeight.current + effectiveDelta; + + const minHeight = config.minHeight ?? 100; + const maxHeight = config.maxHeight ?? 600; + + setHeight(Math.max(minHeight, Math.min(maxHeight, newHeight))); + }, + [isResizing, config.minHeight, config.maxHeight, handlePosition] + ); + + const handleResizeEnd = useCallback(() => { + setIsResizing(false); + }, []); + + // Global event listeners during resize + useEffect(() => { + if (!isResizing) { + return; + } + + document.addEventListener("mousemove", handleResizeMove); + document.addEventListener("mouseup", handleResizeEnd); + document.body.style.cursor = "ns-resize"; + document.body.style.userSelect = "none"; + + return () => { + document.removeEventListener("mousemove", handleResizeMove); + document.removeEventListener("mouseup", handleResizeEnd); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + }, [isResizing, handleResizeMove, handleResizeEnd]); + + return { height, isResizing, handleResizeStart }; +}; + +interface ResizeHandleProps { + position: "top" | "bottom"; + isResizing: boolean; + onMouseDown: (event: React.MouseEvent) => void; +} + +/** + * Reusable resize handle button component. + */ +const ResizeHandle: React.FC = ({ + position, + isResizing, + onMouseDown, +}) => ( + + {renderHeaderAction?.()} + + + {isExpanded && renderContent()} + + ); +}; + +interface VerticalSubViewsContainerProps { + /** Array of subviews to display as collapsible sections */ + subViews: SubView[]; + /** Whether sections should be expanded by default */ + defaultExpanded?: boolean; + /** + * Position of resize handles for resizable sections. + * Use 'top' when the container is aligned to the bottom of its parent. + * Defaults to 'bottom'. + */ + resizeHandlePosition?: "top" | "bottom"; +} + +/** + * Container that displays subviews as vertically stacked collapsible sections. + * Used in the LeftSideBar for Token Types, Differential Equations, and Nodes. + */ +export const VerticalSubViewsContainer: React.FC< + VerticalSubViewsContainerProps +> = ({ subViews, defaultExpanded = true, resizeHandlePosition = "bottom" }) => { + return ( + <> + {subViews.map((subView, index) => ( + + ))} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/constants/ui.ts b/libs/@hashintel/petrinaut/src/constants/ui.ts index ddcc7960c50..2816ef61e75 100644 --- a/libs/@hashintel/petrinaut/src/constants/ui.ts +++ b/libs/@hashintel/petrinaut/src/constants/ui.ts @@ -2,6 +2,14 @@ * UI-related constants for the Petrinaut editor. */ +import type { SubView } from "../components/sub-view"; +import { diagnosticsSubView } from "../views/Editor/subviews/diagnostics"; +import { differentialEquationsListSubView } from "../views/Editor/subviews/differential-equations-list"; +import { nodesListSubView } from "../views/Editor/subviews/nodes-list"; +import { parametersListSubView } from "../views/Editor/subviews/parameters-list"; +import { simulationSettingsSubView } from "../views/Editor/subviews/simulation-settings"; +import { typesListSubView } from "../views/Editor/subviews/types-list"; + // Panel margin (spacing around panels) export const PANEL_MARGIN = 10; @@ -23,3 +31,19 @@ export const MAX_PROPERTIES_PANEL_WIDTH = 800; export const DEFAULT_BOTTOM_PANEL_HEIGHT = 180; export const MIN_BOTTOM_PANEL_HEIGHT = 100; export const MAX_BOTTOM_PANEL_HEIGHT = 600; + +// +// SubViews +// + +export const LEFT_SIDEBAR_SUBVIEWS: SubView[] = [ + typesListSubView, + differentialEquationsListSubView, + parametersListSubView, + nodesListSubView, +]; + +export const BOTTOM_PANEL_SUBVIEWS: SubView[] = [ + diagnosticsSubView, + simulationSettingsSubView, +]; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/differential-equations-section.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/differential-equations-section.tsx deleted file mode 100644 index e9f2ba9f846..00000000000 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/differential-equations-section.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; -import { useState } from "react"; -import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; -import { v4 as uuidv4 } from "uuid"; - -import { InfoIconTooltip } from "../../../../components/tooltip"; -import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../../core/default-codes"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; -import { useSimulationStore } from "../../../../state/simulation-provider"; - -const sectionContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[8px]", - paddingBottom: "[16px]", - borderBottom: "[1px solid rgba(0, 0, 0, 0.1)]", -}); - -const headerRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", -}); - -const sectionToggleButtonStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", - fontWeight: 600, - fontSize: "[13px]", - color: "[#333]", - cursor: "pointer", - background: "[transparent]", - border: "none", - padding: "spacing.1", - borderRadius: "radius.4", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, -}); - -const addButtonStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "spacing.1", - borderRadius: "radius.2", - cursor: "pointer", - fontSize: "[18px]", - color: "core.gray.60", - background: "[transparent]", - border: "none", - width: "[24px]", - height: "[24px]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - color: "core.gray.90", - }, - _disabled: { - cursor: "not-allowed", - opacity: "[0.4]", - _hover: { - backgroundColor: "[transparent]", - color: "core.gray.60", - }, - }, -}); - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[4px]", -}); - -const equationRowStyle = cva({ - base: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: "[4px 2px 4px 8px]", - fontSize: "[13px]", - borderRadius: "[4px]", - cursor: "pointer", - }, - variants: { - isSelected: { - true: { - backgroundColor: "[rgba(59, 130, 246, 0.15)]", - _hover: { - backgroundColor: "[rgba(59, 130, 246, 0.2)]", - }, - }, - false: { - backgroundColor: "[#f9fafb]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, - }, - }, - }, -}); - -const equationNameContainerStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", -}); - -const deleteButtonStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "spacing.1", - borderRadius: "radius.2", - cursor: "pointer", - fontSize: "[14px]", - color: "core.gray.40", - background: "[transparent]", - border: "none", - width: "[20px]", - height: "[20px]", - _hover: { - backgroundColor: "[rgba(239, 68, 68, 0.1)]", - color: "core.red.60", - }, - _disabled: { - cursor: "not-allowed", - opacity: "[0.3]", - _hover: { - backgroundColor: "[transparent]", - color: "core.gray.40", - }, - }, -}); - -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", - padding: "spacing.4", - textAlign: "center", -}); - -export const DifferentialEquationsSection: React.FC = () => { - const [isExpanded, setIsExpanded] = useState(true); - - const { - petriNetDefinition: { types, differentialEquations }, - addDifferentialEquation, - removeDifferentialEquation, - } = useSDCPNContext(); - - const selectedResourceId = useEditorStore( - (state) => state.selectedResourceId, - ); - const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, - ); - - // Check if simulation is running or paused - const simulationState = useSimulationStore((state) => state.state); - const isSimulationActive = - simulationState === "Running" || simulationState === "Paused"; - - return ( -
-
- - -
- {isExpanded && ( -
- {differentialEquations.map((eq) => { - const isSelected = selectedResourceId === eq.id; - - return ( -
{ - // Don't trigger selection if clicking the delete button - if ( - event.target instanceof HTMLElement && - event.target.closest("button[aria-label^='Delete']") - ) { - return; - } - setSelectedResourceId(eq.id); - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - setSelectedResourceId(eq.id); - } - }} - className={equationRowStyle({ isSelected })} - > -
- {eq.name} -
- -
- ); - })} - {differentialEquations.length === 0 && ( -
- No differential equations yet -
- )} -
- )} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/nodes-section.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/nodes-section.tsx deleted file mode 100644 index 57446169287..00000000000 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/nodes-section.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; -import { useState } from "react"; -import { - FaChevronDown, - FaChevronRight, - FaCircle, - FaSquare, -} from "react-icons/fa6"; - -import { InfoIconTooltip } from "../../../../components/tooltip"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; - -const sectionContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[8px]", - flex: "[1]", - minHeight: "[0]", -}); - -const sectionToggleButtonStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", - fontWeight: 600, - fontSize: "[13px]", - color: "[#333]", - cursor: "pointer", - background: "[transparent]", - border: "none", - padding: "spacing.1", - borderRadius: "radius.4", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, -}); - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[2px]", - overflowY: "auto", - flex: "[1]", -}); - -const nodeRowStyle = cva({ - base: { - display: "flex", - alignItems: "center", - gap: "[6px]", - padding: "[4px 9px]", - borderRadius: "radius.4", - cursor: "default", - transition: "[all 0.15s ease]", - }, - variants: { - isSelected: { - true: { - backgroundColor: "core.blue.20", - _hover: { - backgroundColor: "core.blue.30", - }, - }, - false: { - backgroundColor: "[transparent]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, - }, - }, - }, -}); - -const nodeIconStyle = cva({ - base: { - flexShrink: 0, - }, - variants: { - isSelected: { - true: { - color: "[#3b82f6]", - }, - false: { - color: "[#9ca3af]", - }, - }, - }, -}); - -const nodeNameStyle = cva({ - base: { - fontSize: "[13px]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }, - variants: { - isSelected: { - true: { - color: "[#1e40af]", - fontWeight: 500, - }, - false: { - color: "[#374151]", - fontWeight: 400, - }, - }, - }, -}); - -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", - padding: "spacing.4", - textAlign: "center", -}); - -export const NodesSection: React.FC = () => { - const [isExpanded, setIsExpanded] = useState(true); - const { - petriNetDefinition: { places, transitions }, - } = useSDCPNContext(); - const selectedResourceId = useEditorStore( - (state) => state.selectedResourceId, - ); - const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, - ); - - const handleLayerClick = (id: string) => { - // Single select: replace selection - setSelectedResourceId(id); - }; - - return ( -
- - - {/* Nodes List */} - {isExpanded && ( -
- {/* Places */} - {places.map((place) => { - const isSelected = selectedResourceId === place.id; - return ( -
handleLayerClick(place.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleLayerClick(place.id); - } - }} - className={nodeRowStyle({ isSelected })} - > - - - {place.name || `Place ${place.id}`} - -
- ); - })} - - {/* Transitions */} - {transitions.map((transition) => { - const isSelected = selectedResourceId === transition.id; - return ( -
handleLayerClick(transition.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - handleLayerClick(transition.id); - } - }} - className={nodeRowStyle({ isSelected })} - > - - - {transition.name || `Transition ${transition.id}`} - -
- ); - })} - - {/* Empty state */} - {places.length === 0 && transitions.length === 0 && ( -
No nodes yet
- )} -
- )} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/types-section.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/types-section.tsx deleted file mode 100644 index 2cef5ab758f..00000000000 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/types-section.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; -import { useState } from "react"; -import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; - -import { InfoIconTooltip } from "../../../../components/tooltip"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; -import { useSimulationStore } from "../../../../state/simulation-provider"; - -const sectionContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[8px]", - paddingBottom: "[16px]", - borderBottom: "[1px solid rgba(0, 0, 0, 0.1)]", -}); - -const headerRowStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", -}); - -const sectionToggleButtonStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", - fontWeight: 600, - fontSize: "[13px]", - color: "[#333]", - cursor: "pointer", - background: "[transparent]", - border: "none", - padding: "spacing.1", - borderRadius: "radius.4", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, -}); - -const addButtonStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "spacing.1", - borderRadius: "radius.2", - cursor: "pointer", - fontSize: "[18px]", - color: "core.gray.60", - background: "[transparent]", - border: "none", - width: "[24px]", - height: "[24px]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - color: "core.gray.90", - }, - _disabled: { - cursor: "not-allowed", - opacity: "[0.4]", - _hover: { - backgroundColor: "[transparent]", - color: "core.gray.60", - }, - }, -}); - -const listContainerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[2px]", - maxHeight: "[200px]", - overflowY: "auto", -}); - -const typeRowStyle = cva({ - base: { - display: "flex", - alignItems: "center", - gap: "[8px]", - padding: "[4px 2px 4px 8px]", - borderRadius: "[4px]", - cursor: "pointer", - }, - variants: { - isSelected: { - true: { - backgroundColor: "[rgba(59, 130, 246, 0.15)]", - _hover: { - backgroundColor: "[rgba(59, 130, 246, 0.2)]", - }, - }, - false: { - backgroundColor: "[transparent]", - _hover: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - }, - }, - }, - }, -}); - -const colorDotStyle = css({ - width: "[12px]", - height: "[12px]", - borderRadius: "[50%]", - flexShrink: 0, -}); - -const typeNameStyle = css({ - flex: "[1]", - fontSize: "[13px]", - color: "[#374151]", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", -}); - -const deleteButtonStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - padding: "spacing.1", - borderRadius: "radius.2", - cursor: "pointer", - fontSize: "[14px]", - color: "core.gray.40", - background: "[transparent]", - border: "none", - width: "[20px]", - height: "[20px]", - _hover: { - backgroundColor: "[rgba(239, 68, 68, 0.1)]", - color: "core.red.60", - }, - _disabled: { - cursor: "not-allowed", - opacity: "[0.3]", - _hover: { - backgroundColor: "[transparent]", - color: "core.gray.40", - }, - }, -}); - -const emptyMessageStyle = css({ - fontSize: "[13px]", - color: "[#9ca3af]", - padding: "spacing.4", - textAlign: "center", -}); - -// Pool of 10 well-differentiated colors for types -const TYPE_COLOR_POOL = [ - "#3b82f6", // Blue - "#ef4444", // Red - "#10b981", // Green - "#f59e0b", // Amber - "#8b5cf6", // Violet - "#ec4899", // Pink - "#14b8a6", // Teal - "#f97316", // Orange - "#6366f1", // Indigo - "#84cc16", // Lime -]; - -/** - * Get the next available color from the pool that's not currently in use. - * If all colors are in use, cycle back to the beginning. - */ -const getNextAvailableColor = (existingColors: string[]): string => { - const unusedColor = TYPE_COLOR_POOL.find( - (color) => !existingColors.includes(color), - ); - return unusedColor ?? TYPE_COLOR_POOL[0]!; -}; - -/** - * Extract the highest type number from existing type names. - * Looks for patterns like "Type 1", "Type 2", "New Type 3", etc. - */ -const getNextTypeNumber = (existingNames: string[]): number => { - let maxNumber = 0; - for (const name of existingNames) { - // Match patterns like "Type 1", "New Type 2", etc. - const match = name.match(/Type\s+(\d+)/i); - if (match) { - const num = Number.parseInt(match[1]!, 10); - if (num > maxNumber) { - maxNumber = num; - } - } - } - return maxNumber + 1; -}; - -export const TypesSection: React.FC = () => { - const [isExpanded, setIsExpanded] = useState(true); - - const { - petriNetDefinition: { types }, - addType, - removeType, - } = useSDCPNContext(); - - const selectedResourceId = useEditorStore( - (state) => state.selectedResourceId, - ); - const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, - ); - - // Check if simulation is running or paused - const simulationState = useSimulationStore((state) => state.state); - const isSimulationActive = - simulationState === "Running" || simulationState === "Paused"; - - return ( -
-
- - -
- - {isExpanded && ( -
- {types.map((type) => { - const isSelected = selectedResourceId === type.id; - - return ( -
{ - // Don't trigger selection if clicking the delete button - if ( - event.target instanceof HTMLElement && - event.target.closest("button[aria-label^='Delete']") - ) { - return; - } - setSelectedResourceId(type.id); - }} - role="button" - tabIndex={0} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - setSelectedResourceId(type.id); - } - }} - className={typeRowStyle({ isSelected })} - > -
- {type.name} - -
- ); - })} - {types.length === 0 && ( -
No token types yet
- )} -
- )} -
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/place-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/place-properties.tsx deleted file mode 100644 index 1ec92d324fd..00000000000 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/place-properties.tsx +++ /dev/null @@ -1,883 +0,0 @@ -/* eslint-disable id-length */ -import { css, cva } from "@hashintel/ds-helpers/css"; -import MonacoEditor from "@monaco-editor/react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - TbArrowRight, - TbDotsVertical, - TbSparkles, - TbTrash, -} from "react-icons/tb"; - -import { Menu } from "../../../../components/menu"; -import { Switch } from "../../../../components/switch"; -import { InfoIconTooltip, Tooltip } from "../../../../components/tooltip"; -import { UI_MESSAGES } from "../../../../constants/ui-messages"; -import { - DEFAULT_VISUALIZER_CODE, - generateDefaultVisualizerCode, -} from "../../../../core/default-codes"; -import { compileVisualizer } from "../../../../core/simulation/compile-visualizer"; -import type { - Color, - DifferentialEquation, - Place, -} from "../../../../core/types/sdcpn"; -import { - mergeParameterValues, - useDefaultParameterValues, -} from "../../../../hooks/use-default-parameter-values"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; -import { useSimulationStore } from "../../../../state/simulation-provider"; -import { InitialStateEditor } from "./initial-state-editor"; -import { VisualizerErrorBoundary } from "./visualizer-error-boundary"; - -const containerStyle = css({ - display: "flex", - flexDirection: "column", - gap: "[12px]", -}); - -const headerContainerStyle = css({ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - marginBottom: "[8px]", -}); - -const headerTitleStyle = css({ - fontWeight: 600, - fontSize: "[16px]", -}); - -const deleteButtonStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "center", - width: "[24px]", - height: "[24px]", - padding: "spacing.0", - border: "none", - background: "[transparent]", - cursor: "pointer", - color: "core.gray.60", - borderRadius: "radius.4", - _hover: { - color: "core.red.60", - backgroundColor: "core.red.10", - }, -}); - -const fieldLabelStyle = css({ - fontWeight: 500, - fontSize: "[12px]", - marginBottom: "[4px]", -}); - -const fieldLabelWithTooltipStyle = css({ - fontWeight: 500, - fontSize: "[12px]", - marginBottom: "[4px]", - display: "flex", - alignItems: "center", -}); - -const inputStyle = cva({ - base: { - fontSize: "[14px]", - padding: "[6px 8px]", - borderRadius: "[4px]", - width: "[100%]", - boxSizing: "border-box", - }, - variants: { - isReadOnly: { - true: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - cursor: "not-allowed", - }, - false: { - backgroundColor: "[white]", - cursor: "text", - }, - }, - hasError: { - true: { - border: "[1px solid #ef4444]", - }, - false: { - border: "[1px solid rgba(0, 0, 0, 0.1)]", - }, - }, - }, - defaultVariants: { - isReadOnly: false, - hasError: false, - }, -}); - -const errorMessageStyle = css({ - fontSize: "[12px]", - color: "[#ef4444]", - marginTop: "[4px]", -}); - -const selectStyle = cva({ - base: { - fontSize: "[14px]", - padding: "[6px 8px]", - border: "[1px solid rgba(0, 0, 0, 0.1)]", - borderRadius: "[4px]", - width: "[100%]", - boxSizing: "border-box", - }, - variants: { - isReadOnly: { - true: { - backgroundColor: "[rgba(0, 0, 0, 0.05)]", - cursor: "not-allowed", - }, - false: { - backgroundColor: "[white]", - cursor: "pointer", - }, - }, - hasMarginBottom: { - true: { - marginBottom: "[8px]", - }, - false: {}, - }, - }, -}); - -const jumpButtonContainerStyle = css({ - textAlign: "right", -}); - -const jumpButtonStyle = css({ - fontSize: "[12px]", - padding: "[4px 8px]", - border: "[1px solid rgba(0, 0, 0, 0.2)]", - borderRadius: "[4px]", - backgroundColor: "[white]", - cursor: "pointer", - color: "[#333]", - display: "inline-flex", - alignItems: "center", - gap: "[6px]", -}); - -const jumpIconStyle = css({ - fontSize: "[14px]", -}); - -const sectionContainerStyle = css({ - marginTop: "[10px]", -}); - -const switchRowStyle = css({ - display: "flex", - alignItems: "center", - gap: "[8px]", - marginBottom: "[8px]", -}); - -const switchContainerStyle = css({ - display: "flex", - alignItems: "center", -}); - -const hintTextStyle = css({ - fontSize: "[11px]", - color: "[#999]", - fontStyle: "italic", - marginTop: "[4px]", -}); - -const diffEqContainerStyle = css({ - marginBottom: "[25px]", -}); - -const menuButtonStyle = css({ - background: "[transparent]", - border: "none", - cursor: "pointer", - padding: "[4px]", - display: "flex", - alignItems: "center", - fontSize: "[18px]", - color: "[rgba(0, 0, 0, 0.6)]", -}); - -const codeHeaderStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: "[4px]", -}); - -const codeHeaderLabelStyle = css({ - fontWeight: 500, - fontSize: "[12px]", -}); - -const editorBorderStyle = css({ - border: "[1px solid rgba(0, 0, 0, 0.1)]", - borderRadius: "[4px]", - overflow: "hidden", -}); - -const aiMenuItemStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", -}); - -const aiIconStyle = css({ - fontSize: "[16px]", -}); - -const visualizerMessageStyle = css({ - padding: "[12px]", - color: "[#666]", -}); - -const visualizerErrorStyle = css({ - padding: "[12px]", - color: "[#d32f2f]", -}); - -const spacerStyle = css({ - height: "[40px]", -}); - -interface PlacePropertiesProps { - place: Place; - types: Color[]; - differentialEquations: DifferentialEquation[]; - globalMode: "edit" | "simulate"; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; -} - -export const PlaceProperties: React.FC = ({ - place, - types, - differentialEquations, - globalMode, - updatePlace, -}) => { - const simulation = useSimulationStore((state) => state.simulation); - const simulationState = useSimulationStore((state) => state.state); - - // Check if simulation is running or paused - const isSimulationActive = - simulationState === "Running" || simulationState === "Paused"; - const isReadOnly = globalMode === "simulate" || isSimulationActive; - const initialMarking = useSimulationStore((state) => state.initialMarking); - const setInitialMarking = useSimulationStore( - (state) => state.setInitialMarking, - ); - const parameterValues = useSimulationStore((state) => state.parameterValues); - const currentlyViewedFrame = useSimulationStore( - (state) => state.currentlyViewedFrame, - ); - - const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, - ); - - const { - petriNetDefinition: { types: availableTypes }, - } = useSDCPNContext(); - - // Store previous visualizer code when toggling off (in case user toggled off by mistake) - const [savedVisualizerCode, setSavedVisualizerCode] = useState< - string | undefined - >(undefined); - useEffect(() => setSavedVisualizerCode(undefined), [place.id]); - - // State for name input validation - const [nameInputValue, setNameInputValue] = useState(place.name); - const [nameError, setNameError] = useState(null); - const [isNameInputFocused, setIsNameInputFocused] = useState(false); - const nameInputRef = useRef(null); - const rootDivRef = useRef(null); - - // Update local state when place changes - useEffect(() => { - setNameInputValue(place.name); - setNameError(null); - }, [place.id, place.name]); - - // Handle clicks outside when name input is focused - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - isNameInputFocused && - rootDivRef.current && - !rootDivRef.current.contains(event.target as Node) - ) { - // Click is outside the root div and input is focused - event.stopPropagation(); - event.preventDefault(); - event.stopImmediatePropagation(); - nameInputRef.current?.blur(); - } - }; - - if (isNameInputFocused) { - // Use capture phase to catch the event before it propagates - document.addEventListener("click", handleClickOutside, true); - return () => { - document.removeEventListener("click", handleClickOutside, true); - }; - } - }, [isNameInputFocused]); - - // Validate PascalCase format - const isPascalCase = (str: string): boolean => { - if (!str) { - return false; - } - // PascalCase: starts with uppercase, contains letters (and optionally numbers at the end) - return /^[A-Z][a-zA-Z]*\d*$/.test(str); - }; - - const handleNameBlur = () => { - if (!nameInputValue.trim()) { - setNameError("Name cannot be empty"); - return; - } - - if (!isPascalCase(nameInputValue)) { - setNameError( - "Name must be in PascalCase (e.g., MyPlaceName or Place2). Any numbers must appear at the end.", - ); - return; - } - - // Valid name - update and clear error - setNameError(null); - if (nameInputValue !== place.name) { - updatePlace(place.id, (existingPlace) => { - existingPlace.name = nameInputValue; - }); - } - }; - - // Get default parameter values from SDCPN definition - const defaultParameterValues = useDefaultParameterValues(); - - // Compile visualizer code once when it changes - const VisualizerComponent = useMemo(() => { - if (!place.visualizerCode) { - return null; - } - - try { - return compileVisualizer(place.visualizerCode); - } catch (error) { - // eslint-disable-next-line no-console - console.error("Failed to compile visualizer code:", error); - return null; - } - }, [place.visualizerCode]); - - // Filter differential equations by place type - const availableDiffEqs = place.colorId - ? differentialEquations.filter((eq) => eq.colorId === place.colorId) - : []; - - const { removePlace } = useSDCPNContext(); - - return ( -
-
-
-
Place
- - - -
-
- -
-
Name
- { - setNameInputValue(event.target.value); - // Clear error when user starts typing - if (nameError) { - setNameError(null); - } - }} - onFocus={() => setIsNameInputFocused(true)} - onBlur={() => { - setIsNameInputFocused(false); - handleNameBlur(); - }} - disabled={isReadOnly} - className={inputStyle({ isReadOnly, hasError: !!nameError })} - /> - {nameError &&
{nameError}
} -
- -
-
- Accepted token type - -
- - - {place.colorId && ( -
- -
- )} -
- -
-
-
- { - updatePlace(place.id, (existingPlace) => { - existingPlace.dynamicsEnabled = checked; - }); - }} - /> -
-
- Dynamics - -
-
- {(place.colorId === null || availableDiffEqs.length === 0) && ( -
- {place.colorId !== null - ? "Create a differential equation for the selected type in the left-hand sidebar first" - : availableTypes.length === 0 - ? "Create a type in the left-hand sidebar first, then select it to enable dynamics." - : "Select a type to enable dynamics"} -
- )} -
- - {place.colorId && - place.dynamicsEnabled && - availableDiffEqs.length > 0 && ( -
-
Differential Equation
- - - {place.differentialEquationId && ( -
- -
- )} -
- )} - - {/* Initial State section - shown in both Edit and Simulate modes */} - {(() => { - const placeType = place.colorId - ? types.find((tp) => tp.id === place.colorId) - : null; - - // Determine if simulation is running (has frames) - const hasSimulationFrames = - simulation !== null && simulation.frames.length > 0; - - // If no type or type has 0 dimensions, show simple number input - if (!placeType || placeType.elements.length === 0) { - // Get token count from simulation frame or initial marking - let currentTokenCount = 0; - if (hasSimulationFrames) { - const currentFrame = simulation.frames[currentlyViewedFrame]; - if (currentFrame) { - const placeState = currentFrame.places.get(place.id); - currentTokenCount = placeState?.count ?? 0; - } - } else { - const currentMarking = initialMarking.get(place.id); - currentTokenCount = currentMarking?.count ?? 0; - } - - return ( -
-
- {hasSimulationFrames ? "State" : "Initial State"} -
-
-
Token count
- { - const count = Math.max( - 0, - Number.parseInt(event.target.value, 10) || 0, - ); - setInitialMarking(place.id, { - values: new Float64Array(0), // Empty array for places without type - count, - }); - }} - disabled={hasSimulationFrames} - className={inputStyle({ isReadOnly: hasSimulationFrames })} - /> -
-
- ); - } - - return ( - - ); - })()} - - {/* Visualizer section */} - {globalMode === "edit" && ( -
-
-
- { - if (checked) { - // Turning on: use saved code if available, otherwise default - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = - savedVisualizerCode ?? DEFAULT_VISUALIZER_CODE; - }); - } else { - // Turning off: save current code and set to undefined - if (place.visualizerCode) { - setSavedVisualizerCode(place.visualizerCode); - } - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = undefined; - }); - } - }} - /> -
-
- Visualizer - -
-
-
- )} - - {place.visualizerCode !== undefined && ( -
- {(() => { - // Determine if we should show visualization (when simulation has frames) - const hasSimulationFrames = - simulation !== null && simulation.frames.length > 0; - const showVisualization = isReadOnly || hasSimulationFrames; - - return ( - <> -
-
- {showVisualization - ? "Visualizer Output" - : "Visualizer Code"} -
- {!showVisualization && ( - - - - } - items={[ - { - id: "load-default", - label: "Load default template", - onClick: () => { - // Get the place's type to generate appropriate default code - const placeType = place.colorId - ? types.find((t) => t.id === place.colorId) - : null; - - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = placeType - ? generateDefaultVisualizerCode(placeType) - : DEFAULT_VISUALIZER_CODE; - }); - }, - }, - { - id: "generate-ai", - label: ( - -
- - Generate with AI -
-
- ), - disabled: true, - onClick: () => { - // TODO: Implement AI generation - }, - }, - ]} - /> - )} -
-
- {showVisualization ? ( - // Show live token values and parameters during simulation - (() => { - // Get place type to determine dimensions - const placeType = place.colorId - ? types.find((tp) => tp.id === place.colorId) - : null; - - if (!placeType) { - return ( -
- Place has no type set -
- ); - } - - const dimensions = placeType.elements.length; - const tokens: Record[] = []; - let parameters: Record = {}; - - // Check if we have simulation frames or use initial marking - if (simulation && simulation.frames.length > 0) { - // Use currently viewed simulation frame - const currentFrame = - simulation.frames[currentlyViewedFrame]; - if (!currentFrame) { - return ( -
- No frame data available -
- ); - } - - const placeState = currentFrame.places.get(place.id); - if (!placeState) { - return ( -
- Place not found in frame -
- ); - } - - const { offset, count } = placeState; - const placeSize = count * dimensions; - const tokenValues = Array.from( - currentFrame.buffer.slice(offset, offset + placeSize), - ); - - // Format tokens as array of objects with named dimensions - for (let i = 0; i < count; i++) { - const token: Record = {}; - for ( - let colIndex = 0; - colIndex < dimensions; - colIndex++ - ) { - const dimensionName = - placeType.elements[colIndex]!.name; - token[dimensionName] = - tokenValues[i * dimensions + colIndex] ?? 0; - } - tokens.push(token); - } - - // Merge SimulationStore values with SDCPN defaults - parameters = mergeParameterValues( - parameterValues, - defaultParameterValues, - ); - } else { - // Use initial marking - const marking = initialMarking.get(place.id); - if (marking && marking.count > 0) { - for (let i = 0; i < marking.count; i++) { - const token: Record = {}; - for ( - let colIndex = 0; - colIndex < dimensions; - colIndex++ - ) { - const dimensionName = - placeType.elements[colIndex]!.name; - token[dimensionName] = - marking.values[i * dimensions + colIndex] ?? 0; - } - tokens.push(token); - } - } - - // Merge SimulationStore values with SDCPN defaults - parameters = mergeParameterValues( - parameterValues, - defaultParameterValues, - ); - } - - // Render the compiled visualizer component - if (!VisualizerComponent) { - return ( -
- Failed to compile visualizer code. Check console for - errors. -
- ); - } - - return ( - - - - ); - })() - ) : ( - // Show code editor in edit mode - { - updatePlace(place.id, (existingPlace) => { - existingPlace.visualizerCode = value ?? ""; - }); - }} - theme="vs-light" - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 12, - lineNumbers: "off", - folding: true, - glyphMargin: false, - lineDecorationsWidth: 0, - lineNumbersMinChars: 3, - padding: { top: 8, bottom: 8 }, - fixedOverflowWidgets: true, - }} - /> - )} -
- - ); - })()} -
- )} - -
-
- ); -}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 4b4ba77e23c..e13704ee1cc 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -12,12 +12,12 @@ import { useSDCPNContext } from "../../state/sdcpn-provider"; import { useSimulationStore } from "../../state/simulation-provider"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { BottomBar } from "./components/BottomBar/bottom-bar"; -import { BottomPanel } from "./components/BottomPanel/bottom-panel"; -import { LeftSideBar } from "./components/LeftSideBar/left-sidebar"; import { ModeSelector } from "./components/mode-selector"; -import { PropertiesPanel } from "./components/PropertiesPanel/properties-panel"; import { exportSDCPN } from "./lib/export-sdcpn"; import { importSDCPN } from "./lib/import-sdcpn"; +import { BottomPanel } from "./panels/BottomPanel/panel"; +import { LeftSideBar } from "./panels/LeftSideBar/panel"; +import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; const fullHeightStyle = css({ height: "[100%]", @@ -71,7 +71,7 @@ export const EditorView = ({ // Get simulation store method to initialize parameter values const initializeParameterValuesFromDefaults = useSimulationStore( - (state) => state.initializeParameterValuesFromDefaults, + (state) => state.initializeParameterValuesFromDefaults ); // Handler for mode change that initializes parameter values when switching to simulate mode diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/bottom-panel.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx similarity index 56% rename from libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/bottom-panel.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx index fcc99ce0708..fc6fae2f24c 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/bottom-panel.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/BottomPanel/panel.tsx @@ -1,17 +1,21 @@ -import { css, cva } from "@hashintel/ds-helpers/css"; +import { css } from "@hashintel/ds-helpers/css"; +import { useCallback } from "react"; import { FaXmark } from "react-icons/fa6"; import { GlassPanel } from "../../../../components/glass-panel"; import { + HorizontalTabsContent, + HorizontalTabsHeader, + HorizontalTabsHeaderAction, +} from "../../../../components/sub-view"; +import { + BOTTOM_PANEL_SUBVIEWS, MAX_BOTTOM_PANEL_HEIGHT, MIN_BOTTOM_PANEL_HEIGHT, PANEL_MARGIN, } from "../../../../constants/ui"; import { useEditorStore } from "../../../../state/editor-provider"; import type { BottomPanelTab } from "../../../../state/editor-store"; -import { DiagnosticsContent } from "./diagnostics-content"; -import { ParametersContent } from "./parameters-content"; -import { SimulationSettingsContent } from "./simulation-settings-content"; const glassPanelBaseStyle = css({ position: "fixed", @@ -32,7 +36,7 @@ const headerStyle = css({ flexShrink: 0, }); -const tabsContainerStyle = css({ +const headerRightStyle = css({ display: "flex", alignItems: "center", gap: "[4px]", @@ -56,51 +60,6 @@ const closeButtonStyle = css({ }, }); -const tabButtonStyle = cva({ - base: { - fontSize: "[11px]", - fontWeight: "[500]", - padding: "[4px 10px]", - textTransform: "uppercase", - borderRadius: "[3px]", - border: "none", - cursor: "pointer", - transition: "[all 0.3s ease]", - background: "[transparent]", - }, - variants: { - active: { - true: { - opacity: "[1]", - backgroundColor: "[rgba(0, 0, 0, 0.08)]", - color: "core.gray.90", - }, - false: { - opacity: "[0.6]", - color: "core.gray.60", - _hover: { - opacity: "[1]", - backgroundColor: "[rgba(0, 0, 0, 0.04)]", - color: "core.gray.80", - }, - }, - }, - }, -}); - -const contentStyle = css({ - fontSize: "[12px]", - padding: "[12px 12px]", - flex: "[1]", - overflowY: "auto", -}); - -const tabs: { id: BottomPanelTab; label: string }[] = [ - { id: "diagnostics", label: "Diagnostics" }, - { id: "parameters", label: "Global Parameters" }, - { id: "simulation-settings", label: "Simulation Settings" }, -]; - /** * BottomPanel shows tabs for Diagnostics, Simulation Settings, and Parameters. * Positioned at the bottom of the viewport. @@ -113,12 +72,20 @@ export const BottomPanel: React.FC = () => { const leftSidebarWidth = useEditorStore((state) => state.leftSidebarWidth); const panelHeight = useEditorStore((state) => state.bottomPanelHeight); const setBottomPanelHeight = useEditorStore( - (state) => state.setBottomPanelHeight, + (state) => state.setBottomPanelHeight ); const activeTab = useEditorStore((state) => state.activeBottomPanelTab); const setActiveTab = useEditorStore((state) => state.setActiveBottomPanelTab); const toggleBottomPanel = useEditorStore((state) => state.toggleBottomPanel); + // Handler for tab change that casts string to BottomPanelTab + const handleTabChange = useCallback( + (tabId: string) => { + setActiveTab(tabId as BottomPanelTab); + }, + [setActiveTab] + ); + if (!isOpen) { return null; } @@ -129,17 +96,6 @@ export const BottomPanel: React.FC = () => { ? leftSidebarWidth + PANEL_MARGIN * 2 : PANEL_MARGIN; - function renderContent() { - switch (activeTab) { - case "diagnostics": - return ; - case "simulation-settings": - return ; - case "parameters": - return ; - } - } - return ( { > {/* Tab Header */}
-
- {tabs.map((tab) => ( - - ))} + +
+ +
-
{/* Scrollable content */} -
{renderContent()}
+ ); }; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/floating-title.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/floating-title.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/floating-title.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/floating-title.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/hamburger-menu.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/hamburger-menu.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/hamburger-menu.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/hamburger-menu.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/left-sidebar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx similarity index 89% rename from libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/left-sidebar.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx index 6986fa1b3ce..ec5324e7a45 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/LeftSideBar/left-sidebar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/panel.tsx @@ -6,17 +6,16 @@ import { import { GlassPanel } from "../../../../components/glass-panel"; import type { MenuItem } from "../../../../components/menu"; +import { VerticalSubViewsContainer } from "../../../../components/sub-view"; import { + LEFT_SIDEBAR_SUBVIEWS, MAX_LEFT_SIDEBAR_WIDTH, MIN_LEFT_SIDEBAR_WIDTH, PANEL_MARGIN, } from "../../../../constants/ui"; import { useEditorStore } from "../../../../state/editor-provider"; -import { DifferentialEquationsSection } from "./differential-equations-section"; import { FloatingTitle } from "./floating-title"; import { HamburgerMenu } from "./hamburger-menu"; -import { NodesSection } from "./nodes-section"; -import { TypesSection } from "./types-section"; const outerContainerStyle = cva({ base: { @@ -50,7 +49,7 @@ const panelContentStyle = cva({ height: "[100%]", padding: "[16px]", flexDirection: "column", - gap: "[16px]", + gap: "[4px]", alignItems: "stretch", }, false: { @@ -148,11 +147,11 @@ export const LeftSideBar: React.FC = ({ }) => { const isOpen = useEditorStore((state) => state.isLeftSidebarOpen); const setLeftSidebarOpen = useEditorStore( - (state) => state.setLeftSidebarOpen, + (state) => state.setLeftSidebarOpen ); const leftSidebarWidth = useEditorStore((state) => state.leftSidebarWidth); const setLeftSidebarWidth = useEditorStore( - (state) => state.setLeftSidebarWidth, + (state) => state.setLeftSidebarWidth ); return ( @@ -208,16 +207,7 @@ export const LeftSideBar: React.FC = ({ {/* Content sections - only visible when open */} {isOpen && ( - <> - {/* Types Section - only in Edit mode */} - - - {/* Differential Equations Section - only in Edit mode */} - - - {/* Nodes Section */} - - + )}
diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/color-select.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/color-select.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/color-select.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/color-select.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/differential-equation-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/differential-equation-properties.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/differential-equation-properties.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/initial-state-editor.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx similarity index 96% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/initial-state-editor.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx index e41aaaf899c..0b6e43d57dc 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/initial-state-editor.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/initial-state-editor.tsx @@ -2,7 +2,6 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { useEffect, useRef, useState } from "react"; import { TbTrash } from "react-icons/tb"; -import { InfoIconTooltip } from "../../../../components/tooltip"; import type { Color } from "../../../../core/types/sdcpn"; import { useSimulationStore } from "../../../../state/simulation-provider"; @@ -14,11 +13,6 @@ const headerRowStyle = css({ height: "[20px]", }); -const headerLabelStyle = css({ - fontWeight: 500, - fontSize: "[12px]", -}); - const clearButtonStyle = css({ fontSize: "[11px]", padding: "[2px 8px]", @@ -250,16 +244,16 @@ export const InitialStateEditor: React.FC = ({ const { height, isResizing, containerRef, startResize } = useResizable(250); const isSimulationNotRun = useSimulationStore( - (state) => state.state === "NotRun", + (state) => state.state === "NotRun" ); const initialMarking = useSimulationStore((state) => state.initialMarking); const setInitialMarking = useSimulationStore( - (state) => state.setInitialMarking, + (state) => state.setInitialMarking ); const simulation = useSimulationStore((state) => state.simulation); const currentlyViewedFrame = useSimulationStore( - (state) => state.currentlyViewedFrame, + (state) => state.currentlyViewedFrame ); // Determine if we should show current simulation state or initial marking @@ -305,7 +299,7 @@ export const InitialStateEditor: React.FC = ({ const tokenValues: number[] = []; for (let colIndex = 0; colIndex < dimensions; colIndex++) { tokenValues.push( - currentMarking.values[i * dimensions + colIndex] ?? 0, + currentMarking.values[i * dimensions + colIndex] ?? 0 ); } tokens.push(tokenValues); @@ -338,7 +332,7 @@ export const InitialStateEditor: React.FC = ({ const tokenValues: number[] = []; for (let colIndex = 0; colIndex < dimensions; colIndex++) { tokenValues.push( - currentMarking.values[i * dimensions + colIndex] ?? 0, + currentMarking.values[i * dimensions + colIndex] ?? 0 ); } tokens.push(tokenValues); @@ -381,7 +375,7 @@ export const InitialStateEditor: React.FC = ({ } } else { newData = prev.map((rowData, index) => - index === row ? [...rowData] : rowData, + index === row ? [...rowData] : rowData ); if (newData[row]) { newData[row][col] = value; @@ -406,7 +400,7 @@ export const InitialStateEditor: React.FC = ({ // Focus the row number cell after state update setTimeout(() => { const rowCell = document.querySelector( - `td[data-row="${newData.length - 1}"]`, + `td[data-row="${newData.length - 1}"]` ); if (rowCell instanceof HTMLElement) { rowCell.focus(); @@ -418,7 +412,7 @@ export const InitialStateEditor: React.FC = ({ // Focus the row number cell after state update setTimeout(() => { const rowCell = document.querySelector( - `td[data-row="${rowIndex}"]`, + `td[data-row="${rowIndex}"]` ); if (rowCell instanceof HTMLElement) { rowCell.focus(); @@ -444,7 +438,7 @@ export const InitialStateEditor: React.FC = ({ const handleKeyDown = ( event: React.KeyboardEvent, row: number, - col: number, + col: number ) => { if (hasSimulation) { return; @@ -514,7 +508,7 @@ export const InitialStateEditor: React.FC = ({ }); setTimeout(() => { const prevCell = cellRefs.current.get( - `${row - 1}-${placeType.elements.length - 1}`, + `${row - 1}-${placeType.elements.length - 1}` ); prevCell?.focus(); }, 0); @@ -607,7 +601,7 @@ export const InitialStateEditor: React.FC = ({ setFocusedCell({ row: row - 1, col: placeType.elements.length - 1 }); setTimeout(() => { const prevCell = cellRefs.current.get( - `${row - 1}-${placeType.elements.length - 1}`, + `${row - 1}-${placeType.elements.length - 1}` ); prevCell?.focus(); }, 0); @@ -718,7 +712,7 @@ export const InitialStateEditor: React.FC = ({ setEditingCell(null); // Focus the next row number cell const nextRowCell = document.querySelector( - `td[data-row="${rowIndex + 1}"]`, + `td[data-row="${rowIndex + 1}"]` ); if (nextRowCell instanceof HTMLElement) { nextRowCell.focus(); @@ -731,7 +725,7 @@ export const InitialStateEditor: React.FC = ({ setEditingCell(null); // Focus the previous row number cell const prevRowCell = document.querySelector( - `td[data-row="${rowIndex - 1}"]`, + `td[data-row="${rowIndex - 1}"]` ); if (prevRowCell instanceof HTMLElement) { prevRowCell.focus(); @@ -744,12 +738,6 @@ export const InitialStateEditor: React.FC = ({ return (
-
- {isSimulationNotRun ? "Initial State" : "State"} - {isSimulationNotRun && ( - - )} -
{isSimulationNotRun && tableData.length > 0 && ( + +
+
+ +
+
Name
+ { + setNameInputValue(event.target.value); + // Clear error when user starts typing + if (nameError) { + setNameError(null); + } + }} + onFocus={() => setIsNameInputFocused(true)} + onBlur={() => { + setIsNameInputFocused(false); + handleNameBlur(); + }} + disabled={isReadOnly} + className={inputStyle({ isReadOnly, hasError: !!nameError })} + /> + {nameError &&
{nameError}
} +
+ +
+
+ Accepted token type + +
+ + + {place.colorId && ( +
+ +
+ )} +
+ +
+
+
+ { + updatePlace(place.id, (existingPlace) => { + existingPlace.dynamicsEnabled = checked; + }); + }} + /> +
+
+ Dynamics + +
+
+ {(place.colorId === null || availableDiffEqs.length === 0) && ( +
+ {place.colorId !== null + ? "Create a differential equation for the selected type in the left-hand sidebar first" + : availableTypes.length === 0 + ? "Create a type in the left-hand sidebar first, then select it to enable dynamics." + : "Select a type to enable dynamics"} +
+ )} +
+ + {place.colorId && + place.dynamicsEnabled && + availableDiffEqs.length > 0 && ( +
+
Differential Equation
+ + + {place.differentialEquationId && ( +
+ +
+ )} +
+ )} + + {/* Visualizer toggle - only shown in edit mode */} + {globalMode === "edit" && ( +
+
+
+ { + if (checked) { + // Turning on: use saved code if available, otherwise default + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = + savedVisualizerCode ?? DEFAULT_VISUALIZER_CODE; + }); + } else { + // Turning off: save current code and set to undefined + if (place.visualizerCode) { + setSavedVisualizerCode(place.visualizerCode); + } + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = undefined; + }); + } + }} + /> +
+
+ Visualizer + +
+
+
+ )} + + {/* Visualizer Code Editor - only shown in edit mode when visualizer is enabled */} + {place.visualizerCode !== undefined && !showVisualizerOutput && ( +
+
+
Visualizer Code
+ + + + } + items={[ + { + id: "load-default", + label: "Load default template", + onClick: () => { + // Get the place's type to generate appropriate default code + const currentPlaceType = place.colorId + ? types.find((t) => t.id === place.colorId) + : null; + + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = currentPlaceType + ? generateDefaultVisualizerCode(currentPlaceType) + : DEFAULT_VISUALIZER_CODE; + }); + }, + }, + { + id: "generate-ai", + label: ( + +
+ + Generate with AI +
+
+ ), + disabled: true, + onClick: () => { + // TODO: Implement AI generation + }, + }, + ]} + /> +
+
+ { + updatePlace(place.id, (existingPlace) => { + existingPlace.visualizerCode = value ?? ""; + }); + }} + theme="vs-light" + options={{ + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 12, + lineNumbers: "off", + folding: true, + glyphMargin: false, + lineDecorationsWidth: 0, + lineNumbersMinChars: 3, + padding: { top: 8, bottom: 8 }, + fixedOverflowWidgets: true, + }} + /> +
+
+ )} +
+ + {/* Fixed SubViews at bottom: Initial State and Visualizer Output */} +
+ + + +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/sortable-arc-item.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/sortable-arc-item.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/sortable-arc-item.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/transition-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/transition-properties.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/type-properties.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/type-properties.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/visualizer-error-boundary.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/visualizer-error-boundary.tsx similarity index 100% rename from libs/@hashintel/petrinaut/src/views/Editor/components/PropertiesPanel/visualizer-error-boundary.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/visualizer-error-boundary.tsx diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/diagnostics-content.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx similarity index 90% rename from libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/diagnostics-content.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx index 4a430254af6..66dd35ff612 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/diagnostics-content.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/diagnostics.tsx @@ -3,9 +3,10 @@ import { useCallback, useMemo, useState } from "react"; import { FaChevronDown, FaChevronRight } from "react-icons/fa6"; import ts from "typescript"; -import { useCheckerContext } from "../../../../state/checker-provider"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; +import type { SubView } from "../../../components/sub-view/types"; +import { useCheckerContext } from "../../../state/checker-provider"; +import { useEditorStore } from "../../../state/editor-provider"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; const emptyMessageStyle = css({ color: "core.gray.50", @@ -98,14 +99,14 @@ const positionStyle = css({ /** * Formats a TypeScript diagnostic message to a readable string */ -const formatDiagnosticMessage = ( - messageText: string | ts.DiagnosticMessageChain, -): string => { +function formatDiagnosticMessage( + messageText: string | ts.DiagnosticMessageChain +): string { if (typeof messageText === "string") { return messageText; } return ts.flattenDiagnosticMessageText(messageText, "\n"); -}; +} // --- Types --- @@ -125,15 +126,15 @@ interface GroupedDiagnostics { /** * DiagnosticsContent shows the full list of diagnostics grouped by entity. */ -export const DiagnosticsContent: React.FC = () => { +const DiagnosticsContent: React.FC = () => { const { checkResult, totalDiagnosticsCount } = useCheckerContext(); const { petriNetDefinition } = useSDCPNContext(); const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, + (state) => state.setSelectedResourceId ); // Track collapsed entities (all expanded by default) const [collapsedEntities, setCollapsedEntities] = useState>( - new Set(), + new Set() ); // Handler to select an entity when clicking on a diagnostic @@ -141,7 +142,7 @@ export const DiagnosticsContent: React.FC = () => { (entityId: string) => { setSelectedResourceId(entityId); }, - [setSelectedResourceId], + [setSelectedResourceId] ); // Group diagnostics by entity (transition or differential equation) @@ -157,14 +158,14 @@ export const DiagnosticsContent: React.FC = () => { if (item.itemType === "differential-equation") { entityType = "differential-equation"; const de = petriNetDefinition.differentialEquations.find( - (deItem) => deItem.id === entityId, + (deItem) => deItem.id === entityId ); entityName = de?.name ?? entityId; subType = null; } else { entityType = "transition"; const transition = petriNetDefinition.transitions.find( - (tr) => tr.id === entityId, + (tr) => tr.id === entityId ); entityName = transition?.name ?? entityId; subType = item.itemType === "transition-lambda" ? "lambda" : "kernel"; @@ -290,3 +291,13 @@ export const DiagnosticsContent: React.FC = () => { ); }; + +/** + * SubView definition for Diagnostics. + */ +export const diagnosticsSubView: SubView = { + id: "diagnostics", + title: "Diagnostics", + tooltip: "View compilation errors and warnings in your model code.", + component: DiagnosticsContent, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx new file mode 100644 index 00000000000..04757212b06 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/differential-equations-list.tsx @@ -0,0 +1,245 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import { v4 as uuidv4 } from "uuid"; + +import type { SubView } from "../../../components/sub-view/types"; +import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "../../../core/default-codes"; +import { useEditorStore } from "../../../state/editor-provider"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; +import { useSimulationStore } from "../../../state/simulation-provider"; + +const listContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[4px]", +}); + +const equationRowStyle = cva({ + base: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "[4px 2px 4px 8px]", + fontSize: "[13px]", + borderRadius: "[4px]", + cursor: "pointer", + }, + variants: { + isSelected: { + true: { + backgroundColor: "[rgba(59, 130, 246, 0.15)]", + _hover: { + backgroundColor: "[rgba(59, 130, 246, 0.2)]", + }, + }, + false: { + backgroundColor: "[#f9fafb]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + }, + }, + }, + }, +}); + +const equationNameContainerStyle = css({ + display: "flex", + alignItems: "center", + gap: "[6px]", +}); + +const deleteButtonStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "spacing.1", + borderRadius: "radius.2", + cursor: "pointer", + fontSize: "[14px]", + color: "core.gray.40", + background: "[transparent]", + border: "none", + width: "[20px]", + height: "[20px]", + _hover: { + backgroundColor: "[rgba(239, 68, 68, 0.1)]", + color: "core.red.60", + }, + _disabled: { + cursor: "not-allowed", + opacity: "[0.3]", + _hover: { + backgroundColor: "[transparent]", + color: "core.gray.40", + }, + }, +}); + +const emptyMessageStyle = css({ + fontSize: "[13px]", + color: "[#9ca3af]", + padding: "spacing.4", + textAlign: "center", +}); + +const addButtonStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "spacing.1", + borderRadius: "radius.2", + cursor: "pointer", + fontSize: "[18px]", + color: "core.gray.60", + background: "[transparent]", + border: "none", + width: "[24px]", + height: "[24px]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + color: "core.gray.90", + }, + _disabled: { + cursor: "not-allowed", + opacity: "[0.4]", + _hover: { + backgroundColor: "[transparent]", + color: "core.gray.60", + }, + }, +}); + +/** + * DifferentialEquationsSectionContent displays the list of differential equations. + * This is the content portion without the collapsible header. + */ +const DifferentialEquationsSectionContent: React.FC = () => { + const { + petriNetDefinition: { differentialEquations }, + removeDifferentialEquation, + } = useSDCPNContext(); + + const selectedResourceId = useEditorStore( + (state) => state.selectedResourceId + ); + const setSelectedResourceId = useEditorStore( + (state) => state.setSelectedResourceId + ); + + // Check if simulation is running or paused + const simulationState = useSimulationStore((state) => state.state); + const isSimulationActive = + simulationState === "Running" || simulationState === "Paused"; + + return ( +
+ {differentialEquations.map((eq) => { + const isSelected = selectedResourceId === eq.id; + + return ( +
{ + // Don't trigger selection if clicking the delete button + if ( + event.target instanceof HTMLElement && + event.target.closest("button[aria-label^='Delete']") + ) { + return; + } + setSelectedResourceId(eq.id); + }} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setSelectedResourceId(eq.id); + } + }} + className={equationRowStyle({ isSelected })} + > +
+ {eq.name} +
+ +
+ ); + })} + {differentialEquations.length === 0 && ( +
No differential equations yet
+ )} +
+ ); +}; + +/** + * DifferentialEquationsSectionHeaderAction renders the add button for the section header. + */ +const DifferentialEquationsSectionHeaderAction: React.FC = () => { + const { + petriNetDefinition: { types, differentialEquations }, + addDifferentialEquation, + } = useSDCPNContext(); + const setSelectedResourceId = useEditorStore( + (state) => state.setSelectedResourceId + ); + + // Check if simulation is running or paused + const simulationState = useSimulationStore((state) => state.state); + const isSimulationActive = + simulationState === "Running" || simulationState === "Paused"; + + return ( + + ); +}; + +/** + * SubView definition for Differential Equations list. + */ +export const differentialEquationsListSubView: SubView = { + id: "differential-equations-list", + title: "Differential Equations", + tooltip: `Differential equations govern how token data changes over time when tokens remain in a place ("dynamics").`, + component: DifferentialEquationsSectionContent, + renderHeaderAction: () => , + resizable: { + defaultHeight: 100, + minHeight: 60, + maxHeight: 250, + }, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/nodes-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/nodes-list.tsx new file mode 100644 index 00000000000..283db158397 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/nodes-list.tsx @@ -0,0 +1,180 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; +import { FaCircle, FaSquare } from "react-icons/fa6"; + +import type { SubView } from "../../../components/sub-view/types"; +import { useEditorStore } from "../../../state/editor-provider"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; + +const listContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[2px]", +}); + +const nodeRowStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "[6px]", + padding: "[4px 9px]", + borderRadius: "radius.4", + cursor: "default", + transition: "[all 0.15s ease]", + }, + variants: { + isSelected: { + true: { + backgroundColor: "core.blue.20", + _hover: { + backgroundColor: "core.blue.30", + }, + }, + false: { + backgroundColor: "[transparent]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + }, + }, + }, + }, +}); + +const nodeIconStyle = cva({ + base: { + flexShrink: 0, + }, + variants: { + isSelected: { + true: { + color: "[#3b82f6]", + }, + false: { + color: "[#9ca3af]", + }, + }, + }, +}); + +const nodeNameStyle = cva({ + base: { + fontSize: "[13px]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + variants: { + isSelected: { + true: { + color: "[#1e40af]", + fontWeight: 500, + }, + false: { + color: "[#374151]", + fontWeight: 400, + }, + }, + }, +}); + +const emptyMessageStyle = css({ + fontSize: "[13px]", + color: "[#9ca3af]", + padding: "spacing.4", + textAlign: "center", +}); + +/** + * NodesSectionContent displays the list of places and transitions. + * This is the content portion without the collapsible header. + */ +const NodesSectionContent: React.FC = () => { + const { + petriNetDefinition: { places, transitions }, + } = useSDCPNContext(); + const selectedResourceId = useEditorStore( + (state) => state.selectedResourceId + ); + const setSelectedResourceId = useEditorStore( + (state) => state.setSelectedResourceId + ); + + const handleLayerClick = (id: string) => { + // Single select: replace selection + setSelectedResourceId(id); + }; + + return ( +
+ {/* Places */} + {places.map((place) => { + const isSelected = selectedResourceId === place.id; + return ( +
handleLayerClick(place.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleLayerClick(place.id); + } + }} + className={nodeRowStyle({ isSelected })} + > + + + {place.name || `Place ${place.id}`} + +
+ ); + })} + + {/* Transitions */} + {transitions.map((transition) => { + const isSelected = selectedResourceId === transition.id; + return ( +
handleLayerClick(transition.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleLayerClick(transition.id); + } + }} + className={nodeRowStyle({ isSelected })} + > + + + {transition.name || `Transition ${transition.id}`} + +
+ ); + })} + + {/* Empty state */} + {places.length === 0 && transitions.length === 0 && ( +
No nodes yet
+ )} +
+ ); +}; + +/** + * SubView definition for Nodes list. + */ +export const nodesListSubView: SubView = { + id: "nodes-list", + title: "Nodes", + tooltip: + "Manage nodes in the net, including places and transitions. Places represent states in the net, and transitions represent events which change the state of the net.", + component: NodesSectionContent, + resizable: { + defaultHeight: 150, + minHeight: 80, + maxHeight: 400, + }, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/parameters-content.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx similarity index 77% rename from libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/parameters-content.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx index c8cced8093c..bf5fc7cb3f9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/parameters-content.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/parameters-list.tsx @@ -1,25 +1,10 @@ import { css, cva } from "@hashintel/ds-helpers/css"; import { v4 as uuidv4 } from "uuid"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; -import { useSimulationStore } from "../../../../state/simulation-provider"; - -const headerStyle = css({ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: "[12px]", -}); - -const titleStyle = css({ - display: "flex", - alignItems: "center", - gap: "[6px]", - fontWeight: 600, - fontSize: "[13px]", - color: "[#333]", -}); +import type { SubView } from "../../../components/sub-view/types"; +import { useEditorStore } from "../../../state/editor-provider"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; +import { useSimulationStore } from "../../../state/simulation-provider"; const addButtonStyle = css({ display: "flex", @@ -145,32 +130,21 @@ const emptyMessageStyle = css({ }); /** - * ParametersContent displays global parameters in the BottomPanel. + * Header action component for adding parameters. + * Shown in the panel header when not in simulation mode. */ -export const ParametersContent: React.FC = () => { +const ParametersHeaderAction: React.FC = () => { const { petriNetDefinition: { parameters }, addParameter, - removeParameter, } = useSDCPNContext(); const globalMode = useEditorStore((state) => state.globalMode); const simulationState = useSimulationStore((state) => state.state); - const selectedResourceId = useEditorStore( - (state) => state.selectedResourceId, - ); const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, - ); - const parameterValues = useSimulationStore((state) => state.parameterValues); - const setParameterValue = useSimulationStore( - (state) => state.setParameterValue, + (state) => state.setSelectedResourceId ); - const isSimulationNotRun = - globalMode === "simulate" && simulationState === "NotRun"; const isSimulationMode = globalMode === "simulate"; - - // Check if simulation is running or paused const isSimulationActive = simulationState === "Running" || simulationState === "Paused"; @@ -187,25 +161,55 @@ export const ParametersContent: React.FC = () => { setSelectedResourceId(id); }; + // Don't show add button in simulation mode + if (isSimulationMode) { + return null; + } + return ( -
-
-
- Parameters are injected into dynamics, lambda, and kernel functions. -
- {!isSimulationMode && ( - - )} -
+ + ); +}; +/** + * ParametersList displays global parameters list as a SubView. + */ +const ParametersList: React.FC = () => { + const { + petriNetDefinition: { parameters }, + removeParameter, + } = useSDCPNContext(); + const globalMode = useEditorStore((state) => state.globalMode); + const simulationState = useSimulationStore((state) => state.state); + const selectedResourceId = useEditorStore( + (state) => state.selectedResourceId + ); + const setSelectedResourceId = useEditorStore( + (state) => state.setSelectedResourceId + ); + const parameterValues = useSimulationStore((state) => state.parameterValues); + const setParameterValue = useSimulationStore( + (state) => state.setParameterValue + ); + + const isSimulationNotRun = + globalMode === "simulate" && simulationState === "NotRun"; + const isSimulationMode = globalMode === "simulate"; + + // Check if simulation is running or paused + const isSimulationActive = + simulationState === "Running" || simulationState === "Paused"; + + return ( +
{parameters.map((param) => { const isSelected = selectedResourceId === param.id; @@ -282,3 +286,20 @@ export const ParametersContent: React.FC = () => {
); }; + +/** + * SubView definition for Global Parameters List. + */ +export const parametersListSubView: SubView = { + id: "parameters-list", + title: "Global Parameters", + tooltip: + "Parameters are injected into dynamics, lambda, and kernel functions.", + component: ParametersList, + renderHeaderAction: () => , + resizable: { + defaultHeight: 100, + minHeight: 60, + maxHeight: 250, + }, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx new file mode 100644 index 00000000000..e071c7f22a1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-initial-state.tsx @@ -0,0 +1,115 @@ +import { css } from "@hashintel/ds-helpers/css"; + +import type { SubView } from "../../../components/sub-view/types"; +import { useSimulationStore } from "../../../state/simulation-provider"; +import { InitialStateEditor } from "../panels/PropertiesPanel/initial-state-editor"; +import { usePlacePropertiesContext } from "../panels/PropertiesPanel/place-properties-context"; + +const inputStyle = css({ + fontSize: "[14px]", + padding: "[6px 8px]", + borderRadius: "[4px]", + width: "[100%]", + boxSizing: "border-box", + border: "[1px solid rgba(0, 0, 0, 0.1)]", + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + cursor: "not-allowed", +}); + +const fieldLabelStyle = css({ + fontWeight: 500, + fontSize: "[12px]", + marginBottom: "[4px]", +}); + +const simpleStateContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[8px]", +}); + +/** + * PlaceInitialStateContent - Renders the initial state editor for a place. + * Uses PlacePropertiesContext to access the current place data. + */ +const PlaceInitialStateContent: React.FC = () => { + const { place, placeType } = usePlacePropertiesContext(); + + const simulation = useSimulationStore((state) => state.simulation); + const initialMarking = useSimulationStore((state) => state.initialMarking); + const setInitialMarking = useSimulationStore( + (state) => state.setInitialMarking + ); + const currentlyViewedFrame = useSimulationStore( + (state) => state.currentlyViewedFrame + ); + + // Determine if simulation is running (has frames) + const hasSimulationFrames = + simulation !== null && simulation.frames.length > 0; + + // If no type or type has 0 dimensions, show simple number input + if (!placeType || placeType.elements.length === 0) { + // Get token count from simulation frame or initial marking + let currentTokenCount = 0; + if (hasSimulationFrames) { + const currentFrame = simulation.frames[currentlyViewedFrame]; + if (currentFrame) { + const placeState = currentFrame.places.get(place.id); + currentTokenCount = placeState?.count ?? 0; + } + } else { + const currentMarking = initialMarking.get(place.id); + currentTokenCount = currentMarking?.count ?? 0; + } + + return ( +
+
Token count
+ { + const count = Math.max( + 0, + Number.parseInt(event.target.value, 10) || 0 + ); + setInitialMarking(place.id, { + values: new Float64Array(0), // Empty array for places without type + count, + }); + }} + disabled={hasSimulationFrames} + className={inputStyle} + /> +
+ ); + } + + return ( + + ); +}; + +/** + * SubView definition for Place Initial State. + * Note: This subview requires PlacePropertiesProvider to be in the component tree. + */ +export const placeInitialStateSubView: SubView = { + id: "place-initial-state", + title: "State", + tooltip: + "Define the initial tokens in this place. During simulation, shows current state.", + component: PlaceInitialStateContent, + resizable: { + defaultHeight: 150, + minHeight: 80, + maxHeight: 400, + }, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx new file mode 100644 index 00000000000..ee78bec7764 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/place-visualizer-output.tsx @@ -0,0 +1,158 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { useMemo } from "react"; + +import type { SubView } from "../../../components/sub-view/types"; +import { compileVisualizer } from "../../../core/simulation/compile-visualizer"; +import { + mergeParameterValues, + useDefaultParameterValues, +} from "../../../hooks/use-default-parameter-values"; +import { useSimulationStore } from "../../../state/simulation-provider"; +import { usePlacePropertiesContext } from "../panels/PropertiesPanel/place-properties-context"; +import { VisualizerErrorBoundary } from "../panels/PropertiesPanel/visualizer-error-boundary"; + +const visualizerMessageStyle = css({ + padding: "[12px]", + color: "[#666]", +}); + +const visualizerErrorStyle = css({ + padding: "[12px]", + color: "[#d32f2f]", +}); + +/** + * PlaceVisualizerOutputContent - Renders the visualizer output for a place. + * Uses PlacePropertiesContext to access the current place data. + */ +const PlaceVisualizerOutputContent: React.FC = () => { + const { place, placeType } = usePlacePropertiesContext(); + + const simulation = useSimulationStore((state) => state.simulation); + const initialMarking = useSimulationStore((state) => state.initialMarking); + const parameterValues = useSimulationStore((state) => state.parameterValues); + const currentlyViewedFrame = useSimulationStore( + (state) => state.currentlyViewedFrame + ); + + // Get default parameter values from SDCPN definition + const defaultParameterValues = useDefaultParameterValues(); + + // Compile visualizer code once when it changes + const VisualizerComponent = useMemo(() => { + if (!place.visualizerCode) { + return null; + } + + try { + return compileVisualizer(place.visualizerCode); + } catch (error) { + // eslint-disable-next-line no-console + console.error("Failed to compile visualizer code:", error); + return null; + } + }, [place.visualizerCode]); + + // If no visualizer code, show nothing + if (!place.visualizerCode) { + return ( +
No visualizer code defined
+ ); + } + + // Get place type to determine dimensions + if (!placeType) { + return
Place has no type set
; + } + + const dimensions = placeType.elements.length; + const tokens: Record[] = []; + let parameters: Record = {}; + + // Check if we have simulation frames or use initial marking + if (simulation && simulation.frames.length > 0) { + // Use currently viewed simulation frame + const currentFrame = simulation.frames[currentlyViewedFrame]; + if (!currentFrame) { + return ( +
No frame data available
+ ); + } + + const placeState = currentFrame.places.get(place.id); + if (!placeState) { + return ( +
Place not found in frame
+ ); + } + + const { offset, count } = placeState; + const placeSize = count * dimensions; + const tokenValues = Array.from( + currentFrame.buffer.slice(offset, offset + placeSize) + ); + + // Format tokens as array of objects with named dimensions + for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + const token: Record = {}; + for (let colIndex = 0; colIndex < dimensions; colIndex++) { + const dimensionName = placeType.elements[colIndex]!.name; + token[dimensionName] = + tokenValues[tokenIndex * dimensions + colIndex] ?? 0; + } + tokens.push(token); + } + + // Merge SimulationStore values with SDCPN defaults + parameters = mergeParameterValues(parameterValues, defaultParameterValues); + } else { + // Use initial marking + const marking = initialMarking.get(place.id); + if (marking && marking.count > 0) { + for (let tokenIndex = 0; tokenIndex < marking.count; tokenIndex++) { + const token: Record = {}; + for (let colIndex = 0; colIndex < dimensions; colIndex++) { + const dimensionName = placeType.elements[colIndex]!.name; + token[dimensionName] = + marking.values[tokenIndex * dimensions + colIndex] ?? 0; + } + tokens.push(token); + } + } + + // Merge SimulationStore values with SDCPN defaults + parameters = mergeParameterValues(parameterValues, defaultParameterValues); + } + + // Render the compiled visualizer component + if (!VisualizerComponent) { + return ( +
+ Failed to compile visualizer code. Check console for errors. +
+ ); + } + + return ( + + + + ); +}; + +/** + * SubView definition for Place Visualizer Output. + * Note: This subview requires PlacePropertiesProvider to be in the component tree. + */ +export const placeVisualizerOutputSubView: SubView = { + id: "place-visualizer-output", + title: "Visualizer Output", + tooltip: + "Custom visualization of tokens in this place, defined by the visualizer code.", + component: PlaceVisualizerOutputContent, + resizable: { + defaultHeight: 200, + minHeight: 100, + maxHeight: 500, + }, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/simulation-settings-content.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx similarity index 92% rename from libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/simulation-settings-content.tsx rename to libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx index 0bc779161cc..91bc1f97bcd 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomPanel/simulation-settings-content.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/simulation-settings.tsx @@ -2,10 +2,11 @@ import { css } from "@hashintel/ds-helpers/css"; import { useState } from "react"; import { TbArrowRight } from "react-icons/tb"; -import { InfoIconTooltip } from "../../../../components/tooltip"; -import { useEditorStore } from "../../../../state/editor-provider"; -import { useSDCPNContext } from "../../../../state/sdcpn-provider"; -import { useSimulationStore } from "../../../../state/simulation-provider"; +import type { SubView } from "../../../components/sub-view/types"; +import { InfoIconTooltip } from "../../../components/tooltip"; +import { useEditorStore } from "../../../state/editor-provider"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; +import { useSimulationStore } from "../../../state/simulation-provider"; const containerStyle = css({ display: "flex", @@ -189,7 +190,7 @@ const editButtonIconStyle = css({ * SimulationSettingsContent displays simulation settings in the BottomPanel. * Split into two sections: Computation and Parameters. */ -export const SimulationSettingsContent: React.FC = () => { +const SimulationSettingsContent: React.FC = () => { const setGlobalMode = useEditorStore((state) => state.setGlobalMode); const simulationState = useSimulationStore((state) => state.state); const simulationError = useSimulationStore((state) => state.error); @@ -197,11 +198,11 @@ export const SimulationSettingsContent: React.FC = () => { const dt = useSimulationStore((state) => state.dt); const setDt = useSimulationStore((state) => state.setDt); const setSelectedResourceId = useEditorStore( - (state) => state.setSelectedResourceId, + (state) => state.setSelectedResourceId ); const parameterValues = useSimulationStore((state) => state.parameterValues); const setParameterValue = useSimulationStore( - (state) => state.setParameterValue, + (state) => state.setParameterValue ); const { @@ -325,3 +326,14 @@ export const SimulationSettingsContent: React.FC = () => {
); }; + +/** + * SubView definition for Simulation Settings tab. + */ +export const simulationSettingsSubView: SubView = { + id: "simulation-settings", + title: "Simulation Settings", + tooltip: + "Configure simulation parameters including time step and ODE solver method.", + component: SimulationSettingsContent, +}; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx new file mode 100644 index 00000000000..2b066944149 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/subviews/types-list.tsx @@ -0,0 +1,305 @@ +import { css, cva } from "@hashintel/ds-helpers/css"; + +import type { SubView } from "../../../components/sub-view/types"; +import { useEditorStore } from "../../../state/editor-provider"; +import { useSDCPNContext } from "../../../state/sdcpn-provider"; +import { useSimulationStore } from "../../../state/simulation-provider"; + +const listContainerStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[2px]", +}); + +const typeRowStyle = cva({ + base: { + display: "flex", + alignItems: "center", + gap: "[8px]", + padding: "[4px 2px 4px 8px]", + borderRadius: "[4px]", + cursor: "pointer", + }, + variants: { + isSelected: { + true: { + backgroundColor: "[rgba(59, 130, 246, 0.15)]", + _hover: { + backgroundColor: "[rgba(59, 130, 246, 0.2)]", + }, + }, + false: { + backgroundColor: "[transparent]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + }, + }, + }, + }, +}); + +const colorDotStyle = css({ + width: "[12px]", + height: "[12px]", + borderRadius: "[50%]", + flexShrink: 0, +}); + +const typeNameStyle = css({ + flex: "[1]", + fontSize: "[13px]", + color: "[#374151]", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}); + +const deleteButtonStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "spacing.1", + borderRadius: "radius.2", + cursor: "pointer", + fontSize: "[14px]", + color: "core.gray.40", + background: "[transparent]", + border: "none", + width: "[20px]", + height: "[20px]", + _hover: { + backgroundColor: "[rgba(239, 68, 68, 0.1)]", + color: "core.red.60", + }, + _disabled: { + cursor: "not-allowed", + opacity: "[0.3]", + _hover: { + backgroundColor: "[transparent]", + color: "core.gray.40", + }, + }, +}); + +const emptyMessageStyle = css({ + fontSize: "[13px]", + color: "[#9ca3af]", + padding: "spacing.4", + textAlign: "center", +}); + +const addButtonStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "spacing.1", + borderRadius: "radius.2", + cursor: "pointer", + fontSize: "[18px]", + color: "core.gray.60", + background: "[transparent]", + border: "none", + width: "[24px]", + height: "[24px]", + _hover: { + backgroundColor: "[rgba(0, 0, 0, 0.05)]", + color: "core.gray.90", + }, + _disabled: { + cursor: "not-allowed", + opacity: "[0.4]", + _hover: { + backgroundColor: "[transparent]", + color: "core.gray.60", + }, + }, +}); + +// Pool of 10 well-differentiated colors for types +const TYPE_COLOR_POOL = [ + "#3b82f6", // Blue + "#ef4444", // Red + "#10b981", // Green + "#f59e0b", // Amber + "#8b5cf6", // Violet + "#ec4899", // Pink + "#14b8a6", // Teal + "#f97316", // Orange + "#6366f1", // Indigo + "#84cc16", // Lime +]; + +/** + * Get the next available color from the pool that's not currently in use. + * If all colors are in use, cycle back to the beginning. + */ +function getNextAvailableColor(existingColors: string[]): string { + const unusedColor = TYPE_COLOR_POOL.find( + (color) => !existingColors.includes(color) + ); + return unusedColor ?? TYPE_COLOR_POOL[0]!; +} + +/** + * Extract the highest type number from existing type names. + * Looks for patterns like "Type 1", "Type 2", "New Type 3", etc. + */ +function getNextTypeNumber(existingNames: string[]): number { + let maxNumber = 0; + for (const name of existingNames) { + // Match patterns like "Type 1", "New Type 2", etc. + const match = name.match(/Type\s+(\d+)/i); + if (match) { + const num = Number.parseInt(match[1]!, 10); + if (num > maxNumber) { + maxNumber = num; + } + } + } + return maxNumber + 1; +} + +/** + * TypesSectionContent displays the list of token types. + * This is the content portion without the collapsible header. + */ +const TypesSectionContent: React.FC = () => { + const { + petriNetDefinition: { types }, + removeType, + } = useSDCPNContext(); + + const selectedResourceId = useEditorStore( + (state) => state.selectedResourceId + ); + const setSelectedResourceId = useEditorStore( + (state) => state.setSelectedResourceId + ); + + // Check if simulation is running or paused + const simulationState = useSimulationStore((state) => state.state); + const isSimulationActive = + simulationState === "Running" || simulationState === "Paused"; + + return ( +
+ {types.map((type) => { + const isSelected = selectedResourceId === type.id; + + return ( +
{ + // Don't trigger selection if clicking the delete button + if ( + event.target instanceof HTMLElement && + event.target.closest("button[aria-label^='Delete']") + ) { + return; + } + setSelectedResourceId(type.id); + }} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + setSelectedResourceId(type.id); + } + }} + className={typeRowStyle({ isSelected })} + > +
+ {type.name} + +
+ ); + })} + {types.length === 0 && ( +
No token types yet
+ )} +
+ ); +}; + +/** + * TypesSectionHeaderAction renders the add button for the types section header. + */ +const TypesSectionHeaderAction: React.FC = () => { + const { + petriNetDefinition: { types }, + addType, + } = useSDCPNContext(); + + // Check if simulation is running or paused + const simulationState = useSimulationStore((state) => state.state); + const isSimulationActive = + simulationState === "Running" || simulationState === "Paused"; + + return ( + + ); +}; + +/** + * SubView definition for Token Types list. + */ +export const typesListSubView: SubView = { + id: "token-types-list", + title: "Token Types", + tooltip: "Manage data types which can be assigned to tokens in a place.", + component: TypesSectionContent, + renderHeaderAction: () => , + resizable: { + defaultHeight: 120, + minHeight: 60, + maxHeight: 300, + }, +};