From d819d6090b9e8658b64ccb3c97cd0b30f439e538 Mon Sep 17 00:00:00 2001 From: Joel Keyser Date: Tue, 6 Feb 2024 10:31:53 -0600 Subject: [PATCH 1/5] docs: add diagram rendering sequence diagram --- docs/diagram-rendering.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/diagram-rendering.md diff --git a/docs/diagram-rendering.md b/docs/diagram-rendering.md new file mode 100644 index 00000000..43160347 --- /dev/null +++ b/docs/diagram-rendering.md @@ -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
apply filter
get only relevant edges + TopicDiagram->>Diagram: Render(diagram) + Diagram->>diagramHooks: layoutedDiagram = useLayoutedDiagram + Note over diagramHooks: perform layout if diagram changed
add positions to nodes and edges + Note over Diagram: move viewport if node added
fit viewport if new topic loaded + Diagram->>ReactFlow: Render(layoutedDiagram) + Note over ReactFlow: Render FlowNode and ScoreEdge components +``` From 542d8c3b2e51249d5c827af692dfb063b3935950 Mon Sep 17 00:00:00 2001 From: Joel Keyser Date: Sat, 10 Feb 2024 15:41:24 -0600 Subject: [PATCH 2/5] fix: rendering extra divider in node details also removed unnecessary schema logic for form in node details --- .../components/TopicPane/GraphPartDetails.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/web/topic/components/TopicPane/GraphPartDetails.tsx b/src/web/topic/components/TopicPane/GraphPartDetails.tsx index 10b13f91..0b351246 100644 --- a/src/web/topic/components/TopicPane/GraphPartDetails.tsx +++ b/src/web/topic/components/TopicPane/GraphPartDetails.tsx @@ -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"; @@ -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>; +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; interface Props { graphPart: GraphPart; @@ -45,7 +43,7 @@ export const GraphPartDetails = ({ graphPart }: Props) => { } = useForm({ mode: "onBlur", reValidateMode: "onBlur", - resolver: zodResolver(formSchema()), + resolver: zodResolver(formSchema), defaultValues: { notes: graphPart.data.notes, }, @@ -104,7 +102,7 @@ export const GraphPartDetails = ({ graphPart }: Props) => { /> - + {isNode(graphPart) && exploreNodeTypes.includes(graphPart.type) && } {isNodeType(graphPart, "question") && } {isNodeType(graphPart, "answer") && } From f315f818472dfffa52f547ceb12c668e707ce5ab Mon Sep 17 00:00:00 2001 From: Joel Keyser Date: Sat, 10 Feb 2024 16:50:49 -0600 Subject: [PATCH 3/5] feat: add causes, solutions, questions filters --- .../topic/components/TopicPane/TopicViews.tsx | 22 ++ src/web/topic/store/nodeHooks.ts | 36 ++ src/web/topic/store/store.ts | 30 +- src/web/topic/utils/diagram.ts | 32 -- src/web/topic/utils/edge.ts | 11 +- src/web/topic/utils/graph.ts | 40 +++ .../FilterOptions/FilterOptions.tsx | 335 ++++++++++++++++++ src/web/view/navigateStore.ts | 38 ++ src/web/view/utils/filter.ts | 173 +++++++++ 9 files changed, 676 insertions(+), 41 deletions(-) create mode 100644 src/web/view/components/FilterOptions/FilterOptions.tsx create mode 100644 src/web/view/utils/filter.ts diff --git a/src/web/topic/components/TopicPane/TopicViews.tsx b/src/web/topic/components/TopicPane/TopicViews.tsx index 1e8370ea..bd1dceda 100644 --- a/src/web/topic/components/TopicPane/TopicViews.tsx +++ b/src/web/topic/components/TopicPane/TopicViews.tsx @@ -3,12 +3,14 @@ import { AutoStories, ExpandLess, ExpandMore, + FilterAlt, School, TableChart, TableView, } from "@mui/icons-material"; import { Collapse, + Divider, List, ListItem, ListItemButton, @@ -17,6 +19,7 @@ import { } from "@mui/material"; import { useState } from "react"; +import { FilterOptions } from "../../../view/components/FilterOptions/FilterOptions"; import { useActiveArguedDiagramPart, useActiveTableProblemNode, @@ -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(); @@ -136,6 +140,24 @@ export const TopicViews = () => { ))} + {(activeView === "topicDiagram" || activeView === "exploreDiagram") && ( + <> + + + + setIsFilterOptionsOpen(!isFilterOptionsOpen)}> + + + + + {isFilterOptionsOpen ? : } + + + + + + + )} ); }; diff --git a/src/web/topic/store/nodeHooks.ts b/src/web/topic/store/nodeHooks.ts index 8a097299..465492a2 100644 --- a/src/web/topic/store/nodeHooks.ts +++ b/src/web/topic/store/nodeHooks.ts @@ -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); +}; diff --git a/src/web/topic/store/store.ts b/src/web/topic/store/store.ts index 1f1ceafc..a5f0ae43 100644 --- a/src/web/topic/store/store.ts +++ b/src/web/topic/store/store.ts @@ -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"; @@ -75,20 +77,36 @@ export const useTopicStore = createWithEqualityFn()( 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" }; }); }; diff --git a/src/web/topic/utils/diagram.ts b/src/web/topic/utils/diagram.ts index 4011193e..1cfa1dc3 100644 --- a/src/web/topic/utils/diagram.ts +++ b/src/web/topic/utils/diagram.ts @@ -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"; @@ -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 }; -}; diff --git a/src/web/topic/utils/edge.ts b/src/web/topic/utils/edge.ts index c9f7e6f6..a1f0433b 100644 --- a/src/web/topic/utils/edge.ts +++ b/src/web/topic/utils/edge.ts @@ -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"; @@ -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)); }; diff --git a/src/web/topic/utils/graph.ts b/src/web/topic/utils/graph.ts index 8228cc3a..caf62888 100644 --- a/src/web/topic/utils/graph.ts +++ b/src/web/topic/utils/graph.ts @@ -1,3 +1,4 @@ +import uniqBy from "lodash/uniqBy"; import { v4 as uuid } from "uuid"; import { RelationName } from "../../../common/edge"; @@ -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) + ); +}; diff --git a/src/web/view/components/FilterOptions/FilterOptions.tsx b/src/web/view/components/FilterOptions/FilterOptions.tsx new file mode 100644 index 00000000..58a66b28 --- /dev/null +++ b/src/web/view/components/FilterOptions/FilterOptions.tsx @@ -0,0 +1,335 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Autocomplete, Stack, TextField } from "@mui/material"; +import { useCallback, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { + useCriteria, + useProblems, + useQuestions, + useSolutions, +} from "../../../topic/store/nodeHooks"; +import { setFilterOptions, useFilterOptions } from "../../navigateStore"; +import { + FilterTypes, + exploreFilterTypes, + filterOptionsSchema, + filterSchemas, + topicFilterTypes, +} from "../../utils/filter"; + +type ValidatedFormData = z.infer; + +// how to build this based on schemas? .merge doesn't work because `type` is overridden by the last schema's literal +interface FormData { + type: FilterTypes; + centralProblemId?: string; + detail: "all" | "connectedToCriteria" | "none"; + solutions: string[]; + criteria: string[]; + centralQuestionId?: string; +} + +/** + * Helper to grab properties from an object whose type isn't narrow enough to know that the property exists. + * + * @returns the value of the property if the object exists with it, otherwise the default value + */ +const getProp = (obj: object | null, prop: string, defaultValue: T): T => { + if (!obj || !(prop in obj)) return defaultValue; + const value = (obj as Record)[prop]; + return value ?? defaultValue; +}; + +interface Props { + activeView: "topicDiagram" | "exploreDiagram"; +} + +/** + * Features: + * - Tracks filter options per diagram, so that you can quickly switch between diagrams without losing filter + * - Reuses field values across filters (e.g. central problem retains value when switching filter type) + * - Defaults field values based on nodes that exist in the diagram + * - Shows field components and validates based on filter type + */ +export const FilterOptions = ({ activeView }: Props) => { + const filterOptions = useFilterOptions(activeView); + const problems = useProblems(); // could consider selecting causes here, but probably don't want causes as options for solutions filter + const questions = useQuestions(); + + const { + control, + handleSubmit, + watch, + setValue, + formState: { errors }, + } = useForm({ + resolver: zodResolver(filterOptionsSchema), + defaultValues: { + type: filterOptions.type, + centralProblemId: getProp( + filterOptions, + "centralProblemId", + problems[0]?.id + ), + detail: getProp<"all" | "connectedToCriteria" | "none">(filterOptions, "detail", "all"), + solutions: getProp(filterOptions, "solutions", []), + // TODO?: ideally this defaults to all criteria so that empty can mean no criteria displayed, + // but we can't rely on `watch("centralProblemId")` with `useCriteria(centralProblemId)` since + // watch isn't usable until after form definition. Potentially could move all defaults to + // be specified on their component, but that's annoying and we'd also have to handle the first + // render, during which `watch` is undefined. + criteria: getProp(filterOptions, "criteria", []), + centralQuestionId: getProp( + filterOptions, + "centralQuestionId", + questions[0]?.id + ), + }, + }); + + const filterTypes = activeView === "topicDiagram" ? topicFilterTypes : exploreFilterTypes; + + const type = watch("type"); + const typeSchemaShape = filterSchemas[type].shape; + + // TODO?: maybe worth extracting a component per field? but a lot is coupled... maybe best would be + // to extract a NodeAutocomplete component, since these are all autocompletes + const centralProblemId = watch("centralProblemId"); + const centralProblemOptions = useMemo(() => { + return problems.map((problem) => ({ label: problem.data.label, id: problem.id })); + }, [problems]); + const centralProblemValue = useMemo(() => { + const value = centralProblemOptions.find((option) => option.id === centralProblemId); + if (centralProblemId && !value) setValue("centralProblemId", centralProblemOptions[0]?.id); // if node is deleted, make sure we don't retain the deleted id to make the form think it's valid + return value ?? centralProblemOptions[0]; + }, [centralProblemId, centralProblemOptions, setValue]); + + const solutions = useSolutions(centralProblemId); + const selectedSolutions = watch("solutions"); + const solutionOptions = useMemo(() => { + return solutions.map((solution) => ({ label: solution.data.label, id: solution.id })); + }, [solutions]); + const solutionValues = useMemo(() => { + return solutionOptions.filter((option) => selectedSolutions.includes(option.id)); + }, [selectedSolutions, solutionOptions]); + + const criteria = useCriteria(centralProblemId); + const selectedCriteria = watch("criteria"); + const criteriaOptions = useMemo(() => { + return criteria.map((criterion) => ({ label: criterion.data.label, id: criterion.id })); + }, [criteria]); + const criteriaValues = useMemo(() => { + return criteriaOptions.filter((option) => selectedCriteria.includes(option.id)); + }, [selectedCriteria, criteriaOptions]); + + const centralQuestionId = watch("centralQuestionId"); + const centralQuestionOptions = useMemo(() => { + return questions.map((question) => ({ label: question.data.label, id: question.id })); + }, [questions]); + const centralQuestionValue = useMemo(() => { + const value = centralQuestionOptions.find((option) => option.id === centralQuestionId); + if (centralQuestionId && !value) setValue("centralQuestionId", centralQuestionOptions[0]?.id); // if node is deleted, make sure we don't retain the deleted id to make the form think it's valid + return value ?? centralQuestionOptions[0]; + }, [centralQuestionId, centralQuestionOptions, setValue]); + + const submit = useCallback(() => { + void handleSubmit((data) => { + // We know that zod has validated the data by this point. + // `FormData` is used for the form's data type so that form `errors` type has all props; + // without this, `errors` only knows the props that intersect all the schemas, i.e. `type`. + setFilterOptions(data as ValidatedFormData); + })(); + }, [handleSubmit]); + + // TODO?: can form onBlur be used to submit when any input changes? + return ( +
+ + {/* GitHub code search found this example implementing Mui Autocomplete with react-hook-form https://github.com/GeoWerkstatt/ews-boda/blob/79cb1484db53170aace5a4b01ed1f9c56269f7c4/src/ClientApp/src/components/SchichtForm.js#L126-L153 */} + ( + { + field.onChange(value); + submit(); // how otherwise to ensure submit happens on change of any form input? + }} + disableClearable + renderInput={(params) => } + size="small" + /> + )} + /> + {"centralProblemId" in filterSchemas[type].shape && ( + ( + { + if (!value) return; + field.onChange(value.id); + submit(); + }} + disableClearable={centralProblemValue !== undefined} + renderInput={(params) => ( + + )} + // required to avoid duplicate key error if two nodes have the same text https://github.com/mui/material-ui/issues/26492#issuecomment-901089142 + renderOption={(props, option) => { + return ( +
  • + {option.label} +
  • + ); + }} + size="small" + /> + )} + /> + )} + {"detail" in typeSchemaShape && ( + ( + option.value)} + onChange={(_event, value) => { + field.onChange(value); + submit(); + }} + disableClearable + renderInput={(params) => } + size="small" + /> + )} + /> + )} + {"solutions" in filterSchemas[type].shape && ( + ( + { + field.onChange(values.map((value) => value.id)); + submit(); + }} + renderInput={(params) => ( + + )} + // required to avoid duplicate key error if two nodes have the same text https://github.com/mui/material-ui/issues/26492#issuecomment-901089142 + renderOption={(props, option) => { + return ( +
  • + {option.label} +
  • + ); + }} + size="small" + /> + )} + /> + )} + {"criteria" in filterSchemas[type].shape && ( + ( + { + field.onChange(values.map((value) => value.id)); + submit(); + }} + renderInput={(params) => ( + + )} + // required to avoid duplicate key error if two nodes have the same text https://github.com/mui/material-ui/issues/26492#issuecomment-901089142 + renderOption={(props, option) => { + return ( +
  • + {option.label} +
  • + ); + }} + size="small" + /> + )} + /> + )} + {"centralQuestionId" in filterSchemas[type].shape && ( + ( + { + if (!value) return; + field.onChange(value.id); + submit(); + }} + disableClearable={centralQuestionValue !== undefined} + renderInput={(params) => ( + + )} + // required to avoid duplicate key error if two nodes have the same text https://github.com/mui/material-ui/issues/26492#issuecomment-901089142 + renderOption={(props, option) => { + return ( +
  • + {option.label} +
  • + ); + }} + size="small" + /> + )} + /> + )} +
    +
    + ); +}; diff --git a/src/web/view/navigateStore.ts b/src/web/view/navigateStore.ts index 2a00d014..5c143bde 100644 --- a/src/web/view/navigateStore.ts +++ b/src/web/view/navigateStore.ts @@ -3,9 +3,11 @@ import Router from "next/router"; import { useEffect, useState } from "react"; import { createWithEqualityFn } from "zustand/traditional"; +import { throwError } from "../../common/errorHandling"; import { useGraphPart } from "../topic/store/graphPartHooks"; import { useNode } from "../topic/store/nodeHooks"; import { useTopicStore } from "../topic/store/store"; +import { FilterOptions } from "./utils/filter"; type View = "topicDiagram" | "exploreDiagram" | "criteriaTable" | "claimTree"; @@ -18,6 +20,8 @@ interface NavigateStoreState { viewingClaimTree: boolean; activeClaimTreeId: string | null; + + filterOptions: Partial>; } const initialState: NavigateStoreState = { @@ -29,6 +33,15 @@ const initialState: NavigateStoreState = { viewingClaimTree: false, activeClaimTreeId: null, + + filterOptions: { + topicDiagram: { + type: "none", + }, + exploreDiagram: { + type: "none", + }, + }, }; const useNavigateStore = createWithEqualityFn()( @@ -91,6 +104,19 @@ export const useActiveArguedDiagramPart = () => { return useGraphPart(activeClaimTreeId); }; +export const useFilterOptions = (view: View) => { + return useNavigateStore((state) => { + return ( + state.filterOptions[view] ?? + throwError( + "Filter options only exist for the topic or explore diagrams", + view, + state.filterOptions + ) + ); + }); +}; + // actions export const setSelected = (graphPartId: string | null) => { useNavigateStore.setState({ selectedGraphPartId: graphPartId }); @@ -140,6 +166,18 @@ export const resetNavigation = () => { useNavigateStore.setState(initialState); }; +export const setFilterOptions = (filterOptions: FilterOptions) => { + const state = useNavigateStore.getState(); + const activeView = getActiveView(state); + + if (!state.filterOptions[activeView]) + throw new Error("Filter options can only be set when viewing the topic or explore diagrams"); + + useNavigateStore.setState({ + filterOptions: { ...state.filterOptions, [activeView]: filterOptions }, + }); +}; + // helpers const getActiveView = (state: NavigateStoreState): View => { if (state.viewingClaimTree) return "claimTree"; diff --git a/src/web/view/utils/filter.ts b/src/web/view/utils/filter.ts new file mode 100644 index 00000000..d811000b --- /dev/null +++ b/src/web/view/utils/filter.ts @@ -0,0 +1,173 @@ +import { z } from "zod"; + +import { exploreRelationNames } from "../../../common/edge"; +import { nodeSchema } from "../../../common/node"; +import { Graph, ancestors, descendants, getRelevantEdges } from "../../topic/utils/graph"; +import { children, parents } from "../../topic/utils/node"; + +// none filter +const noneSchema = z.object({ + type: z.literal("none"), +}); + +/** + * Description: + * - Show all recursive "causes" child relations from the central problem + * + * Use cases: + * - Brainstorm causes + */ +const applyCausesFilter = (graph: Graph, filterOptions: CausesOptions) => { + const centralProblem = graph.nodes.find((node) => node.id === filterOptions.centralProblemId); + if (!centralProblem) return graph; + + const causes = descendants(centralProblem, graph, ["causes"]); + + const nodes = [centralProblem, ...causes]; + const edges = getRelevantEdges(nodes, graph); + + return { nodes, edges }; +}; + +const causesSchema = z.object({ + type: z.literal("causes"), + centralProblemId: nodeSchema.shape.id, +}); + +type CausesOptions = z.infer; + +/** + * Description: + * - Show problem + * - Show selected criteria with all depth-1 related parents (causes, effects, benefits, detriments) + * - Show selected solutions with all components, recursive effects, benefits, detriments + * + * Detail options: + * - all: see description + * - connectedToCriteria: for solutions, only show details that are connected to selected criteria + * - none: for solutions, don't show details + * + * Use cases: + * - Brainstorm solutions + * - Detail a solution + * - Compare solutions + */ +const applySolutionsFilter = (graph: Graph, filterOptions: SolutionsOptions) => { + const centralProblem = graph.nodes.find((node) => node.id === filterOptions.centralProblemId); + if (!centralProblem) return graph; + + const problemChildren = children(centralProblem, graph); + const selectedSolutions = problemChildren.filter( + (child) => + child.type === "solution" && + (filterOptions.solutions.length === 0 || filterOptions.solutions.includes(child.id)) + ); + const selectedCriteria = problemChildren.filter( + (child) => + child.type === "criterion" && + (filterOptions.criteria.length === 0 || filterOptions.criteria.includes(child.id)) + ); + + const criteriaParents = selectedCriteria.flatMap((criterion) => + // filter problem because we want to include the problem regardless of if we're showing criteria, for context + parents(criterion, graph).filter((parent) => parent.type !== "problem") + ); + + const solutionDetails = + filterOptions.detail === "none" + ? [] + : selectedSolutions.flatMap((solution) => ancestors(solution, graph, ["has", "creates"])); + + const criteriaIds = selectedCriteria.map((criterion) => criterion.id); + const filteredSolutionDetails = + filterOptions.detail === "none" + ? [] + : filterOptions.detail === "all" + ? solutionDetails + : solutionDetails.filter((detail) => + ancestors(detail, graph).some((ancestor) => criteriaIds.includes(ancestor.id)) + ); + + const nodes = [ + centralProblem, + ...selectedSolutions, + ...selectedCriteria, + ...criteriaParents, + ...filteredSolutionDetails, + ]; + const edges = getRelevantEdges(nodes, graph); + + return { nodes, edges }; +}; + +const solutionsSchema = z.object({ + type: z.literal("solutions"), + centralProblemId: nodeSchema.shape.id, + detail: z.union([z.literal("all"), z.literal("connectedToCriteria"), z.literal("none")]), + solutions: z.array(nodeSchema.shape.id), + criteria: z.array(nodeSchema.shape.id), +}); + +type SolutionsOptions = z.infer; + +/** + * Description: + * - Show question, depth-1 parents for context, all recursive child questions, answers, facts, + * solutions + * + * Use cases: + * - Explore a question + */ +const applyQuestionFilter = (graph: Graph, filterOptions: QuestionOptions) => { + const centralQuestion = graph.nodes.find((node) => node.id === filterOptions.centralQuestionId); + if (!centralQuestion) return graph; + + const parentsForContext = parents(centralQuestion, graph); + const exploreChildren = descendants(centralQuestion, graph, exploreRelationNames); + + const nodes = [centralQuestion, ...parentsForContext, ...exploreChildren]; + const edges = getRelevantEdges(nodes, graph); + + return { nodes, edges }; +}; + +const questionSchema = z.object({ + type: z.literal("question"), + centralQuestionId: nodeSchema.shape.id, +}); + +type QuestionOptions = z.infer; + +// filter methods + +// TODO?: is there a way to type-guarantee that these values come from the defined schemas? +export const topicFilterTypes = ["none", "causes", "solutions"] as const; +export const exploreFilterTypes = ["none", "question"] as const; + +const filterTypes = [...topicFilterTypes, ...exploreFilterTypes] as const; +export type FilterTypes = typeof filterTypes[number]; + +export const applyFilter = (graph: Graph, options: FilterOptions): Graph => { + // TODO?: is there a way to use a Record rather than a big if-else? + // while still maintaining that the applyMethod only accepts the correct options type + if (options.type === "none") return graph; + else if (options.type === "causes") return applyCausesFilter(graph, options); + else if (options.type === "solutions") return applySolutionsFilter(graph, options); + else return applyQuestionFilter(graph, options); +}; + +export const filterOptionsSchema = z.discriminatedUnion("type", [ + noneSchema, + causesSchema, + solutionsSchema, + questionSchema, +]); + +export const filterSchemas = { + none: noneSchema, + causes: causesSchema, + solutions: solutionsSchema, + question: questionSchema, +}; + +export type FilterOptions = z.infer; From 1f838b8f03925d712a0f8ef1d63975a01f99580e Mon Sep 17 00:00:00 2001 From: Joel Keyser Date: Sat, 10 Feb 2024 16:51:52 -0600 Subject: [PATCH 4/5] touchup: re-zoom after changing filter --- src/web/common/event.ts | 1 + src/web/topic/components/Diagram/Diagram.tsx | 10 ++++++---- src/web/view/navigateStore.ts | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/web/common/event.ts b/src/web/common/event.ts index 41b4c39d..fedee9f5 100644 --- a/src/web/common/event.ts +++ b/src/web/common/event.ts @@ -7,6 +7,7 @@ interface Events { addNode: (node: Node) => void; errored: () => void; loadedTopicData: () => void; + changedFilter: () => void; } export const emitter = createNanoEvents(); diff --git a/src/web/topic/components/Diagram/Diagram.tsx b/src/web/topic/components/Diagram/Diagram.tsx index 1bc5a066..715a506f 100644 --- a/src/web/topic/components/Diagram/Diagram.tsx +++ b/src/web/topic/components/Diagram/Diagram.tsx @@ -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(null); const { sessionUser } = useSessionUser(); @@ -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(); }; }, []); @@ -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); diff --git a/src/web/view/navigateStore.ts b/src/web/view/navigateStore.ts index 5c143bde..9030447b 100644 --- a/src/web/view/navigateStore.ts +++ b/src/web/view/navigateStore.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { createWithEqualityFn } from "zustand/traditional"; import { throwError } from "../../common/errorHandling"; +import { emitter } from "../common/event"; import { useGraphPart } from "../topic/store/graphPartHooks"; import { useNode } from "../topic/store/nodeHooks"; import { useTopicStore } from "../topic/store/store"; @@ -176,6 +177,8 @@ export const setFilterOptions = (filterOptions: FilterOptions) => { useNavigateStore.setState({ filterOptions: { ...state.filterOptions, [activeView]: filterOptions }, }); + + emitter.emit("changedFilter"); }; // helpers From a67d514e21bd60434475139facbadacd6e86eaf1 Mon Sep 17 00:00:00 2001 From: Joel Keyser Date: Sat, 10 Feb 2024 16:52:30 -0600 Subject: [PATCH 5/5] touchup: remove unnecessary true expression --- src/web/view/components/Perspectives/Perspectives.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web/view/components/Perspectives/Perspectives.tsx b/src/web/view/components/Perspectives/Perspectives.tsx index eb3664ca..d929399d 100644 --- a/src/web/view/components/Perspectives/Perspectives.tsx +++ b/src/web/view/components/Perspectives/Perspectives.tsx @@ -16,8 +16,8 @@ export const Perspectives = () => { return (