-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Initial draft of relational rule map component #56
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; | ||
} |
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} />; | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure I understand this pattern here. Like it is nice to encapsulate this behaviour but it just feels a little confusing or odd to follow to me. Would it be simpler to just have the "useRuleGraph" useEffect code just in a util function and just have the useEffect living directly in RuleRelationsGraph and calling that util function? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the code uses the context hook to handle the description box modal, I was experiencing difficulty in implementing it directly in the RuleRelationsGraph due to the hook rules. For improved clarity i could move the logic from the hook file into a new subcomponent 'RuleGraph' which would hold the useRuleGraph hook and the GraphContent functional component? since the GraphContent component is the only place the useRuleGraph hook is utilized, it would make sense to have them together, and then only import the GraphContent for rendering of the actual svg? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great. Looks more clear to me now |
||
|
||
/** | ||
* 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; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can just be a local path