diff --git a/Generator/DTO/Relationship.cs b/Generator/DTO/Relationship.cs index dd1656b..c26986a 100644 --- a/Generator/DTO/Relationship.cs +++ b/Generator/DTO/Relationship.cs @@ -2,10 +2,12 @@ namespace Generator.DTO; + public record Relationship( - string Name, - string TableSchema, - string LookupDisplayName, + bool IsCustom, + string Name, + string TableSchema, + string LookupDisplayName, string RelationshipSchema, bool IsManyToMany, CascadeConfiguration? CascadeConfiguration); diff --git a/Generator/DataverseService.cs b/Generator/DataverseService.cs index 7da0d5c..b964a54 100644 --- a/Generator/DataverseService.cs +++ b/Generator/DataverseService.cs @@ -95,7 +95,6 @@ public async Task> GetFilteredMetadata() .Where(r => entityLogicalNamesInSolution.Contains(r.IntersectEntityName.ToLower())) .ToList(), }) - .Where(x => x.RelevantAttributes.Count > 0) .Where(x => x.EntityMetadata.DisplayName.UserLocalizedLabel?.Label != null) .ToList(); @@ -146,8 +145,9 @@ private static Record MakeRecord( .ToList(); var oneToMany = (entity.OneToManyRelationships ?? Enumerable.Empty()) - .Where(x => logicalToSchema.ContainsKey(x.ReferencingEntity) && attributeLogicalToSchema[x.ReferencingEntity].ContainsKey(x.ReferencingAttribute)) + .Where(x => logicalToSchema.ContainsKey(x.ReferencingEntity) && logicalToSchema[x.ReferencingEntity].IsInSolution && attributeLogicalToSchema[x.ReferencingEntity].ContainsKey(x.ReferencingAttribute)) .Select(x => new DTO.Relationship( + x.IsCustomRelationship ?? false, x.ReferencingEntityNavigationPropertyName, logicalToSchema[x.ReferencingEntity].Name, attributeLogicalToSchema[x.ReferencingEntity][x.ReferencingAttribute], @@ -157,8 +157,9 @@ private static Record MakeRecord( .ToList(); var manyToMany = relevantManyToMany - .Where(x => logicalToSchema.ContainsKey(x.Entity1LogicalName)) + .Where(x => logicalToSchema.ContainsKey(x.Entity1LogicalName) && logicalToSchema[x.Entity1LogicalName].IsInSolution) .Select(x => new DTO.Relationship( + x.IsCustomRelationship ?? false, x.Entity1AssociatedMenuConfiguration.Behavior == AssociatedMenuBehavior.UseLabel ? x.Entity1AssociatedMenuConfiguration.Label.UserLocalizedLabel?.Label ?? x.Entity1NavigationPropertyName : x.Entity1NavigationPropertyName, @@ -276,7 +277,6 @@ await Parallel.ForEachAsync( async (objectId, token) => { metadata.Add(await client.RetrieveEntityAsync(objectId, token)); - }); return metadata; @@ -299,7 +299,6 @@ await Parallel.ForEachAsync( async (logicalName, token) => { metadata.Add(await client.RetrieveEntityByLogicalNameAsync(logicalName, token)); - }); return metadata; diff --git a/Website/.github/instructions/copilot.instructions.md b/Website/.github/instructions/copilot.instructions.md new file mode 100644 index 0000000..2d000e6 --- /dev/null +++ b/Website/.github/instructions/copilot.instructions.md @@ -0,0 +1,4 @@ +--- +applyTo: '**' +--- +Read file at `ai-rules/README.md` for instructions on how to use this file. \ No newline at end of file diff --git a/Website/ai-rules/README.md b/Website/ai-rules/README.md new file mode 100644 index 0000000..7e87b82 --- /dev/null +++ b/Website/ai-rules/README.md @@ -0,0 +1,416 @@ +# 🧠 AI Agent Rules for DataModelViewer Codebase + +You are an expert in **TypeScript**, **Node.js**, **Next.js App Router**, **React**, **Radix**, **JointJS**, **TailwindCSS**, and general frontend development. + +--- + +## πŸ“˜ Project Overview + +This is a **DataModelViewer** built with Next.js 15, React 19, TypeScript, and JointJS for data model visualization. It uses a modern tech stack including **shadcn/ui**, **Tailwind CSS**, and follows React best practices. + +--- + +## πŸ›  Core Technologies & Dependencies + +- **Framework**: Next.js 15 (App Router) +- **UI Library**: React 19 +- **Language**: TypeScript (strict mode) +- **Styling**: Tailwind CSS + shadcn/ui +- **Diagram Library**: JointJS (@joint/core v4.1.3) +- **Icons**: Lucide React +- **Utilities**: Lodash, clsx, tailwind-merge +- **Authentication**: jose (JWT) + +--- + +## πŸ— Architecture Patterns + +### πŸ“‚ 1. File Structure +``` +Website/ +β”œβ”€β”€ app/ # Next.js App Router +β”œβ”€β”€ components/ # Reusable components +β”‚ β”œβ”€β”€ ui/ # shadcn/ui components +β”‚ β”œβ”€β”€ diagram/ # Diagram-specific +β”‚ └── entity/ # Entity components +β”œβ”€β”€ contexts/ # React Context +β”œβ”€β”€ hooks/ # Custom hooks +β”œβ”€β”€ lib/ # Utilities/constants +β”œβ”€β”€ routes/ # Route components +└── public/ # Static assets +``` + +--- + +### 🧩 2. Component Architecture +- Use **functional components** and hooks. +- Always **strictly type** props and state. +- Define **props interfaces**. +- Use destructuring with defaults. + +--- + +### βš™οΈ 3. State Management +- **React Context** for global state (e.g., `DiagramContext`). +- **Custom Hooks** for complex logic (`useDiagram`, `useEntitySelection`). +- **useState** for local state. +- **useRef** for mutable values not requiring re-renders. + +--- + +## πŸ§‘β€πŸ’» Coding Standards + +### 1️⃣ TypeScript Conventions +```typescript +interface ComponentProps { + data: EntityData; + onSelect?: (id: string) => void; + className?: string; +} + +const handleClick = (e: React.MouseEvent) => { + // handler logic +}; + +const useCustomHook = (): CustomHookReturn => { + // hook logic +}; +``` + +--- + +### 2️⃣ Component Patterns +```typescript +interface IComponentName { + data: number[]; + onSelect: () => void; + className: string; +} + +function ComponentName({ data, onSelect, className }: IComponentName) { + const [state, setState] = useState(); + + useEffect(() => { + // effect + }, [dependencies]); + + const handleAction = useCallback(() => { + // action + }, [dependencies]); + + return ( +
+ {/* content */} +
+ ); +} +``` + +--- + +### 3️⃣ Styling Conventions +- Use **Tailwind utility classes**. +- For variants, use **class-variance-authority**. +- Ensure **responsive design**. + +--- + +## 🟒 Diagram-Specific Rules + +### 1️⃣ JointJS Integration + +1. **Model and View Separation** + - Keep model logic separate from view rendering. + - Define `joint.dia.Element` and `joint.dia.Link` for business data. + - Use `joint.shapes` for reusable shapes. + - Use `ElementView` for rendering/UI. + +2. **Graph as Single Source of Truth** + - Always store state in `joint.dia.Graph`. + - Never rely on paper alone. + - Use `element.set()` to trigger events. + +3. **Batch Operations** + ```typescript + graph.startBatch('batch-updates'); + graph.addCells([cell1, cell2, link]); + cell1.resize(100, 80); + graph.stopBatch('batch-updates'); + ``` + +4. **Unique IDs** + - Ensure each element/link has a unique `id`. + +5. **Paper Events** + - Use events (`cell:pointerdown`, `element:pointerclick`) for interactions. + - Avoid mixing DOM events. + +6. **Memory Cleanup** + - Call `paper.remove()` and `graph.clear()` when destroying. + - Remove listeners. + +7. **Debounce/Throttle** + - Throttle high-frequency handlers (`cell:position`). + +8. **Styling** + - Use Tailwind classes over hardcoded styles. + +--- + +### 2️⃣ Entity Element Patterns +- **Custom Elements**: Extend JointJS. +- **Ports**: Consistent naming. +- **Data Binding**: Link model data to visuals. +- **Events**: Proper interaction handling. + +--- + +## πŸ“ File Naming + +- **Components**: PascalCase (`DiagramCanvas.tsx`) +- **Hooks**: camelCase with `use` (`useDiagram.ts`) +- **Utilities**: camelCase (`utils.ts`) +- **Shared Types**: `types.ts` + +--- + +## 🧷 Import & Export + +### Import Organization +```typescript +// React & Next.js +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; + +// Third-party +import { dia } from '@joint/core'; +import { Button } from '@/components/ui/button'; + +// Local +import { useDiagramContext } from '@/contexts/DiagramContext'; +import { cn } from '@/lib/utils'; + +// Relative +import { EntityElement } from './entity/entity'; +``` + +--- + +### Export Patterns +```typescript +// Named component +export const ComponentName: React.FC = () => {}; + +// Default page +export default function PageName() {} + +// Utilities +export function utilityFunction() {} +export const CONSTANT_VALUE = 'value'; +``` + +--- + +## πŸš€ Performance Guidelines + +### React +- `useCallback` for handlers. +- `useMemo` for expensive computations. +- `React.memo` for frequently re-rendered components. +- Always include dependencies in hooks. + +--- + +### Diagram +- **Debounce** expensive handlers. +- **Clean up** listeners. +- **Refs over state** for mutable values. +- **Batch** updates. + +--- + +### Bundle +- Use **dynamic imports** for large components. +- Ensure **tree shaking**. +- Leverage **Next.js code splitting**. + +--- + +## ⚠️ Error Handling + +### TypeScript +- Strict mode. +- Type guards. +- Optional chaining. +- Null checks. + +--- + +### Runtime +- Error boundaries. +- Try/catch for async. +- Validate all props and data. + +--- + +## πŸ§ͺ Testing + +### Components +- Unit tests. +- Integration tests. +- Accessibility tests. + +--- + +### Diagram +- Mock JointJS. +- Test interactions and state. + +--- + +## πŸ”’ Security + +### Authentication +- Use **jose** for JWT. +- Protect routes. +- Validate inputs. + +--- + +### Data +- Sanitize before rendering. +- Escape output (prevent XSS). +- Use CSRF protection if needed. + +--- + +## πŸ—‚ Documentation Standards + +- Use **JSDoc** for complex logic. +- Add **inline comments** where necessary. +- Include **TODOs** for incomplete work. +- Document setup, usage, and architecture in README. + +--- + +## 🏷 Common Patterns + +### Custom Hook +```typescript +export const useCustomHook = (): HookReturn => { + const [state, setState] = useState(initialState); + + const action = useCallback(() => { + // logic + }, []); + + useEffect(() => { + // setup + return () => { + // cleanup + }; + }, []); + + return { state, action }; +}; +``` + +--- + +### Context Provider +```typescript +const Context = createContext(null); + +export const Provider: React.FC = ({ children }) => { + const value = useCustomHook(); + return {children}; +}; + +export const useContext = (): ContextType => { + const context = useContext(Context); + if (!context) throw new Error('Must be used within Provider'); + return context; +}; +``` + +--- + +### Component Composition +```typescript +const ParentComponent = () => ( + + + +); + +const ChildComponent = () => { + const context = useContext(); + return
{/* Use context */}
; +}; +``` + +--- + +## 🚫 Anti-Patterns to Avoid + +### React +- Avoid `useState` for refsβ€”use `useRef`. +- Don’t create objects in renderβ€”use `useMemo`. +- Always clean up effects. +- Never mutate state directly. + +--- + +### Diagram +- Don’t reinitialize `paper` unnecessarilyβ€”use refs. +- Always clean up event listeners. +- Avoid storing zoom/pan in React state. +- Debounce expensive ops. + +--- + +### TypeScript +- Avoid `any`. +- Fix all errorsβ€”don’t ignore them. +- Use type guards over assertions. +- Always check for `null`. + +--- + +## 🧭 Migration Guidelines + +### Updating Dependencies +- Review changelogs for breaking changes. +- Update gradually. +- Test thoroughly. +- Verify TypeScript compatibility. + +--- + +### Refactoring +- Preserve functionality. +- Update tests. +- Document changes. +- Validate performance. + +--- + +## πŸ†˜ Emergency Procedures + +### When Things Break +- Check console errors. +- Inspect network requests. +- Verify React state/context. +- Roll back via Git if needed. + +--- + +### Performance Issues +- Profile with React DevTools. +- Check for excessive re-renders. +- Analyze bundle size. +- Apply optimizations. + +--- + +**Remember:** Prioritize clarity and maintainability. When in doubt, follow established patterns and documented practices. diff --git a/Website/app/diagram/page.tsx b/Website/app/diagram/page.tsx new file mode 100644 index 0000000..a208404 --- /dev/null +++ b/Website/app/diagram/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { TouchProvider } from "@/components/ui/hybridtooltop"; +import { Loading } from "@/components/ui/loading"; +import DiagramView from "@/components/diagram/DiagramView"; +import { Suspense } from "react"; + +export default function Home() { + return }> + + + + +} \ No newline at end of file diff --git a/Website/components/SidebarNavRail.tsx b/Website/components/SidebarNavRail.tsx index 1626e3e..01344bb 100644 --- a/Website/components/SidebarNavRail.tsx +++ b/Website/components/SidebarNavRail.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useRouter, usePathname } from "next/navigation"; -import { LogOut, Info, Database, PencilRuler } from "lucide-react"; +import { LogOut, Info, Database, PencilRuler, PlugZap, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useSidebarDispatch } from "@/contexts/SidebarContext"; @@ -11,13 +11,23 @@ const navItems = [ href: "/", active: true, disabled: false, + new: false, }, { label: "Diagram Viewer", icon: , - href: "#", + href: "/diagram", + active: false, + disabled: false, + new: true, + }, + { + label: "Dependency Viewer", + icon: , + href: "/dependency", active: false, disabled: true, + new: false, }, ]; @@ -35,19 +45,25 @@ export default function SidebarNavRail() { {/* Nav Items */}
{navItems.map((item) => ( - +
+ + {item.new && ( +
+ +
+ )} +
))}
diff --git a/Website/components/diagram/DiagramCanvas.tsx b/Website/components/diagram/DiagramCanvas.tsx new file mode 100644 index 0000000..3fd0516 --- /dev/null +++ b/Website/components/diagram/DiagramCanvas.tsx @@ -0,0 +1,48 @@ +import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; +import React, { useRef, useEffect } from 'react'; + +interface DiagramCanvasProps { + children?: React.ReactNode; +} + +export const DiagramCanvas: React.FC = ({ children }) => { + const canvasRef = useRef(null); + + const { + isPanning, + initializePaper, + destroyPaper + } = useDiagramViewContext(); + + useEffect(() => { + if (canvasRef.current) { + initializePaper(canvasRef.current, { + background: { + color: 'transparent' // Make paper background transparent to show CSS dots + } + }); + + return () => { + destroyPaper(); + }; + } + }, [initializePaper, destroyPaper]); + + return ( +
+
+ + {/* Panning indicator */} + {isPanning && ( +
+ Panning... +
+ )} + + {children} +
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramControls.tsx b/Website/components/diagram/DiagramControls.tsx new file mode 100644 index 0000000..d1ecd64 --- /dev/null +++ b/Website/components/diagram/DiagramControls.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { + ZoomIn, + ZoomOut, + RotateCcw, + Maximize, + Settings, + Layers, + Search +} from 'lucide-react'; +import { useDiagramViewContext } from '@/contexts/DiagramViewContext'; + +export const DiagramControls: React.FC = () => { + const { + resetView, + fitToScreen + } = useDiagramViewContext(); + + return ( +
+
+

View Controls

+
+ + +
+
+ + + +
+

Tools

+
+ + + +
+
+
+ ); +}; + +export const DiagramZoomDisplay: React.FC = () => { + const { zoom } = useDiagramViewContext(); + + return ( +
+ Zoom: {Math.round(zoom * 100)}% +
+ ); +}; + +export const DiagramZoomControls: React.FC = () => { + const { zoomIn, zoomOut } = useDiagramViewContext(); + + return ( +
+ + +
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramRenderer.ts b/Website/components/diagram/DiagramRenderer.ts new file mode 100644 index 0000000..63c7ded --- /dev/null +++ b/Website/components/diagram/DiagramRenderer.ts @@ -0,0 +1,161 @@ +import { dia } from '@joint/core'; +import { AttributeType, EntityType } from '@/lib/Types'; +import { EntityElement } from '@/components/diagram/elements/EntityElement'; + +export type IPortMap = Record; + +export abstract class DiagramRenderer { + protected graph: dia.Graph; + protected setSelectedKey?: (key: string | undefined) => void; + protected onLinkClickHandler?: (link: dia.Link) => void; + private instanceId: string; + protected currentSelectedKey?: string; + + constructor( + graph: dia.Graph | undefined | null, + options?: { + setSelectedKey?: (key: string | undefined) => void; + onLinkClick?: (link: dia.Link) => void; + }) { + this.instanceId = Math.random().toString(36).substr(2, 9); + if (!graph) throw new Error("Graph must be defined"); + this.graph = graph; + this.setSelectedKey = options?.setSelectedKey; + this.onLinkClickHandler = options?.onLinkClick; + + // Bind methods to preserve context + this.onLinkClick = this.onLinkClick.bind(this); + this.onDocumentClick = this.onDocumentClick.bind(this); + } + + abstract onDocumentClick(event: MouseEvent): void; + + abstract createEntity(entity: EntityType, position: { x: number, y: number }): { + element: dia.Element, + portMap: IPortMap + }; + + abstract createLinks(entity: EntityType, entityMap: Map, allEntities: EntityType[]): void; + + abstract highlightSelectedKey( + graph: dia.Graph, + entities: EntityType[], + selectedKey: string + ): void; + + abstract updateEntityAttributes(graph: dia.Graph, selectedKey: string | undefined): void; + + abstract onLinkClick(linkView: dia.LinkView, evt: dia.Event): void; + + abstract getVisibleAttributes(entity: EntityType): AttributeType[]; + + // Helper method to set selected key and track it internally + protected setAndTrackSelectedKey(key: string | undefined): void { + this.currentSelectedKey = key; + this.setSelectedKey?.(key); + } + + // Helper method to get current selected key + protected getCurrentSelectedKey(): string | undefined { + return this.currentSelectedKey; + } + + // Method to sync internal state when selectedKey is set externally + public updateSelectedKey(key: string | undefined): void { + this.currentSelectedKey = key; + } + + // Unified method to update an entity regardless of type + updateEntity(entitySchemaName: string, updatedEntity: EntityType): void { + // Find the entity element in the graph + const allElements = this.graph.getElements(); + + const entityElement = allElements.find(el => + (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && + el.get('data')?.entity?.SchemaName === entitySchemaName + ); + + if (entityElement) { + // Update the element's data + entityElement.set('data', { entity: updatedEntity }); + + // Call the appropriate update method based on entity type + if (entityElement.get('type') === 'delegate.entity') { + // For detailed entities, use updateAttributes + const entityElementTyped = entityElement as unknown as { updateAttributes: (entity: EntityType) => void }; + if (entityElementTyped.updateAttributes) { + entityElementTyped.updateAttributes(updatedEntity); + } + } else if (entityElement.get('type') === 'delegate.simple-entity') { + // For simple entities, use updateEntity + const simpleEntityElementTyped = entityElement as unknown as { updateEntity: (entity: EntityType) => void }; + if (simpleEntityElementTyped.updateEntity) { + simpleEntityElementTyped.updateEntity(updatedEntity); + } + } + + // Recreate links for this entity to reflect attribute changes + this.recreateEntityLinks(updatedEntity); + } + } + + // Helper method to recreate links for a specific entity + private recreateEntityLinks(entity: EntityType): void { + // Remove existing links for this entity + const allElements = this.graph.getElements(); + const entityElement = allElements.find(el => + (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') && + el.get('data')?.entity?.SchemaName === entity.SchemaName + ); + + if (entityElement) { + // Remove all links connected to this entity + const connectedLinks = this.graph.getConnectedLinks(entityElement); + connectedLinks.forEach(link => link.remove()); + } + + // Recreate the entity map for link creation + const entityMap = new Map(); + + allElements.forEach(el => { + if (el.get('type') === 'delegate.entity' || el.get('type') === 'delegate.simple-entity') { + const entityData = el.get('data')?.entity; + if (entityData) { + // Create appropriate port map based on entity type + let portMap: IPortMap; + if (el.get('type') === 'delegate.entity') { + // For detailed entities, get the actual port map + const { portMap: detailedPortMap } = EntityElement.getVisibleItemsAndPorts(entityData); + portMap = detailedPortMap; + } else { + // For simple entities, use basic 4-directional ports + portMap = { + top: 'port-top', + right: 'port-right', + bottom: 'port-bottom', + left: 'port-left' + }; + } + + entityMap.set(entityData.SchemaName, { element: el, portMap }); + } + } + }); + + // Recreate links for all entities (this ensures all relationships are updated) + const allEntities: EntityType[] = []; + entityMap.forEach((entityInfo) => { + const entityData = entityInfo.element.get('data')?.entity; + if (entityData) { + allEntities.push(entityData); + } + }); + + entityMap.forEach((entityInfo) => { + const entityData = entityInfo.element.get('data')?.entity; + if (entityData) { + this.createLinks(entityData, entityMap, allEntities); + } + }); + } +} \ No newline at end of file diff --git a/Website/components/diagram/DiagramResetButton.tsx b/Website/components/diagram/DiagramResetButton.tsx new file mode 100644 index 0000000..477e68f --- /dev/null +++ b/Website/components/diagram/DiagramResetButton.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { RotateCcw } from 'lucide-react'; + +interface DiagramResetButtonProps { + onReset: () => void; + disabled?: boolean; +} + +export const DiagramResetButton: React.FC = ({ + onReset, + disabled = false +}) => { + return ( + + ); +}; \ No newline at end of file diff --git a/Website/components/diagram/DiagramView.tsx b/Website/components/diagram/DiagramView.tsx new file mode 100644 index 0000000..81f057e --- /dev/null +++ b/Website/components/diagram/DiagramView.tsx @@ -0,0 +1,895 @@ +'use client'; + +import React, { useEffect, useMemo, useState, useCallback, useRef } from 'react' +import { dia, util } from '@joint/core' +import { Groups } from "../../generated/Data" +import { SquareElement } from '@/components/diagram/elements/SquareElement'; +import { TextElement } from '@/components/diagram/elements/TextElement'; +import { DiagramCanvas } from '@/components/diagram/DiagramCanvas'; +import { ZoomCoordinateIndicator } from '@/components/diagram/ZoomCoordinateIndicator'; +import { EntityActionsPane, LinkPropertiesPane, LinkProperties } from '@/components/diagram/panes'; +import { SquarePropertiesPane } from '@/components/diagram/panes/SquarePropertiesPane'; +import { TextPropertiesPane } from '@/components/diagram/panes/TextPropertiesPane'; +import { calculateGridLayout, getDefaultLayoutOptions, calculateEntityHeight, estimateEntityDimensions } from '@/components/diagram/GridLayoutManager'; +import { AttributeType } from '@/lib/Types'; +import { AppSidebar } from '../AppSidebar'; +import { DiagramViewProvider, useDiagramViewContext } from '@/contexts/DiagramViewContext'; +import { SidebarDiagramView } from './SidebarDiagramView'; +import { useSidebarDispatch } from '@/contexts/SidebarContext'; +import { SimpleDiagramRenderer } from './renderers/SimpleDiagramRender'; +import { DetailedDiagramRender } from './renderers/DetailedDiagramRender'; + +interface IDiagramView {} + +const DiagramContent = () => { + const { + graph, + paper, + selectedGroup, + currentEntities, + zoom, + mousePosition, + selectGroup, + fitToScreen, + addAttributeToEntity, + removeAttributeFromEntity, + diagramType, + removeEntityFromDiagram + } = useDiagramViewContext(); + + const [selectedKey, setSelectedKey] = useState(); + const [selectedEntityForActions, setSelectedEntityForActions] = useState(); + const [isLoading, setIsLoading] = useState(true); + + // Persistent tracking of entity positions across renders + const entityPositionsRef = useRef>(new Map()); + + // Track previous diagram type to detect changes + const previousDiagramTypeRef = useRef(diagramType); + + // Wrapper for setSelectedKey to pass to renderer + const handleSetSelectedKey = useCallback((key: string | undefined) => { + setSelectedKey(key); + }, []); + + // Link click handler to pass to renderer + const handleLinkClick = useCallback((link: dia.Link) => { + setSelectedLink(link); + setIsLinkPropertiesSheetOpen(true); + }, []); + const [isEntityActionsSheetOpen, setIsEntityActionsSheetOpen] = useState(false); + const [selectedSquare, setSelectedSquare] = useState(null); + const [isSquarePropertiesSheetOpen, setIsSquarePropertiesSheetOpen] = useState(false); + const [selectedText, setSelectedText] = useState(null); + const [isTextPropertiesSheetOpen, setIsTextPropertiesSheetOpen] = useState(false); + const [selectedLink, setSelectedLink] = useState(null); + const [isLinkPropertiesSheetOpen, setIsLinkPropertiesSheetOpen] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [resizeData, setResizeData] = useState<{ + element: SquareElement; + handle: string; + startSize: { width: number; height: number }; + startPosition: { x: number; y: number }; + startPointer: { x: number; y: number }; + } | null>(null); + + const renderer = useMemo(() => { + if (!graph) return null; + + const RendererClass = (() => { + switch (diagramType) { + case 'simple': + return SimpleDiagramRenderer; + case 'detailed': + return DetailedDiagramRender; + default: + return SimpleDiagramRenderer; // fallback + } + })(); + + return new RendererClass(graph, { + setSelectedKey: handleSetSelectedKey, + onLinkClick: handleLinkClick + }); + }, [diagramType, graph, handleSetSelectedKey, handleLinkClick]); + + useEffect(() => { + if (Groups.length > 0 && !selectedGroup) { + selectGroup(Groups[0]); + } + }, [Groups, selectedGroup, selectGroup]); + + // Handle loading state when basic dependencies are ready + useEffect(() => { + if (graph && renderer) { // Remove paper dependency here since it might not be ready + // If we have the basic dependencies but no selected group or no entities, stop loading + if (!selectedGroup || currentEntities.length === 0) { + setIsLoading(false); + } + } + }, [graph, renderer, selectedGroup, currentEntities]); // Remove paper from dependencies + + useEffect(() => { + if (!renderer) return; + + // Bind the method to the renderer instance + const boundOnDocumentClick = renderer.onDocumentClick.bind(renderer); + document.addEventListener('click', boundOnDocumentClick); + return () => { + document.removeEventListener('click', boundOnDocumentClick); + }; + }, [renderer]); + + useEffect(() => { + if (!graph || !paper || !selectedGroup || !renderer) { + return; + } + + // Check if diagram type has changed and clear all positions if so + let diagramTypeChanged = false; + if (previousDiagramTypeRef.current !== diagramType) { + console.log(`πŸ”„ Diagram type changed from ${previousDiagramTypeRef.current} to ${diagramType}, clearing all entity positions`); + entityPositionsRef.current.clear(); + previousDiagramTypeRef.current = diagramType; + diagramTypeChanged = true; + } + + // Set loading state when starting diagram creation + setIsLoading(true); + + // If there are no entities, set loading to false immediately + if (currentEntities.length === 0) { + setIsLoading(false); + return; + } + + // Preserve squares, text elements, and existing entity positions before clearing + const squares = graph.getElements().filter(element => element.get('type') === 'delegate.square'); + const textElements = graph.getElements().filter(element => element.get('type') === 'delegate.text'); + const existingEntities = graph.getElements().filter(element => { + const entityData = element.get('data'); + return entityData?.entity; // This is an entity element + }); + + const squareData = squares.map(square => ({ + element: square, + data: square.get('data'), + position: square.position(), + size: square.size() + })); + const textData = textElements.map(textElement => ({ + element: textElement, + data: textElement.get('data'), + position: textElement.position(), + size: textElement.size() + })); + + // Update persistent position tracking with current positions + // Skip this if diagram type changed to ensure all entities are treated as new + if (!diagramTypeChanged) { + console.log('πŸ” Before update - entityPositionsRef has:', Array.from(entityPositionsRef.current.keys())); + existingEntities.forEach(element => { + const entityData = element.get('data'); + if (entityData?.entity?.SchemaName) { + const position = element.position(); + entityPositionsRef.current.set(entityData.entity.SchemaName, position); + console.log(`πŸ“ Updated position for ${entityData.entity.SchemaName}:`, position); + } + }); + } else { + console.log('πŸ”„ Skipping position update due to diagram type change'); + } + + // Clean up position tracking for entities that are no longer in currentEntities + const currentEntityNames = new Set(currentEntities.map(e => e.SchemaName)); + console.log('πŸ“‹ Current entities:', Array.from(currentEntityNames)); + for (const [schemaName] of entityPositionsRef.current) { + if (!currentEntityNames.has(schemaName)) { + console.log(`πŸ—‘οΈ Removing position tracking for deleted entity: ${schemaName}`); + entityPositionsRef.current.delete(schemaName); + } + } + console.log('πŸ” After cleanup - entityPositionsRef has:', Array.from(entityPositionsRef.current.keys())); + + // Clear existing elements + graph.clear(); + + // Re-add preserved squares with their data + squareData.forEach(({ element, data, position, size }) => { + element.addTo(graph); + element.position(position.x, position.y); + element.resize(size.width, size.height); + element.set('data', data); + element.toBack(); // Keep squares at the back + }); + + // Re-add preserved text elements with their data + textData.forEach(({ element, data, position, size }) => { + element.addTo(graph); + element.position(position.x, position.y); + element.resize(size.width, size.height); + element.set('data', data); + element.toFront(); // Keep text elements at the front + }); + + // Calculate grid layout + const layoutOptions = getDefaultLayoutOptions(diagramType); + + // Get actual container dimensions + const containerRect = paper?.el?.getBoundingClientRect(); + const actualContainerWidth = containerRect?.width || layoutOptions.containerWidth; + const actualContainerHeight = containerRect?.height || layoutOptions.containerHeight; + + // Update layout options with actual container dimensions + const updatedLayoutOptions = { + ...layoutOptions, + containerWidth: actualContainerWidth, + containerHeight: actualContainerHeight, + diagramType: diagramType + }; + + // Separate new entities from existing ones using persistent position tracking + const newEntities = currentEntities.filter(entity => + !entityPositionsRef.current.has(entity.SchemaName) + ); + const existingEntitiesWithPositions = currentEntities.filter(entity => + entityPositionsRef.current.has(entity.SchemaName) + ); + + console.log('πŸ†• New entities (no tracked position):', newEntities.map(e => e.SchemaName)); + console.log('πŸ“Œ Existing entities (have tracked position):', existingEntitiesWithPositions.map(e => e.SchemaName)); + + // Store entity elements and port maps by SchemaName for easy lookup + const entityMap = new Map(); + const placedEntityPositions: { x: number; y: number; width: number; height: number }[] = []; + + // First, create existing entities with their preserved positions + console.log('πŸ”§ Creating existing entities with preserved positions...'); + existingEntitiesWithPositions.forEach((entity) => { + const position = entityPositionsRef.current.get(entity.SchemaName); + if (!position) return; // Skip if position is undefined + + console.log(`πŸ“ Placing existing entity ${entity.SchemaName} at:`, position); + const { element, portMap } = renderer.createEntity(entity, position); + entityMap.set(entity.SchemaName, { element, portMap }); + + // Track this position for collision avoidance + const dimensions = estimateEntityDimensions(entity, diagramType); + placedEntityPositions.push({ + x: position.x, + y: position.y, + width: dimensions.width, + height: dimensions.height + }); + }); + + console.log('🚧 Collision avoidance positions:', placedEntityPositions); + + // Then, create new entities with grid layout that avoids already placed entities + if (newEntities.length > 0) { + console.log('πŸ†• Creating new entities with grid layout...'); + // Calculate actual heights for new entities based on diagram type + const entityHeights = newEntities.map(entity => calculateEntityHeight(entity, diagramType)); + const maxEntityHeight = Math.max(...entityHeights, layoutOptions.entityHeight); + + const adjustedLayoutOptions = { + ...updatedLayoutOptions, + entityHeight: maxEntityHeight, + diagramType: diagramType + }; + + console.log('πŸ“Š Grid layout options:', adjustedLayoutOptions); + console.log('🚧 Avoiding existing positions:', placedEntityPositions); + + const layout = calculateGridLayout(newEntities, adjustedLayoutOptions, placedEntityPositions); + console.log('πŸ“ Calculated grid positions:', layout.positions); + + // Create new entities with grid layout positions + newEntities.forEach((entity, index) => { + const position = layout.positions[index] || { x: 50, y: 50 }; + console.log(`πŸ†• Placing new entity ${entity.SchemaName} at:`, position); + const { element, portMap } = renderer.createEntity(entity, position); + entityMap.set(entity.SchemaName, { element, portMap }); + + // Update persistent position tracking for newly placed entities + entityPositionsRef.current.set(entity.SchemaName, position); + console.log(`πŸ’Ύ Saved position for ${entity.SchemaName}:`, position); + }); + } else { + console.log('βœ… No new entities to place with grid layout'); + } + + util.nextFrame(() => { + currentEntities.forEach(entity => { + renderer.createLinks(entity, entityMap, currentEntities); + }); + }); + + // Auto-fit to screen after a short delay to ensure all elements are rendered + setTimeout(() => { + fitToScreen(); + // Set loading to false once diagram is complete + setIsLoading(false); + }, 200); + }, [graph, paper, selectedGroup, currentEntities, diagramType]); + + useEffect(() => { + if (!graph || !renderer) return; + + // Sync the renderer's internal selectedKey state + renderer.updateSelectedKey(selectedKey); + + // Reset all links to default color first + graph.getLinks().forEach(link => { + link.attr('line/stroke', '#42a5f5'); + link.attr('line/strokeWidth', 2); + link.attr('line/targetMarker/stroke', '#42a5f5'); + link.attr('line/targetMarker/fill', '#42a5f5'); + link.attr('line/sourceMarker/stroke', '#42a5f5'); + }); + + // Only highlight if there's a selected key + if (selectedKey) { + renderer.highlightSelectedKey(graph, currentEntities, selectedKey); + } + }, [selectedKey, graph, currentEntities, renderer]); + + useEffect(() => { + if (!graph || !renderer) return; + renderer.updateEntityAttributes(graph, selectedKey); + }, [selectedKey, graph, renderer]); + + useEffect(() => { + if (!paper || !renderer) return; + + // Handle link clicks + paper.on('link:pointerclick', renderer.onLinkClick); + + // Handle entity clicks + const handleElementClick = (elementView: dia.ElementView, evt: dia.Event) => { + evt.stopPropagation(); + const element = elementView.model; + const elementType = element.get('type'); + + if (elementType === 'delegate.square') { + const squareElement = element as SquareElement; + + // Only open properties panel for squares (resize handles are shown on hover) + setSelectedSquare(squareElement); + setIsSquarePropertiesSheetOpen(true); + return; + } + + if (elementType === 'delegate.text') { + const textElement = element as TextElement; + + // Open properties panel for text elements + setSelectedText(textElement); + setIsTextPropertiesSheetOpen(true); + return; + } + + // Handle entity clicks + // Check if the click target is an attribute button + const target = evt.originalEvent?.target as HTMLElement; + const isAttributeButton = target?.closest('button[data-schema-name]'); + + // If clicking on an attribute, let the renderer handle it and don't open the entity actions sheet + if (isAttributeButton) { + return; + } + + const entityData = element.get('data'); + + if (entityData?.entity) { + setSelectedEntityForActions(entityData.entity.SchemaName); + setIsEntityActionsSheetOpen(true); + } + }; + + // Handle element hover for cursor indication + const handleElementMouseEnter = (elementView: dia.ElementView) => { + const element = elementView.model; + const elementType = element.get('type'); + + if (elementType === 'delegate.square') { + // Handle square hover + elementView.el.style.cursor = 'pointer'; + // Add a subtle glow effect for squares + element.attr('body/filter', 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))'); + + // Don't show resize handles on general hover - only on edge hover + return; + } + + if (elementType === 'delegate.text') { + // Handle text hover + elementView.el.style.cursor = 'pointer'; + // Add a subtle glow effect for text elements + element.attr('body/filter', 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.5))'); + return; + } + + // Handle entity hover + const entityData = element.get('data'); + + if (entityData?.entity) { + // Change cursor on the SVG element + elementView.el.style.cursor = 'pointer'; + + // Find the foreignObject and its HTML content for the border effect + const foreignObject = elementView.el.querySelector('foreignObject'); + const htmlContent = foreignObject?.querySelector('[data-entity-schema]') as HTMLElement; + + if (htmlContent && !htmlContent.hasAttribute('data-hover-active')) { + htmlContent.setAttribute('data-hover-active', 'true'); + htmlContent.style.border = '1px solid #3b82f6'; + htmlContent.style.borderRadius = '10px'; + } + } + }; + + const handleElementMouseLeave = (elementView: dia.ElementView) => { + const element = elementView.model; + const elementType = element.get('type'); + + if (elementType === 'delegate.square') { + // Handle square hover leave + elementView.el.style.cursor = 'default'; + // Remove glow effect + element.attr('body/filter', 'none'); + + // Hide resize handles when leaving square area (unless selected for properties) + const squareElement = element as SquareElement; + if (selectedSquare?.id !== squareElement.id) { + squareElement.hideResizeHandles(); + } + return; + } + + if (elementType === 'delegate.text') { + // Handle text hover leave + elementView.el.style.cursor = 'default'; + // Remove glow effect + element.attr('body/filter', 'none'); + return; + } + + // Handle entity hover leave + const entityData = element.get('data'); + + if (entityData?.entity) { + // Remove hover styling + elementView.el.style.cursor = 'default'; + + // Remove border from HTML content + const foreignObject = elementView.el.querySelector('foreignObject'); + const htmlContent = foreignObject?.querySelector('[data-entity-schema]') as HTMLElement; + + if (htmlContent) { + htmlContent.removeAttribute('data-hover-active'); + htmlContent.style.border = 'none'; + } + } + }; + + paper.on('element:pointerclick', handleElementClick); + paper.on('element:mouseenter', handleElementMouseEnter); + paper.on('element:mouseleave', handleElementMouseLeave); + + // Handle mouse movement over squares to show resize handles only near edges + const handleSquareMouseMove = (cellView: dia.CellView, evt: dia.Event) => { + const element = cellView.model; + const elementType = element.get('type'); + + if (elementType === 'delegate.square') { + const squareElement = element as SquareElement; + const bbox = element.getBBox(); + + // Check if clientX and clientY are defined before using them + if (evt.clientX === undefined || evt.clientY === undefined) return; + + const paperLocalPoint = paper.clientToLocalPoint(evt.clientX, evt.clientY); + + const edgeThreshold = 15; // pixels from edge to show handles + const isNearEdge = ( + // Near left or right edge + (paperLocalPoint.x <= bbox.x + edgeThreshold || + paperLocalPoint.x >= bbox.x + bbox.width - edgeThreshold) || + // Near top or bottom edge + (paperLocalPoint.y <= bbox.y + edgeThreshold || + paperLocalPoint.y >= bbox.y + bbox.height - edgeThreshold) + ); + + if (isNearEdge) { + squareElement.showResizeHandles(); + cellView.el.style.cursor = 'move'; + } else { + // Only hide if not selected for properties (check current state) + const currentSelectedSquare = selectedSquare; + if (currentSelectedSquare?.id !== squareElement.id) { + squareElement.hideResizeHandles(); + } + cellView.el.style.cursor = 'move'; + } + } + }; + + paper.on('cell:mousemove', handleSquareMouseMove); + + // Handle pointer down for resize handles - capture before other events + paper.on('cell:pointerdown', (cellView: dia.CellView, evt: dia.Event) => { + const element = cellView.model; + const elementType = element.get('type'); + + if (elementType === 'delegate.square') { + const target = evt.target as HTMLElement; + + // More reliable selector detection for resize handles + let selector = target.getAttribute('joint-selector'); + + if (!selector) { + // Try to find parent with selector + let parent = target.parentElement; + let depth = 0; + while (parent && !selector && depth < 5) { + selector = parent.getAttribute('joint-selector'); + parent = parent.parentElement; + depth++; + } + } + + if (selector && selector.startsWith('resize-')) { + evt.stopPropagation(); + evt.preventDefault(); + + const squareElement = element as SquareElement; + const bbox = element.getBBox(); + + const resizeInfo = { + element: squareElement, + handle: selector, + startSize: { width: bbox.width, height: bbox.height }, + startPosition: { x: bbox.x, y: bbox.y }, + startPointer: { x: evt.clientX || 0, y: evt.clientY || 0 } + }; + + setResizeData(resizeInfo); + setIsResizing(true); + } + } + }); + + return () => { + paper.off('link:pointerclick', renderer.onLinkClick); + paper.off('element:pointerclick', handleElementClick); + paper.off('element:mouseenter', handleElementMouseEnter); + paper.off('element:mouseleave', handleElementMouseLeave); + paper.off('cell:mousemove', handleSquareMouseMove); + paper.off('cell:pointerdown'); + }; + }, [paper, renderer, selectedSquare]); + + // Handle resize operations + useEffect(() => { + if (!isResizing || !resizeData || !paper) return; + + let animationId: number; + + const handleMouseMove = (evt: MouseEvent) => { + if (!resizeData) return; + + // Cancel previous animation frame to prevent stacking + if (animationId) { + cancelAnimationFrame(animationId); + } + + // Use requestAnimationFrame for smooth updates + animationId = requestAnimationFrame(() => { + const { element, handle, startSize, startPosition, startPointer } = resizeData; + const deltaX = evt.clientX - startPointer.x; + const deltaY = evt.clientY - startPointer.y; + + const newSize = { width: startSize.width, height: startSize.height }; + const newPosition = { x: startPosition.x, y: startPosition.y }; + + // Calculate new size and position based on resize handle + switch (handle) { + case 'resize-se': // Southeast + newSize.width = Math.max(50, startSize.width + deltaX); + newSize.height = Math.max(30, startSize.height + deltaY); + break; + case 'resize-sw': // Southwest + newSize.width = Math.max(50, startSize.width - deltaX); + newSize.height = Math.max(30, startSize.height + deltaY); + newPosition.x = startPosition.x + deltaX; + break; + case 'resize-ne': // Northeast + newSize.width = Math.max(50, startSize.width + deltaX); + newSize.height = Math.max(30, startSize.height - deltaY); + newPosition.y = startPosition.y + deltaY; + break; + case 'resize-nw': // Northwest + newSize.width = Math.max(50, startSize.width - deltaX); + newSize.height = Math.max(30, startSize.height - deltaY); + newPosition.x = startPosition.x + deltaX; + newPosition.y = startPosition.y + deltaY; + break; + case 'resize-e': // East + newSize.width = Math.max(50, startSize.width + deltaX); + break; + case 'resize-w': // West + newSize.width = Math.max(50, startSize.width - deltaX); + newPosition.x = startPosition.x + deltaX; + break; + case 'resize-s': // South + newSize.height = Math.max(30, startSize.height + deltaY); + break; + case 'resize-n': // North + newSize.height = Math.max(30, startSize.height - deltaY); + newPosition.y = startPosition.y + deltaY; + break; + } + + // Apply the new size and position in a single batch update + element.resize(newSize.width, newSize.height); + element.position(newPosition.x, newPosition.y); + }); + }; + + const handleMouseUp = () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + setIsResizing(false); + setResizeData(null); + }; + + // Add global event listeners + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + // Cleanup + return () => { + if (animationId) { + cancelAnimationFrame(animationId); + } + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, [isResizing, resizeData, paper]); + + // Handle clicking outside to deselect squares + useEffect(() => { + if (!paper) return; + + const handleBlankClick = () => { + if (selectedSquare) { + selectedSquare.hideResizeHandles(); + setSelectedSquare(null); + setIsSquarePropertiesSheetOpen(false); + } + }; + + paper.on('blank:pointerclick', handleBlankClick); + + return () => { + paper.off('blank:pointerclick', handleBlankClick); + }; + }, [paper, selectedSquare]); + + const handleAddAttribute = (attribute: AttributeType) => { + if (!selectedEntityForActions || !renderer) return; + addAttributeToEntity(selectedEntityForActions, attribute, renderer); + }; + + const handleRemoveAttribute = (attribute: AttributeType) => { + if (!selectedEntityForActions || !renderer) return; + removeAttributeFromEntity(selectedEntityForActions, attribute, renderer); + }; + + const handleDeleteEntity = () => { + if (selectedEntityForActions) { + removeEntityFromDiagram(selectedEntityForActions); + setIsEntityActionsSheetOpen(false); + setSelectedEntityForActions(undefined); + } + }; + + const handleDeleteSquare = () => { + if (selectedSquare && graph) { + // Remove the square from the graph + selectedSquare.remove(); + // Clear the selection + setSelectedSquare(null); + setIsSquarePropertiesSheetOpen(false); + } + }; + + const handleDeleteText = () => { + if (selectedText && graph) { + // Remove the text from the graph + selectedText.remove(); + // Clear the selection + setSelectedText(null); + setIsTextPropertiesSheetOpen(false); + } + }; + + const handleUpdateLink = (linkId: string | number, properties: LinkProperties) => { + if (!graph) return; + + const link = graph.getCell(linkId) as dia.Link; + if (!link) return; + + // Update link appearance + link.attr('line/stroke', properties.color); + link.attr('line/strokeWidth', properties.strokeWidth); + link.attr('line/targetMarker/stroke', properties.color); + link.attr('line/targetMarker/fill', properties.color); + link.attr('line/sourceMarker/stroke', properties.color); + + if (properties.strokeDasharray) { + link.attr('line/strokeDasharray', properties.strokeDasharray); + } else { + link.removeAttr('line/strokeDasharray'); + } + + // Update or remove label + if (properties.label) { + link.label(0, { + attrs: { + rect: { + fill: 'white', + stroke: '#e5e7eb', + strokeWidth: 1, + rx: 4, + ry: 4, + ref: 'text', + refX: -8, + refY: 0, + refWidth: '100%', + refHeight: '100%', + refWidth2: 16, + refHeight2: 8, + }, + text: { + text: properties.label, + fill: properties.color, + fontSize: 14, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + textAnchor: 'start', + dominantBaseline: 'central', + } + }, + position: { + distance: 0.5, + offset: -1 + } + }); + } else { + link.removeLabel(0); + } + }; + + // Find the selected entity for actions + const selectedEntityForActionsData = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); + + // Find the entity display name for the modal + const selectedEntity = currentEntities.find(entity => entity.SchemaName === selectedEntityForActions); + + // Get available and visible attributes for the selected entity + const availableAttributes = selectedEntity?.Attributes || []; + const visibleAttributes = selectedEntity && renderer + ? renderer.getVisibleAttributes(selectedEntity) + : []; + + return ( + <> +
+ {/* Beta Disclaimer Banner */} +
+
+
+ Ξ² +
+

+ Open Beta Feature: This ER Diagram feature is currently in beta. Some functionality may not work fully. +

+
+
+ + {/* Diagram Area */} +
+ {isLoading && ( +
+
+
+ {[...Array(3)].map((_, i) => ( +
+ ))} +
+

+ Loading diagram... +

+
+
+ )} + + {/* Zoom and Coordinate Indicator */} + + +
+
+ + {/* Entity Actions Pane */} + + + {/* Square Properties Pane */} + + + {/* Text Properties Pane */} + + + {/* Link Properties Pane */} + { + setIsLinkPropertiesSheetOpen(open); + if (!open) setSelectedLink(null); + }} + selectedLink={selectedLink} + onUpdateLink={handleUpdateLink} + /> + + ) +}; + +export default function DiagramView({ }: IDiagramView) { + const dispatch = useSidebarDispatch(); + + useEffect(() => { + dispatch({ type: "SET_ELEMENT", payload: }) + }, []) + + return ( + +
+ + +
+
+ ); +} diff --git a/Website/components/diagram/GridLayoutManager.ts b/Website/components/diagram/GridLayoutManager.ts new file mode 100644 index 0000000..b23e3e2 --- /dev/null +++ b/Website/components/diagram/GridLayoutManager.ts @@ -0,0 +1,218 @@ +import { EntityType } from '@/lib/Types'; +import { EntityElement } from '@/components/diagram/elements/EntityElement'; + +export type DiagramType = 'simple' | 'detailed'; + +export interface GridLayoutOptions { + containerWidth: number; + containerHeight: number; + entityWidth: number; + entityHeight: number; + padding: number; + margin: number; + diagramType?: DiagramType; +} + +export interface GridPosition { + x: number; + y: number; +} + +export interface GridLayoutResult { + positions: GridPosition[]; + gridWidth: number; + gridHeight: number; + columns: number; + rows: number; +} + +/** + * Calculates the actual height of an entity based on its visible attributes and diagram type + */ +export const calculateEntityHeight = (entity: EntityType, diagramType: DiagramType = 'detailed'): number => { + // For simple diagrams, use fixed small dimensions + if (diagramType === 'simple') { + return 80; // Fixed height for simple entities + } + + // For detailed diagrams, calculate based on content + const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); + const itemHeight = 28; + const itemYSpacing = 8; + const addButtonHeight = 32; // Space for add button + const headerHeight = 80; + const startY = headerHeight + itemYSpacing * 2; + + // Calculate height including the add button + return startY + visibleItems.length * (itemHeight + itemYSpacing) + addButtonHeight + itemYSpacing; +}; + +/** + * Calculates optimal grid layout for entities based on screen aspect ratio + * Optionally avoids existing entity positions + */ +export const calculateGridLayout = ( + entities: EntityType[], + options: GridLayoutOptions, + existingPositions?: { x: number; y: number; width: number; height: number }[] +): GridLayoutResult => { + const { containerWidth, padding, margin } = options; + + if (entities.length === 0) { + return { + positions: [], + gridWidth: 0, + gridHeight: 0, + columns: 0, + rows: 0 + }; + } + + // If we have existing positions, we need to find the best starting position for new entities + let startColumn = 0; + let startRow = 0; + + if (existingPositions && existingPositions.length > 0) { + // Find the rightmost and bottommost positions + const maxX = Math.max(...existingPositions.map(pos => pos.x + pos.width)); + const maxY = Math.max(...existingPositions.map(pos => pos.y + pos.height)); + + // Get sample entity dimensions for spacing calculations + const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); + + // Start new entities to the right of existing ones, or on the next row + startColumn = Math.floor((maxX + padding - margin) / (sampleDimensions.width + padding)); + if (startColumn * (sampleDimensions.width + padding) + margin + sampleDimensions.width > containerWidth) { + // Move to next row if we can't fit horizontally + startColumn = 0; + startRow = Math.floor((maxY + padding - margin) / (sampleDimensions.height + padding)); + } + } + + // Determine how many columns can fit based on actual entity dimensions + const sampleEntityDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); + const actualEntityWidth = sampleEntityDimensions.width; + const maxColumns = Math.max(1, Math.floor((containerWidth - margin * 2 + padding) / (actualEntityWidth + padding))); + + // For collision avoidance, we'll place entities sequentially from the calculated starting position + const positions: GridPosition[] = []; + let currentColumn = startColumn; + let currentRow = startRow; + + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + const entityDimensions = estimateEntityDimensions(entity, options.diagramType); + const height = entityDimensions.height; + const width = entityDimensions.width; + + // Find next available position that doesn't collide + let foundValidPosition = false; + let attempts = 0; + const maxAttempts = maxColumns * 10; // Prevent infinite loop + + while (!foundValidPosition && attempts < maxAttempts) { + // If we exceed the max columns, move to next row + if (currentColumn >= maxColumns) { + currentColumn = 0; + currentRow++; + } + + const x = margin + currentColumn * (width + padding); + const y = margin + currentRow * (height + padding); + + // Check if this position is occupied by existing entities + const isOccupied = existingPositions && existingPositions.length > 0 ? existingPositions.some(pos => { + const entityRight = x + width; + const entityBottom = y + height; + const existingRight = pos.x + pos.width; + const existingBottom = pos.y + pos.height; + + // Check for overlap with padding buffer + const buffer = padding / 4; + return !(entityRight + buffer < pos.x || + x > existingRight + buffer || + entityBottom + buffer < pos.y || + y > existingBottom + buffer); + }) : false; + + if (!isOccupied) { + positions.push({ x, y }); + foundValidPosition = true; + } + + // Move to next position + currentColumn++; + attempts++; + } + + if (!foundValidPosition) { + // Fallback: place at calculated position anyway (should not happen with enough attempts) + const x = margin + currentColumn * (width + padding); + const y = margin + currentRow * (height + padding); + positions.push({ x, y }); + currentColumn++; + } + } + + const sampleDimensions = estimateEntityDimensions(entities[0] || { Attributes: [] }, options.diagramType); + const gridWidth = Math.min(entities.length, maxColumns) * sampleDimensions.width + (Math.min(entities.length, maxColumns) - 1) * padding; + const gridHeight = (currentRow + 1) * (sampleDimensions.height + padding) - padding; + + return { + positions, + gridWidth, + gridHeight, + columns: Math.min(entities.length, maxColumns), + rows: currentRow + 1 + }; +}; + + +/** + * Estimates entity dimensions based on content and diagram type + */ +export const estimateEntityDimensions = (entity: EntityType, diagramType: DiagramType = 'detailed'): { width: number; height: number } => { + if (diagramType === 'simple') { + // Fixed dimensions for simple entities + return { + width: 200, + height: 80 + }; + } + + // Base dimensions for detailed entities + const baseWidth = 480; // Match the entity width used in EntityElement + const height = calculateEntityHeight(entity, diagramType); // Use actual calculated height + + return { + width: baseWidth, + height: height + }; +}; + +/** + * Gets default layout options based on diagram type + */ +export const getDefaultLayoutOptions = (diagramType: DiagramType = 'detailed'): GridLayoutOptions => { + if (diagramType === 'simple') { + return { + containerWidth: 1920, + containerHeight: 1080, + entityWidth: 200, // Smaller width for simple entities + entityHeight: 80, // Smaller height for simple entities + padding: 40, // Less padding for simple diagrams + margin: 40, // Less margin for simple diagrams + diagramType: 'simple' + }; + } + + return { + containerWidth: 1920, // Use a wider default container + containerHeight: 1080, // Use a taller default container + entityWidth: 480, + entityHeight: 400, // This will be overridden by actual calculation + padding: 80, // Reduced padding for better space utilization + margin: 80, + diagramType: 'detailed' + }; +}; \ No newline at end of file diff --git a/Website/components/diagram/GroupSelector.tsx b/Website/components/diagram/GroupSelector.tsx new file mode 100644 index 0000000..7ba4ba5 --- /dev/null +++ b/Website/components/diagram/GroupSelector.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { GroupType } from '@/lib/Types'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { FolderOpen, Folder } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface GroupSelectorProps { + groups: GroupType[]; + selectedGroup: GroupType | null; + onGroupSelect: (group: GroupType) => void; +} + +export const GroupSelector: React.FC = ({ + groups, + selectedGroup, + onGroupSelect +}) => { + return ( +
+
+

Groups

+ + {groups.length} total + +
+ + + +
+
+ {groups.map((group) => { + const isSelected = selectedGroup?.Name === group.Name; + const entityCount = group.Entities.length; + + return ( + + ); + })} +
+
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/SidebarDiagramView.tsx b/Website/components/diagram/SidebarDiagramView.tsx new file mode 100644 index 0000000..8405835 --- /dev/null +++ b/Website/components/diagram/SidebarDiagramView.tsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Button } from '@/components/ui/button'; +import { ChevronDown, ChevronRight, Database, Square, Type, Settings, Hammer, Users, Save, Upload, Smartphone, RotateCcw, Trash2 } from 'lucide-react'; +import { useDiagramViewContextSafe } from '@/contexts/DiagramViewContext'; +import { AddEntityPane, AddGroupPane, ResetToGroupPane } from '@/components/diagram/panes'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { GroupType } from '@/lib/Types'; + +interface ISidebarDiagramViewProps { + +} + +export const SidebarDiagramView = ({ }: ISidebarDiagramViewProps) => { + const diagramContext = useDiagramViewContextSafe(); + const isMobile = useIsMobile(); + const [isDataExpanded, setIsDataExpanded] = useState(true); + const [isGeneralExpanded, setIsGeneralExpanded] = useState(false); + const [isEntitySheetOpen, setIsEntitySheetOpen] = useState(false); + const [isGroupSheetOpen, setIsGroupSheetOpen] = useState(false); + const [isResetSheetOpen, setIsResetSheetOpen] = useState(false); + + // If not in diagram context, show a message or return null + if (!diagramContext) { + return ( +
+
+ +

Diagram tools are only available on the diagram page.

+
+
+ ); + } + + const { addEntityToDiagram, addGroupToDiagram, addSquareToDiagram, addTextToDiagram, saveDiagram, loadDiagram, currentEntities, diagramType, updateDiagramType, clearDiagram } = diagramContext; + + const handleLoadDiagram = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + loadDiagram(file).catch(error => { + alert('Failed to load diagram: ' + error.message); + }); + } + // Reset input value to allow loading the same file again + event.target.value = ''; + }; + + const handleResetToGroup = (group: GroupType) => { + // First clear the entire diagram + clearDiagram(); + // Then add the selected group + addGroupToDiagram(group); + }; + + // Use the clearDiagram function from the hook + // const clearDiagram function is already available from the context + + return ( +
+ + + + + + + + + + + + {/* Mobile Notice */} + {isMobile && ( +
+
+ +
+

Mobile Mode

+

+ Some advanced features may have limited functionality on mobile devices. + For the best experience, use a desktop computer. +

+
+
+
+ )} + + {/* Data Section */} + + + + + + + + + + + {/* General Section */} + + + + + + + + + +
+ + +

Layers functionality coming soon...

+
+ + +
+
+

Diagram Type

+

+ Choose between simple or detailed entity view +

+
+ + +
+
+ +
+
+

Save & Load

+

+ Save your diagram or load an existing one +

+
+ + +
+
+
+ +
+
+

Current Settings

+
+

Diagram Type: {diagramType}

+

Entities in Diagram: {currentEntities.length}

+
+
+
+ +
+
+

Diagram Actions

+

+ Reset or clear your diagram +

+
+ + +
+
+
+
+
+
+ + {/* Add Entity Pane */} + + + {/* Add Group Pane */} + + + {/* Reset to Group Pane */} + +
+ ); +} \ No newline at end of file diff --git a/Website/components/diagram/ZoomCoordinateIndicator.tsx b/Website/components/diagram/ZoomCoordinateIndicator.tsx new file mode 100644 index 0000000..6ed9891 --- /dev/null +++ b/Website/components/diagram/ZoomCoordinateIndicator.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { ZoomIn, MousePointer } from 'lucide-react'; + +interface ZoomCoordinateIndicatorProps { + zoom: number; + mousePosition: { x: number; y: number } | null; +} + +export const ZoomCoordinateIndicator: React.FC = ({ + zoom, + mousePosition +}) => { + const zoomPercentage = Math.round(zoom * 100); + + return ( +
+
+
+ + + {zoomPercentage}% + +
+ + {mousePosition && ( + <> +
+
+ + + X: {mousePosition.x}, Y: {mousePosition.y} + +
+ + )} +
+
+ ); +}; \ No newline at end of file diff --git a/Website/components/diagram/avoid-router/avoidrouter.ts b/Website/components/diagram/avoid-router/avoidrouter.ts new file mode 100644 index 0000000..e676263 --- /dev/null +++ b/Website/components/diagram/avoid-router/avoidrouter.ts @@ -0,0 +1,415 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { AvoidLib } from 'libavoid-js'; +import { g, util, mvc, dia } from '@joint/core'; + +const defaultPin = 1; + +// The Avoid type is used for static access to the library +type Avoid = ReturnType; + +interface AvoidRouterOptions { + shapeBufferDistance?: number; + portOverflow?: number; + idealNudgingDistance?: number; + commitTransactions?: boolean; +} + +export class AvoidRouter { + graph: dia.Graph; + connDirections: Record; + shapeRefs: Record; + edgeRefs: Record; + pinIds: Record; + linksByPointer: Record; + avoidRouter: any; + avoidConnectorCallback: (ptr: any) => void; + id: number; + margin!: number; + portOverflow!: number; + commitTransactions: boolean; + graphListener?: mvc.Listener; + + private static isLoaded = false; + + static async load(): Promise { + if (AvoidRouter.isLoaded) { + console.log('Avoid library is already initialized'); + return; + } + + try { + await AvoidLib.load("/libavoid.wasm"); + AvoidRouter.isLoaded = true; + } catch (error) { + console.error('Failed to load Avoid library:', error); + throw error; + } + } + + constructor(graph: dia.Graph, options: AvoidRouterOptions = {}) { + const Avoid = AvoidLib.getInstance(); + + this.graph = graph; + + this.connDirections = { + top: Avoid.ConnDirUp, + right: Avoid.ConnDirRight, + bottom: Avoid.ConnDirDown, + left: Avoid.ConnDirLeft, + all: Avoid.ConnDirAll, + }; + + this.shapeRefs = {}; + this.edgeRefs = {}; + this.pinIds = {}; + this.linksByPointer = {}; + this.avoidConnectorCallback = this.onAvoidConnectorChange.bind(this); + this.id = 100000; + this.commitTransactions = options.commitTransactions ?? true; + + this.createAvoidRouter(options); + } + + createAvoidRouter(options: AvoidRouterOptions = {}) { + const { + shapeBufferDistance = 0, + portOverflow = 0, + idealNudgingDistance = 10, + } = options; + + this.margin = shapeBufferDistance; + this.portOverflow = portOverflow; + + const Avoid = AvoidLib.getInstance(); + const router = new Avoid.Router(Avoid.OrthogonalRouting); + + router.setRoutingParameter(Avoid.idealNudgingDistance, idealNudgingDistance); + router.setRoutingParameter(Avoid.shapeBufferDistance, shapeBufferDistance); + router.setRoutingOption(Avoid.nudgeOrthogonalTouchingColinearSegments, false); + router.setRoutingOption(Avoid.performUnifyingNudgingPreprocessingStep, true); + router.setRoutingOption(Avoid.nudgeSharedPathsWithCommonEndPoint, true); + router.setRoutingOption(Avoid.nudgeOrthogonalSegmentsConnectedToShapes, true); + + this.avoidRouter = router; + } + + getAvoidRectFromElement(element: dia.Element): any { + const Avoid = AvoidLib.getInstance(); + const { x, y, width, height } = element.getBBox(); + return new Avoid.Rectangle( + new Avoid.Point(x, y), + new Avoid.Point(x + width, y + height) + ); + } + + getVerticesFromAvoidRoute(route: any): Array<{ x: number; y: number }> { + const vertices: Array<{ x: number; y: number }> = []; + for (let i = 1; i < route.size() - 1; i++) { + const { x, y } = route.get_ps(i); + vertices.push({ x, y }); + } + return vertices; + } + + updateShape(element: dia.Element): void { + // Skip squares and text elements - they shouldn't be obstacles for routing + if (element.get('type') === 'delegate.square' || element.get('type') === 'delegate.text') { + return; + } + + const Avoid = AvoidLib.getInstance(); + const shapeRect = this.getAvoidRectFromElement(element); + + if (this.shapeRefs[element.id]) { + this.avoidRouter.moveShape(this.shapeRefs[element.id], shapeRect); + return; + } + + const shapeRef = new Avoid.ShapeRef(this.avoidRouter, shapeRect); + this.shapeRefs[element.id] = shapeRef; + + const centerPin = new Avoid.ShapeConnectionPin( + shapeRef, + defaultPin, + 0.5, + 0.5, + true, + 0, + Avoid.ConnDirAll + ); + centerPin.setExclusive(false); + + element.getPortGroupNames().forEach((group: string) => { + const portsPositions = element.getPortsPositions(group); + const { width, height } = element.size(); + const rect = new g.Rect(0, 0, width, height); + Object.keys(portsPositions).forEach((portId: string) => { + const { x, y } = portsPositions[portId]; + const side = rect.sideNearestToPoint({ x, y }); + const pin = new Avoid.ShapeConnectionPin( + shapeRef, + this.getConnectionPinId(element.id.toString(), portId), + x / width, + y / height, + true, + 0, + this.connDirections[side] + ); + pin.setExclusive(false); + }); + }); + } + + getConnectionPinId(elementId: dia.Cell.ID, portId: string): number { + const pinKey = `${elementId}:${portId}`; + if (pinKey in this.pinIds) return this.pinIds[pinKey]; + const pinId = this.id++; + this.pinIds[pinKey] = pinId; + return pinId; + } + + updateConnector(link: dia.Link): any { + const Avoid = AvoidLib.getInstance(); + + const { id: sourceId, port: sourcePortId = null } = link.source(); + const { id: targetId, port: targetPortId = null } = link.target(); + + if (!sourceId || !targetId) { + this.deleteConnector(link); + return null; + } + + const sourceConnEnd = new Avoid.ConnEnd( + this.shapeRefs[sourceId], + sourcePortId ? this.getConnectionPinId(sourceId, sourcePortId) : defaultPin + ); + + const targetConnEnd = new Avoid.ConnEnd( + this.shapeRefs[targetId], + targetPortId ? this.getConnectionPinId(targetId, targetPortId) : defaultPin + ); + + let connRef = this.edgeRefs[link.id]; + + if (!connRef) { + connRef = new Avoid.ConnRef(this.avoidRouter); + this.linksByPointer[connRef.g] = link; + this.edgeRefs[link.id] = connRef; + connRef.setCallback(this.avoidConnectorCallback, connRef); + } + + connRef.setSourceEndpoint(sourceConnEnd); + connRef.setDestEndpoint(targetConnEnd); + + return connRef; + } + + deleteConnector(link: dia.Link): void { + const connRef = this.edgeRefs[link.id]; + if (!connRef) return; + this.avoidRouter.deleteConnector(connRef); + delete this.linksByPointer[connRef.g]; + delete this.edgeRefs[link.id]; + } + + deleteShape(element: dia.Element): void { + // Skip squares and text elements - they weren't added as obstacles + if (element.get('type') === 'delegate.square' || element.get('type') === 'delegate.text') { + return; + } + + const shapeRef = this.shapeRefs[element.id]; + if (!shapeRef) return; + this.avoidRouter.deleteShape(shapeRef); + delete this.shapeRefs[element.id]; + } + + getLinkAnchorDelta(element: dia.Element, portId: string | null, point: g.Point): g.Point { + let anchorPosition: g.Point; + const bbox = element.getBBox(); + if (portId) { + const port = element.getPort(portId); + const portPosition = element.getPortsPositions(port.group || '')[portId]; + anchorPosition = element.position().offset(portPosition); + } else { + anchorPosition = bbox.center(); + } + return point.difference(anchorPosition); + } + + routeLink(link: dia.Link): void { + const connRef = this.edgeRefs[link.id]; + if (!connRef) return; + + const route = connRef.displayRoute(); + const sourcePoint = new g.Point(route.get_ps(0)); + const targetPoint = new g.Point(route.get_ps(route.size() - 1)); + + const { id: sourceId, port: sourcePortId = null } = link.source(); + const { id: targetId, port: targetPortId = null } = link.target(); + + const sourceElement = link.getSourceElement(); + const targetElement = link.getTargetElement(); + + if (!sourceElement || !targetElement) return; + + const sourceAnchorDelta = this.getLinkAnchorDelta(sourceElement, sourcePortId, sourcePoint); + const targetAnchorDelta = this.getLinkAnchorDelta(targetElement, targetPortId, targetPoint); + + const linkAttributes: dia.Link.Attributes = { + source: { + id: sourceId, + port: sourcePortId || null, + anchor: { name: 'modelCenter', args: { dx: sourceAnchorDelta.x, dy: sourceAnchorDelta.y } }, + }, + target: { + id: targetId, + port: targetPortId || null, + anchor: { name: 'modelCenter', args: { dx: targetAnchorDelta.x, dy: targetAnchorDelta.y } }, + }, + }; + + if (this.isRouteValid(route, sourceElement, targetElement, sourcePortId, targetPortId)) { + linkAttributes.vertices = this.getVerticesFromAvoidRoute(route); + linkAttributes.router = null; + } else { + linkAttributes.vertices = []; + linkAttributes.router = { + name: 'rightAngle', + args: { + margin: this.margin - this.portOverflow, + }, + }; + } + + link.set(linkAttributes, { avoidRouter: true }); + } + + routeAll(): void { + this.graph.getElements().forEach((element) => this.updateShape(element)); + this.graph.getLinks().forEach((link) => this.updateConnector(link)); + this.avoidRouter.processTransaction(); + } + + resetLink(link: dia.Link): void { + const newAttributes = util.cloneDeep(link.attributes); + newAttributes.vertices = []; + newAttributes.router = null; + delete newAttributes.source.anchor; + delete newAttributes.target.anchor; + link.set(newAttributes, { avoidRouter: true }); + } + + addGraphListeners(): void { + this.removeGraphListeners(); + + const listener = new mvc.Listener(); + listener.listenTo(this.graph, { + remove: (cell: dia.Cell) => this.onCellRemoved(cell), + add: (cell: dia.Cell) => this.onCellAdded(cell), + change: (cell: dia.Cell, opt: any) => this.onCellChanged(cell, opt), + reset: (_: any, opt: { previousModels: dia.Cell[] }) => this.onGraphReset(opt.previousModels), + }); + + this.graphListener = listener; + } + + removeGraphListeners(): void { + this.graphListener?.stopListening(); + delete this.graphListener; + } + + onCellRemoved(cell: dia.Cell): void { + if (cell.isElement()) { + this.deleteShape(cell); + } else { + this.deleteConnector(cell as dia.Link); + } + this.avoidRouter.processTransaction(); + } + + onCellAdded(cell: dia.Cell): void { + if (cell.isElement()) { + this.updateShape(cell); + } else { + this.updateConnector(cell as dia.Link); + } + this.avoidRouter.processTransaction(); + } + + onCellChanged(cell: dia.Cell, opt: any): void { + if (opt.avoidRouter) return; + let needsRerouting = false; + if ('source' in cell.changed || 'target' in cell.changed) { + if (!cell.isLink()) return; + if (!this.updateConnector(cell as dia.Link)) { + this.resetLink(cell as dia.Link); + } + needsRerouting = true; + } + if ('position' in cell.changed || 'size' in cell.changed) { + if (!cell.isElement()) return; + this.updateShape(cell); + needsRerouting = true; + } + + if (this.commitTransactions && needsRerouting) { + this.avoidRouter.processTransaction(); + } + } + + onGraphReset(previousModels: dia.Cell[]): void { + previousModels.forEach((cell) => { + if (cell.isElement()) { + this.deleteShape(cell); + } else { + this.deleteConnector(cell as dia.Link); + } + }); + + this.routeAll(); + } + + onAvoidConnectorChange(connRefPtr: any): void { + const link = this.linksByPointer[connRefPtr]; + if (!link) return; + this.routeLink(link); + } + + isRouteValid( + route: any, + sourceElement: dia.Element, + targetElement: dia.Element, + sourcePortId: string | null, + targetPortId: string | null + ): boolean { + const size = route.size(); + if (size > 2) return true; + + const sourcePs = route.get_ps(0); + const targetPs = route.get_ps(size - 1); + + if (sourcePs.x !== targetPs.x && sourcePs.y !== targetPs.y) { + return false; + } + + const margin = this.margin; + + if ( + sourcePortId && + targetElement.getBBox().inflate(margin).containsPoint(sourcePs) + ) { + return false; + } + + if ( + targetPortId && + sourceElement.getBBox().inflate(margin).containsPoint(targetPs) + ) { + return false; + } + + return true; + } +} diff --git a/Website/components/diagram/elements/EntityAttribute.ts b/Website/components/diagram/elements/EntityAttribute.ts new file mode 100644 index 0000000..ae8bdae --- /dev/null +++ b/Website/components/diagram/elements/EntityAttribute.ts @@ -0,0 +1,48 @@ +import { AttributeType } from "@/lib/Types"; + +interface IEntityAttribute { + attribute: AttributeType; + isKey: boolean; + isSelected?: boolean; +} + +export const EntityAttribute = ({ attribute, isKey, isSelected = false }: IEntityAttribute): string => { + let icon = ''; + if (isKey) { + icon = `` + } else if (attribute.AttributeType === 'LookupAttribute') { + icon = `` + } else if (attribute.AttributeType === 'StringAttribute') { + icon = `` + } else if (attribute.AttributeType === 'IntegerAttribute' || attribute.AttributeType === 'DecimalAttribute') { + icon = `` + } else if (attribute.AttributeType === 'DateTimeAttribute') { + icon = `` + } else if (attribute.AttributeType === 'BooleanAttribute') { + icon = `` + } else if (attribute.AttributeType === 'ChoiceAttribute') { + icon = `` + } + + const isClickable = isKey || attribute.AttributeType === 'LookupAttribute'; + const buttonClasses = `w-full rounded-sm my-[4px] p-[4px] flex items-center h-[28px] ${isClickable ? 'transition-colors duration-300 hover:bg-blue-200 cursor-pointer' : ''}`; + const bgClass = isSelected ? 'bg-red-200 border-2 border-red-400' : 'bg-gray-100'; + + const titleText = isKey + ? 'Click to highlight incoming relationships' + : attribute.AttributeType === 'LookupAttribute' + ? 'Click to highlight outgoing relationships' + : ''; + + return ` + + `; +}; \ No newline at end of file diff --git a/Website/components/diagram/elements/EntityBody.ts b/Website/components/diagram/elements/EntityBody.ts new file mode 100644 index 0000000..f283292 --- /dev/null +++ b/Website/components/diagram/elements/EntityBody.ts @@ -0,0 +1,39 @@ +import { AttributeType, EntityType } from '@/lib/Types' +import { EntityAttribute } from './EntityAttribute'; + +interface IEntityBody { + entity: EntityType; + visibleItems: AttributeType[]; + selectedKey?: string; +} + +export function EntityBody({ entity, visibleItems, selectedKey }: IEntityBody): string { + + const icon = entity.IconBase64 != null + ? `data:image/svg+xml;base64,${entity.IconBase64}` + : '/vercel.svg'; + + return ` +
+ + +
+
+ +
+
+

${entity.DisplayName}

+

${entity.SchemaName}

+
+
+ +
+ ${visibleItems.map((attribute, i) => (EntityAttribute({ + attribute, + isKey: i == 0, + isSelected: selectedKey === attribute.SchemaName + }))).join('')} +
+
+ `; +} diff --git a/Website/components/diagram/elements/EntityElement.ts b/Website/components/diagram/elements/EntityElement.ts new file mode 100644 index 0000000..7fefef9 --- /dev/null +++ b/Website/components/diagram/elements/EntityElement.ts @@ -0,0 +1,191 @@ +import { AttributeType, EntityType } from '@/lib/Types'; +import { dia } from '@joint/core'; +import { EntityBody } from './EntityBody'; + +interface IEntityElement { + entity: EntityType; +} + +export class EntityElement extends dia.Element { + + initialize(...args: Parameters) { + super.initialize(...args); + const { entity } = this.get('data') as IEntityElement; + if (entity) this.updateAttributes(entity); + } + + static getVisibleItemsAndPorts(entity: EntityType) { + // Get the visible attributes list - if not set, use default logic + const visibleAttributeSchemaNames = (entity as EntityType & { visibleAttributeSchemaNames?: string[] }).visibleAttributeSchemaNames; + + if (visibleAttributeSchemaNames) { + // Use the explicit visible attributes list + const visibleItems = entity.Attributes.filter(attr => + visibleAttributeSchemaNames.includes(attr.SchemaName) + ); + + // Always ensure primary key is first if it exists + const primaryKeyAttribute = entity.Attributes.find(attr => attr.IsPrimaryId); + if (primaryKeyAttribute && !visibleItems.some(attr => attr.IsPrimaryId)) { + visibleItems.unshift(primaryKeyAttribute); + } else if (primaryKeyAttribute) { + // Move primary key to front if it exists + const filteredItems = visibleItems.filter(attr => !attr.IsPrimaryId); + visibleItems.splice(0, visibleItems.length, primaryKeyAttribute, ...filteredItems); + } + + // Map SchemaName to port name + const portMap: Record = {}; + for (const attr of visibleItems) { + portMap[attr.SchemaName.toLowerCase()] = `port-${attr.SchemaName.toLowerCase()}`; + } + return { visibleItems, portMap }; + } + + // Fallback to default logic for entities without explicit visible list + // Get the primary key attribute + const primaryKeyAttribute = entity.Attributes.find(attr => attr.IsPrimaryId) ?? + { DisplayName: "Key", SchemaName: entity.SchemaName + "id" } as AttributeType; + + // Get custom lookup attributes (initially visible) + const customLookupAttributes = entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" && + attr.IsCustomAttribute + ); + + // Combine primary key and custom lookup attributes + const visibleItems = [ + primaryKeyAttribute, + ...customLookupAttributes + ]; + + // Map SchemaName to port name + const portMap: Record = {}; + for (const attr of visibleItems) { + portMap[attr.SchemaName.toLowerCase()] = `port-${attr.SchemaName.toLowerCase()}`; + } + return { visibleItems, portMap }; + } + + updateAttributes(entity: EntityType) { + const { visibleItems } = EntityElement.getVisibleItemsAndPorts(entity); + const selectedKey = (this.get('data') as IEntityElement & { selectedKey?: string })?.selectedKey; + const html = EntityBody({ entity, visibleItems, selectedKey }); + + // Markup + const baseMarkup = [ + { tagName: 'rect', selector: 'body' }, + { tagName: 'foreignObject', selector: 'fo' } + ]; + + this.set('markup', baseMarkup); + + const itemHeight = 28; + const itemYSpacing = 8; + const headerHeight = 80; + const startY = headerHeight + itemYSpacing * 2; + + // Calculate height dynamically based on number of visible items + const height = startY + visibleItems.length * (itemHeight + itemYSpacing) + 2; + + const leftPorts: dia.Element.Port[] = []; + const rightPorts: dia.Element.Port[] = []; + + visibleItems.forEach((attr, i) => { + const portId = `port-${attr.SchemaName.toLowerCase()}`; + const yPosition = startY + i * (itemHeight + itemYSpacing); + + const portConfig = { + id: portId, + group: attr.AttributeType === "LookupAttribute" ? 'right' : 'left', + args: { y: yPosition }, + attrs: { + circle: { + r: 6, + magnet: true, + stroke: '#31d0c6', + fill: '#fff', + strokeWidth: 2 + } + } + }; + + // Only LookupAttributes get ports (for relationships) + // Other attributes are just displayed in the entity + if (attr.AttributeType === "LookupAttribute") { + portConfig.group = 'right'; + rightPorts.push(portConfig); + } else if (i === 0) { // Key attribute gets a left port + portConfig.group = 'left'; + leftPorts.push(portConfig); + } + // Other attributes don't get ports - they're just displayed + }); + + this.set('ports', { + groups: { + left: { + position: { + name: 'left', + }, + attrs: { + circle: { + r: 6, + magnet: true, + stroke: '#31d0c6', + fill: '#fff', + strokeWidth: 2 + } + } + }, + right: { + position: { + name: 'right', + }, + attrs: { + circle: { + r: 6, + magnet: true, + stroke: '#31d0c6', + fill: '#fff', + strokeWidth: 2 + } + } + } + }, + items: [...leftPorts, ...rightPorts] + }); + + this.set('attrs', { + ...this.get('attrs'), + fo: { + refWidth: '100%', + refHeight: '100%', + html + } + }); + + this.resize(480, height); + } + + defaults() { + return { + type: 'delegate.entity', + size: { width: 480, height: 360 }, + attrs: { + body: { + refWidth: '100%', + refHeight: '100%', + fill: '#fff', + stroke: '#d1d5db', + rx: 12 + }, + fo: { + refX: 0, + refY: 0 + } + }, + markup: [] // dynamic in updateItems + }; + } +} diff --git a/Website/components/diagram/elements/SimpleEntityElement.ts b/Website/components/diagram/elements/SimpleEntityElement.ts new file mode 100644 index 0000000..f667d93 --- /dev/null +++ b/Website/components/diagram/elements/SimpleEntityElement.ts @@ -0,0 +1,147 @@ +import { EntityType } from '@/lib/Types'; +import { dia } from '@joint/core'; + +interface ISimpleEntityElement { + entity: EntityType; +} + +export class SimpleEntityElement extends dia.Element { + + initialize(...args: Parameters) { + super.initialize(...args); + const { entity } = this.get('data') as ISimpleEntityElement; + if (entity) this.updateEntity(entity); + + // Add 4 ports: top, left, right, bottom, and make them invisible + this.set('ports', { + groups: { + top: { + position: { name: 'top' }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', + }, + }, + }, + left: { + position: { name: 'left', args: { dx: 6 } }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', + }, + }, + }, + right: { + position: { name: 'right' }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', + }, + }, + }, + bottom: { + position: { name: 'bottom' }, + attrs: { + circle: { + r: 6, + magnet: true, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + visibility: 'hidden', + }, + }, + }, + }, + items: [ + { id: 'port-top', group: 'top' }, + { id: 'port-left', group: 'left' }, + { id: 'port-right', group: 'right' }, + { id: 'port-bottom', group: 'bottom' }, + ], + }); + } + + updateEntity(entity: EntityType) { + const html = this.createSimpleEntityHTML(entity); + + // Markup + const baseMarkup = [ + { tagName: 'rect', selector: 'body' }, + { tagName: 'foreignObject', selector: 'fo' } + ]; + + this.set('markup', baseMarkup); + + // Simple entity with just name - fixed size + const width = 200; + const height = 80; + + this.set('attrs', { + ...this.get('attrs'), + body: { + refWidth: '100%', + refHeight: '100%', + fill: '#fff', + stroke: '#d1d5db', + rx: 12 + }, + fo: { + refWidth: '100%', + refHeight: '100%', + html + } + }); + + this.resize(width, height); + } + + private createSimpleEntityHTML(entity: EntityType): string { + return ` +
+
+
+

${entity.DisplayName}

+

${entity.SchemaName}

+
+
+
+ `; + } + + defaults() { + return { + type: 'delegate.entity', + size: { width: 200, height: 80 }, + attrs: { + body: { + refWidth: '100%', + refHeight: '100%', + fill: '#fff', + stroke: '#d1d5db', + rx: 12 + }, + fo: { + refX: 0, + refY: 0 + } + }, + markup: [] // dynamic in updateEntity + }; + } +} \ No newline at end of file diff --git a/Website/components/diagram/elements/SquareElement.ts b/Website/components/diagram/elements/SquareElement.ts new file mode 100644 index 0000000..e17f391 --- /dev/null +++ b/Website/components/diagram/elements/SquareElement.ts @@ -0,0 +1,290 @@ +import { dia } from '@joint/core'; +import { PRESET_COLORS } from '../shared/DiagramConstants'; + +export interface SquareElementData { + id?: string; + borderColor?: string; + fillColor?: string; + borderWidth?: number; + borderType?: 'solid' | 'dashed' | 'dotted'; + opacity?: number; + isSelected?: boolean; +} + +export class SquareElement extends dia.Element { + + initialize(...args: Parameters) { + super.initialize(...args); + this.updateSquareAttrs(); + } + + updateSquareAttrs() { + const data = this.get('data') as SquareElementData || {}; + const { + borderColor = PRESET_COLORS.borders[0].value, + fillColor = PRESET_COLORS.fills[0].value, + borderWidth = 2, + borderType = 'dashed', + opacity = 0.7 + } = data; + + this.attr({ + body: { + fill: fillColor, + fillOpacity: opacity, + stroke: borderColor, + strokeWidth: borderWidth, + strokeDasharray: this.getStrokeDashArray(borderType), + rx: 8, // Rounded corners + ry: 8 + } + }); + } + + private getStrokeDashArray(borderType: string): string { + switch (borderType) { + case 'dashed': + return '10,5'; + case 'dotted': + return '2,3'; + default: + return 'none'; + } + } + + defaults() { + return { + type: 'delegate.square', + size: { width: 150, height: 100 }, + attrs: { + body: { + refWidth: '100%', + refHeight: '100%', + fill: '#f1f5f9', + fillOpacity: 0.7, + stroke: '#64748b', + strokeWidth: 2, + rx: 8, + ry: 8, + cursor: 'pointer' + }, + // Resize handles - initially hidden + 'resize-nw': { + ref: 'body', + refX: 0, + refY: 0, + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'nw-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + 'resize-ne': { + ref: 'body', + refX: '100%', + refY: 0, + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'ne-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + 'resize-sw': { + ref: 'body', + refX: 0, + refY: '100%', + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'sw-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + 'resize-se': { + ref: 'body', + refX: '100%', + refY: '100%', + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'se-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + // Side handles + 'resize-n': { + ref: 'body', + refX: '50%', + refY: 0, + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'n-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + 'resize-s': { + ref: 'body', + refX: '50%', + refY: '100%', + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 's-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + 'resize-w': { + ref: 'body', + refX: 0, + refY: '50%', + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'w-resize', + visibility: 'hidden', + pointerEvents: 'all' + }, + 'resize-e': { + ref: 'body', + refX: '100%', + refY: '50%', + x: -4, + y: -4, + width: 8, + height: 8, + fill: '#3b82f6', + stroke: '#1e40af', + strokeWidth: 1, + cursor: 'e-resize', + visibility: 'hidden', + pointerEvents: 'all' + } + }, + markup: [ + { + tagName: 'rect', + selector: 'body' + }, + // Resize handles + { tagName: 'rect', selector: 'resize-nw' }, + { tagName: 'rect', selector: 'resize-ne' }, + { tagName: 'rect', selector: 'resize-sw' }, + { tagName: 'rect', selector: 'resize-se' }, + { tagName: 'rect', selector: 'resize-n' }, + { tagName: 'rect', selector: 'resize-s' }, + { tagName: 'rect', selector: 'resize-w' }, + { tagName: 'rect', selector: 'resize-e' } + ] + }; + } + + // Method to update square properties + updateSquareData(data: Partial) { + const currentData = this.get('data') || {}; + this.set('data', { ...currentData, ...data }); + this.updateSquareAttrs(); + } + + // Get current square data + getSquareData(): SquareElementData { + return this.get('data') || {}; + } + + // Show resize handles + showResizeHandles() { + const handles = ['resize-nw', 'resize-ne', 'resize-sw', 'resize-se', 'resize-n', 'resize-s', 'resize-w', 'resize-e']; + handles.forEach(handle => { + this.attr(`${handle}/visibility`, 'visible'); + }); + + // Update data to track selection state + const currentData = this.get('data') || {}; + this.set('data', { ...currentData, isSelected: true }); + } + + // Hide resize handles + hideResizeHandles() { + const handles = ['resize-nw', 'resize-ne', 'resize-sw', 'resize-se', 'resize-n', 'resize-s', 'resize-w', 'resize-e']; + handles.forEach(handle => { + this.attr(`${handle}/visibility`, 'hidden'); + }); + + // Update data to track selection state + const currentData = this.get('data') || {}; + this.set('data', { ...currentData, isSelected: false }); + } + + // Check if resize handles are visible + areResizeHandlesVisible(): boolean { + const data = this.get('data') as SquareElementData || {}; + return data.isSelected || false; + } + + // Get the resize handle that was clicked + getResizeHandle(target: HTMLElement): string | null { + // Check if the target itself has the selector + const selector = target.getAttribute('data-selector'); + if (selector && selector.startsWith('resize-')) { + return selector; + } + + // Check parent elements for the selector + let currentElement = target.parentElement; + while (currentElement) { + const parentSelector = currentElement.getAttribute('data-selector'); + if (parentSelector && parentSelector.startsWith('resize-')) { + return parentSelector; + } + currentElement = currentElement.parentElement; + } + + // Alternative approach: check the SVG element class or tag + const tagName = target.tagName?.toLowerCase(); + if (tagName === 'rect') { + // Check if this rect is one of our resize handles + const parent = target.parentElement; + if (parent) { + // Look for JointJS generated elements with our selector + const allRects = parent.querySelectorAll('rect[data-selector^="resize-"]'); + for (let i = 0; i < allRects.length; i++) { + if (allRects[i] === target) { + return (allRects[i] as HTMLElement).getAttribute('data-selector'); + } + } + } + } + + return null; + } +} diff --git a/Website/components/diagram/elements/SquareElementView.ts b/Website/components/diagram/elements/SquareElementView.ts new file mode 100644 index 0000000..2d3ec4a --- /dev/null +++ b/Website/components/diagram/elements/SquareElementView.ts @@ -0,0 +1,42 @@ +import { dia } from '@joint/core'; + +export class SquareElementView extends dia.ElementView { + pointermove(evt: dia.Event, x: number, y: number): void { + // Check if we're in resize mode by looking at element data + const element = this.model; + const data = element.get('data') || {}; + + if (data.isSelected) { + // Don't allow normal dragging when resize handles are visible + return; + } + + // For unselected elements, use normal behavior + super.pointermove(evt, x, y); + } + + pointerdown(evt: dia.Event, x: number, y: number): void { + const target = evt.target as HTMLElement; + + // Check if clicking on a resize handle + let selector = target.getAttribute('joint-selector'); + if (!selector) { + let parent = target.parentElement; + let depth = 0; + while (parent && depth < 3) { + selector = parent.getAttribute('joint-selector'); + if (selector) break; + parent = parent.parentElement; + depth++; + } + } + + if (selector && selector.startsWith('resize-')) { + // For resize handles, don't start drag but allow event to bubble + return; + } + + // For all other clicks, use normal behavior + super.pointerdown(evt, x, y); + } +} diff --git a/Website/components/diagram/elements/TextElement.ts b/Website/components/diagram/elements/TextElement.ts new file mode 100644 index 0000000..64eb78e --- /dev/null +++ b/Website/components/diagram/elements/TextElement.ts @@ -0,0 +1,143 @@ +import { dia, shapes, util } from '@joint/core'; + +// Helper function to measure text width +function measureTextWidth(text: string, fontSize: number, fontFamily: string): number { + // Create a temporary canvas element to measure text + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + if (!context) return 8; // fallback value + + context.font = `${fontSize}px ${fontFamily}`; + const metrics = context.measureText(text); + return metrics.width / text.length; // return average character width +} + +export interface TextElementData { + text: string; + fontSize: number; + fontFamily: string; + color: string; + backgroundColor: string; + padding: number; + borderRadius: number; + textAlign: 'left' | 'center' | 'right'; +} + +export class TextElement extends shapes.standard.Rectangle { + + defaults() { + return util.defaultsDeep({ + type: 'delegate.text', + size: { width: 200, height: 40 }, + attrs: { + root: { + magnetSelector: 'false' + }, + body: { + fill: 'transparent', + stroke: 'transparent', + strokeWidth: 0, + rx: 4, + ry: 4 + }, + label: { + text: 'Text Element', + fontSize: 14, + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + fill: '#000000', + textAnchor: 'start', + textVerticalAnchor: 'top', + x: 8, + y: 8 + } + } + }, super.defaults); + } + + constructor(attributes?: dia.Element.Attributes, options?: dia.Graph.Options) { + super(attributes, options); + + // Set initial data if provided + const initialData: TextElementData = { + text: 'Text Element', + fontSize: 14, + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + color: '#000000', + backgroundColor: 'transparent', + padding: 8, + borderRadius: 4, + textAlign: 'left', + ...attributes?.data + }; + + this.set('data', initialData); + this.updateTextElement(initialData); + } + + updateTextElement(data: TextElementData) { + // Update the visual appearance based on data + this.attr({ + body: { + fill: data.backgroundColor, + rx: data.borderRadius, + ry: data.borderRadius + }, + label: { + text: data.text, + fontSize: data.fontSize, + fontFamily: data.fontFamily, + fill: data.color, + textAnchor: this.getTextAnchor(data.textAlign), + textVerticalAnchor: 'top', + x: this.getTextX(data.textAlign, data.padding), + y: data.padding + } + }); + + // Adjust element size based on text content + this.adjustSizeToText(data); + } + + private getTextAnchor(textAlign: 'left' | 'center' | 'right'): string { + switch (textAlign) { + case 'center': return 'middle'; + case 'right': return 'end'; + default: return 'start'; + } + } + + private getTextX(textAlign: 'left' | 'center' | 'right', padding: number): number { + const size = this.size(); + switch (textAlign) { + case 'center': return size.width / 2; + case 'right': return size.width - padding; + default: return padding; + } + } + + private adjustSizeToText(data: TextElementData) { + const charWidth = measureTextWidth(data.text, data.fontSize, data.fontFamily); + const textWidth = data.text.length * charWidth; + const minWidth = Math.max(textWidth + (data.padding * 2), 100); + const minHeight = Math.max(data.fontSize + (data.padding * 2), 30); + + this.resize(minWidth, minHeight); + } + + getTextData(): TextElementData { + return this.get('data') || {}; + } + + updateTextData(newData: Partial) { + const currentData = this.getTextData(); + const updatedData = { ...currentData, ...newData }; + this.set('data', updatedData); + this.updateTextElement(updatedData); + } +} + +// Register the custom element +(shapes as Record).delegate = { + ...((shapes as Record).delegate || {}), + text: TextElement +}; diff --git a/Website/components/diagram/panes/AddAttributePane.tsx b/Website/components/diagram/panes/AddAttributePane.tsx new file mode 100644 index 0000000..c8fc130 --- /dev/null +++ b/Website/components/diagram/panes/AddAttributePane.tsx @@ -0,0 +1,182 @@ +'use client'; + +import React, { useState } from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter +} from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Type, + Calendar, + Hash, + Search, + DollarSign, + ToggleLeft, + FileText, + List, + Activity, +} from 'lucide-react'; +import { AttributeType } from '@/lib/Types'; + +export interface AddAttributePaneProps { + isOpen: boolean; + onClose: () => void; + onAddAttribute: (attribute: AttributeType) => void; + entityName?: string; + availableAttributes: AttributeType[]; + visibleAttributes: AttributeType[]; +} + +const getAttributeIcon = (attributeType: string) => { + switch (attributeType) { + case 'StringAttribute': return Type; + case 'IntegerAttribute': return Hash; + case 'DecimalAttribute': return DollarSign; + case 'DateTimeAttribute': return Calendar; + case 'BooleanAttribute': return ToggleLeft; + case 'ChoiceAttribute': return List; + case 'LookupAttribute': return Search; + case 'FileAttribute': return FileText; + case 'StatusAttribute': return Activity; + default: return Type; + } +}; + +const getAttributeTypeLabel = (attributeType: string) => { + switch (attributeType) { + case 'StringAttribute': return 'Text'; + case 'IntegerAttribute': return 'Number (Whole)'; + case 'DecimalAttribute': return 'Number (Decimal)'; + case 'DateTimeAttribute': return 'Date & Time'; + case 'BooleanAttribute': return 'Yes/No'; + case 'ChoiceAttribute': return 'Choice'; + case 'LookupAttribute': return 'Lookup'; + case 'FileAttribute': return 'File'; + case 'StatusAttribute': return 'Status'; + default: return attributeType.replace('Attribute', ''); + } +}; + +export const AddAttributePane: React.FC = ({ + isOpen, + onClose, + onAddAttribute, + entityName, + availableAttributes, + visibleAttributes +}) => { + const [searchQuery, setSearchQuery] = useState(''); + + // Filter out attributes that are already visible in the diagram + const visibleAttributeNames = visibleAttributes.map(attr => attr.SchemaName); + const addableAttributes = availableAttributes.filter(attr => + !visibleAttributeNames.includes(attr.SchemaName) && + attr.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleAddAttribute = (attribute: AttributeType) => { + onAddAttribute(attribute); + setSearchQuery(''); + onClose(); + }; + + return ( + + + + + Add Existing Attribute ({availableAttributes.length}) + + {entityName ? `Select an attribute from "${entityName}" to add to the diagram.` : 'Select an attribute to add to the diagram.'} + + + +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="Search by attribute name..." + /> +
+ + {/* Available Attributes */} +
+ +
+
+ {addableAttributes.length === 0 ? ( +
+ {searchQuery ? 'No attributes found matching your search.' : 'No attributes available to add.'} +
+ ) : ( +
+ {addableAttributes.map((attribute) => { + const AttributeIcon = getAttributeIcon(attribute.AttributeType); + const typeLabel = getAttributeTypeLabel(attribute.AttributeType); + + return ( + handleAddAttribute(attribute)} + > + +
+
+ +
+
+
+ {attribute.DisplayName} +
+
+ {typeLabel} β€’ {attribute.SchemaName} +
+
+ {attribute.Description && ( + + +
+ ? +
+
+ +

{attribute.Description}

+
+
+ )} +
+
+
+ ); + })} +
+ )} +
+
+
+ + + + +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/AddEntityPane.tsx b/Website/components/diagram/panes/AddEntityPane.tsx new file mode 100644 index 0000000..c96e38d --- /dev/null +++ b/Website/components/diagram/panes/AddEntityPane.tsx @@ -0,0 +1,238 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Search } from 'lucide-react'; +import { Groups } from '@/generated/Data'; +import { EntityType, GroupType, AttributeType } from '@/lib/Types'; +import { useAttributeSelection } from '@/hooks/useAttributeSelection'; +import { AttributeSelectionPanel } from './AttributeSelectionPanel'; + +export interface AddEntityPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onAddEntity: (entity: EntityType, selectedAttributes?: string[]) => void; + currentEntities: EntityType[]; +} + +export const AddEntityPane: React.FC = ({ + isOpen, + onOpenChange, + onAddEntity, + currentEntities +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedEntity, setSelectedEntity] = useState(null); + const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); + + const { + attributeMode, + setAttributeMode, + customSelectedAttributes, + getSelectedAttributes, + initializeCustomAttributes, + toggleCustomAttribute, + resetCustomAttributes, + getAttributeModeDescription, + } = useAttributeSelection('custom-lookups'); + + // Filter groups and entities based on search term + const filteredData = useMemo(() => { + if (!searchTerm.trim()) { + return Groups; + } + + const lowerSearchTerm = searchTerm.toLowerCase(); + return Groups.map(group => ({ + ...group, + Entities: group.Entities.filter(entity => + entity.DisplayName.toLowerCase().includes(lowerSearchTerm) || + entity.SchemaName.toLowerCase().includes(lowerSearchTerm) || + group.Name.toLowerCase().includes(lowerSearchTerm) + ) + })).filter(group => + group.Name.toLowerCase().includes(lowerSearchTerm) || + group.Entities.length > 0 + ); + }, [searchTerm]); + + const handleAddEntity = (entity: EntityType) => { + const selectedAttributes = getSelectedAttributes(entity); + onAddEntity(entity, selectedAttributes); + onOpenChange(false); + setSelectedEntity(null); + resetCustomAttributes(); + }; + + const handleEntityClick = (entity: EntityType) => { + if (attributeMode === 'custom') { + setSelectedEntity(entity); + initializeCustomAttributes(entity); + } else { + handleAddEntity(entity); + } + }; + + const handleCustomAttributeToggle = (attributeSchemaName: string, checked: boolean) => { + toggleCustomAttribute(attributeSchemaName, checked); + }; + + return ( + + + + Add Entity to Diagram + +
+ {/* Attribute Selection Options */} + + + {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Groups and Entities List */} + {!selectedEntity ? ( +
+ {filteredData.map((group: GroupType) => ( +
+

+ {group.Name} +

+
+ {group.Entities.map((entity: EntityType) => { + const isAlreadyInDiagram = currentEntities.some(e => e.SchemaName === entity.SchemaName); + return ( + + ); + })} +
+
+ ))} + {filteredData.length === 0 && ( +
+ No entities found matching your search. +
+ )} +
+ ) : ( + /* Custom Attribute Selection View */ +
+
+
+

Configure {selectedEntity.DisplayName}

+

Select attributes to include

+
+ +
+ +
+ {selectedEntity.Attributes.map((attribute: AttributeType) => { + const isChecked = customSelectedAttributes.includes(attribute.SchemaName); + const isPrimaryKey = attribute.IsPrimaryId; + + return ( +
+ + handleCustomAttributeToggle(attribute.SchemaName, checked) + } + /> +
+
+ {attribute.DisplayName} + {isPrimaryKey && ( + + Primary Key + + )} + {attribute.AttributeType === "LookupAttribute" && ( + + Lookup + + )} +
+

{attribute.SchemaName}

+ {attribute.Description && ( +

{attribute.Description}

+ )} +
+
+ ); + })} +
+ +
+ +
+
+ )} +
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/AddGroupPane.tsx b/Website/components/diagram/panes/AddGroupPane.tsx new file mode 100644 index 0000000..031c55d --- /dev/null +++ b/Website/components/diagram/panes/AddGroupPane.tsx @@ -0,0 +1,182 @@ +'use client'; + +import React, { useState, useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Search, Database } from 'lucide-react'; +import { Groups } from '@/generated/Data'; +import { EntityType, GroupType } from '@/lib/Types'; +import { useAttributeSelection } from '@/hooks/useAttributeSelection'; +import { AttributeSelectionPanel } from './AttributeSelectionPanel'; + +export interface AddGroupPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onAddGroup: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; + currentEntities: EntityType[]; +} + +export const AddGroupPane: React.FC = ({ + isOpen, + onOpenChange, + onAddGroup, + currentEntities +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [isAttributeSettingsExpanded, setIsAttributeSettingsExpanded] = useState(false); + + const { + attributeMode, + setAttributeMode, + getSelectedAttributes, + getAttributeModeDescription, + } = useAttributeSelection('custom-lookups'); + + // Filter groups based on search term + const filteredGroups = useMemo(() => { + if (!searchTerm.trim()) { + return Groups; + } + + const lowerSearchTerm = searchTerm.toLowerCase(); + return Groups.filter(group => + group.Name.toLowerCase().includes(lowerSearchTerm) + ); + }, [searchTerm]); + + const handleAddGroup = (group: GroupType) => { + // Create attribute selection map for all entities in the group + const selectedAttributes: { [entitySchemaName: string]: string[] } = {}; + + group.Entities.forEach(entity => { + selectedAttributes[entity.SchemaName] = getSelectedAttributes(entity); + }); + + onAddGroup(group, selectedAttributes); + onOpenChange(false); + }; + + // Calculate how many entities from each group are already in the diagram + const getGroupStatus = (group: GroupType) => { + const entitiesInDiagram = group.Entities.filter(entity => + currentEntities.some(e => e.SchemaName === entity.SchemaName) + ).length; + const totalEntities = group.Entities.length; + return { entitiesInDiagram, totalEntities }; + }; + + return ( + + + + Add Group to Diagram + +
+ {/* Attribute Selection Options */} + + + {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Groups List */} +
+ {filteredGroups.map((group: GroupType) => { + const { entitiesInDiagram, totalEntities } = getGroupStatus(group); + const isFullyInDiagram = entitiesInDiagram === totalEntities && totalEntities > 0; + const isPartiallyInDiagram = entitiesInDiagram > 0 && entitiesInDiagram < totalEntities; + + return ( +
+
+
+ +
+

{group.Name}

+

+ {group.Entities.length} entities +

+
+
+
+
+ {entitiesInDiagram}/{totalEntities} entities +
+ {isFullyInDiagram && ( + + All in Diagram + + )} + {isPartiallyInDiagram && ( + + Partially Added + + )} +
+
+ +
+ {group.Entities.slice(0, 5).map((entity: EntityType) => { + const isInDiagram = currentEntities.some(e => e.SchemaName === entity.SchemaName); + return ( + + {entity.DisplayName} + + ); + })} + {group.Entities.length > 5 && ( + + +{group.Entities.length - 5} more + + )} +
+ + +
+ ); + })} + {filteredGroups.length === 0 && ( +
+ No groups found matching your search. +
+ )} +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/AttributeSelectionPanel.tsx b/Website/components/diagram/panes/AttributeSelectionPanel.tsx new file mode 100644 index 0000000..d917981 --- /dev/null +++ b/Website/components/diagram/panes/AttributeSelectionPanel.tsx @@ -0,0 +1,97 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ChevronDown, ChevronRight, Settings } from 'lucide-react'; +import { AttributeSelectionMode } from '@/hooks/useAttributeSelection'; + +export interface AttributeSelectionPanelProps { + attributeMode: AttributeSelectionMode; + setAttributeMode: (mode: AttributeSelectionMode) => void; + isExpanded: boolean; + setIsExpanded: (expanded: boolean) => void; + getAttributeModeDescription: (mode: AttributeSelectionMode) => string; +} + +export const AttributeSelectionPanel: React.FC = ({ + attributeMode, + setAttributeMode, + isExpanded, + setIsExpanded, + getAttributeModeDescription +}) => { + return ( + + + + + +
+ +
+
+ setAttributeMode('minimal')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('custom-lookups')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('all-lookups')} + className="w-4 h-4" + /> + +
+
+ setAttributeMode('custom')} + className="w-4 h-4" + /> + +
+
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/EntityActionsPane.tsx b/Website/components/diagram/panes/EntityActionsPane.tsx new file mode 100644 index 0000000..532c5e8 --- /dev/null +++ b/Website/components/diagram/panes/EntityActionsPane.tsx @@ -0,0 +1,282 @@ +'use client'; + +import React, { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + Trash2, + Plus, + ChevronDown, + ChevronRight, + Type, + Calendar, + Hash, + Search, + DollarSign, + ToggleLeft, + FileText, + List, + Activity +} from 'lucide-react'; +import { EntityType, AttributeType } from '@/lib/Types'; + +export interface EntityActionsPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedEntity: EntityType | null; + onDeleteEntity: () => void; + onAddAttribute?: (attribute: AttributeType) => void; + onRemoveAttribute?: (attribute: AttributeType) => void; + availableAttributes?: AttributeType[]; + visibleAttributes?: AttributeType[]; +} + +const getAttributeIcon = (attributeType: string) => { + switch (attributeType) { + case 'StringAttribute': return Type; + case 'IntegerAttribute': return Hash; + case 'DecimalAttribute': return DollarSign; + case 'DateTimeAttribute': return Calendar; + case 'BooleanAttribute': return ToggleLeft; + case 'ChoiceAttribute': return List; + case 'LookupAttribute': return Search; + case 'FileAttribute': return FileText; + case 'StatusAttribute': return Activity; + default: return Type; + } +}; + +const getAttributeTypeLabel = (attributeType: string) => { + switch (attributeType) { + case 'StringAttribute': return 'Text'; + case 'IntegerAttribute': return 'Number (Whole)'; + case 'DecimalAttribute': return 'Number (Decimal)'; + case 'DateTimeAttribute': return 'Date & Time'; + case 'BooleanAttribute': return 'Yes/No'; + case 'ChoiceAttribute': return 'Choice'; + case 'LookupAttribute': return 'Lookup'; + case 'FileAttribute': return 'File'; + case 'StatusAttribute': return 'Status'; + default: return attributeType.replace('Attribute', ''); + } +}; + +export const EntityActionsPane: React.FC = ({ + isOpen, + onOpenChange, + selectedEntity, + onDeleteEntity, + onAddAttribute, + onRemoveAttribute, + availableAttributes = [], + visibleAttributes = [] +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isAttributesExpanded, setIsAttributesExpanded] = useState(false); + const [isRemoveAttributesExpanded, setIsRemoveAttributesExpanded] = useState(false); + + // Filter out attributes that are already visible in the diagram + const visibleAttributeNames = visibleAttributes.map(attr => attr.SchemaName); + const addableAttributes = availableAttributes.filter(attr => + !visibleAttributeNames.includes(attr.SchemaName) && + attr.DisplayName.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const handleAddAttribute = (attribute: AttributeType) => { + if (onAddAttribute) { + onAddAttribute(attribute); + setSearchQuery(''); + setIsAttributesExpanded(false); + } + }; + + const handleRemoveAttribute = (attribute: AttributeType) => { + if (onRemoveAttribute) { + onRemoveAttribute(attribute); + } + }; + + // Filter removable attributes (exclude primary key) + const removableAttributes = visibleAttributes.filter(attr => + !attr.IsPrimaryId // Don't allow removing primary key - all other visible attributes can be removed + ); + + return ( + + + + + Entity Actions + + + {selectedEntity && ( +
+
+

{selectedEntity.DisplayName}

+

{selectedEntity.SchemaName}

+ {selectedEntity.Description && ( +

{selectedEntity.Description}

+ )} +
+ +
+
+

Actions

+ + {/* Add Attribute Section */} + {onAddAttribute && availableAttributes.length > 0 && ( + + + + + + {/* Search */} +
+ setSearchQuery(e.target.value)} + placeholder="Search attributes..." + className="text-sm" + /> +
+ + {/* Available Attributes */} +
+ {addableAttributes.length === 0 ? ( +
+ {searchQuery ? 'No attributes found.' : 'No attributes available.'} +
+ ) : ( + addableAttributes.map((attribute) => { + const AttributeIcon = getAttributeIcon(attribute.AttributeType); + const typeLabel = getAttributeTypeLabel(attribute.AttributeType); + + return ( +
handleAddAttribute(attribute)} + > + +
+
+ {attribute.DisplayName} +
+
+ {typeLabel} +
+
+ {attribute.Description && ( + + +
+ ? +
+
+ +

{attribute.Description}

+
+
+ )} +
+ ); + }) + )} +
+
+
+ )} + + {/* Remove Attribute Section */} + {onRemoveAttribute && removableAttributes.length > 0 && ( + + + + + + {/* Removable Attributes */} +
+ {removableAttributes.map((attribute) => { + const AttributeIcon = getAttributeIcon(attribute.AttributeType); + const typeLabel = getAttributeTypeLabel(attribute.AttributeType); + + return ( +
handleRemoveAttribute(attribute)} + > + +
+
+ {attribute.DisplayName} +
+
+ {typeLabel} +
+
+ +
+ ); + })} +
+
+ Note: Primary key cannot be removed. +
+
+
+ )} + + +
+
+ +
+
+

Entity Information

+
+

Attributes: {selectedEntity.Attributes.length}

+

Relationships: {selectedEntity.Relationships?.length || 0}

+

Is Activity: {selectedEntity.IsActivity ? 'Yes' : 'No'}

+

Audit Enabled: {selectedEntity.IsAuditEnabled ? 'Yes' : 'No'}

+
+
+
+
+ )} +
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/LinkPropertiesPane.tsx b/Website/components/diagram/panes/LinkPropertiesPane.tsx new file mode 100644 index 0000000..06cbff3 --- /dev/null +++ b/Website/components/diagram/panes/LinkPropertiesPane.tsx @@ -0,0 +1,224 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { dia } from '@joint/core'; +import { PRESET_COLORS, LINE_STYLES, STROKE_WIDTHS } from '../shared/DiagramConstants'; + +interface LinkPropertiesPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedLink: dia.Link | null; + onUpdateLink: (linkId: string | number, properties: LinkProperties) => void; +} + +export type { LinkPropertiesPaneProps }; + +export interface LinkProperties { + color: string; + strokeWidth: number; + strokeDasharray?: string; + label?: string; +} + +export const LinkPropertiesPane: React.FC = ({ + isOpen, + onOpenChange, + selectedLink, + onUpdateLink +}) => { + const [color, setColor] = useState(PRESET_COLORS.borders[1].value); // Default to Blue + const [strokeWidth, setStrokeWidth] = useState(2); + const [lineStyle, setLineStyle] = useState('none'); + const [label, setLabel] = useState(''); + const [customColor, setCustomColor] = useState(PRESET_COLORS.borders[1].value); + + // Load current link properties when selectedLink changes + useEffect(() => { + if (selectedLink) { + const currentColor = selectedLink.attr('line/stroke') || PRESET_COLORS.borders[1].value; + const currentStrokeWidth = selectedLink.attr('line/strokeWidth') || 2; + const currentDasharray = selectedLink.attr('line/strokeDasharray') || 'none'; + const currentLabel = selectedLink.label(0)?.attrs?.text?.text || ''; + + setColor(currentColor); + setCustomColor(currentColor); + setStrokeWidth(currentStrokeWidth); + setLineStyle(currentDasharray === '' ? 'none' : currentDasharray); + setLabel(currentLabel); + } + }, [selectedLink]); + + // Apply changes immediately when any property changes + useEffect(() => { + if (selectedLink) { + const properties: LinkProperties = { + color, + strokeWidth, + strokeDasharray: lineStyle && lineStyle !== 'none' ? lineStyle : undefined, + label: label || undefined + }; + onUpdateLink(selectedLink.id, properties); + } + }, [color, strokeWidth, lineStyle, label, selectedLink, onUpdateLink]); + + const handleClearLabel = () => { + setLabel(''); + }; + + const handleUseRelationshipName = () => { + const relationshipName = getRelationshipName(); + if (relationshipName) { + setLabel(relationshipName); + } + }; + + const getRelationshipName = () => { + if (!selectedLink) return null; + + // Try to get the relationship name stored on the link + const relationshipName = selectedLink.get('relationshipName'); + return relationshipName || null; + }; + + const handleColorChange = (newColor: string) => { + setColor(newColor); + setCustomColor(newColor); + }; + + return ( + + + + Link Properties + + Customize the appearance and label of the relationship link. + + + +
+ {/* Label Section */} +
+ + setLabel(e.target.value)} + /> +
+ + +
+

+ Optional text to display on the link +

+
+ + + + {/* Color Section */} +
+ +
+ {PRESET_COLORS.borders.map((presetColor) => ( + + ))} +
+ +
+ handleColorChange(e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleColorChange(e.target.value)} + placeholder="#3b82f6" + className="flex-1 text-sm" + /> +
+
+ + + + {/* Line Style Section */} +
+ + +
+ + {/* Stroke Width Section */} +
+ + +
+ + +
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/ResetToGroupPane.tsx b/Website/components/diagram/panes/ResetToGroupPane.tsx new file mode 100644 index 0000000..968a00d --- /dev/null +++ b/Website/components/diagram/panes/ResetToGroupPane.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Button } from '@/components/ui/button'; +import { RotateCcw } from 'lucide-react'; +import { Groups } from '../../../generated/Data'; +import { GroupType } from '@/lib/Types'; + +interface IResetToGroupPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onResetToGroup: (group: GroupType) => void; +} + +export const ResetToGroupPane = ({ isOpen, onOpenChange, onResetToGroup }: IResetToGroupPaneProps) => { + const [selectedGroupForReset, setSelectedGroupForReset] = useState(''); + + const handleResetToGroup = () => { + if (!selectedGroupForReset) return; + + const selectedGroup = Groups.find(group => group.Name === selectedGroupForReset); + if (selectedGroup) { + onResetToGroup(selectedGroup); + onOpenChange(false); + setSelectedGroupForReset(''); + } + }; + + const handleCancel = () => { + onOpenChange(false); + setSelectedGroupForReset(''); + }; + + return ( + + + + Reset Diagram to Group + + Choose a group to reset the diagram and show only entities from that group. + This will clear the current diagram and add all entities from the selected group. + + + +
+
+ + +
+ +
+
+
+

Warning

+

This will clear all current elements from your diagram and replace them with entities from the selected group.

+
+
+
+ +
+ + +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/SquarePropertiesPane.tsx b/Website/components/diagram/panes/SquarePropertiesPane.tsx new file mode 100644 index 0000000..77e7b06 --- /dev/null +++ b/Website/components/diagram/panes/SquarePropertiesPane.tsx @@ -0,0 +1,244 @@ +import React, { useState, useEffect } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Square, Trash2 } from 'lucide-react'; +import { SquareElement, SquareElementData } from '../elements/SquareElement'; +import { PRESET_COLORS } from '../shared/DiagramConstants'; + +export interface SquarePropertiesPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedSquare: SquareElement | null; + onDeleteSquare?: () => void; +} + +export const SquarePropertiesPane: React.FC = ({ + isOpen, + onOpenChange, + selectedSquare, + onDeleteSquare +}) => { + const [squareData, setSquareData] = useState({ + borderColor: PRESET_COLORS.borders[0].value, + fillColor: PRESET_COLORS.fills[0].value, + borderWidth: 2, + borderType: 'dashed', + opacity: 0.7 + }); + + // Update local state when selected square changes + useEffect(() => { + if (selectedSquare) { + const data = selectedSquare.getSquareData(); + setSquareData({ + borderColor: data.borderColor || PRESET_COLORS.borders[0].value, + fillColor: data.fillColor || PRESET_COLORS.fills[0].value, + borderWidth: data.borderWidth || 2, + borderType: data.borderType || 'dashed', + opacity: data.opacity || 0.7 + }); + } + }, [selectedSquare]); + + const handleDataChange = (key: keyof SquareElementData, value: string | number) => { + const newData = { ...squareData, [key]: value }; + setSquareData(newData); + + // Apply changes immediately to the square + if (selectedSquare) { + selectedSquare.updateSquareData(newData); + } + }; + + const handlePresetFillColor = (color: string) => { + handleDataChange('fillColor', color); + }; + + const handlePresetBorderColor = (color: string) => { + handleDataChange('borderColor', color); + }; + + const handleDeleteSquare = () => { + if (selectedSquare && onDeleteSquare) { + onDeleteSquare(); + onOpenChange(false); // Close the panel after deletion + } + }; + + if (!selectedSquare) { + return null; + } + + return ( + + + + + + Square Properties + + + +
+ {/* Fill Color Section */} +
+ +
+ {PRESET_COLORS.fills.map((color) => ( + + ))} +
+
+ handleDataChange('fillColor', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('fillColor', e.target.value)} + placeholder="#f1f5f9" + className="flex-1 text-sm" + /> +
+
+ + + + {/* Border Section */} +
+ + + {/* Border Color */} +
+ +
+ {PRESET_COLORS.borders.map((color) => ( + + ))} +
+
+ handleDataChange('borderColor', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('borderColor', e.target.value)} + placeholder="#64748b" + className="flex-1 text-sm" + /> +
+
+ + {/* Border Width */} +
+ + handleDataChange('borderWidth', parseInt(e.target.value) || 0)} + className="text-sm" + /> +
+ + {/* Border Type */} +
+ + +
+
+ + + + {/* Opacity Section */} +
+ +
+ handleDataChange('opacity', parseFloat(e.target.value))} + className="w-full" + /> +
+ {Math.round((squareData.opacity || 0.7) * 100)}% +
+
+
+ + + + {/* Delete Section */} +
+ + +
+
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/TextPropertiesPane.tsx b/Website/components/diagram/panes/TextPropertiesPane.tsx new file mode 100644 index 0000000..7a06df3 --- /dev/null +++ b/Website/components/diagram/panes/TextPropertiesPane.tsx @@ -0,0 +1,279 @@ +import React, { useState, useEffect } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Type, Trash2 } from 'lucide-react'; +import { TextElement, TextElementData } from '../elements/TextElement'; + +export interface TextPropertiesPaneProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedText: TextElement | null; + onDeleteText?: () => void; +} + +const FONT_SIZES = [ + { name: 'Small', value: 12 }, + { name: 'Normal', value: 14 }, + { name: 'Medium', value: 16 }, + { name: 'Large', value: 20 }, + { name: 'Extra Large', value: 24 } +]; + +const FONT_FAMILIES = [ + { name: 'System Font', value: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif' }, + { name: 'Arial', value: 'Arial, sans-serif' }, + { name: 'Helvetica', value: 'Helvetica, Arial, sans-serif' }, + { name: 'Times', value: 'Times, "Times New Roman", serif' }, + { name: 'Courier', value: 'Courier, "Courier New", monospace' } +]; + +export const TextPropertiesPane: React.FC = ({ + isOpen, + onOpenChange, + selectedText, + onDeleteText +}) => { + const [textData, setTextData] = useState({ + text: 'Text Element', + fontSize: 14, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + color: '#000000', + backgroundColor: 'transparent', + padding: 8, + borderRadius: 4, + textAlign: 'left' + }); + + // Update local state when selected text changes + useEffect(() => { + if (selectedText) { + const data = selectedText.getTextData(); + setTextData({ + text: data.text || 'Text Element', + fontSize: data.fontSize || 14, + fontFamily: data.fontFamily || '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + color: data.color || '#000000', + backgroundColor: data.backgroundColor || 'transparent', + padding: data.padding || 8, + borderRadius: data.borderRadius || 4, + textAlign: data.textAlign || 'left' + }); + } + }, [selectedText]); + + // Apply changes immediately when any property changes + useEffect(() => { + if (selectedText) { + selectedText.updateTextData(textData); + } + }, [textData, selectedText]); + + const handleDataChange = (key: keyof TextElementData, value: string | number) => { + setTextData(prev => ({ ...prev, [key]: value })); + }; + + const handleDeleteText = () => { + if (selectedText && onDeleteText) { + onDeleteText(); + onOpenChange(false); + } + }; + + if (!selectedText) { + return null; + } + + return ( + + + + + + Text Properties + + + +
+ {/* Text Content */} +
+ + ) => handleDataChange('text', e.target.value)} + /> +
+ + + + {/* Typography */} +
+ + + {/* Font Family */} +
+ + +
+ + {/* Font Size */} +
+ + +
+ + {/* Text Alignment */} +
+ + +
+
+ + + + {/* Colors */} +
+ + + {/* Text Color */} +
+ +
+ handleDataChange('color', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('color', e.target.value)} + placeholder="#000000" + className="flex-1 text-sm" + /> +
+
+ + {/* Background Color */} +
+ +
+ handleDataChange('backgroundColor', e.target.value)} + className="w-12 h-8 p-1 border-2" + /> + handleDataChange('backgroundColor', e.target.value)} + placeholder="transparent" + className="flex-1 text-sm" + /> +
+
+
+ + + + {/* Layout */} +
+ + + {/* Padding */} +
+ + handleDataChange('padding', parseInt(e.target.value) || 0)} + className="text-sm" + /> +
+ + {/* Border Radius */} +
+ + handleDataChange('borderRadius', parseInt(e.target.value) || 0)} + className="text-sm" + /> +
+
+ + + + {/* Delete Section */} + {onDeleteText && ( +
+ + +
+ )} +
+
+
+ ); +}; diff --git a/Website/components/diagram/panes/index.ts b/Website/components/diagram/panes/index.ts new file mode 100644 index 0000000..ff2ccf1 --- /dev/null +++ b/Website/components/diagram/panes/index.ts @@ -0,0 +1,14 @@ +export { AddEntityPane } from './AddEntityPane'; +export { AddGroupPane } from './AddGroupPane'; +export { EntityActionsPane } from './EntityActionsPane'; +export { SquarePropertiesPane } from './SquarePropertiesPane'; +export { LinkPropertiesPane } from './LinkPropertiesPane'; +export { TextPropertiesPane } from './TextPropertiesPane'; +export { ResetToGroupPane } from './ResetToGroupPane'; + +export type { AddEntityPaneProps } from './AddEntityPane'; +export type { AddGroupPaneProps } from './AddGroupPane'; +export type { EntityActionsPaneProps } from './EntityActionsPane'; +export type { SquarePropertiesPaneProps } from './SquarePropertiesPane'; +export type { TextPropertiesPaneProps } from './TextPropertiesPane'; +export type { LinkPropertiesPaneProps, LinkProperties } from './LinkPropertiesPane'; diff --git a/Website/components/diagram/renderers/DetailedDiagramRender.ts b/Website/components/diagram/renderers/DetailedDiagramRender.ts new file mode 100644 index 0000000..9b209dc --- /dev/null +++ b/Website/components/diagram/renderers/DetailedDiagramRender.ts @@ -0,0 +1,208 @@ +// DetailedDiagramRender.ts +import { dia, shapes } from '@joint/core'; +import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; +import { EntityElement } from '../elements/EntityElement'; +import { AttributeType, EntityType } from '@/lib/Types'; + +export class DetailedDiagramRender extends DiagramRenderer { + + onDocumentClick(event: MouseEvent): void { + const target = (event.target as HTMLElement).closest('button[data-schema-name]') as HTMLElement; + + if (target) { + const schemaName = target.dataset.schemaName!; + // Toggle functionality: if clicking the same key, deselect it + const currentSelectedKey = this.getCurrentSelectedKey(); + if (currentSelectedKey === schemaName) { + this.setAndTrackSelectedKey(undefined); + } else { + this.setAndTrackSelectedKey(schemaName); + } + } else { + this.setAndTrackSelectedKey(undefined); + } + } + + createEntity(entity: EntityType, position: { x: number, y: number }) { + const { portMap } = EntityElement.getVisibleItemsAndPorts(entity); + const entityElement = new EntityElement({ + position, + data: { entity } + }); + + entityElement.addTo(this.graph); + return { element: entityElement, portMap }; + } + + createLinks(entity: EntityType, entityMap: Map, allEntities: EntityType[]) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) return; + + const { portMap } = entityInfo; + const visibleItems = this.getVisibleAttributes(entity); + + for (let i = 1; i < visibleItems.length; i++) { + const attr = visibleItems[i]; + if (attr.AttributeType !== 'LookupAttribute') continue; + + for (const target of attr.Targets) { + const targetInfo = entityMap.get(target.Name); + if (!targetInfo) continue; + + const sourcePort = portMap[attr.SchemaName.toLowerCase()]; + const targetPort = targetInfo.portMap[`${target.Name.toLowerCase()}id`]; + if (!sourcePort || !targetPort) continue; + + // Find the corresponding relationship for this lookup attribute + // Check both source and target entities as the relationship could be defined on either side + let relationship = entity.Relationships.find(rel => + rel.TableSchema === target.Name && + rel.Name === attr.SchemaName + ); + + // If not found in source entity, check the target entity + if (!relationship) { + const targetEntity = allEntities.find(e => e.SchemaName === target.Name); + if (targetEntity) { + // Look for the reverse relationship in the target entity + relationship = targetEntity.Relationships.find(rel => + rel.TableSchema === entity.SchemaName + ); + } + } + + const link = new shapes.standard.Link({ + source: { id: entityInfo.element.id, port: sourcePort }, + target: { id: targetInfo.element.id, port: targetPort }, + router: { name: 'avoid', args: {} }, + connector: { name: 'jumpover', args: { radius: 8 } }, + attrs: { + line: { + stroke: '#42a5f5', + strokeWidth: 2, + sourceMarker: { + type: 'ellipse', + cx: -6, + cy: 0, + rx: 4, + ry: 4, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + }, + targetMarker: { + type: 'path', + d: 'M 6 -3 L 0 0 L 6 3 Z', + fill: '#42a5f5', + stroke: '#42a5f5' + } + } + } + }); + + // Store relationship metadata on the link + if (relationship) { + link.set('relationshipName', relationship.LookupDisplayName); + link.set('relationshipSchema', relationship.RelationshipSchema); + link.set('sourceEntity', entity.SchemaName); + link.set('targetEntity', target.Name); + } + + link.addTo(this.graph); + } + } + } + + highlightSelectedKey(graph: dia.Graph, entities: EntityType[], selectedKey: string): void { + // Find the attribute and its entity + let selectedAttribute: AttributeType | undefined; + let entityWithAttribute: EntityType | undefined; + + for (const entity of entities) { + const attribute = entity.Attributes.find(a => a.SchemaName === selectedKey); + if (attribute) { + selectedAttribute = attribute; + entityWithAttribute = entity; + break; + } + } + + if (!selectedAttribute || !entityWithAttribute) return; + + // Reset all links to default color first + graph.getLinks().forEach(link => { + link.attr('line/stroke', '#42a5f5'); + link.attr('line/strokeWidth', 2); + link.attr('line/targetMarker/stroke', '#42a5f5'); + link.attr('line/targetMarker/fill', '#42a5f5'); + link.attr('line/sourceMarker/stroke', '#42a5f5'); + }); + + // Find the entity element + const entityElement = graph.getElements().find(el => + el.get('type') === 'delegate.entity' && + el.get('data')?.entity?.SchemaName === entityWithAttribute.SchemaName + ); + + if (!entityElement) return; + + const portId = `port-${selectedKey.toLowerCase()}`; + + // Highlight different types of relationships based on attribute type + if (selectedAttribute.IsPrimaryId) { + // For primary keys, highlight incoming relationships (where this entity is the target) + graph.getLinks().forEach(link => { + const target = link.target(); + if (target.id === entityElement.id && target.port === portId) { + link.attr('line/stroke', '#ff6b6b'); + link.attr('line/strokeWidth', 4); + link.attr('line/targetMarker/stroke', '#ff6b6b'); + link.attr('line/targetMarker/fill', '#ff6b6b'); + link.attr('line/sourceMarker/stroke', '#ff6b6b'); + } + }); + } else if (selectedAttribute.AttributeType === 'LookupAttribute') { + // For lookup attributes, highlight outgoing relationships (where this entity is the source) + graph.getLinks().forEach(link => { + const source = link.source(); + if (source.id === entityElement.id && source.port === portId) { + link.attr('line/stroke', '#ff6b6b'); + link.attr('line/strokeWidth', 4); + link.attr('line/targetMarker/stroke', '#ff6b6b'); + link.attr('line/targetMarker/fill', '#ff6b6b'); + link.attr('line/sourceMarker/stroke', '#ff6b6b'); + } + }); + } + } + + updateEntityAttributes(graph: dia.Graph, selectedKey: string | undefined): void { + graph.getElements().forEach(el => { + if (el.get('type') === 'delegate.entity') { + const currentData = el.get('data'); + el.set('data', { ...currentData, selectedKey }); + + const entityElement = el as unknown as EntityElement; + if (entityElement.updateAttributes) { + entityElement.updateAttributes(currentData.entity); + } + } + }); + } + + onLinkClick(linkView: dia.LinkView, evt: dia.Event): void { + evt.stopPropagation(); + + const link = linkView.model as dia.Link; + if (this.onLinkClickHandler) { + this.onLinkClickHandler(link); + } else { + // Fallback alert if no handler is provided + alert('Relationship info (detailed view)'); + } + } + + getVisibleAttributes(entity: EntityType): AttributeType[] { + return EntityElement.getVisibleItemsAndPorts(entity).visibleItems; + } +} diff --git a/Website/components/diagram/renderers/SimpleDiagramRender.ts b/Website/components/diagram/renderers/SimpleDiagramRender.ts new file mode 100644 index 0000000..f391085 --- /dev/null +++ b/Website/components/diagram/renderers/SimpleDiagramRender.ts @@ -0,0 +1,158 @@ +// SimpleDiagramRenderer.ts +import { dia, shapes } from '@joint/core'; +import { SimpleEntityElement } from '@/components/diagram/elements/SimpleEntityElement'; +import { DiagramRenderer, IPortMap } from '../DiagramRenderer'; +import { AttributeType, EntityType } from '@/lib/Types'; + +export class SimpleDiagramRenderer extends DiagramRenderer { + + onDocumentClick(): void { } + + createEntity(entity: EntityType, position: { x: number, y: number }) { + const entityElement = new SimpleEntityElement({ + position, + data: { entity } + }); + + entityElement.addTo(this.graph); + + // 4-directional port map + const portMap = { + top: 'port-top', + right: 'port-right', + bottom: 'port-bottom', + left: 'port-left' + }; + + return { element: entityElement, portMap }; + } + + createLinks(entity: EntityType, entityMap: Map, allEntities: EntityType[]) { + const entityInfo = entityMap.get(entity.SchemaName); + if (!entityInfo) return; + + // Get visible attributes for this entity + const visibleAttributes = this.getVisibleAttributes(entity); + + for (const attr of visibleAttributes) { + if (attr.AttributeType !== 'LookupAttribute') continue; + + for (const target of attr.Targets) { + const targetInfo = entityMap.get(target.Name); + if (!targetInfo) continue; + + const isSelfRef = entityInfo.element.id === targetInfo.element.id; + + // Find the corresponding relationship for this lookup attribute + // Check both source and target entities as the relationship could be defined on either side + let relationship = entity.Relationships.find(rel => + rel.TableSchema === target.Name && + rel.Name === attr.SchemaName + ); + + // If not found in source entity, check the target entity + if (!relationship) { + const targetEntity = allEntities.find(e => e.SchemaName === target.Name); + if (targetEntity) { + // Look for the reverse relationship in the target entity + relationship = targetEntity.Relationships.find(rel => + rel.TableSchema === entity.SchemaName + ); + } + } + + const link = new shapes.standard.Link({ + source: isSelfRef + ? { id: entityInfo.element.id, port: entityInfo.portMap.right } + : { id: entityInfo.element.id }, + target: isSelfRef + ? { id: targetInfo.element.id, port: targetInfo.portMap.left } + : { id: targetInfo.element.id }, + router: { name: 'avoid', args: {} }, + connector: { name: 'jumpover', args: { radius: 8 } }, + attrs: { + line: { + stroke: '#42a5f5', + strokeWidth: 2, + sourceMarker: { + type: 'ellipse', + cx: -6, + cy: 0, + rx: 4, + ry: 4, + fill: '#fff', + stroke: '#42a5f5', + strokeWidth: 2, + }, + targetMarker: { + type: 'path', + d: 'M 6 -3 L 0 0 L 6 3 Z', + fill: '#42a5f5', + stroke: '#42a5f5' + } + } + } + }); + + // Store relationship metadata on the link + if (relationship) { + link.set('relationshipName', relationship.LookupDisplayName); + link.set('relationshipSchema', relationship.RelationshipSchema); + link.set('sourceEntity', entity.SchemaName); + link.set('targetEntity', target.Name); + } + + link.addTo(this.graph); + } + } + } + + highlightSelectedKey(graph: dia.Graph, entities: EntityType[], selectedKey: string): void { + const entity = entities.find(e => + e.Attributes.some(a => a.SchemaName === selectedKey && a.IsPrimaryId) + ); + if (!entity) return; + + const entityId = graph.getElements().find(el => + el.get('type') === 'delegate.entity' && + el.get('data')?.entity?.SchemaName === entity.SchemaName + )?.id; + + if (!entityId) return; + + graph.getLinks().forEach(link => { + const target = link.target(); + if (target.id === entityId) { + link.attr('line/stroke', '#ff6b6b'); + link.attr('line/strokeWidth', 4); + } + }); + } + + updateEntityAttributes(): void { + // Simple entities don't display key attributes, so nothing to do + } + + onLinkClick(linkView: dia.LinkView, evt: dia.Event): void { + evt.stopPropagation(); + + const link = linkView.model as dia.Link; + if (this.onLinkClickHandler) { + this.onLinkClickHandler(link); + } else { + // Fallback alert if no handler is provided + alert('Relationship info (simple view)'); + } + } + + getVisibleAttributes(entity: EntityType): AttributeType[] { + // For simple entities, use the visibleAttributeSchemaNames to determine which attributes are "visible" + // If no visibleAttributeSchemaNames is set, only show primary key attributes by default + const visibleSchemaNames = entity.visibleAttributeSchemaNames || + entity.Attributes.filter(attr => attr.IsPrimaryId).map(attr => attr.SchemaName); + + return entity.Attributes.filter(attr => + visibleSchemaNames.includes(attr.SchemaName) + ); + } +} diff --git a/Website/components/diagram/shared/DiagramConstants.ts b/Website/components/diagram/shared/DiagramConstants.ts new file mode 100644 index 0000000..a917045 --- /dev/null +++ b/Website/components/diagram/shared/DiagramConstants.ts @@ -0,0 +1,30 @@ +// Shared color and style constants for diagram elements + +export const PRESET_COLORS = { + fills: [ + { name: 'Light Green', value: '#dcfce7' }, + { name: 'Light Blue', value: '#dbeafe' }, + { name: 'Light Yellow', value: '#fefce8' }, + { name: 'Light Red', value: '#fee2e2' }, + { name: 'Light Purple', value: '#f3e8ff' }, + ], + borders: [ + { name: 'Green', value: '#22c55e' }, + { name: 'Blue', value: '#3b82f6' }, + { name: 'Yellow', value: '#eab308' }, + { name: 'Red', value: '#ef4444' }, + { name: 'Purple', value: '#a855f7' }, + ] +}; + +export const LINE_STYLES = [ + { name: 'Solid', value: 'none' }, + { name: 'Dashed', value: '5,5' }, + { name: 'Dotted', value: '2,2' } +]; + +export const STROKE_WIDTHS = [ + { name: 'Thin', value: 1 }, + { name: 'Normal', value: 2 }, + { name: 'Thick', value: 3 } +]; diff --git a/Website/components/ui/card.tsx b/Website/components/ui/card.tsx new file mode 100644 index 0000000..e855d73 --- /dev/null +++ b/Website/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/Website/components/ui/checkbox.tsx b/Website/components/ui/checkbox.tsx new file mode 100644 index 0000000..df61a13 --- /dev/null +++ b/Website/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/Website/contexts/DiagramViewContext.tsx b/Website/contexts/DiagramViewContext.tsx new file mode 100644 index 0000000..1b0d4e0 --- /dev/null +++ b/Website/contexts/DiagramViewContext.tsx @@ -0,0 +1,33 @@ +import React, { createContext, useContext, ReactNode } from 'react'; +import { useDiagram, DiagramState, DiagramActions } from '@/hooks/useDiagram'; + +interface DiagramViewContextType extends DiagramState, DiagramActions {} + +const DiagramViewContext = createContext(null); + +interface DiagramViewProviderProps { + children: ReactNode; +} + +export const DiagramViewProvider: React.FC = ({ children }) => { + const diagramViewState = useDiagram(); + + return ( + + {children} + + ); +}; + +export const useDiagramViewContext = (): DiagramViewContextType => { + const context = useContext(DiagramViewContext); + if (!context) { + throw new Error('useDiagramViewContext must be used within a DiagramViewProvider'); + } + return context; +}; + +export const useDiagramViewContextSafe = (): DiagramViewContextType | null => { + const context = useContext(DiagramViewContext); + return context; +}; \ No newline at end of file diff --git a/Website/hooks/useAttributeSelection.ts b/Website/hooks/useAttributeSelection.ts new file mode 100644 index 0000000..738c064 --- /dev/null +++ b/Website/hooks/useAttributeSelection.ts @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { AttributeType, EntityType } from '@/lib/Types'; + +export type AttributeSelectionMode = 'minimal' | 'custom-lookups' | 'all-lookups' | 'custom'; + +export interface AttributeSelectionConfig { + mode: AttributeSelectionMode; + customSelectedAttributes: string[]; +} + +export const useAttributeSelection = (initialMode: AttributeSelectionMode = 'custom-lookups') => { + const [attributeMode, setAttributeMode] = useState(initialMode); + const [customSelectedAttributes, setCustomSelectedAttributes] = useState([]); + + const getSelectedAttributes = (entity: EntityType): string[] => { + switch (attributeMode) { + case 'minimal': + // Only primary key (handled by default in useDiagram) + return []; + case 'custom-lookups': + return entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) + .map(attr => attr.SchemaName); + case 'all-lookups': + return entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute") + .map(attr => attr.SchemaName); + case 'custom': + return customSelectedAttributes; + default: + return []; + } + }; + + const initializeCustomAttributes = (entity: EntityType) => { + // Initialize with current default (custom lookups) + const defaultSelected = entity.Attributes + .filter(attr => attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute) + .map(attr => attr.SchemaName); + setCustomSelectedAttributes(defaultSelected); + }; + + const toggleCustomAttribute = (attributeSchemaName: string, checked: boolean) => { + if (checked) { + setCustomSelectedAttributes(prev => [...prev, attributeSchemaName]); + } else { + setCustomSelectedAttributes(prev => prev.filter(name => name !== attributeSchemaName)); + } + }; + + const resetCustomAttributes = () => { + setCustomSelectedAttributes([]); + }; + + const getAttributeModeDescription = (mode: AttributeSelectionMode): string => { + switch (mode) { + case 'minimal': + return 'Primary key only'; + case 'custom-lookups': + return 'Custom lookup attributes'; + case 'all-lookups': + return 'All lookup attributes'; + case 'custom': + return 'Pick specific attributes'; + default: + return ''; + } + }; + + return { + attributeMode, + setAttributeMode, + customSelectedAttributes, + setCustomSelectedAttributes, + getSelectedAttributes, + initializeCustomAttributes, + toggleCustomAttribute, + resetCustomAttributes, + getAttributeModeDescription, + }; +}; diff --git a/Website/hooks/useDiagram.ts b/Website/hooks/useDiagram.ts new file mode 100644 index 0000000..d281449 --- /dev/null +++ b/Website/hooks/useDiagram.ts @@ -0,0 +1,882 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import { dia, routers, shapes } from '@joint/core'; +import { GroupType, EntityType, AttributeType } from '@/lib/Types'; +import { SquareElement } from '@/components/diagram/elements/SquareElement'; +import { SquareElementView } from '@/components/diagram/elements/SquareElementView'; +import { TextElement } from '@/components/diagram/elements/TextElement'; +import { AvoidRouter } from '@/components/diagram/avoid-router/avoidrouter'; +import { DiagramRenderer } from '@/components/diagram/DiagramRenderer'; +import { SimpleDiagramRenderer } from '@/components/diagram/renderers/SimpleDiagramRender'; +import { DetailedDiagramRender } from '@/components/diagram/renderers/DetailedDiagramRender'; +import { PRESET_COLORS } from '@/components/diagram/shared/DiagramConstants'; + +export type DiagramType = 'simple' | 'detailed'; + +export interface DiagramState { + zoom: number; + isPanning: boolean; + selectedElements: string[]; + paper: dia.Paper | null; + graph: dia.Graph | null; + selectedGroup: GroupType | null; + currentEntities: EntityType[]; + mousePosition: { x: number; y: number } | null; + panPosition: { x: number; y: number }; + diagramType: DiagramType; +} + +export interface DiagramActions { + zoomIn: () => void; + zoomOut: () => void; + resetView: () => void; + fitToScreen: () => void; + setZoom: (zoom: number) => void; + setIsPanning: (isPanning: boolean) => void; + selectElement: (elementId: string) => void; + clearSelection: () => void; + initializePaper: (container: HTMLElement, options?: any) => void; + destroyPaper: () => void; + selectGroup: (group: GroupType) => void; + updateMousePosition: (position: { x: number; y: number } | null) => void; + updatePanPosition: (position: { x: number; y: number }) => void; + addAttributeToEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; + removeAttributeFromEntity: (entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => void; + updateDiagramType: (type: DiagramType) => void; + addEntityToDiagram: (entity: EntityType, selectedAttributes?: string[]) => void; + addGroupToDiagram: (group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => void; + removeEntityFromDiagram: (entitySchemaName: string) => void; + addSquareToDiagram: () => void; + addTextToDiagram: () => void; + saveDiagram: () => void; + loadDiagram: (file: File) => Promise; + clearDiagram: () => void; +} + +export const useDiagram = (): DiagramState & DiagramActions => { + const paperRef = useRef(null); + const graphRef = useRef(null); + const zoomRef = useRef(1); + const isPanningRef = useRef(false); + const cleanupRef = useRef<(() => void) | null>(null); + const isAddingAttributeRef = useRef(false); + + const [zoom, setZoomState] = useState(1); + const [isPanning, setIsPanningState] = useState(false); + const [selectedElements, setSelectedElements] = useState([]); + const [selectedGroup, setSelectedGroup] = useState(null); + const [currentEntities, setCurrentEntities] = useState([]); + const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); + const [panPosition, setPanPosition] = useState({ x: 0, y: 0 }); + const [diagramType, setDiagramType] = useState('simple'); + + // State variables to track initialization status for React dependencies + const [paperInitialized, setPaperInitialized] = useState(false); + const [graphInitialized, setGraphInitialized] = useState(false); + + // Update state when refs change (for UI updates) + const updateZoomDisplay = useCallback((newZoom: number) => { + zoomRef.current = newZoom; + setZoomState(newZoom); + }, []); + + const updatePanningDisplay = useCallback((newPanning: boolean) => { + isPanningRef.current = newPanning; + setIsPanningState(newPanning); + }, []); + + const zoomIn = useCallback(() => { + if (paperRef.current) { + const currentScale = paperRef.current.scale(); + const newScale = Math.min(currentScale.sx * 1.2, 3); + paperRef.current.scale(newScale, newScale); + updateZoomDisplay(newScale); + } + }, [updateZoomDisplay]); + + const zoomOut = useCallback(() => { + if (paperRef.current) { + const currentScale = paperRef.current.scale(); + const newScale = Math.max(currentScale.sx / 1.2, 0.1); + paperRef.current.scale(newScale, newScale); + updateZoomDisplay(newScale); + } + }, [updateZoomDisplay]); + + const resetView = useCallback(() => { + if (paperRef.current) { + paperRef.current.scale(1, 1); + paperRef.current.translate(0, 0); + updateZoomDisplay(1); + setPanPosition({ x: 0, y: 0 }); + clearSelection(); + } + }, [updateZoomDisplay]); + + const fitToScreen = useCallback(() => { + if (paperRef.current && graphRef.current) { + const elements = graphRef.current.getElements(); + if (elements.length > 0) { + const bbox = graphRef.current.getBBox(); + if (bbox) { + const paperSize = paperRef.current.getComputedSize(); + const scaleX = (paperSize.width - 100) / bbox.width; + const scaleY = (paperSize.height - 100) / bbox.height; + const scale = Math.min(scaleX, scaleY, 2); + paperRef.current.scale(scale, scale); + + // Center the content manually + const centerX = (paperSize.width - bbox.width * scale) / 2 - bbox.x * scale; + const centerY = (paperSize.height - bbox.height * scale) / 2 - bbox.y * scale; + paperRef.current.translate(centerX, centerY); + + updateZoomDisplay(scale); + setPanPosition({ x: centerX, y: centerY }); + } + } + } + }, [updateZoomDisplay]); + + const setZoom = useCallback((newZoom: number) => { + if (paperRef.current) { + paperRef.current.scale(newZoom, newZoom); + updateZoomDisplay(newZoom); + } + }, [updateZoomDisplay]); + + const setIsPanning = useCallback((newPanning: boolean) => { + updatePanningDisplay(newPanning); + }, [updatePanningDisplay]); + + const selectElement = useCallback((elementId: string) => { + setSelectedElements(prev => + prev.includes(elementId) ? prev : [...prev, elementId] + ); + }, []); + + const clearSelection = useCallback(() => { + setSelectedElements([]); + }, []); + + const selectGroup = useCallback((group: GroupType) => { + setSelectedGroup(group); + + // Initialize entities with default visible attributes + const entitiesWithVisibleAttributes = group.Entities.map(entity => { + // Get primary key + const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); + + // Get custom lookup attributes (initially visible) + const customLookupAttributes = entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute + ); + + // Create initial visible attributes list + const initialVisibleAttributes = [ + ...(primaryKey ? [primaryKey.SchemaName] : []), + ...customLookupAttributes.map(attr => attr.SchemaName) + ]; + + return { + ...entity, + visibleAttributeSchemaNames: initialVisibleAttributes + }; + }); + + setCurrentEntities(entitiesWithVisibleAttributes); + clearSelection(); + }, [clearSelection]); + + const updateMousePosition = useCallback((position: { x: number; y: number } | null) => { + setMousePosition(position); + }, []); + + const updatePanPosition = useCallback((position: { x: number; y: number }) => { + setPanPosition(position); + }, []); + + const addAttributeToEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { + // Prevent double additions + if (isAddingAttributeRef.current) { + return; + } + + isAddingAttributeRef.current = true; + + if (!graphRef.current) { + isAddingAttributeRef.current = false; + return; + } + + // Update the currentEntities state first + setCurrentEntities(prev => { + const updated = prev.map(entity => { + if (entity.SchemaName === entitySchemaName) { + // Check if attribute already exists in the entity + const attributeExists = entity.Attributes.some((attr: AttributeType) => + attr.SchemaName === attribute.SchemaName + ); + + // Get current visible attributes list + const currentVisibleAttributes = (entity.visibleAttributeSchemaNames || []); + + if (attributeExists) { + // Attribute already exists, just add it to visible list if not already there + return { + ...entity, + visibleAttributeSchemaNames: currentVisibleAttributes.includes(attribute.SchemaName) + ? currentVisibleAttributes + : [...currentVisibleAttributes, attribute.SchemaName] + }; + } else { + // Attribute doesn't exist, add it to entity and make it visible + return { + ...entity, + Attributes: [...entity.Attributes, attribute], + visibleAttributeSchemaNames: [...currentVisibleAttributes, attribute.SchemaName] + }; + } + } + return entity; + }); + + // Update the diagram using the renderer's unified method + if (renderer) { + const updatedEntity = updated.find(e => e.SchemaName === entitySchemaName); + if (updatedEntity) { + renderer.updateEntity(entitySchemaName, updatedEntity); + } + } + + return updated; + }); + + // Reset the flag + isAddingAttributeRef.current = false; + }, []); + + const removeAttributeFromEntity = useCallback((entitySchemaName: string, attribute: AttributeType, renderer?: DiagramRenderer) => { + if (!graphRef.current) { + return; + } + + // Update the currentEntities state first + setCurrentEntities(prev => { + const updated = prev.map(entity => { + if (entity.SchemaName === entitySchemaName) { + // Remove from visible attributes list + const updatedVisibleAttributes = (entity.visibleAttributeSchemaNames || []) + .filter((attrName: string) => attrName !== attribute.SchemaName); + + return { + ...entity, + visibleAttributeSchemaNames: updatedVisibleAttributes + }; + } + return entity; + }); + + // Update the diagram using the renderer's unified method + if (renderer) { + const updatedEntity = updated.find(e => e.SchemaName === entitySchemaName); + if (updatedEntity) { + renderer.updateEntity(entitySchemaName, updatedEntity); + } + } + + return updated; + }); + }, []); + + const updateDiagramType = useCallback((type: DiagramType) => { + setDiagramType(type); + }, []); + + const addEntityToDiagram = useCallback((entity: EntityType, selectedAttributes?: string[]) => { + if (!graphRef.current || !paperRef.current) { + return; + } + + // Check if entity already exists in the diagram + const existingEntity = currentEntities.find(e => e.SchemaName === entity.SchemaName); + if (existingEntity) { + return; // Entity already in diagram + } + + let initialVisibleAttributes: string[]; + + if (selectedAttributes) { + // Use provided selected attributes + initialVisibleAttributes = selectedAttributes; + } else { + // Initialize entity with default visible attributes + const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); + const customLookupAttributes = entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute + ); + + initialVisibleAttributes = [ + ...(primaryKey ? [primaryKey.SchemaName] : []), + ...customLookupAttributes.map(attr => attr.SchemaName) + ]; + } + + const entityWithVisibleAttributes = { + ...entity, + visibleAttributeSchemaNames: initialVisibleAttributes + }; + + // Update current entities + const updatedEntities = [...currentEntities, entityWithVisibleAttributes]; + setCurrentEntities(updatedEntities); + }, [currentEntities, diagramType, fitToScreen]); + + const addGroupToDiagram = useCallback((group: GroupType, selectedAttributes?: { [entitySchemaName: string]: string[] }) => { + if (!graphRef.current || !paperRef.current) { + return; + } + + // Filter out entities that are already in the diagram + const newEntities = group.Entities.filter(entity => + !currentEntities.some(e => e.SchemaName === entity.SchemaName) + ); + + if (newEntities.length === 0) { + return; // All entities from this group are already in diagram + } + + // Initialize new entities with provided or default visible attributes + const entitiesWithVisibleAttributes = newEntities.map(entity => { + let initialVisibleAttributes: string[]; + + if (selectedAttributes && selectedAttributes[entity.SchemaName]) { + // Use the provided selected attributes + initialVisibleAttributes = selectedAttributes[entity.SchemaName]; + } else { + // Fall back to default (primary key + custom lookup attributes) + const primaryKey = entity.Attributes.find(attr => attr.IsPrimaryId); + const customLookupAttributes = entity.Attributes.filter(attr => + attr.AttributeType === "LookupAttribute" && attr.IsCustomAttribute + ); + + initialVisibleAttributes = [ + ...(primaryKey ? [primaryKey.SchemaName] : []), + ...customLookupAttributes.map(attr => attr.SchemaName) + ]; + } + + return { + ...entity, + visibleAttributeSchemaNames: initialVisibleAttributes + }; + }); + + // Update current entities with new entities from the group + const updatedEntities = [...currentEntities, ...entitiesWithVisibleAttributes]; + setCurrentEntities(updatedEntities); + }, [currentEntities]); + + const removeEntityFromDiagram = useCallback((entitySchemaName: string) => { + if (!graphRef.current) { + return; + } + + // Remove the entity from currentEntities state + const updatedEntities = currentEntities.filter(entity => entity.SchemaName !== entitySchemaName); + setCurrentEntities(updatedEntities); + + // Find and remove the entity element from the graph + const entityElement = graphRef.current.getElements().find(el => + el.get('type') === 'delegate.entity' && + el.get('data')?.entity?.SchemaName === entitySchemaName + ); + + if (entityElement) { + // Remove all links connected to this entity + const connectedLinks = graphRef.current.getConnectedLinks(entityElement); + connectedLinks.forEach(link => link.remove()); + + // Remove the entity element + entityElement.remove(); + } + }, [currentEntities, fitToScreen]); + + const addSquareToDiagram = useCallback(() => { + if (!graphRef.current || !paperRef.current) { + return; + } + + // Get all existing elements to find the lowest Y position (bottom-most) + const allElements = graphRef.current.getElements(); + let lowestY = 50; // Default starting position + + if (allElements.length > 0) { + // Find the bottom-most element and add margin + allElements.forEach(element => { + const bbox = element.getBBox(); + const elementBottom = bbox.y + bbox.height; + if (elementBottom > lowestY) { + lowestY = elementBottom + 30; // Add 30px margin + } + }); + } + + // Create a new square element + const squareElement = new SquareElement({ + position: { + x: 100, // Fixed X position + y: lowestY + }, + data: { + id: `square-${Date.now()}`, // Unique ID + borderColor: PRESET_COLORS.borders[0].value, + fillColor: PRESET_COLORS.fills[0].value, + borderWidth: 2, + borderType: 'dashed', + opacity: 0.7 + } + }); + + // Add the square to the graph + squareElement.addTo(graphRef.current); + + // Send square to the back so it renders behind entities + squareElement.toBack(); + + return squareElement; + }, []); + + const addTextToDiagram = useCallback(() => { + if (!graphRef.current || !paperRef.current) { + return; + } + + // Get all existing elements to find the lowest Y position (bottom-most) + const allElements = graphRef.current.getElements(); + let lowestY = 50; // Default starting position + + if (allElements.length > 0) { + // Find the bottom-most element and add margin + allElements.forEach(element => { + const bbox = element.getBBox(); + const elementBottom = bbox.y + bbox.height; + if (elementBottom > lowestY) { + lowestY = elementBottom + 30; // Add 30px margin + } + }); + } + + // Create a new text element + const textElement = new TextElement({ + position: { + x: 100, // Fixed X position + y: lowestY + }, + size: { width: 120, height: 25 }, + attrs: { + body: { + fill: 'transparent', + stroke: 'none' + }, + label: { + text: 'Sample Text', + fill: 'black', + fontSize: 14, + fontFamily: 'Inter', + textAnchor: 'start', + textVerticalAnchor: 'top', + x: 2, + y: 2 + } + } + }); + + // Don't call updateTextElement in constructor to avoid positioning conflicts + textElement.set('data', { + text: 'Text Element', + fontSize: 14, + fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', + color: 'black', + backgroundColor: 'transparent', + padding: 8, + borderRadius: 4, + textAlign: 'left' + }); + + // Add the text to the graph + textElement.addTo(graphRef.current); + + return textElement; + }, []); + + const saveDiagram = useCallback(() => { + if (!graphRef.current) { + console.warn('No graph available to save'); + return; + } + + // Use JointJS built-in JSON export + const graphJSON = graphRef.current.toJSON(); + + // Create diagram data structure with additional metadata + const diagramData = { + version: '1.0', + timestamp: new Date().toISOString(), + diagramType, + currentEntities, + graph: graphJSON, + viewState: { + panPosition, + zoom + } + }; + + // Create blob and download + const jsonString = JSON.stringify(diagramData, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + // Create download link + const link = document.createElement('a'); + link.href = url; + link.download = `diagram-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [graphRef, diagramType, currentEntities, panPosition, zoom]); + + const loadDiagram = useCallback(async (file: File): Promise => { + try { + const text = await file.text(); + const diagramData = JSON.parse(text); + + if (!graphRef.current || !paperRef.current) { + console.warn('Graph or paper not available for loading'); + return; + } + + // Clear current diagram + graphRef.current.clear(); + + // Use JointJS built-in JSON import + if (diagramData.graph) { + // Manual recreation approach since cellNamespace isn't working + const cells = diagramData.graph.cells || []; + + cells.forEach((cellData: any) => { + let cell; + + if (cellData.type === 'delegate.square') { + cell = new SquareElement({ + id: cellData.id, + position: cellData.position, + size: cellData.size, + attrs: cellData.attrs, + data: cellData.data + }); + } else if (cellData.type === 'delegate.text') { + cell = new TextElement({ + id: cellData.id, + position: cellData.position, + size: cellData.size, + attrs: cellData.attrs, + data: cellData.data + }); + } else { + try { + // Create a temporary graph to deserialize the cell + const tempGraph = new dia.Graph(); + tempGraph.fromJSON({ cells: [cellData] }); + cell = tempGraph.getCells()[0]; + } catch (error) { + console.warn('Failed to create cell:', cellData.type, error); + return; + } + } + + if (cell) { + graphRef.current!.addCell(cell); + } + }); + + } else { + console.warn('No graph data found in diagram file'); + } + + // Restore diagram type + if (diagramData.diagramType) { + setDiagramType(diagramData.diagramType); + } + + // Restore entities + if (diagramData.currentEntities) { + setCurrentEntities(diagramData.currentEntities); + } + + // Restore view settings + if (diagramData.viewState) { + const { panPosition: savedPanPosition, zoom: savedZoom } = diagramData.viewState; + + if (savedZoom && paperRef.current) { + paperRef.current.scale(savedZoom, savedZoom); + updateZoomDisplay(savedZoom); + } + + if (savedPanPosition && paperRef.current) { + paperRef.current.translate(savedPanPosition.x, savedPanPosition.y); + setPanPosition(savedPanPosition); + } + } + } catch (error) { + console.error('Failed to load diagram:', error); + console.error('Error stack:', error instanceof Error ? error.stack : 'No stack trace'); + throw new Error('Failed to load diagram file. Please check the file format.'); + } + }, [graphRef, paperRef, updateZoomDisplay]); + + const clearDiagram = useCallback(() => { + if (!graphRef.current) { + console.warn('Graph not available for clearing'); + return; + } + + // Clear the entire diagram + graphRef.current.clear(); + + // Reset currentEntities state + setCurrentEntities([]); + + // Clear selection + clearSelection(); + + }, [graphRef, clearSelection, setCurrentEntities]); + + const initializePaper = useCallback(async (container: HTMLElement, options: any = {}) => { + // Create graph if it doesn't exist + if (!graphRef.current) { + graphRef.current = new dia.Graph(); + setGraphInitialized(true); + } + + try { + await AvoidRouter.load(); + } catch (error) { + console.error('❌ Failed to initialize AvoidRouter:', error); + // Continue without avoid router if it fails + } + + let avoidRouter; + try { + avoidRouter = new AvoidRouter(graphRef.current, { + shapeBufferDistance: 10, + idealNudgingDistance: 15, + }); + avoidRouter.routeAll(); + avoidRouter.addGraphListeners(); + (routers as any).avoid = function(vertices: any, options: any, linkView: any) { + const graph = linkView.model.graph as dia.Graph; + const avoidRouterInstance = (graph as any).__avoidRouter__ as AvoidRouter; + + if (!avoidRouterInstance) { + console.warn('AvoidRouter not initialized on graph.'); + return null; + } + + const link = linkView.model as dia.Link; + + // This will update link using libavoid if possible + avoidRouterInstance.updateConnector(link); + const connRef = avoidRouterInstance.edgeRefs[link.id]; + if (!connRef) return null; + + const route = connRef.displayRoute(); + return avoidRouterInstance.getVerticesFromAvoidRoute(route); + }; + (graphRef.current as any).__avoidRouter__ = avoidRouter; + } catch (error) { + console.error('Failed to initialize AvoidRouter instance:', error); + // Continue without avoid router functionality + } + + // Create paper with light amber background + const paper = new dia.Paper({ + el: container, + model: graphRef.current, + width: '100%', + height: '100%', + gridSize: 8, + background: { + color: '#fef3c7', // Light amber background + ...options.background + }, + // Configure custom views + cellViewNamespace: { + 'delegate': { + 'square': SquareElementView + } + }, + // Disable interactive for squares when resize handles are visible + interactive: function(cellView: any) { + const element = cellView.model; + if (element.get('type') === 'delegate.square') { + const data = element.get('data') || {}; + // Disable dragging if resize handles are visible + if (data.isSelected) { + return false; + } + } + return true; // Enable dragging for other elements or unselected squares + }, + ...options + }); + + paperRef.current = paper; + setPaperInitialized(true); + + // Setup event listeners + paper.on('blank:pointerdown', () => { + updatePanningDisplay(true); + const paperEl = paper.el as HTMLElement; + paperEl.style.cursor = 'grabbing'; + }); + + paper.on('blank:pointerup', () => { + updatePanningDisplay(false); + const paperEl = paper.el as HTMLElement; + paperEl.style.cursor = 'default'; + }); + + paper.on('blank:pointermove', (evt: any) => { + if (isPanningRef.current) { + const currentTranslate = paper.translate(); + const deltaX = evt.originalEvent.movementX || 0; + const deltaY = evt.originalEvent.movementY || 0; + const newTranslateX = currentTranslate.tx + deltaX; + const newTranslateY = currentTranslate.ty + deltaY; + paper.translate(newTranslateX, newTranslateY); + updatePanPosition({ x: newTranslateX, y: newTranslateY }); + } + }); + + // Add mouse move listener for coordinate tracking + const paperEl = paper.el as HTMLElement; + const handleMouseMove = (e: MouseEvent) => { + const rect = paperEl.getBoundingClientRect(); + const currentTranslate = paper.translate(); + const currentScale = paper.scale(); + + // Calculate mouse position relative to diagram coordinates + const mouseX = (e.clientX - rect.left - currentTranslate.tx) / currentScale.sx; + const mouseY = (e.clientY - rect.top - currentTranslate.ty) / currentScale.sy; + + updateMousePosition({ x: Math.round(mouseX), y: Math.round(mouseY) }); + }; + + const handleMouseLeave = () => { + updateMousePosition(null); + }; + + // Add wheel event listener for zoom + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const currentScale = paper.scale(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newScale = Math.max(0.1, Math.min(3, currentScale.sx * delta)); + + // Get mouse position relative to paper + const rect = paperEl.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Calculate zoom center + const currentTranslate = paper.translate(); + const zoomCenterX = (mouseX - currentTranslate.tx) / currentScale.sx; + const zoomCenterY = (mouseY - currentTranslate.ty) / currentScale.sy; + + // Apply zoom + paper.scale(newScale, newScale); + + // Adjust translation to zoom towards mouse position + const newTranslateX = mouseX - zoomCenterX * newScale; + const newTranslateY = mouseY - zoomCenterY * newScale; + paper.translate(newTranslateX, newTranslateY); + + updateZoomDisplay(newScale); + updatePanPosition({ x: newTranslateX, y: newTranslateY }); + }; + + paperEl.addEventListener('wheel', handleWheel); + paperEl.addEventListener('mousemove', handleMouseMove); + paperEl.addEventListener('mouseleave', handleMouseLeave); + + // Store cleanup function + cleanupRef.current = () => { + paperEl.removeEventListener('wheel', handleWheel); + paperEl.removeEventListener('mousemove', handleMouseMove); + paperEl.removeEventListener('mouseleave', handleMouseLeave); + paper.remove(); + }; + + return paper; + }, [updateZoomDisplay, updatePanningDisplay, updateMousePosition, updatePanPosition, setGraphInitialized, setPaperInitialized]); + + const destroyPaper = useCallback(() => { + if (cleanupRef.current) { + cleanupRef.current(); + cleanupRef.current = null; + } + paperRef.current = null; + graphRef.current = null; + setPaperInitialized(false); + setGraphInitialized(false); + }, [setPaperInitialized, setGraphInitialized]); + + // Cleanup on unmount + useEffect(() => { + return () => { + destroyPaper(); + }; + }, [destroyPaper]); + + return { + // State + zoom, + isPanning, + selectedElements, + paper: paperInitialized ? paperRef.current : null, + graph: graphInitialized ? graphRef.current : null, + selectedGroup, + currentEntities, + mousePosition, + panPosition, + diagramType, + + // Actions + zoomIn, + zoomOut, + resetView, + fitToScreen, + setZoom, + setIsPanning, + selectElement, + clearSelection, + initializePaper, + destroyPaper, + selectGroup, + updateMousePosition, + updatePanPosition, + addAttributeToEntity, + removeAttributeFromEntity, + updateDiagramType, + addEntityToDiagram, + addGroupToDiagram, + removeEntityFromDiagram, + addSquareToDiagram, + addTextToDiagram, + saveDiagram, + loadDiagram, + clearDiagram, + }; +}; \ No newline at end of file diff --git a/Website/lib/Types.ts b/Website/lib/Types.ts index 217576c..6c63ea1 100644 --- a/Website/lib/Types.ts +++ b/Website/lib/Types.ts @@ -26,6 +26,7 @@ export type EntityType = { SecurityRoles: SecurityRole[], Keys: Key[], IconBase64: string | null, + visibleAttributeSchemaNames?: string[], } export const enum RequiredLevel { @@ -162,6 +163,7 @@ export type CascadeConfigurationType = { } export type RelationshipType = { + IsCustom: boolean, Name: string, TableSchema: string, LookupDisplayName: string, diff --git a/Website/middleware.ts b/Website/middleware.ts index fbcaf8c..b23d217 100644 --- a/Website/middleware.ts +++ b/Website/middleware.ts @@ -15,5 +15,5 @@ export async function middleware(request: NextRequest) { } export const config = { - matcher: '/', + matcher: ['/', '/diagram'], } \ No newline at end of file diff --git a/Website/package-lock.json b/Website/package-lock.json index 41b536b..4c7f1e3 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -8,10 +8,13 @@ "name": "website", "version": "1.2.4", "dependencies": { + "@joint/core": "^4.1.3", + "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -21,6 +24,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^5.9.6", + "libavoid-js": "^0.4.5", "lucide-react": "^0.462.0", "next": "^15.3.4", "react": "^19.0.0", @@ -30,6 +34,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@types/lodash": "^4.17.19", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -640,6 +645,12 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@joint/core": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@joint/core/-/core-4.1.3.tgz", + "integrity": "sha512-X769blCoVxtx6NNm/cbHDXDOa+Gt7eZwrJMLnqJw8c5NkjmcYWCY1kA3ep8RfRRVG76f3QLNd9a8Q/aItI/WWw==", + "license": "MPL-2.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -966,6 +977,189 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.1.tgz", @@ -1849,6 +2043,168 @@ } } }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-select": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", @@ -2944,6 +3300,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-NYqRyg/hIQrYPT9lbOeYc3kIRabJDn/k4qQHIXUpx88CBDww2fD15Sg5kbXlW86zm2XEW4g0QxkTI3/Kfkc7xQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", @@ -5636,6 +5999,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libavoid-js": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/libavoid-js/-/libavoid-js-0.4.5.tgz", + "integrity": "sha512-9BrYRXAQ+nmLuHZSqf4z52YN8TroBPxyqo6A6h6Pj03j5UYNx/Hhnd/rg+kiLVrE76wzBeBVO3OW7kaEpzYC9Q==", + "license": "LGPL-2.1-or-later" + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", diff --git a/Website/package.json b/Website/package.json index 12270b8..e9d984d 100644 --- a/Website/package.json +++ b/Website/package.json @@ -10,10 +10,13 @@ "prepipeline": "node scripts/copyStub.js" }, "dependencies": { + "@joint/core": "^4.1.3", + "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", @@ -23,6 +26,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "jose": "^5.9.6", + "libavoid-js": "^0.4.5", "lucide-react": "^0.462.0", "next": "^15.3.4", "react": "^19.0.0", @@ -32,6 +36,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@types/lodash": "^4.17.19", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/Website/public/libavoid.wasm b/Website/public/libavoid.wasm new file mode 100644 index 0000000..5043d87 Binary files /dev/null and b/Website/public/libavoid.wasm differ