Skip to content

Commit

Permalink
Initial draft of relational rule map component that visually represen…
Browse files Browse the repository at this point in the history
…ts rule relationships.
  • Loading branch information
brysonjbest committed Dec 12, 2024
1 parent 0144983 commit e503c97
Show file tree
Hide file tree
Showing 25 changed files with 2,387 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
140 changes: 140 additions & 0 deletions app/components/RuleRelationsDisplay/RuleRelationsDisplay.tsx
Original file line number Diff line number Diff line change
@@ -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<SVGSVGElement>;
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 <svg ref={svgRef} className={styles.svg} />;
}

/**
* 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<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(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 (
<div ref={containerRef} className={styles.container}>
<RuleModalProvider>
<RuleGraphControls
searchTerm={searchTerm}
categoryFilter={categoryFilter}
showDraftRules={showDraftRules}
isLegendMinimized={isLegendMinimized}
categories={categories}
embeddedCategory={filter}
onSearchChange={handleSearchChange}
onCategoryChange={handleCategoryChange}
onShowDraftRulesChange={handleShowDraftRulesChange}
onLegendToggle={handleLegendToggle}
onClearFilters={handleClearFilters}
location={location}
basicLegend={basicLegend}
/>
<GraphContent
rules={rules}
svgRef={svgRef}
dimensions={dimensions}
searchTerm={searchTerm}
categoryFilter={categoryFilter}
showDraftRules={showDraftRules}
/>
<DescriptionManager />
</RuleModalProvider>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
open={true}
onCancel={closeModal}
footer={null}
destroyOnClose
keyboard={true}
maskClosable={true}
aria-modal="true"
>
<div role="document" tabIndex={0}>
<RuleDescription
data={{
label: selectedRule.label,
name: selectedRule.name,
filepath: selectedRule.filepath,
description: selectedRule.description || undefined,
url: selectedRule.url,
isPublished: selectedRule.isPublished,
}}
onClose={closeModal}
visible={true}
/>
</div>
</Modal>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as d3 from "d3";

// Zoom and Pan controls for graph navigation
export const GraphNavigation = (
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
g: d3.Selection<SVGGElement, unknown, null, undefined>,
zoom: d3.ZoomBehavior<Element, unknown>
) => {
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);
});
};
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit e503c97

Please sign in to comment.