Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/genre-tree-view/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 20 additions & 0 deletions packages/genre-tree-view/src/GenreTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
45 changes: 45 additions & 0 deletions packages/genre-tree-view/src/GenreTreeWheelBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export function WheelCore({
additionalActions,
showToolbar = true,
renderExtraDetails,
selectedNodeId,
}: WheelCoreProps) {
const treeOrientation: TreeOrientation =
direction === "left" ? "horizontal-anchored" : "vertical";
Expand Down Expand Up @@ -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 (
<div
ref={viewportRef}
Expand Down
20 changes: 20 additions & 0 deletions packages/genre-tree-view/src/GenreTreeWheelRadialBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export function WheelRadialCore({
additionalActions,
showToolbar = true,
renderExtraDetails,
selectedNodeId,
}: WheelRadialCoreProps) {
const groups = useMemo(() => groupNodesByRoot(nodes), [nodes]);

Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions packages/genre-tree-view/src/GenreTreeWheelRadialPopCoreBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions packages/genre-tree-view/src/__tests__/GenreTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<GenreTree nodes={TREE} selectedNodeId={null} />,
);
const wrapper = container.firstChild as HTMLElement;
const rectSpy = mockRects(container, wrapper, 400);
expect(container.querySelector(".gtv-info-panel")).toBeFalsy();

rerender(<GenreTree nodes={TREE} selectedNodeId="child-a" />);

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(
<GenreTree nodes={TREE} interactive={false} selectedNodeId={null} />,
);

rerender(<GenreTree nodes={TREE} interactive={false} selectedNodeId="child-a" />);

expect(container.querySelector(".gtv-info-panel")).toBeFalsy();
});
});
});
});
55 changes: 55 additions & 0 deletions packages/genre-tree-view/src/__tests__/GenreTreeWheel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<GenreTreeWheel nodes={NODES} selectedNodeId={null} />,
);
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(<GenreTreeWheel nodes={NODES} selectedNodeId="b-child" />);

expect(chipFor(container, "Electronic").className).toContain("gtv-wheel-chip--selected");
expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Techno");

rerender(<GenreTreeWheel nodes={NODES} selectedNodeId={null} />);
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(
<GenreTreeWheel nodes={NODES} selectedNodeId={null} />,
);
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(<GenreTreeWheel nodes={NODES} selectedNodeId="a-child" />);

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(
<GenreTreeWheel nodes={NODES} selectedNodeId={null} />,
);

rerender(<GenreTreeWheel nodes={NODES} selectedNodeId="does-not-exist" />);

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(<GenreTreeWheel nodes={NODES} />);
const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<GenreTreeWheelRadial nodes={NODES_FIVE} selectedNodeId={null} />,
);
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(<GenreTreeWheelRadial nodes={NODES_FIVE} selectedNodeId="d-child" />);

expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass");

rerender(<GenreTreeWheelRadial nodes={NODES_FIVE} selectedNodeId="d-child" />);
expect(container.querySelector(".gtv-info-panel-title")?.textContent).toBe("Bluegrass");

rerender(<GenreTreeWheelRadial nodes={NODES_FIVE} selectedNodeId={null} />);
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(<GenreTreeWheelRadial nodes={NODES_FIVE} />);
const wheelContainer = container.querySelector(".gtv-wheel-container") as HTMLElement;
Expand Down
Loading