UNPKG

react-occult

Version:

Layered Information Visualization based on React and D3

955 lines (882 loc) 23.5 kB
import * as React from 'react'; import { Mark } from 'semiotic-mark'; import Annotation from '../Annotation'; import { line } from 'd3-shape'; import AnnotationCalloutCircle from 'react-annotation/lib/Types/AnnotationCalloutCircle'; import AnnotationBracket from 'react-annotation/lib/Types/AnnotationBracket'; import AnnotationXYThreshold from 'react-annotation/lib/Types/AnnotationXYThreshold'; import { packEnclose } from 'd3-hierarchy'; import { max, min, sum, extent } from 'd3-array'; import pointOnArcAtAngle from '../../utils/pointOnArcAtAngle'; import CircleEnclosure from './widgets/CircleEnclosure'; import RectangleEnclosure from './widgets/RectangleEnclosure'; import SpanOrDiv from '../../widgets/SpanOrDiv'; import findFirstAccessorValue from './findFirstAccessorValue'; import { curveHash } from '../../pipeline/toRenderedLines'; import TooltipPositioner from '../../layers/InteractionLayer/TooltipPositioner'; function polarToCartesian(centerX, centerY, radius, angleInDegrees) { const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; return { x: centerX + radius * Math.cos(angleInRadians), y: centerY + radius * Math.sin(angleInRadians) }; } function pieContentGenerator({ column, useSpans }) { return ( <SpanOrDiv span={useSpans} className="tooltip-content"> <p key="or-annotation-1">{column.name}</p> <p key="or-annotation-2">{`${(column.pct * 100).toFixed(0)}%`}</p> </SpanOrDiv> ); } function arcBracket({ x, y, radius, startAngle, endAngle, inset, outset, curly = true }) { const start = polarToCartesian(x, y, radius + outset, endAngle); const end = polarToCartesian(x, y, radius + outset, startAngle); const innerStart = polarToCartesian(x, y, radius + outset - inset, endAngle); const innerEnd = polarToCartesian(x, y, radius + outset - inset, startAngle); const angleSize = endAngle - startAngle; const largeArcFlag = angleSize <= 180 ? '0' : '1'; let d; if (curly) { const curlyOffset = Math.min(10, angleSize / 4); const middleLeft = polarToCartesian( x, y, radius + outset, (startAngle + endAngle) / 2 + curlyOffset ); const middle = polarToCartesian( x, y, radius + outset + 10, (startAngle + endAngle) / 2 ); const middleRight = polarToCartesian( x, y, radius + outset, (startAngle + endAngle) / 2 - curlyOffset ); d = [ 'M', innerStart.x, innerStart.y, 'L', start.x, start.y, 'A', radius + outset, radius + outset, 0, 0, 0, middleLeft.x, middleLeft.y, 'A', radius + outset, radius + outset, 1, 0, 1, middle.x, middle.y, 'A', radius + outset, radius + outset, 1, 0, 1, middleRight.x, middleRight.y, 'A', radius + outset, radius + outset, 0, 0, 0, end.x, end.y, 'L', innerEnd.x, innerEnd.y ].join(' '); } else { d = [ 'M', innerStart.x, innerStart.y, 'L', start.x, start.y, 'A', radius + outset, radius + outset, 0, largeArcFlag, 0, end.x, end.y, 'L', innerEnd.x, innerEnd.y ].join(' '); } const midAngle = (startAngle + endAngle) / 2; let textOffset, largeTextArcFlag, finalTextEnd, finalTextStart, arcFlip; const lowerArc = midAngle > 90 && midAngle < 270; if (lowerArc) { textOffset = 12; largeTextArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; arcFlip = 0; } else { largeTextArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; textOffset = 5; arcFlip = 1; } textOffset += curly ? 10 : 0; const textStart = polarToCartesian( x, y, radius + outset + textOffset, endAngle ); const textEnd = polarToCartesian( x, y, radius + outset + textOffset, startAngle ); if (lowerArc) { finalTextStart = textStart; finalTextEnd = textEnd; } else { finalTextStart = textEnd; finalTextEnd = textStart; } const textD = [ 'M', finalTextStart.x, finalTextStart.y, 'A', radius + outset + textOffset, radius + outset + textOffset, arcFlip, largeTextArcFlag, arcFlip, finalTextEnd.x, finalTextEnd.y ].join(' '); return { arcPath: d, textArcPath: textD }; } export const getColumnScreenCoordinates = ({ d, projectedColumns, oAccessor, summaryType, type, projection, adjustedPosition, adjustedSize }) => { const column = d.column || projectedColumns[d.facetColumn] || projectedColumns[findFirstAccessorValue(oAccessor, d)]; if (!column) { return { coordinates: [0, 0], pieces: undefined, column: undefined }; } const pieces = column.pieceData || column.pieces; const positionValue = (summaryType.type && summaryType.type !== 'none') || ['swarm', 'ordinalpoint', 'clusterbar'].find(p => p === type.type) ? max(pieces.map(p => p.scaledValue)) : projection === 'horizontal' ? max( pieces.map(p => (p.value >= 0 ? p.scaledValue + p.bottom : p.bottom)) ) : min( pieces.map(p => (p.value >= 0 ? p.bottom - p.scaledValue : p.bottom)) ); let xPosition = column.middle + adjustedPosition[0]; let yPosition = projection === 'horizontal' ? adjustedSize[0] - positionValue : (summaryType.type && summaryType.type !== 'none') || ['swarm', 'ordinalpoint', 'clusterbar'].find(p => p === type.type) ? adjustedSize[1] - positionValue : positionValue; yPosition += 10; if (projection === 'horizontal') { yPosition = column.middle; xPosition = positionValue + adjustedPosition[0]; } else if (projection === 'radial') { [xPosition, yPosition] = pointOnArcAtAngle( [d.arcAngles.translate[0], d.arcAngles.translate[1]], d.arcAngles.midAngle, d.arcAngles.length ); yPosition += 10; } return { coordinates: [xPosition, yPosition], pieces, column }; }; export const svgHighlightRule = ({ d, pieceIDAccessor, orFrameRender, oAccessor }) => { const thisID = pieceIDAccessor(d); const thisO = findFirstAccessorValue(oAccessor, d); const { pieces } = orFrameRender; const { styleFn } = pieces; const foundPieces = (pieces && pieces.data .filter(p => { return ( (thisID === undefined || pieceIDAccessor({ ...p.piece, ...p.piece.data }) === thisID) && (thisO === undefined || findFirstAccessorValue(oAccessor, p.piece.data) === thisO) ); }) .map((p, q) => { let styleObject = { style: styleFn({ ...p.piece, ...p.piece.data }) }; if (d.style && typeof d.style === 'function') { styleObject = { style: { ...styleObject, ...d.style({ ...p.piece, ...p.piece.data }) } }; } else if (d.style) { styleObject = { style: { ...styleObject, ...d.style } }; } const styledD = { ...p.renderElement, ...styleObject }; const className = `highlight-annotation ${(d.class && typeof d.class === 'function' && d.class(p.piece.data, q)) || (d.class && d.class) || ''}`; if (React.isValidElement(p.renderElement)) { return React.cloneElement(p.renderElement, { ...styleObject, className }); } return ( <Mark fill="none" stroke="black" strokeWidth="2px" key={`highlight-piece-${q}`} {...styledD} className={className} /> ); })) || []; return [...foundPieces]; }; export const findIDPiece = (pieceIDAccessor, oColumn, d) => { const foundIDValue = pieceIDAccessor(d); const pieceID = foundIDValue === '' && d.rName ? d.rName : foundIDValue; const basePieces = oColumn && oColumn.pieceData.filter( r => r.rName === pieceID || pieceIDAccessor(r.data) === pieceID ); if ( pieceID === '' || basePieces === undefined || basePieces === false || basePieces.length !== 1 ) return d; const basePiece = basePieces[0]; const reactAnnotationProps = [ 'type', 'label', 'note', 'connector', 'disabled', 'color', 'subject' ]; if (basePiece) { reactAnnotationProps.forEach(prop => { if (d[prop]) basePiece[prop] = d[prop]; }); } return basePiece; }; export const screenProject = ({ p, adjustedSize, rScale, oColumn, rAccessor, idPiece, projection, rScaleType }) => { const pValue = findFirstAccessorValue(rAccessor, p) || p.value; let o; if (oColumn) { o = oColumn.middle; } else { o = 0; } if (oColumn && projection === 'radial') { return pointOnArcAtAngle( [adjustedSize[0] / 2, adjustedSize[1] / 2], oColumn.pct_middle, idPiece && (idPiece.x || idPiece.scaledValue) ? idPiece.x / 2 || (idPiece.bottom + idPiece.scaledValue / 2) / 2 : pValue / 2 ); } if (projection === 'horizontal') { return [ idPiece && (idPiece.x || idPiece.scaledValue) ? idPiece.x === undefined ? idPiece.x : idPiece.value >= 0 ? idPiece.bottom + idPiece.scaledValue / 2 : idPiece.bottom : rScale(pValue), o ]; } const newScale = rScaleType .copy() .domain(rScale.domain()) .range(rScale.range().reverse()); return [ o, idPiece && (idPiece.x || idPiece.scaledValue) ? idPiece.y === undefined ? idPiece.value >= 0 ? idPiece.bottom - idPiece.scaledValue : idPiece.bottom : idPiece.y : newScale(pValue) ]; }; export const svgORRule = ({ d, i, screenCoordinates, projection }) => { return ( <Mark markType="text" key={`${d.label}annotationtext${i}`} forceUpdate={true} x={screenCoordinates[0] + (projection === 'horizontal' ? 10 : 0)} y={screenCoordinates[1] + (projection === 'vertical' ? 10 : 0)} className={`annotation annotation-or-label ${d.className || ''}`} textAnchor="middle" > {d.label} </Mark> ); }; export const basicReactAnnotationRule = ({ d, i, screenCoordinates }) => { const noteData = Object.assign( { dx: 0, dy: 0, note: { label: d.label, orientation: d.orientation, align: d.align }, connector: { end: 'arrow' } }, d, { x: screenCoordinates[0], y: screenCoordinates[1], type: typeof d.type === 'function' ? d.type : undefined, screenCoordinates } ); if (d.fixedX) noteData.x = d.fixedX; if (d.fixedY) noteData.y = d.fixedY; return <Annotation key={d.key || `annotation-${i}`} noteData={noteData} />; }; export const svgEncloseRule = ({ d, i, screenCoordinates }) => { const circle = packEnclose( screenCoordinates.map(p => ({ x: p[0], y: p[1], r: 2 })) ); return CircleEnclosure({ d, i, circle }); }; export const svgRRule = ({ d, i, screenCoordinates, rScale, rAccessor, adjustedSize, adjustedPosition, projection }) => { let x, y, xPosition, yPosition, subject, dx, dy; if (projection === 'radial') { return ( <Annotation key={d.key || `annotation-${i}`} noteData={Object.assign( { dx: 50, dy: 50, note: { label: d.label }, connector: { end: 'arrow' } }, d, { type: AnnotationCalloutCircle, subject: { radius: rScale(findFirstAccessorValue(rAccessor, d)) / 2, radiusPadding: 0 }, x: adjustedSize[0] / 2, y: adjustedSize[1] / 2 } )} /> ); } else if (projection === 'horizontal') { dx = 50; dy = 50; yPosition = d.offset || i * 25; x = screenCoordinates[0]; y = yPosition; subject = { x, y1: 0, y2: adjustedSize[1] + adjustedPosition[1] }; } else { dx = 50; dy = -20; xPosition = d.offset || i * 25; y = screenCoordinates[1]; x = xPosition; subject = { y, x1: 0, x2: adjustedSize[0] + adjustedPosition[0] }; } const noteData = Object.assign( { dx, dy, note: { label: d.label }, connector: { end: 'arrow' } }, d, { type: AnnotationXYThreshold, x, y, subject } ); return <Annotation key={d.key || `annotation-${i}`} noteData={noteData} />; }; export const svgCategoryRule = ({ projection, d, i, categories, adjustedSize }) => { const { bracketType = 'curly', position = projection === 'vertical' ? 'top' : 'left', depth = 30, offset = 0, padding = 0 } = d; const actualCategories = Array.isArray(d.categories) ? d.categories : [d.categories]; const cats = actualCategories.map(c => categories[c]); if (projection === 'radial') { const arcPadding = padding / adjustedSize[1]; const leftX = min( cats.map(p => p.pct_start + p.pct_padding / 2 + arcPadding / 2) ); const rightX = max( cats.map(p => p.pct_start + p.pct - p.pct_padding / 2 - arcPadding / 2) ); const chartSize = Math.min(adjustedSize[0], adjustedSize[1]) / 2; const centerX = adjustedSize[0] / 2; const centerY = adjustedSize[1] / 2; const { arcPath, textArcPath } = arcBracket({ x: 0, y: 0, radius: chartSize, startAngle: leftX * 360, endAngle: rightX * 360, inset: depth, outset: offset, curly: bracketType === 'curly' }); const textPathID = `text-path-${i}-${Math.random()}`; return ( <g className="category-annotation annotation" transform={`translate(${centerX},${centerY})`} > <path d={arcPath} fill="none" stroke="black" /> <path id={textPathID} d={textArcPath} style={{ display: 'none' }} /> <text font-size="12.5"> <textPath startOffset={'50%'} textAnchor={'middle'} xlinkHref={`#${textPathID}`} > {d.label} </textPath> </text> </g> ); } else { const leftX = min(cats.map(p => p.x)); const rightX = max(cats.map(p => p.x + p.width)); if (projection === 'vertical') { let yPosition = position === 'top' ? 0 : adjustedSize[1]; yPosition += position === 'top' ? -offset : offset; const noteData = { type: AnnotationBracket, y: yPosition, x: leftX - padding, note: { title: d.title || d.label, label: d.title ? d.label : undefined }, subject: { type: bracketType, width: rightX - leftX + padding * 2, depth: position === 'top' ? -depth : depth } }; return ( <Annotation key={d.key || `annotation-${i}`} noteData={noteData} /> ); } else if (projection === 'horizontal') { let yPosition = position === 'left' ? 0 : adjustedSize[0]; yPosition += position === 'left' ? -offset : offset; const noteData = { type: AnnotationBracket, x: yPosition, y: leftX - padding, note: { title: d.title || d.label, label: d.title ? d.label : undefined }, subject: { type: bracketType, height: rightX - leftX + padding * 2, depth: position === 'left' ? -depth : depth } }; return ( <Annotation key={d.key || `annotation-${i}`} noteData={noteData} /> ); } } }; export const htmlFrameHoverRule = ({ d, i, rAccessor, oAccessor, projection, tooltipContent, optimizeCustomTooltipPosition, useSpans, pieceIDAccessor, projectedColumns, adjustedSize, rScale, type, rScaleType }) => { tooltipContent = tooltipContent === 'pie' ? () => pieContentGenerator({ column: d.column, useSpans }) : tooltipContent; //To string because React gives a DOM error if it gets a date let contentFill; const pO = findFirstAccessorValue(oAccessor, d) || d.column; const oColumn = projectedColumns[pO]; const idPiece = findIDPiece(pieceIDAccessor, oColumn, d); if (!idPiece) { return null; } const screenCoordinates = ((type.type === 'clusterbar' || type.type === 'ordinalpoint' || type.type === 'swarm') && d.x !== undefined && d.y !== undefined) || d.isSummaryData ? [d.x, d.y] : screenProject({ p: d, adjustedSize, rScale, oColumn, rAccessor, idPiece, projection, rScaleType }); if (d.isSummaryData) { let summaryContent = d.label; if (d.pieces && d.pieces.length !== 0) { if (d.pieces.length === 1) { summaryContent = []; rAccessor.forEach(actualRAccessor => { summaryContent.push(actualRAccessor(d.pieces[0].data)); }); } else { summaryContent = []; rAccessor.forEach(actualRAccessor => { const pieceData = extent( d.pieces.map(p => p.data).map(actualRAccessor) ); summaryContent.push(`From ${pieceData[0]} to ${pieceData[1]}`); }); } } const summaryLabel = ( <p key="html-annotation-content-2">{summaryContent}</p> ); contentFill = [ <p key="html-annotation-content-1">{d.key}</p>, summaryLabel, <p key="html-annotation-content-3">{d.value}</p> ]; } else if (d.data) { contentFill = []; oAccessor.forEach((actualOAccessor, i) => { if (actualOAccessor(idPiece.data)) contentFill.push( <p key={`html-annotation-content-o-${i}`}> {actualOAccessor(idPiece.data).toString()} </p> ); }); rAccessor.forEach((actualRAccessor, i) => { if (actualRAccessor(idPiece.data)) contentFill.push( <p key={`html-annotation-content-r-${i}`}> {actualRAccessor(idPiece.data).toString()} </p> ); }); } else if (d.label) { contentFill = d.label; } let content = ( <SpanOrDiv span={useSpans} className="tooltip-content"> {contentFill} </SpanOrDiv> ); if (d.type === 'frame-hover' && tooltipContent && idPiece) { const tooltipContentArgs = { ...idPiece, ...idPiece.data }; content = optimizeCustomTooltipPosition ? ( <TooltipPositioner tooltipContent={tooltipContent} tooltipContentArgs={tooltipContentArgs} /> ) : ( tooltipContent(tooltipContentArgs) ); } return ( <SpanOrDiv span={useSpans} key={`xylabel-${i}`} className={`annotation annotation-or-label ${projection} ${d.className || ''}`} style={{ position: 'absolute', top: `${screenCoordinates[1]}px`, left: `${screenCoordinates[0]}px` }} > {content} </SpanOrDiv> ); }; export const htmlColumnHoverRule = ({ d, i, summaryType, oAccessor, type, adjustedPosition, adjustedSize, projection, tooltipContent, optimizeCustomTooltipPosition, useSpans, projectedColumns }) => { //we need to ignore negative pieces to make sure the hover behavior populates on top of the positive bar const { coordinates: [xPosition, yPosition], pieces, column } = getColumnScreenCoordinates({ d, projectedColumns, oAccessor, summaryType, type, projection, adjustedPosition, adjustedSize }); if (column === undefined) { return null; } //To string because React gives a DOM error if it gets a date const oContent = []; oAccessor.forEach((actualOAccessor, i) => { if (pieces[0].data) oContent.push( <p key={`or-annotation-o-${i}`}> {actualOAccessor(pieces[0].data).toString()} </p> ); }); let content = ( <SpanOrDiv span={useSpans} className="tooltip-content"> {oContent} <p key="or-annotation-2"> {sum(pieces.map(p => p.value).filter(p => p > 0))} </p> </SpanOrDiv> ); if (d.type === 'column-hover' && tooltipContent) { if (tooltipContent === 'pie') { tooltipContent = pieContentGenerator; } const tooltipContentArgs = { ...d, pieces: pieces.map(p => p.data), column, oAccessor }; content = optimizeCustomTooltipPosition ? ( <TooltipPositioner tooltipContent={tooltipContent} tooltipContentArgs={tooltipContentArgs} /> ) : ( tooltipContent(tooltipContentArgs) ); } else if (d.label) { content = ( <SpanOrDiv span={useSpans} className="tooltip-content"> {d.label} </SpanOrDiv> ); } return ( <SpanOrDiv span={useSpans} key={`orlabel-${i}`} className={`annotation annotation-or-label ${projection} ${d.className || ''}`} style={{ position: 'absolute', top: `${yPosition}px`, left: `${xPosition}px` }} > {content} </SpanOrDiv> ); }; export const svgRectEncloseRule = ({ d, i, screenCoordinates }) => { const bboxNodes = screenCoordinates.map(p => { return { x0: (p.x0 = p[0]), x1: (p.x1 = p[0]), y0: (p.y0 = p[1]), y1: (p.y1 = p[1]) }; }); return RectangleEnclosure({ bboxNodes, d, i }); }; export const svgOrdinalLine = ({ screenCoordinates, d, voronoiHover }) => { const lineGenerator = line() .x(d => d[0]) .y(d => d[1]); if (d.curve) { const interpolator = curveHash[d.curve] || d.curve; lineGenerator.curve(interpolator); } const lineStyle = typeof d.lineStyle === 'function' ? d.lineStyle(d) : d.lineStyle || {}; return ( <g> <path stroke="black" fill="none" style={lineStyle} d={lineGenerator(screenCoordinates)} /> {(d.points || d.interactive) && screenCoordinates.map((p, q) => { const pointStyle = typeof d.pointStyle === 'function' ? d.pointStyle(d.coordinates[q], q) : d.pointStyle || {}; return ( <g transform={`translate(${p[0]},${p[1]})`} key={`ordinal-line-point-${q}`} > {d.points && ( <circle style={pointStyle} r={d.radius || 5} fill="black" /> )} {d.interactive && ( <circle style={{ pointerEvents: 'all' }} r={d.hoverRadius || 15} fill="pink" opacity={0} onMouseEnter={() => voronoiHover({ type: 'frame-hover', ...d.coordinates[q], data: d.coordinates[q] }) } onMouseOut={() => voronoiHover()} /> )} } </g> ); })} </g> ); };