|
| 1 | +import { createElement, useRef, useEffect, useCallback, MouseEvent } from 'react' |
| 2 | +import { |
| 3 | + useDimensions, |
| 4 | + useTheme, |
| 5 | + midAngle, |
| 6 | + getPolarLabelProps, |
| 7 | + degreesToRadians, |
| 8 | + getRelativeCursor, |
| 9 | + Margin, |
| 10 | + Container, |
| 11 | +} from '@nivo/core' |
| 12 | +import { findArcUnderCursor } from '@nivo/arcs' |
| 13 | +import { useInheritedColor } from '@nivo/colors' |
| 14 | +import { renderLegendToCanvas } from '@nivo/legends' |
| 15 | +import { useTooltip } from '@nivo/tooltip' |
| 16 | +import { useChord, useChordSelection, useCustomLayerProps } from './hooks' |
| 17 | +import { ArcDatum, ChordCanvasProps } from './types' |
| 18 | +import { canvasDefaultProps } from './defaults' |
| 19 | + |
| 20 | +const getArcFromMouseEvent = ({ |
| 21 | + event, |
| 22 | + canvasEl, |
| 23 | + center, |
| 24 | + margin, |
| 25 | + radius, |
| 26 | + innerRadius, |
| 27 | + arcs, |
| 28 | +}: { |
| 29 | + event: MouseEvent |
| 30 | + canvasEl: HTMLCanvasElement |
| 31 | + center: [number, number] |
| 32 | + margin: Margin |
| 33 | + radius: number |
| 34 | + innerRadius: number |
| 35 | + arcs: ArcDatum[] |
| 36 | +}) => { |
| 37 | + const [x, y] = getRelativeCursor(canvasEl, event) |
| 38 | + const centerX = margin.left + center[0] |
| 39 | + const centerY = margin.top + center[1] |
| 40 | + |
| 41 | + return findArcUnderCursor(centerX, centerY, radius, innerRadius, arcs, x, y) |
| 42 | +} |
| 43 | + |
| 44 | +type InnerChordCanvasProps = Omit<ChordCanvasProps, 'renderWrapper' | 'theme'> |
| 45 | + |
| 46 | +const InnerChordCanvas = ({ |
| 47 | + pixelRatio = canvasDefaultProps.pixelRatio, |
| 48 | + margin: partialMargin, |
| 49 | + data, |
| 50 | + keys, |
| 51 | + width, |
| 52 | + height, |
| 53 | + label = canvasDefaultProps.label, |
| 54 | + valueFormat, |
| 55 | + innerRadiusRatio = canvasDefaultProps.innerRadiusRatio, |
| 56 | + innerRadiusOffset = canvasDefaultProps.innerRadiusOffset, |
| 57 | + padAngle = canvasDefaultProps.padAngle, |
| 58 | + layers = canvasDefaultProps.layers, |
| 59 | + colors = canvasDefaultProps.colors, |
| 60 | + arcBorderWidth = canvasDefaultProps.arcBorderWidth, |
| 61 | + arcBorderColor = canvasDefaultProps.arcBorderColor, |
| 62 | + arcOpacity = canvasDefaultProps.arcOpacity, |
| 63 | + arcHoverOpacity = canvasDefaultProps.arcHoverOpacity, |
| 64 | + arcHoverOthersOpacity = canvasDefaultProps.arcHoverOthersOpacity, |
| 65 | + arcTooltip = canvasDefaultProps.arcTooltip, |
| 66 | + ribbonBorderWidth = canvasDefaultProps.ribbonBorderWidth, |
| 67 | + ribbonBorderColor = canvasDefaultProps.ribbonBorderColor, |
| 68 | + ribbonOpacity = canvasDefaultProps.ribbonOpacity, |
| 69 | + ribbonHoverOpacity = canvasDefaultProps.ribbonHoverOpacity, |
| 70 | + ribbonHoverOthersOpacity = canvasDefaultProps.arcHoverOthersOpacity, |
| 71 | + enableLabel = canvasDefaultProps.enableLabel, |
| 72 | + labelOffset = canvasDefaultProps.labelOffset, |
| 73 | + labelRotation = canvasDefaultProps.labelRotation, |
| 74 | + labelTextColor = canvasDefaultProps.labelTextColor, |
| 75 | + isInteractive = canvasDefaultProps.isInteractive, |
| 76 | + onArcMouseEnter, |
| 77 | + onArcMouseMove, |
| 78 | + onArcMouseLeave, |
| 79 | + onArcClick, |
| 80 | + legends = canvasDefaultProps.legends, |
| 81 | +}: InnerChordCanvasProps) => { |
| 82 | + const canvasEl = useRef<HTMLCanvasElement | null>(null) |
| 83 | + |
| 84 | + const { innerWidth, innerHeight, outerWidth, outerHeight, margin } = useDimensions( |
| 85 | + width, |
| 86 | + height, |
| 87 | + partialMargin |
| 88 | + ) |
| 89 | + |
| 90 | + const { center, radius, innerRadius, arcGenerator, ribbonGenerator, arcs, ribbons } = useChord({ |
| 91 | + data, |
| 92 | + keys, |
| 93 | + label, |
| 94 | + valueFormat, |
| 95 | + width: innerWidth, |
| 96 | + height: innerHeight, |
| 97 | + innerRadiusRatio, |
| 98 | + innerRadiusOffset, |
| 99 | + padAngle, |
| 100 | + colors, |
| 101 | + }) |
| 102 | + |
| 103 | + const { currentArc, setCurrentArc, getArcOpacity, getRibbonOpacity } = useChordSelection({ |
| 104 | + arcs, |
| 105 | + arcOpacity, |
| 106 | + arcHoverOpacity, |
| 107 | + arcHoverOthersOpacity, |
| 108 | + ribbons, |
| 109 | + ribbonOpacity, |
| 110 | + ribbonHoverOpacity, |
| 111 | + ribbonHoverOthersOpacity, |
| 112 | + }) |
| 113 | + |
| 114 | + const theme = useTheme() |
| 115 | + const getLabelTextColor = useInheritedColor(labelTextColor, theme) |
| 116 | + const getArcBorderColor = useInheritedColor(arcBorderColor, theme) |
| 117 | + const getRibbonBorderColor = useInheritedColor(ribbonBorderColor, theme) |
| 118 | + |
| 119 | + const layerContext = useCustomLayerProps({ |
| 120 | + center, |
| 121 | + radius, |
| 122 | + arcs, |
| 123 | + arcGenerator, |
| 124 | + ribbons, |
| 125 | + ribbonGenerator, |
| 126 | + }) |
| 127 | + |
| 128 | + useEffect(() => { |
| 129 | + if (canvasEl.current === null) return |
| 130 | + |
| 131 | + canvasEl.current.width = outerWidth * pixelRatio |
| 132 | + canvasEl.current.height = outerHeight * pixelRatio |
| 133 | + |
| 134 | + const ctx = canvasEl.current.getContext('2d')! |
| 135 | + |
| 136 | + ctx.scale(pixelRatio, pixelRatio) |
| 137 | + |
| 138 | + ctx.fillStyle = theme.background |
| 139 | + ctx.fillRect(0, 0, outerWidth, outerHeight) |
| 140 | + |
| 141 | + if (radius <= 0) return |
| 142 | + |
| 143 | + layers.forEach(layer => { |
| 144 | + if (layer === 'ribbons') { |
| 145 | + ctx.save() |
| 146 | + ctx.translate(margin.left + center[0], margin.top + center[1]) |
| 147 | + |
| 148 | + ribbonGenerator.context(ctx) |
| 149 | + ribbons.forEach(ribbon => { |
| 150 | + ctx.save() |
| 151 | + |
| 152 | + ctx.globalAlpha = getRibbonOpacity(ribbon) |
| 153 | + ctx.fillStyle = ribbon.source.color |
| 154 | + ctx.beginPath() |
| 155 | + ribbonGenerator(ribbon) |
| 156 | + ctx.fill() |
| 157 | + |
| 158 | + if (ribbonBorderWidth > 0) { |
| 159 | + ctx.strokeStyle = getRibbonBorderColor({ |
| 160 | + ...ribbon, |
| 161 | + color: ribbon.source.color, |
| 162 | + }) |
| 163 | + ctx.lineWidth = ribbonBorderWidth |
| 164 | + ctx.stroke() |
| 165 | + } |
| 166 | + |
| 167 | + ctx.restore() |
| 168 | + }) |
| 169 | + |
| 170 | + ctx.restore() |
| 171 | + } |
| 172 | + |
| 173 | + if (layer === 'arcs') { |
| 174 | + ctx.save() |
| 175 | + ctx.translate(margin.left + center[0], margin.top + center[1]) |
| 176 | + |
| 177 | + arcGenerator.context(ctx) |
| 178 | + arcs.forEach(arc => { |
| 179 | + ctx.save() |
| 180 | + |
| 181 | + ctx.globalAlpha = getArcOpacity(arc) |
| 182 | + ctx.fillStyle = arc.color |
| 183 | + ctx.beginPath() |
| 184 | + arcGenerator(arc) |
| 185 | + ctx.fill() |
| 186 | + |
| 187 | + if (arcBorderWidth > 0) { |
| 188 | + ctx.strokeStyle = getArcBorderColor(arc) |
| 189 | + ctx.lineWidth = arcBorderWidth |
| 190 | + ctx.stroke() |
| 191 | + } |
| 192 | + |
| 193 | + ctx.restore() |
| 194 | + }) |
| 195 | + |
| 196 | + ctx.restore() |
| 197 | + } |
| 198 | + |
| 199 | + if (layer === 'labels' && enableLabel === true) { |
| 200 | + ctx.save() |
| 201 | + ctx.translate(margin.left + center[0], margin.top + center[1]) |
| 202 | + |
| 203 | + ctx.font = `${theme.labels.text.fontSize}px ${ |
| 204 | + theme.labels.text.fontFamily || 'sans-serif' |
| 205 | + }` |
| 206 | + |
| 207 | + arcs.forEach(arc => { |
| 208 | + const angle = midAngle(arc) |
| 209 | + const props = getPolarLabelProps(radius + labelOffset, angle, labelRotation) |
| 210 | + |
| 211 | + ctx.save() |
| 212 | + ctx.translate(props.x, props.y) |
| 213 | + ctx.rotate(degreesToRadians(props.rotate)) |
| 214 | + |
| 215 | + ctx.textAlign = props.align |
| 216 | + ctx.textBaseline = props.baseline |
| 217 | + ctx.fillStyle = getLabelTextColor(arc, theme) |
| 218 | + ctx.fillText(arc.label, 0, 0) |
| 219 | + |
| 220 | + ctx.restore() |
| 221 | + }) |
| 222 | + |
| 223 | + ctx.restore() |
| 224 | + } |
| 225 | + |
| 226 | + if (layer === 'legends') { |
| 227 | + ctx.save() |
| 228 | + ctx.translate(margin.left, margin.top) |
| 229 | + |
| 230 | + const legendData = arcs.map(arc => ({ |
| 231 | + id: arc.id, |
| 232 | + label: arc.label, |
| 233 | + color: arc.color, |
| 234 | + })) |
| 235 | + |
| 236 | + legends.forEach(legend => { |
| 237 | + renderLegendToCanvas(ctx, { |
| 238 | + ...legend, |
| 239 | + data: legendData, |
| 240 | + containerWidth: innerWidth, |
| 241 | + containerHeight: innerHeight, |
| 242 | + theme, |
| 243 | + }) |
| 244 | + }) |
| 245 | + |
| 246 | + ctx.restore() |
| 247 | + } |
| 248 | + |
| 249 | + if (typeof layer === 'function') { |
| 250 | + layer(ctx, layerContext) |
| 251 | + } |
| 252 | + }) |
| 253 | + }, [ |
| 254 | + canvasEl, |
| 255 | + innerWidth, |
| 256 | + innerHeight, |
| 257 | + outerWidth, |
| 258 | + outerHeight, |
| 259 | + margin, |
| 260 | + pixelRatio, |
| 261 | + theme, |
| 262 | + layers, |
| 263 | + arcs, |
| 264 | + arcGenerator, |
| 265 | + getArcOpacity, |
| 266 | + arcBorderWidth, |
| 267 | + getArcBorderColor, |
| 268 | + ribbons, |
| 269 | + ribbonGenerator, |
| 270 | + getRibbonOpacity, |
| 271 | + ribbonBorderWidth, |
| 272 | + getRibbonBorderColor, |
| 273 | + enableLabel, |
| 274 | + labelOffset, |
| 275 | + labelRotation, |
| 276 | + getLabelTextColor, |
| 277 | + legends, |
| 278 | + layerContext, |
| 279 | + ]) |
| 280 | + |
| 281 | + const { showTooltipFromEvent, hideTooltip } = useTooltip() |
| 282 | + |
| 283 | + const handleMouseHover = useCallback( |
| 284 | + event => { |
| 285 | + if (canvasEl.current === null) return |
| 286 | + |
| 287 | + const arc = getArcFromMouseEvent({ |
| 288 | + event, |
| 289 | + canvasEl: canvasEl.current, |
| 290 | + center, |
| 291 | + margin, |
| 292 | + radius, |
| 293 | + innerRadius, |
| 294 | + arcs, |
| 295 | + }) |
| 296 | + |
| 297 | + if (arc) { |
| 298 | + setCurrentArc(arc) |
| 299 | + showTooltipFromEvent(createElement(arcTooltip, { arc }), event) |
| 300 | + !currentArc && onArcMouseEnter && onArcMouseEnter(arc, event) |
| 301 | + onArcMouseMove && onArcMouseMove(arc, event) |
| 302 | + currentArc && |
| 303 | + currentArc.id !== arc.id && |
| 304 | + onArcMouseLeave && |
| 305 | + onArcMouseLeave(arc, event) |
| 306 | + } else { |
| 307 | + setCurrentArc(null) |
| 308 | + hideTooltip() |
| 309 | + currentArc && onArcMouseLeave && onArcMouseLeave(currentArc, event) |
| 310 | + } |
| 311 | + }, |
| 312 | + [ |
| 313 | + canvasEl, |
| 314 | + center, |
| 315 | + margin, |
| 316 | + radius, |
| 317 | + innerRadius, |
| 318 | + arcs, |
| 319 | + setCurrentArc, |
| 320 | + showTooltipFromEvent, |
| 321 | + hideTooltip, |
| 322 | + onArcMouseEnter, |
| 323 | + onArcMouseMove, |
| 324 | + onArcMouseLeave, |
| 325 | + ] |
| 326 | + ) |
| 327 | + |
| 328 | + const handleMouseLeave = useCallback(() => { |
| 329 | + setCurrentArc(null) |
| 330 | + hideTooltip() |
| 331 | + }, [setCurrentArc, hideTooltip]) |
| 332 | + |
| 333 | + const handleClick = useCallback( |
| 334 | + event => { |
| 335 | + if (canvasEl.current === null || !onArcClick) return |
| 336 | + |
| 337 | + const arc = getArcFromMouseEvent({ |
| 338 | + event, |
| 339 | + canvasEl: canvasEl.current, |
| 340 | + center, |
| 341 | + margin, |
| 342 | + radius, |
| 343 | + innerRadius, |
| 344 | + arcs, |
| 345 | + }) |
| 346 | + |
| 347 | + arc && onArcClick(arc, event) |
| 348 | + }, |
| 349 | + [canvasEl, center, margin, radius, innerRadius, arcs, onArcClick] |
| 350 | + ) |
| 351 | + |
| 352 | + return ( |
| 353 | + <canvas |
| 354 | + ref={canvasEl} |
| 355 | + width={outerWidth * pixelRatio} |
| 356 | + height={outerHeight * pixelRatio} |
| 357 | + style={{ |
| 358 | + width: outerWidth, |
| 359 | + height: outerHeight, |
| 360 | + cursor: isInteractive ? 'auto' : 'normal', |
| 361 | + }} |
| 362 | + onMouseEnter={isInteractive ? handleMouseHover : undefined} |
| 363 | + onMouseMove={isInteractive ? handleMouseHover : undefined} |
| 364 | + onMouseLeave={isInteractive ? handleMouseLeave : undefined} |
| 365 | + onClick={isInteractive ? handleClick : undefined} |
| 366 | + /> |
| 367 | + ) |
| 368 | +} |
| 369 | + |
| 370 | +export const ChordCanvas = ({ |
| 371 | + theme, |
| 372 | + isInteractive = canvasDefaultProps.isInteractive, |
| 373 | + animate = canvasDefaultProps.animate, |
| 374 | + motionConfig = canvasDefaultProps.motionConfig, |
| 375 | + renderWrapper, |
| 376 | + ...otherProps |
| 377 | +}: ChordCanvasProps) => ( |
| 378 | + <Container {...{ isInteractive, animate, motionConfig, theme, renderWrapper }}> |
| 379 | + <InnerChordCanvas isInteractive={isInteractive} {...otherProps} /> |
| 380 | + </Container> |
| 381 | +) |
0 commit comments