diff --git a/app/components/RuleRelationsDisplay/RuleRelationsDisplay.module.css b/app/components/RuleRelationsDisplay/RuleRelationsDisplay.module.css new file mode 100644 index 0000000..4f4e10d --- /dev/null +++ b/app/components/RuleRelationsDisplay/RuleRelationsDisplay.module.css @@ -0,0 +1,96 @@ +.container { + position: relative; + width: 100%; + height: 100vh; + border: 1px solid #ccc; + border-radius: 6px; + min-height: 400px; + max-height: 80vh; +} + +.controls { + position: absolute; + top: 20px; + left: 20px; + z-index: 1000; + background: white; + padding: 15px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: max-height 0.3s ease-in-out; + overflow: hidden; + margin: 5px; + border: 1px solid #ccc; + background-color: rgba(255, 255, 255, 0.9); + width: min-content; +} + +.input { + width: min-content !important; +} + +.select { + min-width: 100px; + width: 200px !important; + flex-shrink: 0; +} + +.collapsible { + transition: opacity 0.2s ease; +} + +.legend { + border-top: 1px solid #ccc; + padding-top: 8px; + font-size: 12px; +} + +.legendTitle { + font-weight: bold; + margin: 0px 8px 0px 0px; +} + +.legendItem { + display: flex; + align-items: center; + gap: 8px; +} + +.legendLine { + width: 20px; + height: 2px; +} + +.parentLine { + composes: legendLine; + background-color: #4169e1; +} + +.childLine { + composes: legendLine; + background-color: #32cd32; +} + +.selectedDot { + width: 12px; + height: 12px; + background-color: #ff7f50; + border-radius: 50%; +} + +.helpList { + margin: 0; + padding-left: 20px; +} + +.instructions { + margin: 0; + composes: legend; + word-wrap: break-word; +} + +.svg { + width: 100%; + height: 100%; + background: #fff; +} \ No newline at end of file diff --git a/app/components/RuleRelationsDisplay/RuleRelationsDisplay.tsx b/app/components/RuleRelationsDisplay/RuleRelationsDisplay.tsx new file mode 100644 index 0000000..f8fcbe9 --- /dev/null +++ b/app/components/RuleRelationsDisplay/RuleRelationsDisplay.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState, RefObject } from "react"; +import { CategoryObject } from "@/app/types/ruleInfo"; +import { RuleMapRule } from "@/app/types/rulemap"; +import styles from "@/app/components/RuleRelationsDisplay/RuleRelationsDisplay.module.css"; +import { RuleGraphControls } from "./subcomponents/RuleGraphControls"; +import { useRuleGraph } from "../../hooks/useRuleGraph"; +import { DescriptionManager } from "./subcomponents/DescriptionManager"; +import { RuleModalProvider } from "../../contexts/RuleModalContext"; + +export interface RuleGraphProps { + rules: RuleMapRule[]; + categories: CategoryObject[]; + embeddedCategory?: string; + width?: number; + height?: number; + filter?: string; + location?: Location; + basicLegend?: boolean; +} + +export interface GraphContentProps { + rules: RuleMapRule[]; + svgRef: RefObject; + dimensions: { width: number; height: number }; + searchTerm: string; + categoryFilter: string | undefined; + showDraftRules: boolean; +} + +// Renders the actual SVG graph visualization using D3.js +// Separated from the main component for better readability +// and to utilize the modal context provider +function GraphContent({ rules, svgRef, dimensions, searchTerm, categoryFilter, showDraftRules }: GraphContentProps) { + useRuleGraph({ + rules, + svgRef, + dimensions, + searchTerm, + categoryFilter, + showDraftRules, + }); + + return ; +} + +/** + * Manages the visualization of rule relationships in a graph format + * Includes search, category filtering and draft rules toggle + */ +export default function RuleRelationsGraph({ + rules, + categories, + width = 1000, + height = 1000, + filter, + location, + basicLegend, +}: RuleGraphProps) { + const containerRef = useRef(null); + const svgRef = useRef(null); + const [dimensions, setDimensions] = useState({ width, height }); + const [searchTerm, setSearchTerm] = useState(""); + const [isLegendMinimized, setIsLegendMinimized] = useState(true); + const [categoryFilter, setCategoryFilter] = useState(filter || undefined); + const [showDraftRules, setShowDraftRules] = useState(true); + + /** + * Sets up a ResizeObserver to handle responsive sizing + * Updates dimensions when the container size changes + */ + useEffect(() => { + if (!containerRef.current) return; + + const resizeObserver = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + setDimensions({ width, height }); + }); + + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, []); + + useEffect(() => { + if (filter) { + setCategoryFilter(filter); + } + }, [filter]); + + const handleSearchChange = (value: string) => { + setSearchTerm(value); + }; + + const handleCategoryChange = (value: string) => { + setCategoryFilter(value); + }; + + const handleShowDraftRulesChange = (value: boolean) => { + setShowDraftRules(value); + }; + + const handleLegendToggle = () => { + setIsLegendMinimized(!isLegendMinimized); + }; + + const handleClearFilters = () => { + setSearchTerm(""); + setCategoryFilter(""); + }; + + return ( +
+ + + + + +
+ ); +} diff --git a/app/components/RuleRelationsDisplay/subcomponents/DescriptionManager.tsx b/app/components/RuleRelationsDisplay/subcomponents/DescriptionManager.tsx new file mode 100644 index 0000000..0d9425f --- /dev/null +++ b/app/components/RuleRelationsDisplay/subcomponents/DescriptionManager.tsx @@ -0,0 +1,37 @@ +import { RuleDescription } from "./RuleDescription"; +import { useRuleModal } from "../../../contexts/RuleModalContext"; +import Modal from "antd/es/modal/Modal"; + +// Manages the display of the rule description modal +export function DescriptionManager() { + const { selectedRule, closeModal } = useRuleModal(); + + if (!selectedRule) return null; + + return ( + +
+ +
+
+ ); +} diff --git a/app/components/RuleRelationsDisplay/subcomponents/GraphNavigation.ts b/app/components/RuleRelationsDisplay/subcomponents/GraphNavigation.ts new file mode 100644 index 0000000..6059600 --- /dev/null +++ b/app/components/RuleRelationsDisplay/subcomponents/GraphNavigation.ts @@ -0,0 +1,59 @@ +import * as d3 from "d3"; + +// Zoom and Pan controls for graph navigation +export const GraphNavigation = ( + svg: d3.Selection, + g: d3.Selection, + zoom: d3.ZoomBehavior +) => { + const controls = svg + .append("g") + .attr("transform", `translate(10, ${svg.node()?.getBoundingClientRect().height! - 60})`); + + controls.append("rect").attr("width", 30).attr("height", 90).attr("fill", "white").attr("stroke", "#999"); + + controls + .append("text") + .attr("x", 15) + .attr("y", 20) + .attr("text-anchor", "middle") + .style("font-size", "18px") + .style("cursor", "pointer") + .text("+") + .on("click", () => { + svg + .transition() + .duration(750) + .call(zoom.scaleBy as any, 1.3); + }); + + controls + .append("text") + .attr("x", 15) + .attr("y", 50) + .attr("text-anchor", "middle") + .style("font-size", "18px") + .style("cursor", "pointer") + .text("⟲") + .on("click", () => { + svg + .transition() + .duration(750) + .call(zoom.transform as any, d3.zoomIdentity); + }); + + controls + .append("text") + .attr("x", 15) + .attr("y", 80) + .attr("text-anchor", "middle") + .style("font-size", "18px") + .style("cursor", "pointer") + .text("−") + .on("click", () => { + svg + .transition() + .duration(750) + .call(zoom.scaleBy as any, 0.7); + }); +}; diff --git a/app/components/RuleRelationsDisplay/subcomponents/RuleDescription.module.css b/app/components/RuleRelationsDisplay/subcomponents/RuleDescription.module.css new file mode 100644 index 0000000..b964e28 --- /dev/null +++ b/app/components/RuleRelationsDisplay/subcomponents/RuleDescription.module.css @@ -0,0 +1,52 @@ +.title { + font-size: 14px; + font-weight: bold; + margin-bottom: 20px; +} + +.metaInfo { + font-family: monospace; + font-size: 11px; + color: #666; + margin-bottom: 10px; +} + +.description { + font-family: sans-serif; + font-size: 12px; + margin: 20px 0; +} + +.link { + color: #0066cc; + font-size: 12px; + text-decoration: underline; + cursor: pointer; + display: block; + margin: 10px 0; + padding: 4px 8px; + border-radius: 4px; + transition: background-color 0.2s ease; +} + +.link:hover, +.link:focus { + background-color: #f0f7ff; + outline: none; + text-decoration: none; +} + +.link:focus { + box-shadow: 0 0 0 2px #0066cc40; +} + +.link[disabled] { + opacity: 0.5; + cursor: not-allowed; +} + +.link[disabled]:hover, +.link[disabled]:focus { + background-color: transparent; + box-shadow: none; +} \ No newline at end of file diff --git a/app/components/RuleRelationsDisplay/subcomponents/RuleDescription.tsx b/app/components/RuleRelationsDisplay/subcomponents/RuleDescription.tsx new file mode 100644 index 0000000..b2ed794 --- /dev/null +++ b/app/components/RuleRelationsDisplay/subcomponents/RuleDescription.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Button, message, Popover } from "antd"; +import styles from "./RuleDescription.module.css"; + +interface RuleDescription { + data: { + label?: string; + name: string; + filepath?: string; + description?: string; + url?: string; + isPublished?: boolean; + }; + onClose: () => void; + visible: boolean; +} + +// Displays the description of a rule +export function RuleDescription({ data, onClose, visible }: RuleDescription) { + if (!visible) return null; + + const handleAppLinkClick = (e: React.MouseEvent | React.KeyboardEvent) => { + if (!data.url) { + e.preventDefault(); + return; + } + const baseUrl = window.location.origin; + window.open(`${baseUrl}/rule/${data.url}`); + }; + + const handleKlammLinkClick = (e: React.MouseEvent | React.KeyboardEvent) => { + if (!data.isPublished && data.url) { + e.preventDefault(); + message.error("Rule exists but is not published in Klamm"); + return; + } + const baseUrl = process.env.NEXT_PUBLIC_KLAMM_URL; + window.open(`${baseUrl}/rules/${data.name}`, "_blank"); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + return ( +
+

{data.label || data.name}

+ +
Name: {data.name}
+
Path: {data.filepath || "N/A"}
+ +
{data.description || "No description available"}
+ + + + + + +
+ ); +} diff --git a/app/components/RuleRelationsDisplay/subcomponents/RuleFilters.ts b/app/components/RuleRelationsDisplay/subcomponents/RuleFilters.ts new file mode 100644 index 0000000..5125155 --- /dev/null +++ b/app/components/RuleRelationsDisplay/subcomponents/RuleFilters.ts @@ -0,0 +1,73 @@ +import { RuleNode, RuleLink } from "@/app/types/rulemap"; + +// Returns nodes for a specific category +// If category is empty, returns all nodes +// If showDraftRules is false, only returns published nodes +// Also returns all parent rules and child rules of the matching nodes +export const getNodesForCategory = ( + nodes: RuleNode[], + category: string | undefined, + showDraftRules: boolean, + getAllParentRules: (nodeId: number) => Set, + getAllChildRules: (nodeId: number) => Set +): Set => { + const matchingNodes = new Set(); + + nodes.forEach((node) => { + if (!showDraftRules && !node.isPublished) return; + + if (!category) { + matchingNodes.add(node.id); + return; + } + + if (node.filepath?.includes(category)) { + matchingNodes.add(node.id); + + const parentRules = getAllParentRules(node.id); + const childRules = getAllChildRules(node.id); + + parentRules.forEach((id) => { + const parentNode = nodes.find((n) => n.id === id); + if (parentNode && (showDraftRules || parentNode.isPublished)) { + matchingNodes.add(id); + } + }); + + childRules.forEach((id) => { + const childNode = nodes.find((n) => n.id === id); + if (childNode && (showDraftRules || childNode.isPublished)) { + matchingNodes.add(id); + } + }); + } + }); + + return matchingNodes; +}; + +// Returns links that connect nodes in the visible set +// If showDraftRules is false, only returns links between published nodes +// Also returns links that connect parent rules and child rules of the visible nodes +// This is used to highlight connections when a node is selected +export const isNodeVisible = ( + node: RuleNode, + searchPattern: string, + visibleNodes: Set, + showDraftRules: boolean +): boolean => { + return ( + (showDraftRules || (node.isPublished ?? false)) && + visibleNodes.has(node.id) && + (searchPattern === "" || node.name.toLowerCase().includes(searchPattern)) + ); +}; + +// Returns links that connect nodes in the visible set +export const isLinkVisible = (link: RuleLink, visibleNodes: Set, showDraftRules: boolean): boolean => { + const sourceVisible = + visibleNodes.has((link.source as any).id) && (showDraftRules || (link.source as any).isPublished); + const targetVisible = + visibleNodes.has((link.target as any).id) && (showDraftRules || (link.target as any).isPublished); + return sourceVisible && targetVisible; +}; diff --git a/app/components/RuleRelationsDisplay/subcomponents/RuleGraphControls.tsx b/app/components/RuleRelationsDisplay/subcomponents/RuleGraphControls.tsx new file mode 100644 index 0000000..de661dc --- /dev/null +++ b/app/components/RuleRelationsDisplay/subcomponents/RuleGraphControls.tsx @@ -0,0 +1,156 @@ +import { Button, Select, Input, Flex, Checkbox, message } from "antd"; +import { CategoryObject } from "@/app/types/ruleInfo"; +import styles from "../RuleRelationsDisplay.module.css"; + +interface RuleGraphControlsProps { + searchTerm: string; + categoryFilter: string | undefined; + showDraftRules: boolean; + isLegendMinimized: boolean; + categories: CategoryObject[]; + embeddedCategory?: string; + onSearchChange: (value: string) => void; + onCategoryChange: (value: string) => void; + onShowDraftRulesChange: (value: boolean) => void; + onLegendToggle: () => void; + onClearFilters: () => void; + location?: Location; + basicLegend?: boolean; +} + +// Legend and search/filter controls for the rule graph +export function RuleGraphControls({ + searchTerm, + categoryFilter, + showDraftRules, + isLegendMinimized, + categories, + embeddedCategory, + onSearchChange, + onCategoryChange, + onShowDraftRulesChange, + onLegendToggle, + onClearFilters, + location, + basicLegend, +}: RuleGraphControlsProps) { + const createEmbedLink = () => { + if (!location) return; + + const baseUrl = `${location.origin}${location.pathname}/embed`; + const params = new URLSearchParams(); + + if (searchTerm) params.set("search", searchTerm); + if (categoryFilter) params.set("category", categoryFilter); + + const embedUrl = `${baseUrl}${params.toString() ? "&" + params.toString() : ""}`; + + navigator.clipboard.writeText(embedUrl); + message.success("Embed link copied to clipboard!"); + }; + + return ( + + + + {!embeddedCategory && !basicLegend && ( + <> + onSearchChange(e.target.value)} + aria-label="Search rules" + /> +