diff --git a/CHANGELOG.md b/CHANGELOG.md
index 13add86..634ec63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+## [1.6.2] - 2026-09-12
+
+### Fixed
+
+- Setting `selectedNodeId` now opens and centers that node's info panel, exactly as a direct click
+ on it would, instead of only highlighting it — so a consumer-driven selection (e.g. from its own
+ search box) produces the same visible result as clicking the node. `GenreTreeWheel` /
+ `GenreTreeWheelRight` swap in the node's owning root first, and `GenreTreeWheelRadialPopCore`
+ expands the center node's subtree first when the selection lands inside it.
+- `GenreTreeWheel`, `GenreTreeWheelRight`, `GenreTreeWheelRadial` and
+ `GenreTreeWheelRadialPopCore` now honour `selectedNodeId` at all — the prop was accepted by
+ their types but ignored, so it had no effect on those four renderers.
+
## [1.6.1] - 2026-09-11
### Fixed
diff --git a/packages/genre-tree-view/package.json b/packages/genre-tree-view/package.json
index d929ca7..49a8d43 100644
--- a/packages/genre-tree-view/package.json
+++ b/packages/genre-tree-view/package.json
@@ -1,6 +1,6 @@
{
"name": "@behindthemusictree/genre-tree-view",
- "version": "1.6.1",
+ "version": "1.6.2",
"description": "Reusable, presentational D3-based genre/hierarchy tree visualization component for React",
"repository": {
"type": "git",
diff --git a/packages/genre-tree-view/src/GenreTree.tsx b/packages/genre-tree-view/src/GenreTree.tsx
index 9c01f5c..2ea6501 100644
--- a/packages/genre-tree-view/src/GenreTree.tsx
+++ b/packages/genre-tree-view/src/GenreTree.tsx
@@ -194,6 +194,26 @@ export function GenreTree({
effectiveSelectedNodeId,
]);
+ // Mirrors the onNodeClick/onSelectNode handling: an externally-controlled selection (e.g. from a
+ // search result) centers the viewport on that node and opens its info panel exactly as a direct
+ // click would. Declared after the draw effect above so the node's group element already exists,
+ // and skipped when `interactive` is false since the panel then belongs to an ancestor.
+ useEffect(() => {
+ if (!interactive || !selectedNodeIdProp) return;
+ if (panel?.node.id === selectedNodeIdProp) return;
+ const targetNode = nodes.find((node) => node.id === selectedNodeIdProp);
+ const element = svgRef.current?.querySelector(
+ `#group-${CSS.escape(selectedNodeIdProp)}`,
+ );
+ if (!targetNode || !element) return;
+ centerOnElementRef.current(
+ element,
+ ZOOM_FOCUS_SCALE,
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
+ );
+ showNodeInfoRef.current(targetNode, element, viewportRef.current);
+ }, [interactive, selectedNodeIdProp, nodes, panel]);
+
useEffect(() => {
const svgElement = svgRef.current;
return () => {
diff --git a/packages/genre-tree-view/src/GenreTreeWheelBase.tsx b/packages/genre-tree-view/src/GenreTreeWheelBase.tsx
index 9bf6bff..576ddd7 100644
--- a/packages/genre-tree-view/src/GenreTreeWheelBase.tsx
+++ b/packages/genre-tree-view/src/GenreTreeWheelBase.tsx
@@ -84,6 +84,7 @@ export function WheelCore({
additionalActions,
showToolbar = true,
renderExtraDetails,
+ selectedNodeId,
}: WheelCoreProps) {
const treeOrientation: TreeOrientation =
direction === "left" ? "horizontal-anchored" : "vertical";
@@ -244,6 +245,50 @@ export function WheelCore({
}
};
+ // An externally-controlled selection (e.g. from a search result) may belong to a root other than
+ // the one currently mounted — swap it in the same way a chip click would before trying to locate
+ // the node's DOM element below. Adjusted during render rather than in an effect so the swap is
+ // part of the same commit (see React's "adjusting state when a prop changes"), and only on an
+ // actual change of the prop so a later chip click isn't undone.
+ const [appliedSelectedNodeId, setAppliedSelectedNodeId] =
+ useState(selectedNodeId);
+ if (selectedNodeId !== appliedSelectedNodeId) {
+ setAppliedSelectedNodeId(selectedNodeId);
+ const owningGroupIndex = selectedNodeId
+ ? groups.findIndex((group) =>
+ group.nodes.some((node) => node.id === selectedNodeId),
+ )
+ : -1;
+ if (
+ owningGroupIndex !== -1 &&
+ groups[owningGroupIndex].root.id !== effectiveRootId
+ ) {
+ handleChipClick(
+ groups[owningGroupIndex].root.id,
+ getChipAngle(owningGroupIndex, groups.length),
+ );
+ }
+ }
+
+ // Mirrors the onNodeClick/onSelectNode handling below: once the owning root's subtree is
+ // mounted, center the viewport on the externally-selected node and open its info panel exactly
+ // as a direct click would.
+ useEffect(() => {
+ if (!selectedNodeId || panel?.node.id === selectedNodeId) return;
+ const targetNode = nodes.find((node) => node.id === selectedNodeId);
+ const element = treeAnchorRef.current?.querySelector(
+ `#group-${CSS.escape(selectedNodeId)}`,
+ );
+ if (!targetNode || !element) return;
+ panZoom.centerOnElement(
+ element,
+ ZOOM_FOCUS_SCALE,
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
+ );
+ showNodeInfo(targetNode, element, viewportRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- panZoom re-created on pan/zoom; guarded by the panel?.node.id check above
+ }, [selectedNodeId, effectiveRootId, nodes, panel, showNodeInfo]);
+
return (
groupNodesByRoot(nodes), [nodes]);
@@ -420,6 +421,25 @@ export function WheelRadialCore({
setTopRootId(rootId);
};
+ // Every root's core subtree is always mounted (see the SVG-building effect above), so an
+ // externally-controlled selection (e.g. from a search result) never needs a root swap — just
+ // locate its element and open the panel exactly as a direct click would.
+ useEffect(() => {
+ if (!selectedNodeId || panel?.node.id === selectedNodeId) return;
+ const targetNode = nodes.find((node) => node.id === selectedNodeId);
+ const element = viewportRef.current?.querySelector(
+ `#group-${CSS.escape(selectedNodeId)}`,
+ );
+ if (!targetNode || !element) return;
+ panZoom.centerOnElement(
+ element,
+ ZOOM_FOCUS_SCALE,
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
+ );
+ showNodeInfo(targetNode, element, viewportRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- panZoom re-created on pan/zoom; guarded by the panel?.node.id check above
+ }, [selectedNodeId, nodes, panel, showNodeInfo]);
+
// One divider per boundary between two angularly-adjacent roots — each root's own continuous
// (unwrapped) angle plus half its weight-proportional width (sectorSpanByRootId) lands exactly on
// the boundary with its next neighbor, since computeRadialLayout tiled the roots' widths
diff --git a/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx b/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx
index 3a702d9..1641d62 100644
--- a/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx
+++ b/packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx
@@ -134,6 +134,7 @@ export function WheelRadialPopCoreCore({
showToolbar = true,
renderExtraDetails,
allowWheelRotation = true,
+ selectedNodeId,
}: WheelRadialPopCoreProps) {
const centerNode = nodes.find(
(node) => node.parentId === null && node.name === CENTER_NODE_NAME,
@@ -828,6 +829,44 @@ export function WheelRadialPopCoreCore({
if (allowWheelRotation) setRotationTopRootId(rootId);
};
+ // Every ring root's core/pop branches are always mounted, but the center "Mainstream Pop" node's
+ // own subtree only renders once expanded — so an externally-controlled selection (e.g. from a
+ // search result) landing in there needs to expand it first. Adjusted during render rather than in
+ // an effect so the expansion is part of the same commit (see React's "adjusting state when a prop
+ // changes"), and only on an actual change of the prop so a later manual collapse isn't undone.
+ const [appliedSelectedNodeId, setAppliedSelectedNodeId] =
+ useState(selectedNodeId);
+ if (selectedNodeId !== appliedSelectedNodeId) {
+ setAppliedSelectedNodeId(selectedNodeId);
+ if (
+ selectedNodeId &&
+ selectedNodeId !== centerNode.id &&
+ centerSubtreeNodeIds.has(selectedNodeId) &&
+ !isPopExpanded
+ ) {
+ setIsPopExpanded(true);
+ }
+ }
+
+ // Mirrors the onNodeClick/onSelectNode handling below: once the node's sector (and, for a
+ // center-subtree node, the expanded center) is mounted, center the viewport on the
+ // externally-selected node and open its info panel exactly as a direct click would.
+ useEffect(() => {
+ if (!selectedNodeId || panel?.node.id === selectedNodeId) return;
+ const targetNode = nodes.find((node) => node.id === selectedNodeId);
+ const element = viewportRef.current?.querySelector(
+ `#group-${CSS.escape(selectedNodeId)}`,
+ );
+ if (!targetNode || !element) return;
+ panZoom.centerOnElement(
+ element,
+ ZOOM_FOCUS_SCALE,
+ resolveInfoPanelObscuredArea(element, viewportRef.current, INFO_PANEL_WIDTH),
+ );
+ showNodeInfo(targetNode, element, viewportRef.current);
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- panZoom re-created on pan/zoom; guarded by the panel?.node.id check above
+ }, [selectedNodeId, nodes, panel, showNodeInfo, isPopExpanded]);
+
// One divider per boundary between two angularly-adjacent ring roots — see WheelRadialCore's own
// copy of this computation for why each root's own continuous angle plus half its
// weight-proportional width (sectorSpanByRootId) lands exactly on the boundary with its next
diff --git a/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx
index e82a869..624f3e7 100644
--- a/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx
+++ b/packages/genre-tree-view/src/__tests__/GenreTree.test.tsx
@@ -531,6 +531,31 @@ describe("GenreTree", () => {
qsSpy.mockRestore();
rectSpy.mockRestore();
});
+
+ it("opens for an externally-controlled selectedNodeId, as a direct click would", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const wrapper = container.firstChild as HTMLElement;
+ const rectSpy = mockRects(container, wrapper, 400);
+ expect(container.querySelector(".gtv-info-panel")).toBeFalsy();
+
+ rerender();
+
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Child A");
+
+ rectSpy.mockRestore();
+ });
+
+ it("leaves the panel to its ancestor when selectedNodeId is set on a non-interactive tree", () => {
+ const { container, rerender } = render(
+ ,
+ );
+
+ rerender();
+
+ expect(container.querySelector(".gtv-info-panel")).toBeFalsy();
+ });
});
});
});
diff --git a/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx
index a93c84f..3b1a2ec 100644
--- a/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx
+++ b/packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx
@@ -404,6 +404,61 @@ describe("GenreTreeWheel", () => {
rectSpy.mockRestore();
});
+ it("opens for an externally-controlled selectedNodeId, swapping in its owning root first", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
+ const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: Element,
+ ) {
+ if (this === wheelContainer) return makeRect(0, 0, 800, 600);
+ return makeRect(400, 0, 10, 10);
+ });
+ expect(chipFor(container, "Rock").className).toContain("gtv-wheel-chip--selected");
+
+ rerender();
+
+ expect(chipFor(container, "Electronic").className).toContain("gtv-wheel-chip--selected");
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Techno");
+
+ rerender();
+ expect(chipFor(container, "Electronic").className).toContain("gtv-wheel-chip--selected");
+
+ rectSpy.mockRestore();
+ });
+
+ it("does not swap roots for an externally-controlled selectedNodeId already in the active root", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
+ const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: Element,
+ ) {
+ if (this === wheelContainer) return makeRect(0, 0, 800, 600);
+ return makeRect(400, 0, 10, 10);
+ });
+
+ rerender();
+
+ expect(chipFor(container, "Rock").className).toContain("gtv-wheel-chip--selected");
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Punk");
+
+ rectSpy.mockRestore();
+ });
+
+ it("ignores an externally-controlled selectedNodeId that matches no node", () => {
+ const { container, rerender } = render(
+ ,
+ );
+
+ rerender();
+
+ expect(container.querySelector(".gtv-info-panel")).toBeFalsy();
+ expect(chipFor(container, "Rock").className).toContain("gtv-wheel-chip--selected");
+ });
+
it("switches to the parent node when its chip is clicked", () => {
const { container } = render();
const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
diff --git a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx
index 5226a1a..9184d87 100644
--- a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx
+++ b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadial.test.tsx
@@ -593,6 +593,31 @@ describe("GenreTreeWheelRadial", () => {
rectSpy.mockRestore();
});
+ it("opens for an externally-controlled selectedNodeId in any sector, no root swap needed", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
+ const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: Element,
+ ) {
+ if (this === wheelContainer) return makeRect(0, 0, 800, 600);
+ return makeRect(400, 0, 10, 10);
+ });
+
+ rerender();
+
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass");
+
+ rerender();
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass");
+
+ rerender();
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass");
+
+ rectSpy.mockRestore();
+ });
+
it("switches to the parent node when its chip is clicked", () => {
const { container } = render();
const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
diff --git a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx
index 19bf144..bab480d 100644
--- a/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx
+++ b/packages/genre-tree-view/src/__tests__/GenreTreeWheelRadialPopCore.test.tsx
@@ -896,6 +896,82 @@ describe("GenreTreeWheelRadialPopCore", () => {
rectSpy.mockRestore();
});
+ it("opens for an externally-controlled selectedNodeId in a ring sector", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
+ const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: Element,
+ ) {
+ if (this === wheelContainer) return makeRect(0, 0, 800, 600);
+ return makeRect(400, 0, 10, 10);
+ });
+
+ rerender();
+
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bebop");
+
+ rerender();
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bebop");
+
+ rectSpy.mockRestore();
+ });
+
+ it("ignores an externally-controlled selectedNodeId equal to the center node itself", () => {
+ const { container, rerender } = render(
+ ,
+ );
+ const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
+ const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: Element,
+ ) {
+ if (this === wheelContainer) return makeRect(0, 0, 800, 600);
+ return makeRect(400, 0, 10, 10);
+ });
+
+ rerender();
+
+ expect(container.querySelector(".gtv-wheel-chip--center.gtv-wheel-chip--circle")).toBeTruthy();
+ expect(container.querySelector(".gtv-info-panel")).toBeFalsy();
+
+ rectSpy.mockRestore();
+ });
+
+ it("expands the center subtree first when an externally-controlled selectedNodeId lands in it", () => {
+ const nodesWithCenterChildren: GenreTreeNode[] = [
+ ...NODES_WITH_POP,
+ { id: "pop-child", parentId: "pop", name: "Radio Hits", itemCount: 1 },
+ { id: "pop-child-2", parentId: "pop", name: "Chart Toppers", itemCount: 1 },
+ ];
+ const { container, rerender } = render(
+ ,
+ );
+ const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
+ const rectSpy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
+ this: Element,
+ ) {
+ if (this === wheelContainer) return makeRect(0, 0, 800, 600);
+ return makeRect(400, 0, 10, 10);
+ });
+ expect(container.querySelector(".gtv-wheel-center-sector #group-pop-child")).toBeFalsy();
+
+ rerender(
+ ,
+ );
+
+ expect(container.querySelector(".gtv-wheel-center-sector #group-pop-child")).toBeTruthy();
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Radio Hits");
+
+ rerender(
+ ,
+ );
+
+ expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Chart Toppers");
+
+ rectSpy.mockRestore();
+ });
+
it("switches to the parent node when its chip is clicked", () => {
const { container } = render();
const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
diff --git a/packages/genre-tree-view/src/types.ts b/packages/genre-tree-view/src/types.ts
index 2d7922d..525b5ec 100644
--- a/packages/genre-tree-view/src/types.ts
+++ b/packages/genre-tree-view/src/types.ts
@@ -111,7 +111,11 @@ export interface GenreTreeProps {
/** Overrides which node is shown highlighted (with unrelated nodes/links dimmed) instead of
* GenreTree's own internally-tracked clicked node — used by `interactive={false}` instances
* (e.g. GenreTreeWheel's nested tree) whose own info panel, and thus own selection, is owned
- * by an ancestor rather than by this instance. Leave unset to use the internal selection. */
+ * by an ancestor rather than by this instance. On the top-level exported renderers
+ * (`GenreTreeWheel`, `GenreTreeWheelRight`, `GenreTreeWheelRadial`,
+ * `GenreTreeWheelRadialPopCore`, `GenreTree` itself), setting this also opens and centers the
+ * info panel for that node — the same effect a direct click on it would have — swapping in its
+ * owning root/sector first if needed. Leave unset to use the internal selection. */
selectedNodeId?: string | null;
/** Renders extra content into the built-in info panel for the clicked node, below its built-in
* fields — e.g. a consumer-fetched detail like essential tracks. The library has no knowledge