{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "radial-cluster", "title": "Radial Cluster", "description": "Radial cluster chart for hierarchical data. Supports data prop or Item/SubItem composition. | 階層データの円環階層図。data プロップまたは Item/SubItem 構成に対応。", "dependencies": [ "d3" ], "registryDependencies": [ "use-mobile" ], "files": [ { "path": "components/chart/radial-cluster.tsx", "content": "\"use client\";\n\nimport * as d3 from \"d3\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { useIsMobile } from \"@/hooks/use-mobile\";\n\nexport interface Node {\n name: string;\n children?: Node[];\n}\n\n// --- Style interfaces ---\n\nexport interface NodeStyle {\n fill?: string;\n stroke?: string;\n r?: number;\n strokeWidth?: number;\n className?: string;\n}\n\nexport interface ConnectorStyle {\n stroke?: string;\n strokeOpacity?: number;\n strokeWidth?: number;\n className?: string;\n}\n\n// --- Default styles ---\n\nconst DEFAULT_NODE_STYLE: NodeStyle = {\n fill: \"#f0a946\",\n stroke: \"white\",\n r: 5,\n strokeWidth: 1.5,\n};\n\nconst DEFAULT_CONNECTOR_STYLE: ConnectorStyle = {\n stroke: \"#d08a20\",\n strokeOpacity: 0.4,\n strokeWidth: 1.5,\n};\n\n// --- Context ---\n\ninterface RadialClusterContextValue {\n data: Node[];\n size: number;\n radius: number;\n styles: {\n rootNode: NodeStyle;\n innerRing: NodeStyle;\n outerRing: NodeStyle;\n leafNode: NodeStyle;\n branchConnector: ConnectorStyle;\n };\n mergeNodeStyle: (key: \"rootNode\" | \"innerRing\" | \"outerRing\" | \"leafNode\", style: Partial) => void;\n mergeConnectorStyle: (style: Partial) => void;\n}\n\nconst RadialClusterContext = createContext(null);\n\n// --- buildDataFromItems ---\n\nfunction buildNodeFromChild(\n child: React.ReactElement,\n ItemComponent: React.ComponentType<{ name: string; children?: React.ReactNode }>,\n SubItemComponent: React.ComponentType<{ name: string; children?: React.ReactNode }>\n): Node | null {\n if (child.type === ItemComponent || child.type === SubItemComponent) {\n const props = child.props as { name: string; children?: React.ReactNode };\n const { name } = props;\n const childChildren = React.Children.toArray(props.children);\n const children = childChildren\n .map((c) => buildNodeFromChild(c as React.ReactElement, ItemComponent, SubItemComponent))\n .filter((n): n is Node => n !== null);\n return { name, children: children.length > 0 ? children : undefined };\n }\n return null;\n}\n\nfunction buildDataFromItems(\n children: React.ReactNode,\n ItemComponent: React.ComponentType<{ name: string; children?: React.ReactNode }>,\n SubItemComponent: React.ComponentType<{ name: string; children?: React.ReactNode }>\n): Node[] {\n const arr = React.Children.toArray(children);\n const rootNode = arr\n .map((c) => buildNodeFromChild(c as React.ReactElement, ItemComponent, SubItemComponent))\n .find((n): n is Node => n !== null);\n return rootNode ? [rootNode] : [];\n}\n\nfunction hasItemChildren(\n children: React.ReactNode,\n ItemComponent: React.ComponentType<{ name: string; children?: React.ReactNode }>,\n SubItemComponent: React.ComponentType<{ name: string; children?: React.ReactNode }>\n): boolean {\n return React.Children.toArray(children).some(\n (c) =>\n React.isValidElement(c) &&\n (c.type === ItemComponent || c.type === SubItemComponent)\n );\n}\n\nfunction hasChartChild(\n children: React.ReactNode,\n ChartComponent: React.ComponentType\n): boolean {\n return React.Children.toArray(children).some(\n (c) => React.isValidElement(c) && c.type === ChartComponent\n );\n}\n\n// --- Subcomponents ---\n\ninterface RadialClusterItemProps {\n name: string;\n children?: React.ReactNode;\n}\n\nfunction RadialClusterItem(_props: RadialClusterItemProps) {\n return null;\n}\n\nfunction RadialClusterSubItem(_props: RadialClusterItemProps) {\n return null;\n}\n\nfunction RadialClusterRootNode(props: Partial) {\n const ctx = useContext(RadialClusterContext);\n useEffect(() => {\n ctx?.mergeNodeStyle(\"rootNode\", props);\n }, [ctx, props.fill, props.stroke, props.r, props.strokeWidth, props.className]);\n return null;\n}\n\nfunction RadialClusterInnerRing(props: Partial) {\n const ctx = useContext(RadialClusterContext);\n useEffect(() => {\n ctx?.mergeNodeStyle(\"innerRing\", props);\n }, [ctx, props.fill, props.stroke, props.r, props.strokeWidth, props.className]);\n return null;\n}\n\nfunction RadialClusterOuterRing(props: Partial) {\n const ctx = useContext(RadialClusterContext);\n useEffect(() => {\n ctx?.mergeNodeStyle(\"outerRing\", props);\n }, [ctx, props.fill, props.stroke, props.r, props.strokeWidth, props.className]);\n return null;\n}\n\nfunction RadialClusterLeafNode(props: Partial) {\n const ctx = useContext(RadialClusterContext);\n useEffect(() => {\n ctx?.mergeNodeStyle(\"leafNode\", props);\n }, [ctx, props.fill, props.stroke, props.r, props.strokeWidth, props.className]);\n return null;\n}\n\nfunction RadialClusterBranchConnector(props: Partial) {\n const ctx = useContext(RadialClusterContext);\n useEffect(() => {\n ctx?.mergeConnectorStyle(props);\n }, [ctx, props.stroke, props.strokeOpacity, props.strokeWidth, props.className]);\n return null;\n}\n\n// --- Chart ---\n\nfunction RadialClusterChart() {\n const ctx = useContext(RadialClusterContext);\n const ref = useRef(null);\n const isMobile = useIsMobile();\n\n const { data, size, radius, styles } = ctx ?? {\n data: [],\n size: 900,\n radius: 450,\n styles: {\n rootNode: DEFAULT_NODE_STYLE,\n innerRing: DEFAULT_NODE_STYLE,\n outerRing: DEFAULT_NODE_STYLE,\n leafNode: DEFAULT_NODE_STYLE,\n branchConnector: DEFAULT_CONNECTOR_STYLE,\n },\n };\n\n const getNodeStyle = useCallback(\n (d: d3.HierarchyPointNode): NodeStyle => {\n const { rootNode, innerRing, outerRing, leafNode } = styles;\n const isLeaf = !d.children || d.children.length === 0;\n if (d.depth === 0) return { ...DEFAULT_NODE_STYLE, ...rootNode };\n if (d.depth === 1) return { ...DEFAULT_NODE_STYLE, ...innerRing };\n if (d.depth === 2) return { ...DEFAULT_NODE_STYLE, ...outerRing };\n if (isLeaf) return { ...DEFAULT_NODE_STYLE, ...leafNode };\n return { ...DEFAULT_NODE_STYLE, ...outerRing };\n },\n [styles]\n );\n\n useEffect(() => {\n if (!data?.length || isMobile) return;\n\n const svg = d3.select(ref.current);\n if (!svg.node()) return;\n svg.selectAll(\"*\").remove();\n\n const root = d3.hierarchy(data[0]);\n const cluster = d3.cluster().size([2 * Math.PI, radius - 60]);\n const clusterData = cluster(root);\n\n const project = (d: d3.HierarchyPointNode) => {\n const r = d.depth * (radius / 4);\n const a = d.x - Math.PI / 1;\n return [r * Math.cos(a), r * Math.sin(a)];\n };\n\n const connectorStyle = { ...DEFAULT_CONNECTOR_STYLE, ...styles.branchConnector };\n\n svg\n .append(\"g\")\n .attr(\"transform\", `translate(${size / 2},${size / 2})`)\n .selectAll(\"path\")\n .data(clusterData.links())\n .join(\"path\")\n .attr(\"d\", (d: d3.HierarchyPointLink) => {\n const source = project(d.source) as [number, number];\n const target = project(d.target) as [number, number];\n return d3.line().curve(d3.curveBumpX)([source, target] as [\n number,\n number\n ][]);\n })\n .attr(\"fill\", \"none\")\n .attr(\"stroke\", connectorStyle.stroke ?? \"none\")\n .attr(\"stroke-opacity\", connectorStyle.strokeOpacity ?? 1)\n .attr(\"stroke-width\", connectorStyle.strokeWidth ?? 1)\n .attr(\"class\", connectorStyle.className ?? \"\");\n\n const nodes = svg\n .append(\"g\")\n .attr(\"transform\", `translate(${size / 2},${size / 2})`)\n .selectAll>(\"g\")\n .data(clusterData.descendants())\n .join(\"g\")\n .attr(\"transform\", (d: d3.HierarchyPointNode) => `translate(${project(d)})`);\n\n nodes.each(function (this: SVGGElement, d: d3.HierarchyPointNode) {\n const style = getNodeStyle(d);\n const g = d3.select>(this);\n g.append(\"circle\")\n .attr(\"r\", style.r ?? 5)\n .attr(\"fill\", style.fill ?? \"currentColor\")\n .attr(\"stroke\", style.stroke ?? \"white\")\n .attr(\"stroke-width\", style.strokeWidth ?? 1.5)\n .attr(\"class\", style.className ?? \"\");\n g.append(\"text\")\n .attr(\"dy\", (n: d3.HierarchyPointNode) => (n.x > Math.PI ? \"1.5em\" : \"-1.5em\"))\n .attr(\"text-anchor\", \"middle\")\n .text((n: d3.HierarchyPointNode) => n.data.name)\n .style(\"text-align\", \"center\");\n });\n }, [data, size, radius, styles, getNodeStyle, isMobile]);\n\n if (isMobile) return null;\n\n return (\n \n );\n}\n\n// --- Root ---\n\ninterface RadialClusterRootProps {\n data?: Node[];\n size?: number;\n radius?: number;\n className?: string;\n children?: React.ReactNode;\n}\n\nfunction RadialClusterRoot({\n data: dataProp,\n size = 900,\n radius: radiusProp,\n className,\n children,\n}: RadialClusterRootProps) {\n const radius = radiusProp ?? size / 2;\n const hasItems = children ? hasItemChildren(children, RadialClusterItem, RadialClusterSubItem) : false;\n const dataFromItems = children\n ? buildDataFromItems(children, RadialClusterItem, RadialClusterSubItem)\n : [];\n const data = hasItems && dataFromItems.length > 0 ? dataFromItems : dataProp ?? [];\n const needsChart = !children || !hasChartChild(children, RadialClusterChart);\n\n const [styles, setStyles] = useState({\n rootNode: DEFAULT_NODE_STYLE,\n innerRing: DEFAULT_NODE_STYLE,\n outerRing: DEFAULT_NODE_STYLE,\n leafNode: DEFAULT_NODE_STYLE,\n branchConnector: DEFAULT_CONNECTOR_STYLE,\n });\n\n const mergeNodeStyle = useCallback(\n (key: \"rootNode\" | \"innerRing\" | \"outerRing\" | \"leafNode\", style: Partial) => {\n setStyles((prev) => ({\n ...prev,\n [key]: { ...prev[key], ...style },\n }));\n },\n []\n );\n\n const mergeConnectorStyle = useCallback((style: Partial) => {\n setStyles((prev) => ({\n ...prev,\n branchConnector: { ...prev.branchConnector, ...style },\n }));\n }, []);\n\n const contextValue = useMemo(\n () => ({\n data,\n size,\n radius,\n styles,\n mergeNodeStyle,\n mergeConnectorStyle,\n }),\n [data, size, radius, styles, mergeNodeStyle, mergeConnectorStyle]\n );\n\n const isMobile = useIsMobile();\n if (isMobile) return null;\n\n return (\n \n \n {children}\n {needsChart && }\n \n \n );\n}\n\n// --- Export compound component ---\n\nRadialClusterRoot.Item = RadialClusterItem;\nRadialClusterRoot.SubItem = RadialClusterSubItem;\nRadialClusterRoot.Chart = RadialClusterChart;\nRadialClusterRoot.RootNode = RadialClusterRootNode;\nRadialClusterRoot.InnerRing = RadialClusterInnerRing;\nRadialClusterRoot.OuterRing = RadialClusterOuterRing;\nRadialClusterRoot.LeafNode = RadialClusterLeafNode;\nRadialClusterRoot.BranchConnector = RadialClusterBranchConnector;\n\nexport const RadialCluster = RadialClusterRoot;\n", "type": "registry:ui" } ], "type": "registry:ui" }