Skip to content

Commit

Permalink
Merge pull request #325 from amelioro/add-diagram-filters
Browse files Browse the repository at this point in the history
Add diagram filters
  • Loading branch information
keyserj authored Feb 10, 2024
2 parents d6b2d95 + a67d514 commit e58de58
Show file tree
Hide file tree
Showing 14 changed files with 718 additions and 57 deletions.
22 changes: 22 additions & 0 deletions docs/diagram-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
```mermaid
sequenceDiagram
autonumber
actor User
participant TopicWorkspace
participant TopicDiagram
participant store
participant Diagram
participant diagramHooks
participant ReactFlow
User->>TopicWorkspace: Visit /[username]/[topic]
TopicWorkspace->>TopicDiagram: Render
TopicDiagram->>store: diagram = useTopicDiagram
Note over store: get topic diagram<br/>apply filter<br/>get only relevant edges
TopicDiagram->>Diagram: Render(diagram)
Diagram->>diagramHooks: layoutedDiagram = useLayoutedDiagram
Note over diagramHooks: perform layout if diagram changed<br/>add positions to nodes and edges
Note over Diagram: move viewport if node added<br/>fit viewport if new topic loaded
Diagram->>ReactFlow: Render(layoutedDiagram)
Note over ReactFlow: Render FlowNode and ScoreEdge components
```
1 change: 1 addition & 0 deletions src/web/common/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface Events {
addNode: (node: Node) => void;
errored: () => void;
loadedTopicData: () => void;
changedFilter: () => void;
}

export const emitter = createNanoEvents<Events>();
Expand Down
10 changes: 6 additions & 4 deletions src/web/topic/components/Diagram/Diagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const onEdgeUpdate: OnEdgeUpdateFunc = (oldEdge, newConnection) => {
};

const DiagramWithoutProvider = (diagram: DiagramData) => {
const [topicNewlyLoaded, setTopicNewlyLoaded] = useState(false);
const [topicViewUpdated, setTopicViewUpdated] = useState(false);
const [newNodeId, setNewNodeId] = useState<string | null>(null);

const { sessionUser } = useSessionUser();
Expand All @@ -88,11 +88,13 @@ const DiagramWithoutProvider = (diagram: DiagramData) => {

useEffect(() => {
const unbindAdd = emitter.on("addNode", (node) => setNewNodeId(node.id));
const unbindLoad = emitter.on("loadedTopicData", () => setTopicNewlyLoaded(true));
const unbindLoad = emitter.on("loadedTopicData", () => setTopicViewUpdated(true));
const unbindFilter = emitter.on("changedFilter", () => setTopicViewUpdated(true));

return () => {
unbindAdd();
unbindLoad();
unbindFilter();
};
}, []);

Expand All @@ -106,9 +108,9 @@ const DiagramWithoutProvider = (diagram: DiagramData) => {
setNewNodeId(null);
}

if (topicNewlyLoaded && hasNewLayout) {
if (topicViewUpdated && hasNewLayout) {
fitViewForNodes(nodes);
setTopicNewlyLoaded(false);
setTopicViewUpdated(false);
}

if (hasNewLayout) setHasNewLayout(false);
Expand Down
18 changes: 8 additions & 10 deletions src/web/topic/components/TopicPane/GraphPartDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { nodeSchema } from "../../../../common/node";
import { exploreNodeTypes, nodeSchema } from "../../../../common/node";
import { useSessionUser } from "../../../common/hooks";
import { setGraphPartNotes } from "../../store/actions";
import { useUserCanEditTopicData } from "../../store/userHooks";
Expand All @@ -21,13 +21,11 @@ import { FactDetails } from "./FactDetails";
import { QuestionDetails } from "./QuestionDetails";
import { SourceDetails } from "./SourceDetails";

const formSchema = () => {
return z.object({
// same restrictions as edge, so we should be fine reusing node's schema
notes: nodeSchema.shape.notes,
});
};
type FormData = z.infer<ReturnType<typeof formSchema>>;
const formSchema = z.object({
// same restrictions as edge, so we should be fine reusing node's schema
notes: nodeSchema.shape.notes,
});
type FormData = z.infer<typeof formSchema>;

interface Props {
graphPart: GraphPart;
Expand All @@ -45,7 +43,7 @@ export const GraphPartDetails = ({ graphPart }: Props) => {
} = useForm<FormData>({
mode: "onBlur",
reValidateMode: "onBlur",
resolver: zodResolver(formSchema()),
resolver: zodResolver(formSchema),
defaultValues: {
notes: graphPart.data.notes,
},
Expand Down Expand Up @@ -104,7 +102,7 @@ export const GraphPartDetails = ({ graphPart }: Props) => {
/>
</ListItem>

<Divider />
{isNode(graphPart) && exploreNodeTypes.includes(graphPart.type) && <Divider />}

{isNodeType(graphPart, "question") && <QuestionDetails questionNode={graphPart} />}
{isNodeType(graphPart, "answer") && <AnswerDetails answerNode={graphPart} />}
Expand Down
22 changes: 22 additions & 0 deletions src/web/topic/components/TopicPane/TopicViews.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import {
AutoStories,
ExpandLess,
ExpandMore,
FilterAlt,
School,
TableChart,
TableView,
} from "@mui/icons-material";
import {
Collapse,
Divider,
List,
ListItem,
ListItemButton,
Expand All @@ -17,6 +19,7 @@ import {
} from "@mui/material";
import { useState } from "react";

import { FilterOptions } from "../../../view/components/FilterOptions/FilterOptions";
import {
useActiveArguedDiagramPart,
useActiveTableProblemNode,
Expand All @@ -33,6 +36,7 @@ import { NestedListItemButton } from "./TopicViews.styles";
export const TopicViews = () => {
const [isClaimsListOpen, setIsClaimsListOpen] = useState(true);
const [isProblemsListOpen, setIsProblemsListOpen] = useState(true);
const [isFilterOptionsOpen, setIsFilterOptionsOpen] = useState(true);

const activeView = useActiveView();
const activeTableProblemNode = useActiveTableProblemNode();
Expand Down Expand Up @@ -136,6 +140,24 @@ export const TopicViews = () => {
))}
</List>
</Collapse>
{(activeView === "topicDiagram" || activeView === "exploreDiagram") && (
<>
<Divider sx={{ marginY: 1 }} />

<ListItem key="5">
<ListItemButton onClick={() => setIsFilterOptionsOpen(!isFilterOptionsOpen)}>
<ListItemIcon>
<FilterAlt />
</ListItemIcon>
<ListItemText primary="Filter Options" />
{isFilterOptionsOpen ? <ExpandLess /> : <ExpandMore />}
</ListItemButton>
</ListItem>
<Collapse in={isFilterOptionsOpen} timeout="auto" unmountOnExit>
<FilterOptions key={activeView} activeView={activeView} />
</Collapse>
</>
)}
</List>
);
};
36 changes: 36 additions & 0 deletions src/web/topic/store/nodeHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,39 @@ export const useIsEdgeSelected = (nodeId: string) => {

return useIsAnyGraphPartSelected(neighborEdges.map((edge) => edge.id));
};

export const useProblems = () => {
return useTopicStore((state) => state.nodes.filter((node) => node.type === "problem"), shallow);
};

export const useQuestions = () => {
return useTopicStore((state) => state.nodes.filter((node) => node.type === "question"), shallow);
};

export const useSolutions = (problemId?: string) => {
return useTopicStore((state) => {
if (!problemId) return [];

const allSolutions = state.nodes.filter((node) => node.type === "solution");
return allSolutions.filter((solution) =>
state.edges.find(
(edge) =>
edge.source === problemId && edge.label === "addresses" && edge.target === solution.id
)
);
}, shallow);
};

export const useCriteria = (problemId?: string) => {
return useTopicStore((state) => {
if (!problemId) return [];

const allCriteria = state.nodes.filter((node) => node.type === "criterion");
return allCriteria.filter((criterion) =>
state.edges.find(
(edge) =>
edge.source === problemId && edge.label === "criterionFor" && edge.target === criterion.id
)
);
}, shallow);
};
30 changes: 24 additions & 6 deletions src/web/topic/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { temporal } from "zundo";
import { devtools, persist } from "zustand/middleware";
import { createWithEqualityFn } from "zustand/traditional";

import { claimRelationNames } from "../../../common/edge";
import { useShowImpliedEdges } from "../../view/actionConfigStore";
import { Diagram, filterHiddenComponents } from "../utils/diagram";
import { Edge, Node, Score, buildNode } from "../utils/graph";
import { useFilterOptions } from "../../view/navigateStore";
import { applyFilter } from "../../view/utils/filter";
import { Diagram } from "../utils/diagram";
import { hideImpliedEdges } from "../utils/edge";
import { Edge, Node, Score, buildNode, getRelevantEdges } from "../utils/graph";
import { apiSyncer } from "./apiSyncerMiddleware";
import { migrate } from "./migrate";
import { getClaimTree, getExploreDiagram, getTopicDiagram } from "./utils";
Expand Down Expand Up @@ -75,20 +77,36 @@ export const useTopicStore = createWithEqualityFn<TopicStoreState>()(

export const useTopicDiagram = (): Diagram => {
const showImpliedEdges = useShowImpliedEdges();
const filterOptions = useFilterOptions("topicDiagram");

return useTopicStore((state) => {
const topicGraph = { nodes: state.nodes, edges: state.edges };
const topicDiagram = getTopicDiagram(topicGraph);
const claimEdges = state.edges.filter((edge) => claimRelationNames.includes(edge.label));
return filterHiddenComponents(topicDiagram, claimEdges, showImpliedEdges);
const { nodes: filteredPrimaryNodes } = applyFilter(topicDiagram, filterOptions);

const nodes = filteredPrimaryNodes;

const relevantEdges = getRelevantEdges(nodes, topicGraph);
const edges = showImpliedEdges
? relevantEdges
: hideImpliedEdges(relevantEdges, { nodes, edges: relevantEdges }, topicGraph);

return { nodes, edges, orientation: "DOWN", type: "topicDiagram" };
});
};

export const useExploreDiagram = (): Diagram => {
const filterOptions = useFilterOptions("exploreDiagram");

return useTopicStore((state) => {
const topicGraph = { nodes: state.nodes, edges: state.edges };
const exploreDiagram = getExploreDiagram(topicGraph);
return filterHiddenComponents(exploreDiagram, [], false); // no need to filter implied edges because explore diagram shouldn't have any
const { nodes: filteredPrimaryNodes } = applyFilter(exploreDiagram, filterOptions);

const nodes = filteredPrimaryNodes;

const edges = getRelevantEdges(nodes, topicGraph);
return { nodes, edges, orientation: "DOWN", type: "exploreDiagram" };
});
};

Expand Down
32 changes: 0 additions & 32 deletions src/web/topic/utils/diagram.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { DiagramType } from "../../../common/diagram";
import { errorWithData } from "../../../common/errorHandling";
import { isEdgeImplied } from "./edge";
import { Edge, Node } from "./graph";
import { Orientation } from "./layout";

Expand Down Expand Up @@ -29,34 +28,3 @@ export const getDiagramTitle = (diagram: Diagram) => {

return rootNode.data.label;
};

/**
* general philosophy on hiding components, to minimize confusion:
* - do not automatically hide components that have already been shown, unless the user chooses to hide them
* - always visually indicate hidden components some way
* - always allow the user to explicitly show/hide components that can be hidden
* - feel free to hide components when they're created if they're implied and have not been shown yet
*/
export const filterHiddenComponents = (
diagram: Diagram,
claimEdges: Edge[],
showImpliedEdges: boolean
): Diagram => {
const shownNodes = diagram.nodes.filter((node) => node.data.showing);
const shownNodeIds = shownNodes.map((node) => node.id);

const shownEdges = diagram.edges.filter((edge) => {
if (!shownNodeIds.includes(edge.source) || !shownNodeIds.includes(edge.target)) return false;

return true;
});

// edges are implied based on other shown nodes & edges, so filter those before filtering implied edges
const shownEdgesAfterImpliedFilter = shownEdges.filter(
(edge) =>
showImpliedEdges ||
!isEdgeImplied(edge, { ...diagram, nodes: shownNodes, edges: shownEdges }, claimEdges)
);

return { ...diagram, nodes: shownNodes, edges: shownEdgesAfterImpliedFilter };
};
11 changes: 8 additions & 3 deletions src/web/topic/utils/edge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { RelationName, claimRelationNames } from "../../../common/edge";
import { NodeType, claimNodeTypes, exploreNodeTypes, nodeTypes } from "../../../common/node";
import { hasClaims } from "./claim";
import { Diagram } from "./diagram";
import { Edge, Graph, Node, RelationDirection, findNode } from "./graph";
import { children, components, parents } from "./node";

Expand Down Expand Up @@ -312,9 +311,15 @@ export const isEdgeImpliedByComposition = (edge: Edge, topicGraph: Graph) => {
// We don't want users to apply scores and then never see them again due to an implied edge being
// hidden. The button to show implied edges should reduce this pain, but maybe we need a better view
// to reduce the need to hide implied edges?
export const isEdgeImplied = (edge: Edge, displayDiagram: Diagram, claimEdges: Edge[]) => {
const isEdgeImplied = (edge: Edge, graph: Graph, claimEdges: Edge[]) => {
if (claimRelationNames.includes(edge.label)) return false; // claims can't be implied
if (hasClaims(edge, claimEdges)) return false;

return isEdgeAShortcut(edge, displayDiagram) || isEdgeImpliedByComposition(edge, displayDiagram);
return isEdgeAShortcut(edge, graph) || isEdgeImpliedByComposition(edge, graph);
};

export const hideImpliedEdges = (edges: Edge[], displayGraph: Graph, topicGraph: Graph) => {
const claimEdges = topicGraph.edges.filter((edge) => claimRelationNames.includes(edge.label));

return edges.filter((edge) => !isEdgeImplied(edge, displayGraph, claimEdges));
};
40 changes: 40 additions & 0 deletions src/web/topic/utils/graph.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import uniqBy from "lodash/uniqBy";
import { v4 as uuid } from "uuid";

import { RelationName } from "../../../common/edge";
Expand Down Expand Up @@ -183,3 +184,42 @@ export const getNodesComposedBy = (node: Node, topicGraph: Graph) => {
.map((node) => node);
});
};

const findNodesRecursivelyFrom = (
fromNode: Node,
toDirection: RelationDirection,
graph: Graph,
labels?: RelationName[]
): Node[] => {
const from = toDirection === "child" ? "source" : "target";
const to = toDirection === "child" ? "target" : "source";

const foundEdges = graph.edges.filter(
(edge) => edge[from] === fromNode.id && (!labels || labels.includes(edge.label))
);
const foundNodes = foundEdges.map((edge) => findNode(edge[to], graph.nodes));

if (foundNodes.length === 0) return [];

const furtherNodes = foundNodes.flatMap((node) =>
findNodesRecursivelyFrom(node, toDirection, graph, labels)
);

return uniqBy(foundNodes.concat(furtherNodes), (node) => node.id);
};

export const ancestors = (fromNode: Node, graph: Graph, labels?: RelationName[]) => {
return findNodesRecursivelyFrom(fromNode, "parent", graph, labels);
};

export const descendants = (fromNode: Node, graph: Graph, labels?: RelationName[]) => {
return findNodesRecursivelyFrom(fromNode, "child", graph, labels);
};

export const getRelevantEdges = (nodes: Node[], graph: Graph) => {
const nodeIds = nodes.map((node) => node.id);

return graph.edges.filter(
(edge) => nodeIds.includes(edge.target) && nodeIds.includes(edge.source)
);
};
Loading

0 comments on commit e58de58

Please sign in to comment.