diff --git a/package.json b/package.json index d0f68614..f84dcc41 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "lint": "next lint --max-warnings 100" }, "dependencies": { + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-separator": "^1.1.7", diff --git a/src/components/ui/MainPanel/MetaDataInfo.tsx b/src/components/ui/MainPanel/MetaDataInfo.tsx index 66937af5..293ef87c 100644 --- a/src/components/ui/MainPanel/MetaDataInfo.tsx +++ b/src/components/ui/MainPanel/MetaDataInfo.tsx @@ -196,7 +196,7 @@ const MetaDataInfo = ({ meta, metadata, setShowMeta, setOpenVariables, popoverSi {`${meta.long_name} `} { popoverSide=="left" ? - + Attributes @@ -209,7 +209,7 @@ const MetaDataInfo = ({ meta, metadata, setShowMeta, setOpenVariables, popoverSi : - +
}

diff --git a/src/components/ui/MainPanel/Variables.tsx b/src/components/ui/MainPanel/Variables.tsx index f490ef1c..733b3add 100644 --- a/src/components/ui/MainPanel/Variables.tsx +++ b/src/components/ui/MainPanel/Variables.tsx @@ -20,6 +20,12 @@ import { DialogContent, DialogTitle, } from "@/components/ui/dialog"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; import { ZarrDataset } from "@/components/zarr/ZarrLoaderLRU"; const Variables = ({ @@ -44,42 +50,105 @@ const Variables = ({ ); const { currentStore } = useZarrStore(useShallow(state => ({ currentStore: state.currentStore, - }))) - const ZarrDS = useMemo(() => new ZarrDataset(currentStore), [currentStore]) + }))); + const ZarrDS = useMemo(() => new ZarrDataset(currentStore), [currentStore]); - const [dimArrays, setDimArrays] = useState([[0],[0],[0]]) - const [dimUnits, setDimUnits] = useState([null,null,null]) - const [dimNames, setDimNames] = useState (["Default"]) + const [dimArrays, setDimArrays] = useState([[0],[0],[0]]); + const [dimUnits, setDimUnits] = useState([null,null,null]); + const [dimNames, setDimNames] = useState(["Default"]); const [selectedIndex, setSelectedIndex] = useState(null); const [selectedVar, setSelectedVar] = useState(null); const [meta, setMeta] = useState(null); const [query, setQuery] = useState(""); + // root *open by default* (collapsible but starts open) + const [openAccordionItems, setOpenAccordionItems] = useState(["root"]); - const filtered = useMemo(() => { + // Build nested variable tree + const tree = useMemo(() => { const q = query.toLowerCase().trim(); - if (!q) return variables; - return variables.filter((variable) => - variable.toLowerCase().includes(q) - ); + let filteredVars = variables ?? []; + + if (q) { + filteredVars = filteredVars.filter((variable) => + variable.toLowerCase().includes(q) + ); + } + + const buildTree = (vars: string[]) => { + const t: any = {}; + vars.forEach((v) => { + const parts = v.split("/"); + let current = t; + parts.forEach((p, i) => { + if (!current[p]) current[p] = i === parts.length - 1 ? null : {}; + current = current[p]; + }); + }); + return t; + }; + + return buildTree(filteredVars); }, [query, variables]); + // Get all group paths (for auto-open when searching) + const getGroupPaths = (subtree: any, basePath = ""): string[] => { + let paths: string[] = []; + Object.entries(subtree).forEach(([key, value]) => { + const currentPath = basePath ? `${basePath}/${key}` : key; + if (value && typeof value === "object") { + paths.push(currentPath); + paths = paths.concat(getGroupPaths(value, currentPath)); + } + }); + return paths; + }; + + // Auto-open accordions that contain matches + useEffect(() => { + if (query.trim()) { + const openPaths = getGroupPaths(tree); + setOpenAccordionItems(["root", ...openPaths]); + } else { + // when not searching keep root open by default + setOpenAccordionItems(["root"]); + } + }, [query, tree]); + + // Handle variable selection + const handleVariableSelect = (val: string, idx: number) => { + setSelectedIndex(idx); + setSelectedVar(val); + GetDimInfo(val).then(e => { + setDimNames(e.dimNames); + setDimArrays(e.dimArrays); + setDimUnits(e.dimUnits); + }); + + if (popoverSide === "left") { + setOpenMetaPopover(true); + } else { + setShowMeta(true); + } + }; + useEffect(() => { if (variables && zMeta && selectedVar) { const relevant = zMeta.find((e: any) => e.name === selectedVar); if (relevant){ setMeta({...relevant, dimInfo : {dimArrays, dimNames, dimUnits}}); - ZarrDS.GetAttributes(selectedVar).then(e=>setMetadata(e)) + ZarrDS.GetAttributes(selectedVar).then(e=>setMetadata(e)); } } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedVar, variables, zMeta, dimArrays, dimNames, dimUnits]); useEffect(()=>{ - setSelectedIndex(null) - setSelectedVar(null) - setMeta(null) - setMetadata(null) - },[initStore]) + setSelectedIndex(null); + setSelectedVar(null); + setMeta(null); + setMetadata(null); + },[initStore, setMetadata]); useEffect(() => { const handleResize = () => { @@ -90,38 +159,99 @@ const Variables = ({ return () => window.removeEventListener("resize", handleResize); }, []); + // Variable item renderer (keeps separator between variables in same group) + const VariableItem = ({ val, idx, arrayLength }: { val: string; idx: number; arrayLength: number }) => { + const variableName = val.split('/').pop() || val; + const isLastItem = idx === arrayLength - 1; + + return ( + +
handleVariableSelect(val, idx)} + > + {variableName} +
+ {!isLastItem && } +
+ ); + }; + + // render a subtree inside an Accordion (ensures AccordionItem children are direct children of an Accordion) + const renderSubtreeAccordion = (subtree: any, basePath = "") => { + // Determine entries (keep variables first then groups for clarity) + const entries = Object.entries(subtree); + const variableEntries = entries.filter(([_, v]) => v === null); + const groupEntries = entries.filter(([_, v]) => v && typeof v === "object"); + + return ( + + {/* variables at this level */} + {variableEntries.length > 0 && ( +
+ {variableEntries.map(([name], idx) => { + const varPath = basePath ? `${basePath}/${name}` : name; + return ( + + ); + })} +
+ )} + + {/* groups at this level */} + {groupEntries.map(([name, subtreeValue]) => { + const currentPath = basePath ? `${basePath}/${name}` : name; + return ( + + + {name} + + + {/* recursively render children inside their own Accordion */} + {renderSubtreeAccordion(subtreeValue, currentPath)} + + + ); + })} +
+ ); + }; + + // render full Variable list under "root" accordion item const VariableList = (
- {filtered.length > 0 ? ( - filtered.map((val, idx) => ( - -
{ - setSelectedIndex(idx); - setSelectedVar(val); - GetDimInfo(val).then(e=>{setDimNames(e.dimNames); setDimArrays(e.dimArrays); setDimUnits(e.dimUnits)}) - if (popoverSide === "left") { - setOpenMetaPopover(true); - } else { - setShowMeta(true); - } - }} - > - {val} -
- {idx !== filtered.length - 1 && } -
- )) + {Object.keys(tree).length > 0 ? ( + + + / + + {/* render the top-level subtree inside its own Accordion so nested AccordionItems are legal */} + {renderSubtreeAccordion(tree, "")} + + + ) : (
- {query - ? "No variables found matching your search." - : "No variables available."} + {query ? "No variables found matching your search." : "No variables available."}
)}
@@ -187,11 +317,11 @@ const Variables = ({
+ className="absolute -top-8" // adjust top position as needed + style={{ + left: `-${280}px`, // move to left of variable popover + }} + /> {meta && ( - + )} @@ -219,7 +349,7 @@ const Variables = ({ {meta && ( ) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } + diff --git a/src/components/zarr/GetMetadata.tsx b/src/components/zarr/GetMetadata.tsx index df8e8d03..74f81f5e 100644 --- a/src/components/zarr/GetMetadata.tsx +++ b/src/components/zarr/GetMetadata.tsx @@ -57,6 +57,11 @@ export async function GetZarrMetadata(groupStore: Promise 1 ? pathParts.slice(0, -1).join('/') : undefined; + // ? should we query the node type instead or in addition? variables.push({ name: item.path.substring(1), @@ -69,7 +74,8 @@ export async function GetZarrMetadata(groupStore: Promise