diff --git a/package.json b/package.json index 2a2623e32..bc816bf3c 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ }, "dependencies": { "d3-format": "^3.1.0", + "data-navigator": "^2.2.0", "immer": ">= 9.0.0", "uuid": ">= 9.0.0", "vega-embed": ">= 6.27.0", diff --git a/src/Chart.css b/src/Chart.css index abbbe5ad9..90ea98617 100644 --- a/src/Chart.css +++ b/src/Chart.css @@ -60,3 +60,43 @@ this removes transitions in the vega tooltip width: auto; height: 100%; } + +/* navigation elements */ +.dn-root { + position: relative; +} +.dn-wrapper { + position: absolute; + top: 0px; + left: 0px; +} +.dn-node { + pointer-events: none; + position: absolute; + padding: 0px; + margin: 0px; + overflow: visible; +} +.dn-node:focus, .dn-node:focus-visible { + outline: none; +} +.dn-node-svg { + position: absolute; + pointer-events: none; +} +.dn-node-path { + fill: none; + stroke: #000000; + stroke-width: 4px; + transform: translateY(2px); +} +.dn-entry-button { + position: absolute; + transform: translate(-9999px, -9999px); + height: 1px; +} +.dn-entry-button:focus { + transform: translate(0px, 0px); + height: auto; + top: -21px; +} \ No newline at end of file diff --git a/src/Navigator.tsx b/src/Navigator.tsx new file mode 100644 index 000000000..d7a5acce5 --- /dev/null +++ b/src/Navigator.tsx @@ -0,0 +1,443 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { FC, MutableRefObject, useEffect, useRef, useState } from 'react'; + +import { NAVIGATION_ID_KEY, NAVIGATION_RULES, NAVIGATION_SEMANTICS } from '@constants'; +import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/chartSpecBuilder'; +import { Scenegraph } from 'vega-scenegraph'; +import { View } from 'vega-view'; + +import { DimensionList, NavigationRules, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator'; +import { describeNode } from '../node_modules/data-navigator/dist/utilities.js'; +import { ChartData, CurrentNodeDetails, Navigation, NavigationEvent, SpatialProperties } from './types'; + +const convertId = (id, nodeLevel) => { + const getValueBetweenStrings = (text, startString, endString) => { + const regex = new RegExp(`${startString}(.*?)${endString}`); + const match = text.match(regex); + + if (match && match[1]) { + return match[1]; + } + return null; + }; + return nodeLevel === 'dimension' + ? id.substring(1) + : nodeLevel === 'division' + ? getValueBetweenStrings(id, '_', '_key_') + : +id.substring(1) + 1; +}; + +export interface NavigationProps { + data: ChartData[]; + chartView: MutableRefObject; + chartLayers: DimensionList; + navigationEventCallback?: (navEvent: NavigationEvent) => void; +} + +export const Navigator: FC = ({ data, chartView, chartLayers, navigationEventCallback }) => { + const focusedElement = useRef({ + id: '', + } as CurrentNodeDetails); + const willFocusAfterRender = useRef(false); + const firstRef = useRef(null); + const secondRef = useRef(null); + + const navigationStructure = buildNavigationStructure(data, { NAVIGATION_ID_KEY }, chartLayers); + const structureNavigationHandler = buildStructureHandler( + { + nodes: navigationStructure.nodes, + edges: navigationStructure.edges, + }, + NAVIGATION_RULES as NavigationRules, + navigationStructure.dimensions || {} + ); + + const entryPoint = structureNavigationHandler.enter(); + + const [navigation, setNavigation] = useState({ + transform: '', + buttonTransform: '', + current: { + id: entryPoint.id, + figureRole: 'figure', + imageRole: 'img', + hasInteractivity: true, + spatialProperties: { + width: '', + height: '', + left: '', + top: '', + }, + semantics: { + label: entryPoint.semantics?.label || '', + }, + }, + }); + + const [childrenInitialized, setInitialization] = useState(false); + + const setNavigationElement = (target) => { + setNavigation({ + transform: '', + buttonTransform: '', + current: { + id: target.id, + figureRole: 'figure', + imageRole: 'img', + hasInteractivity: true, + spatialProperties: target.spatialProperties || { + width: '', + height: '', + left: '', + top: '', + }, + semantics: { + label: target.semantics?.label || '', + }, + }, + } as Navigation); + }; + + useEffect(() => { + if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { + focusedElement.current = { id: navigation.current.id }; + willFocusAfterRender.current = false; + let refToFocus: HTMLDivElement | undefined = undefined; + if (firstRef.current?.id === navigation.current.id) { + refToFocus = firstRef.current; + } else if (secondRef.current?.id === navigation.current.id) { + refToFocus = secondRef.current + } + if (refToFocus) { + refToFocus.focus() + } + } + }, [navigation]); + + const initializeRenderingProperties = (id: string): SpatialProperties | void => { + const nodeToCheck: NodeObject = navigationStructure.nodes[id]; + if (!nodeToCheck.spatialProperties) { + if (!chartView.current) { + // I want to do this, but will leave it out for now + // window.setTimeout(()=>{ + // initializeRenderingProperties(id) + // }, 500) + } else { + const root: Scenegraph = chartView.current.scenegraph().root; + const items = root.items; + let offset = -root.bounds.x1; + if (items.length !== 1) { + // console.log("what is in items??",items) + } + if (root.items[0]?.items?.length) { + // const roles = ["mark", "legend", "axis", "scope"] + // const marktypes = ["rect", "group", "arc"] + const dimensions = Object.keys(navigationStructure.dimensions || {}); + const keysToMatch: string[] = []; + dimensions.forEach((d) => { + keysToMatch.push(navigationStructure.dimensions?.[d].dimensionKey || ''); + }); + const setDimensionSpatialProperties = (i, semanticKey) => { + dimensions.forEach((d) => { + if (navigationStructure.dimensions && navigationStructure.dimensions[d]) { + const dimension = navigationStructure.dimensions[d]; + const dimensionNode = navigationStructure.nodes[dimension.nodeId]; + const divisions = Object.keys(dimension.divisions); + const hasDivisions = divisions.length !== 1; + const childrenCount = hasDivisions + ? divisions.length + : Object.keys(dimension.divisions[divisions[0]].values).length; + const isPlural = childrenCount === 1 ? '' : 's'; + dimensionNode.spatialProperties = { + width: `${i.bounds.x2 - i.bounds.x1}px`, + height: `${i.bounds.y2 - i.bounds.y1}px`, + left: `${i.bounds.x1 + offset}px`, + top: `${i.bounds.y1}px`, + }; + dimensionNode.semantics = { + label: `${ + navigationStructure.dimensions?.[d].dimensionKey + }. Contains ${childrenCount} ${ + hasDivisions + ? NAVIGATION_SEMANTICS[semanticKey].DIVISION + : NAVIGATION_SEMANTICS[semanticKey].CHILD + }${isPlural}. Press ${NAVIGATION_RULES.child.key} key to navigate.`, + }; + } + }); + }; + const setDivisionSpatialProperties = (i, semanticKey) => { + dimensions.forEach((d) => { + if (navigationStructure.dimensions && navigationStructure.dimensions[d]) { + const dimension = navigationStructure.dimensions[d]; + const divisions = Object.keys(dimension.divisions); + if (divisions.length > 1) { + divisions.forEach((div) => { + const division = dimension.divisions[div]; + const children = Object.keys(division.values); + const childrenCount = children.length; + const divisionNode = navigationStructure.nodes[division.id]; + const isPlural = childrenCount === 1 ? '' : 's'; + const spatialBounds = { + x1: Infinity, + x2: -Infinity, + y1: Infinity, + y2: -Infinity, + }; + children.forEach((c) => { + const child = navigationStructure.nodes[c]; + if (child.spatialProperties) { + const left = +child.spatialProperties.left.replace(/px/g, ''); + const right = +child.spatialProperties.width.replace(/px/g, '') + left; + const top = +child.spatialProperties.top.replace(/px/g, ''); + const bottom = +child.spatialProperties.height.replace(/px/g, '') + top; + spatialBounds.x1 = left < spatialBounds.x1 ? left : spatialBounds.x1; + spatialBounds.x2 = right > spatialBounds.x2 ? right : spatialBounds.x2; + spatialBounds.y1 = top < spatialBounds.y1 ? top : spatialBounds.y1; + spatialBounds.y2 = + bottom > spatialBounds.y2 ? bottom : spatialBounds.y2; + } + }); + divisionNode.spatialProperties = { + width: `${spatialBounds.x2 - spatialBounds.x1}px`, + height: `${spatialBounds.y2 - spatialBounds.y1}px`, + left: `${spatialBounds.x1}px`, + top: `${spatialBounds.y1}px`, + }; + const key = navigationStructure.dimensions?.[d].dimensionKey || "" + const divisionType = division.values[Object.keys(division.values)[0]][key] + divisionNode.semantics = { + label: `${divisionType}, ${NAVIGATION_SEMANTICS[semanticKey].DIVISION} of ${key}. Contains ${childrenCount} ${NAVIGATION_SEMANTICS[semanticKey].CHILD}${isPlural}. Press ${NAVIGATION_RULES.child.key} key to navigate.`, + }; + }); + } + } + }); + }; + const setChildSpatialProperties = (i, semanticKey) => { + const datum = {}; + keysToMatch.forEach((key) => { + datum[key] = i.datum[key]; + }); + if (i.datum[NAVIGATION_ID_KEY] && navigationStructure.nodes[i.datum[NAVIGATION_ID_KEY]]) { + const correspondingNode = navigationStructure.nodes[i.datum[NAVIGATION_ID_KEY]]; + correspondingNode.spatialProperties = { + width: `${i.width}px`, + height: `${i.height}px`, + left: `${i.x + offset}px`, + top: `${i.y}px`, + }; + correspondingNode.semantics = { + label: describeNode(datum, { + semanticLabel: NAVIGATION_SEMANTICS[semanticKey].CHILD + '.', + }), + }; + } + }; + root.items[0].items.forEach((i) => { + if (i.name && i.name.indexOf('bar0_group') !== -1 && i.name.indexOf('focus') === -1) { + // these are bars! + setDimensionSpatialProperties(i, 'BAR'); + i.items.forEach((bg) => { + // using the view we can check for additional scales, if they exist, this needs an offset + // NOTE: as of right now, only dodged charts have a scale and need this extra offset calc! + offset = Object.keys(bg.context?.scales || {}).length ? -root.bounds.x1 + bg.bounds.x1 : offset; + bg.items.forEach((bg_i) => { + if ( + bg_i.marktype === 'rect' && + bg_i.role === 'mark' && + bg_i.name.indexOf('_background') === -1 && + bg_i.name.indexOf('focus') === -1 + ) { + bg_i.items.forEach((bar) => { + setChildSpatialProperties(bar, 'BAR'); + }); + } + }); + }); + setDivisionSpatialProperties(i, 'BAR'); + } else if (i.marktype === 'arc' && i.role === 'mark') { + // this is a pie chart! + // console.log("pie slices",i) + } else if (i.role === 'axis') { + // this is an axis! + // console.log(i.role, i) + } else if (i.role === 'legend') { + // this is a legend! + // console.log(i.role, i) + } else if (i.marktype === 'rule' && i.role === 'mark' && i.name) { + // this is a special mark? + // console.log("TBD: possible baseline/etc mark", i) + } else if (i.role === 'scope' && i.marktype === 'group' && i.name) { + // console.log("TBD: need to determine if combo, line, or area") + // ** if scatter: i.items[0].items[0].items + // ** if line: i.items are lines, forEach(l) : + // l.items[0].items + } + }); + dimensions.forEach((d) => { + const dimension = navigationStructure.dimensions?.[d]; + if (dimension && dimension.divisions) { + } + }); + } + } + } + }; + + const enterChart = () => { + /* + this is a brain-melting problem but on chrome, entering the chart with a mouse click doesn't show the focus indication at first + this works on firefox! so this likely has to do with how chrome interprets focus-visible versus focus (you can play with these + options in dev tools, via toggling focus versus focus-visible, to see) + */ + initializeRenderingProperties(navigation.current.id); + setInitialization(true); + setNavigationElement(navigationStructure.nodes[navigation.current.id]); + willFocusAfterRender.current = true; + if (navigationEventCallback) { + const node = navigationStructure.nodes[navigation.current.id]; + const nodeLevel = + node.dimensionLevel === 1 ? 'dimension' : node.dimensionLevel === 2 ? 'division' : 'child'; + navigationEventCallback({ + nodeId: navigation.current.id, + eventType: 'enter', + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel, + }); + } + }; + const handleFocus = (e) => { + initializeRenderingProperties(e.target.id); + focusedElement.current = { id: e.target.id }; + if (navigationEventCallback) { + const node = navigationStructure.nodes[e.target.id]; + const nodeLevel = + node.dimensionLevel === 1 ? 'dimension' : node.dimensionLevel === 2 ? 'division' : 'child'; + navigationEventCallback({ + nodeId: e.target.id, + eventType: 'focus', + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel, + }); + } + }; + const handleBlur = () => { + const blurredId = navigation.current.id; + focusedElement.current = { id: '' }; + if (navigationEventCallback) { + const node = navigationStructure.nodes[blurredId]; + const nodeLevel = + node.dimensionLevel === 1 ? 'dimension' : node.dimensionLevel === 2 ? 'division' : 'child'; + navigationEventCallback({ + nodeId: blurredId, + eventType: 'blur', + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel, + }); + } + }; + const handleKeydown = (e) => { + const direction = structureNavigationHandler.keydownValidator(e); + const target = e.target as HTMLElement; + if (direction) { + e.preventDefault(); + const nextNode = structureNavigationHandler.move(target.id, direction); + if (nextNode) { + setNavigationElement(nextNode); + willFocusAfterRender.current = true; + } + } + if (e.code === 'Space') { + e.preventDefault(); + if (navigationEventCallback) { + const node = navigationStructure.nodes[target.id]; + const nodeLevel = + node.dimensionLevel === 1 ? 'dimension' : node.dimensionLevel === 2 ? 'division' : 'child'; + navigationEventCallback({ + nodeId: target.id, + eventType: 'selection', + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel, + }); + } + } + }; + + const dummySpecs: Navigation = { + ...navigation, + }; + dummySpecs.current = { id: 'old' + navigation.current.id }; + dummySpecs.current.semantics = { label: '' }; + dummySpecs.current.spatialProperties = { ...navigation.current.spatialProperties }; + const firstProps = !firstRef.current || focusedElement.current.id !== firstRef.current.id ? navigation : dummySpecs; + const secondProps = firstProps.current.id === navigation.current.id ? dummySpecs : navigation; + + const figures = ( +
+
+
+
+ ); + /* + goals: + - add exit event + handling + - add alt text to root chart element + - possibly also hide vega's stuff + - append an exit element within the appended parent element for our navigation stuff + - add help menu/popup on "help" command? + - add handling for resizing/etc + */ + return ( + <> +
+ + {childrenInitialized ? figures : null} +
+ + ); +}; diff --git a/src/RscChart.tsx b/src/RscChart.tsx index a6f956521..c6379f761 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -16,6 +16,7 @@ import { FILTERED_TABLE, GROUP_DATA, LEGEND_TOOLTIP_DELAY, + NAVIGATION_ID_KEY, SELECTED_ITEM, SELECTED_SERIES, SERIES_ID, @@ -37,12 +38,14 @@ import { sanitizeRscChartChildren, setSelectedSignals, } from '@utils'; +import { Navigator } from 'Navigator'; import { renderToStaticMarkup } from 'react-dom/server'; import { Item } from 'vega'; import { Handler, Position, Options as TooltipOptions } from 'vega-tooltip'; import { ActionButton, Dialog, DialogTrigger, View as SpectrumView } from '@adobe/react-spectrum'; +import { addSimpleDataIDs as addNavigationIds } from '../node_modules/data-navigator/dist/structure.js'; import './Chart.css'; import { VegaChart } from './VegaChart'; import { @@ -51,6 +54,7 @@ import { Datum, LegendDescription, MarkBounds, + NavigationEvent, RscChartProps, TooltipAnchor, TooltipPlacement, @@ -112,6 +116,18 @@ export const RscChart = forwardRef( const sanitizedChildren = sanitizeRscChartChildren(props.children); + /* + chartLayers and addNavigationIds ensure that our Navigator can correctly build and use both data + and vega's view (rendered) properties for each datum, adding NAVIGATION_ID_KEY allows us to link + the data in our navigation structure to the rendering data vega creates from the spec + Note: chartLayers is mutated/populated by useSpec and addNavigationIds mutates the input data + */ + const chartLayers = []; + addNavigationIds({ + idKey: NAVIGATION_ID_KEY, + data, + addIds: true, + }); // THE MAGIC, builds our spec const spec = useSpec({ backgroundColor, @@ -130,9 +146,10 @@ export const RscChart = forwardRef( opacities, colorScheme, title, + chartLayers, UNSAFE_vegaSpec, }); - + // see controlledHoveredIdSignal for pattern to update focus signal const { controlledHoveredIdSignal, controlledHoveredGroupSignal } = useSpecProps(spec); const chartConfig = useMemo(() => getChartConfig(config, colorScheme), [config, colorScheme]); @@ -224,12 +241,43 @@ export const RscChart = forwardRef( if (legendIsToggleable) { signals.hiddenSeries = legendHiddenSeries; } + // see "selected" signals[SELECTED_ITEM] = selectedData?.[idKey] ?? null; signals[SELECTED_SERIES] = selectedData?.[SERIES_ID] ?? null; return signals; }, [colorScheme, idKey, legendHiddenSeries, legendIsToggleable]); + const navigationEventCallback = (navData: NavigationEvent) => { + if (chartView.current) { + if (navData.eventType === 'focus') { + chartView.current.signal('focusedItem', null); + chartView.current.signal('focusedDimension', null); + chartView.current.signal('focusedRegion', null); + switch (navData.nodeLevel) { + case 'dimension': + chartView.current.signal('focusedRegion', 'chart'); + break; + case 'division': + chartView.current.signal('focusedDimension', navData.vegaId); + break; + case 'child': + chartView.current.signal('focusedItem', navData.vegaId); + break; + default: + break; + } + chartView.current.runAsync(); + } else if (navData.eventType === 'blur') { + chartView.current.signal('focusedItem', null); + chartView.current.signal('focusedDimension', null); + chartView.current.signal('focusedRegion', null); + } else if (navData.eventType === 'selection') { + // this is where we would run an equivalent click event for a chart element! + } + } + // set signals here! + }; return ( <>
( if (legendIsToggleable) { view.signal('hiddenSeries', legendHiddenSeries); } + // this is where the magic happens setSelectedSignals({ idKey, selectedData: selectedData.current, @@ -309,6 +358,16 @@ export const RscChart = forwardRef( popover={popover} /> ))} + {chartLayers.length ? ( + + ) : ( +
+ )} ); } diff --git a/src/VegaChart.tsx b/src/VegaChart.tsx index 1095e9f1f..46d0b3e85 100644 --- a/src/VegaChart.tsx +++ b/src/VegaChart.tsx @@ -134,5 +134,5 @@ export const VegaChart: FC = ({ width, ]); - return
; + return ; }; diff --git a/src/constants.ts b/src/constants.ts index e0c2ba786..b0dd8cba0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,3 +116,104 @@ export enum INTERACTION_MODE { export const HOVER_SIZE = 3000; export const HOVER_SHAPE_COUNT = 3; export const HOVER_SHAPE = 'diamond'; + +// navigation +export const NAVIGATION_ID_KEY = "dnNodeId" +export const NAVIGATION_SEMANTICS = { + BAR: { + CHILD: "Bar", + DIVISION: "Division", + CHART: "Bar chart" + }, + LINE: { + CHILD: "Line", + DIVISION: "Division", + CHART: "Line chart" + }, + AREA: { + CHILD: "Area", + DIVISION: "Division", + CHART: "Area chart" + }, + PIE: { + CHILD: "Slice", + DIVISION: "Division", + CHART: "Pie chart" + }, + STACK: { + CHILD: "Stack", + DIVISION: "Division", + CHART: "Stacked bar chart" + }, + SCATTER: { + CHILD: "Data point", + DIVISION: "Division", + CHART: "Scatter plot" + }, + DODGE: { + CHILD: "Group", + DIVISION: "Division", + CHART: "Dodged bar chart" + }, + COMBO: { + CHILD: "Group", + DIVISION: "Division", + CHART: "Combo chart" + } +} +export const NAVIGATION_RULES = { + // utility keybinds + "exit": {key: 'Escape', direction: 'target'}, + "help": {key: 'Quote', direction: 'target'}, + "undo": {key: 'SemiColon', direction: 'target'}, + // generics + "left": {key: 'ArrowLeft', direction: 'source'}, + "right": {key: 'ArrowRight', direction: 'target'}, + "up": {key: 'ArrowUp', direction: 'source'}, + "down": {key: 'ArrowDown', direction: 'target'}, + "child": {key: 'Enter', direction: 'target'}, + // dimension + "parent-dimension": {direction: 'source', key: 'Backspace'}, + // color + "parent-color": {direction: 'source', key: 'Slash'}, + // metric + "parent-metric": {direction: 'source', key: 'BackSlash'} + // unused pairs + // "previous-metric": {direction: 'source', key: 'BracketLeft'}, + // "next-metric": {direction: 'target', key: 'BracketRight'}, + // "previous-dimension": {direction: 'source', key: 'Comma'}, + // "next-dimension": {direction: 'target', key: 'Period'}, +} +export const NAVIGATION_PAIRS = { + DIMENSION: { + parent_child: ["parent-dimension","child"], + sibling_sibling: ["left","right"], + }, + METRIC: { + parent_child: ["parent-metric","child"], + sibling_sibling: ["up","down"], + }, + COLOR: { + parent_child: ["parent-color","child"], + sibling_sibling: ["up","down"], + }, + HORIZONTAL: { + parent_child: ["parent-dimension","child"], + sibling_sibling: ["left","right"], + }, + VERTICAL: { + parent_child: ["parent-color","child"], + sibling_sibling: ["up","down"], + } +} +/* + child : {key: 'Enter', direction: 'target'} + left : {key: 'ArrowLeft', direction: 'source'} + next-browser : {direction: 'target', key: 'RightBracket'} + next-downloads : {direction: 'target', key: 'Backslash'} + parent-browser : {direction: 'source', key: 'KeyW'} + parent-downloads : {direction: 'source', key: 'KeyJ'} + previous-browser : {direction: 'source', key: 'LeftBracket'} + previous-downloads : {direction: 'source', key: 'Slash'} + right : {key: 'ArrowRight', direction: 'target'} +*/ \ No newline at end of file diff --git a/src/hooks/useChartProps.tsx b/src/hooks/useChartProps.tsx index a19c04b04..1d52fd61b 100644 --- a/src/hooks/useChartProps.tsx +++ b/src/hooks/useChartProps.tsx @@ -13,7 +13,12 @@ import { useDarkMode } from 'storybook-dark-mode'; import { ChartProps } from '../types'; +/** + * This hook syncs the color scheme with the storybook dark mode + * @param props + * @returns + */ export default function useChartProps(props: ChartProps): ChartProps { const darkMode = useDarkMode(); - return { colorScheme: darkMode ? 'dark' : 'light', ...props }; + return { colorScheme: darkMode ? 'dark' : 'light', debug: true, ...props }; } diff --git a/src/hooks/useSpec.tsx b/src/hooks/useSpec.tsx index 39faeb3ff..0988e6837 100644 --- a/src/hooks/useSpec.tsx +++ b/src/hooks/useSpec.tsx @@ -34,6 +34,7 @@ export default function useSpec({ symbolShapes, symbolSizes, title, + chartLayers, UNSAFE_vegaSpec, }: SanitizedSpecProps): Spec { return useMemo(() => { @@ -70,6 +71,7 @@ export default function useSpec({ opacities, symbolShapes, symbolSizes, + chartLayers, title, }) ) diff --git a/src/specBuilder/bar/barSpecBuilder.ts b/src/specBuilder/bar/barSpecBuilder.ts index c35520b95..bc0a954ce 100644 --- a/src/specBuilder/bar/barSpecBuilder.ts +++ b/src/specBuilder/bar/barSpecBuilder.ts @@ -36,7 +36,7 @@ import { getScaleIndexByType, } from '@specBuilder/scale/scaleSpecBuilder'; import { addHighlightedItemSignalEvents, getGenericValueSignal } from '@specBuilder/signal/signalSpecBuilder'; -import { getFacetsFromProps } from '@specBuilder/specUtils'; +import { getColorValue, getFacetsFromProps } from '@specBuilder/specUtils'; import { addTrendlineData, getTrendlineMarks, setTrendlineSignals } from '@specBuilder/trendline'; import { sanitizeMarkChildren, toCamelCase } from '@utils'; import { produce } from 'immer'; @@ -114,6 +114,9 @@ export const addSignals = produce((signals, props) => // We use this value to calculate ReferenceLine positions. const { paddingInner } = getBarPadding(paddingRatio, barPaddingOuter); signals.push(getGenericValueSignal('paddingInner', paddingInner)); + signals.push(getGenericValueSignal('focusedItem')); + signals.push(getGenericValueSignal('focusedDimension')); + signals.push(getGenericValueSignal('focusedRegion')); if (!children.length) { return; @@ -273,7 +276,7 @@ export const addMarks = produce((marks, props) => { } else if (props.type === 'stacked') { barMarks.push(...getStackedBarMarks(props)); } else { - barMarks.push(getDodgedMark(props)); + barMarks.push(...getDodgedMark(props)); } const popovers = getPopovers(props); @@ -290,6 +293,26 @@ export const addMarks = produce((marks, props) => { } marks.push(...getTrendlineMarks(props)); + marks.push({ + name: 'chartFocusRing', + type: 'rect', + interactive: false, + encode: { + enter: { + strokeWidth: { value: 2 }, + fill: { value: 'transparent' }, + stroke: { value: getColorValue('static-blue', props.colorScheme) }, + cornerRadius: { value: 4 }, + }, + update: { + x: { value: 0 }, + x2: { signal: 'width' }, + y: { value: 0 }, + y2: { signal: 'height' }, + opacity: [{ test: "focusedRegion === 'chart'", value: 1 }, { value: 0 }], + }, + }, + }); }); export const getRepeatedScale = (props: BarSpecProps): Scale => { diff --git a/src/specBuilder/bar/dodgedBarUtils.ts b/src/specBuilder/bar/dodgedBarUtils.ts index a0938e8a1..1135fb85d 100644 --- a/src/specBuilder/bar/dodgedBarUtils.ts +++ b/src/specBuilder/bar/dodgedBarUtils.ts @@ -11,7 +11,8 @@ */ import { BACKGROUND_COLOR } from '@constants'; import { getInteractive } from '@specBuilder/marks/markUtils'; -import { GroupMark } from 'vega'; +import { getColorValue } from '@specBuilder/specUtils'; +import { GroupMark, RectMark } from 'vega'; import { BarSpecProps } from '../../types'; import { getAnnotationMarks } from './barAnnotationUtils'; @@ -23,46 +24,95 @@ import { getDodgedGroupMark, } from './barUtils'; -export const getDodgedMark = (props: BarSpecProps): GroupMark => { - const { children, name } = props; +export const getDodgedMark = (props: BarSpecProps): [GroupMark, RectMark] => { + const { children, colorScheme, name, dimension } = props; - return { - ...getDodgedGroupMark(props), - marks: [ - // background bars - { - name: `${name}_background`, - from: { data: `${name}_facet` }, - type: 'rect', - interactive: false, - encode: { - enter: { - ...getBaseBarEnterEncodings(props), - fill: { signal: BACKGROUND_COLOR }, - }, - update: { - ...getDodgedDimensionEncodings(props), + return [ + { + ...getDodgedGroupMark(props), + marks: [ + // background bars + { + name: `${name}_background`, + from: { data: `${name}_facet` }, + type: 'rect', + interactive: false, + encode: { + enter: { + ...getBaseBarEnterEncodings(props), + fill: { signal: BACKGROUND_COLOR }, + }, + update: { + ...getDodgedDimensionEncodings(props), + }, }, }, - }, - // bars - { - name, - from: { data: `${name}_facet` }, - type: 'rect', - interactive: getInteractive(children, props), - encode: { - enter: { - ...getBaseBarEnterEncodings(props), - ...getBarEnterEncodings(props), - }, - update: { - ...getDodgedDimensionEncodings(props), - ...getBarUpdateEncodings(props), + // bars + { + name, + from: { data: `${name}_facet` }, + type: 'rect', + interactive: getInteractive(children, props), + encode: { + enter: { + ...getBaseBarEnterEncodings(props), + ...getBarEnterEncodings(props), + }, + update: { + ...getDodgedDimensionEncodings(props), + ...getBarUpdateEncodings(props), + }, }, }, + getBarFocusRing(props), + ...getAnnotationMarks(props, `${name}_facet`, `${name}_position`, `${name}_dodgeGroup`), + ], + }, + { + name: `${name}_group_focusRing`, + type: 'rect', + from: { data: `${name}_group` }, + interactive: false, + encode: { + enter: { + strokeWidth: { value: 2 }, + fill: { value: 'transparent' }, + stroke: { value: getColorValue('static-blue', colorScheme) }, + cornerRadius: { value: 4 }, + }, + update: { + x: { signal: 'datum.bounds.x1 - 2' }, + x2: { signal: 'datum.bounds.x2 + 2' }, + y: { signal: 'datum.bounds.y1 - 2' }, + y2: { signal: 'datum.bounds.y2 + 2' }, + opacity: [{ test: `focusedDimension === datum.datum.${dimension}`, value: 1 }, { value: 0 }], + }, + }, + }, + ]; +}; + +export const getBarFocusRing = (props: BarSpecProps): RectMark => { + const { colorScheme, idKey, name } = props; + return { + name: `${name}_focusRing`, + type: 'rect', + from: { data: name }, + interactive: false, + encode: { + enter: { + strokeWidth: { value: 2 }, + fill: { value: 'transparent' }, + stroke: { value: getColorValue('static-blue', colorScheme) }, + cornerRadius: { value: 4 }, + }, + update: { + x: { signal: 'datum.bounds.x1 - 2' }, + x2: { signal: 'datum.bounds.x2 + 2' }, + y: { signal: 'datum.bounds.y1 - 2' }, + y2: { signal: 'datum.bounds.y2 + 2' }, + opacity: [{ test: `focusedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], }, - ...getAnnotationMarks(props, `${name}_facet`, `${name}_position`, `${name}_dodgeGroup`), - ], + }, }; }; diff --git a/src/specBuilder/bar/stackedBarUtils.ts b/src/specBuilder/bar/stackedBarUtils.ts index 5c497d275..cc4fc5a93 100644 --- a/src/specBuilder/bar/stackedBarUtils.ts +++ b/src/specBuilder/bar/stackedBarUtils.ts @@ -11,6 +11,7 @@ */ import { BACKGROUND_COLOR, FILTERED_TABLE } from '@constants'; import { getInteractive } from '@specBuilder/marks/markUtils'; +import { getColorValue } from '@specBuilder/specUtils'; import { GroupMark, Mark, RectEncodeEntry, RectMark } from 'vega'; import { BarSpecProps } from '../../types'; @@ -26,7 +27,7 @@ import { } from './barUtils'; import { getTrellisProperties, isTrellised } from './trellisedBarUtils'; -export const getStackedBarMarks = (props: BarSpecProps): Mark[] => { +export const getStackedBarMarks = (props: BarSpecProps): [GroupMark, RectMark] => { const marks: Mark[] = []; // add background marks // these marks make it so that when the opacity of a bar is lowered (like on hover), you can't see the grid lines behind the bars @@ -35,6 +36,9 @@ export const getStackedBarMarks = (props: BarSpecProps): Mark[] => { // bar mark marks.push(getStackedBar(props)); + // bar focus ring + marks.push(getStackedBarFocusRing(props)); + // add annotation marks marks.push( ...getAnnotationMarks( @@ -45,7 +49,35 @@ export const getStackedBarMarks = (props: BarSpecProps): Mark[] => { ) ); - return marks; + return [ + { + name: `${props.name}_group`, + type: 'group', + from: { facet: { data: FILTERED_TABLE, name: `${props.name}_facet`, groupby: props.dimension } }, + marks, + }, + { + name: `${props.name}_group_focusRing`, + type: 'rect', + from: { data: `${props.name}_group` }, + interactive: false, + encode: { + enter: { + strokeWidth: { value: 2 }, + fill: { value: 'transparent' }, + stroke: { value: getColorValue('static-blue', props.colorScheme) }, + cornerRadius: { value: 4 }, + }, + update: { + x: { signal: 'datum.bounds.x1 - 2' }, + x2: { signal: 'datum.bounds.x2 + 2' }, + y: { signal: 'datum.bounds.y1 - 2' }, + y2: { signal: 'datum.bounds.y2 + 2' }, + opacity: [{ test: `focusedDimension === datum.datum.${props.dimension}`, value: 1 }, { value: 0 }], + }, + }, + }, + ]; }; export const getDodgedAndStackedBarMark = (props: BarSpecProps): GroupMark => { @@ -70,7 +102,8 @@ export const getStackedBackgroundBar = (props: BarSpecProps): RectMark => { return { name: `${name}_background`, type: 'rect', - from: { data: isDodgedAndStacked(props) ? `${name}_facet` : getBaseDataSourceName(props) }, + // from: { data: isDodgedAndStacked(props) ? `${name}_facet` : getBaseDataSourceName(props) }, + from: { data: `${name}_facet` }, interactive: false, encode: { enter: { @@ -89,7 +122,8 @@ export const getStackedBar = (props: BarSpecProps): RectMark => { return { name, type: 'rect', - from: { data: isDodgedAndStacked(props) ? `${name}_facet` : getBaseDataSourceName(props) }, + // from: { data: isDodgedAndStacked(props) ? `${name}_facet` : getBaseDataSourceName(props) }, + from: { data: `${name}_facet` }, interactive: getInteractive(children, props), encode: { enter: { @@ -104,6 +138,31 @@ export const getStackedBar = (props: BarSpecProps): RectMark => { }; }; +export const getStackedBarFocusRing = (props: BarSpecProps): RectMark => { + const { colorScheme, idKey, name } = props; + return { + name: `${name}_focusRing`, + type: 'rect', + from: { data: name }, + interactive: false, + encode: { + enter: { + strokeWidth: { value: 2 }, + fill: { value: 'transparent' }, + stroke: { value: getColorValue('static-blue', colorScheme) }, + cornerRadius: { value: 4 }, + }, + update: { + x: { signal: 'datum.bounds.x1 - 2' }, + x2: { signal: 'datum.bounds.x2 + 2' }, + y: { signal: 'datum.bounds.y1 - 2' }, + y2: { signal: 'datum.bounds.y2 + 2' }, + opacity: [{ test: `focusedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], + }, + }, + }; +}; + export const getStackedDimensionEncodings = (props: BarSpecProps): RectEncodeEntry => { const { dimension, orientation } = props; if (isDodgedAndStacked(props)) { diff --git a/src/specBuilder/chartSpecBuilder.test.ts b/src/specBuilder/chartSpecBuilder.test.ts index 9d1cb8a5c..872c5bb8d 100644 --- a/src/specBuilder/chartSpecBuilder.test.ts +++ b/src/specBuilder/chartSpecBuilder.test.ts @@ -75,6 +75,7 @@ const defaultSpecProps: SanitizedSpecProps = { symbolShapes: ['rounded-square'], symbolSizes: ['XS', 'XL'], title: '', + chartLayers: [], UNSAFE_vegaSpec: undefined, }; diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index a141fe163..dbe900ed3 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -27,6 +27,8 @@ import { SYMBOL_SHAPE_SCALE, SYMBOL_SIZE_SCALE, TABLE, + NAVIGATION_ID_KEY, + NAVIGATION_PAIRS } from '@constants'; import { Area, Axis, Bar, Legend, Line, Scatter, Title } from '@rsc'; import { Combo } from '@rsc/alpha'; @@ -34,6 +36,8 @@ import { BigNumber, Donut } from '@rsc/rc'; import colorSchemes from '@themes/colorSchemes'; import { produce } from 'immer'; import { Data, LinearScale, OrdinalScale, PointScale, Scale, Signal, Spec } from 'vega'; +import {default as DataNavigator} from 'data-navigator' +import {StructureOptions, DimensionList, DimensionDatum, Structure, NavigationRules, Dimensions, DimensionNavigationRules, ChildmostNavigationStrategy} from '../../node_modules/data-navigator/dist/src/data-navigator' import { AreaElement, @@ -98,6 +102,7 @@ export function buildSpec(props: SanitizedSpecProps) { opacities, symbolShapes, symbolSizes, + chartLayers, title, } = props; let spec = initializeSpec(null, { backgroundColor, colorScheme, description, title }); @@ -119,6 +124,7 @@ export function buildSpec(props: SanitizedSpecProps) { let { areaCount, axisCount, barCount, comboCount, donutCount, legendCount, lineCount, scatterCount } = initializeComponentCounts(); const specProps = { colorScheme, idKey, highlightedItem }; + // const chartLayers: DimensionList = [] spec = [...children] .sort((a, b) => buildOrder.get(a.type) - buildOrder.get(b.type)) .reduce((acc: Spec, cur) => { @@ -186,9 +192,139 @@ export function buildSpec(props: SanitizedSpecProps) { // clear out all scales that don't have any fields on the domain spec = removeUnusedScales(spec); + // now that our vega spec is made, we can generate our navigation spec (which are our dimensions) + buildNavigationDimensions(spec,children,chartLayers) return spec; } +export const buildNavigationDimensions = (spec, children, out: DimensionList) => { + let popped: DimensionDatum | undefined = undefined; + let navigableChartType = ""; + let isDodged = false; + const navigableDimensions = {}; + const childArray = [...children] + let count = 0; + + const dimensionTypes = { + "categorical": { + type: "categorical", + createNumericalSubdivisions: 1 + }, + // curently we don't support any numerical scales, but later (for scatter) you'll want to + "numerical": { + type: "numerical", + createNumericalSubdivisions: 4 // we should *at least* divide numerical dimensions into navigable quartiles + } + } + + let i = 0; + for (i = 0; i < childArray.length; i++) { + // below, we validate that we only create data navigator dimensions for "valid" chart types + // as of now, only "bar" chart variants are valid + if (childArray[i].type.displayName === Bar.displayName) { + if (!navigableChartType) { + // we should ideally have all of this stored somewhere! + // below are the scale types that we want to look for that bar might use + // (not sure if bar uses time, but I included it here) + navigableChartType = childArray[i].type.displayName + navigableDimensions[childArray[i].type.displayName] = { + ordinal: dimensionTypes.categorical, + band: dimensionTypes.categorical, + point: dimensionTypes.categorical, + time: dimensionTypes.categorical + } + } + if (!isDodged && childArray[i].props?.type === 'dodged') { + isDodged = true + } + } + } + const scales = (spec.scales || []); + scales.forEach(s => { + // the code below probably doesn't need too much tweaking between types? + // we don't want to include "legend" from the scales, since that is redundant + // we also want to make sure that we include the fields here! + if (navigableDimensions[navigableChartType]?.[s.type] && s.domain?.fields?.[0] && !(s.name?.includes("legend"))) { + count++ + // since we only support left/right (1 dimension) and up/down (second dimension), we don't want more than 3 dimensions + if (count < 3) { + let childmostNavigation: ChildmostNavigationStrategy = "within" + // generally, we default to left/right but sometimes also want to check for y axis stuff + let navigationRules = !s.name?.includes("y") ? NAVIGATION_PAIRS.HORIZONTAL : NAVIGATION_PAIRS.VERTICAL + + if (count === 2) { + // if we have a bar chart with 2 dimensions that isn't dodged? + // this is a stacked bar, so we nav across instead of within + if (navigableChartType === Bar.displayName && !isDodged && out[0].behavior) { + childmostNavigation = "across" + out[0].behavior.childmostNavigation = "across" + } + + navigationRules = NAVIGATION_PAIRS.VERTICAL + if (s.name?.includes("x") || out[0].navigationRules === NAVIGATION_PAIRS.VERTICAL) { + // if we already used vertical or current is horizontal, we want to set current dimension to horizonal + navigationRules = NAVIGATION_PAIRS.HORIZONTAL + // we want to make absolutely sure that the previous dimension is correct as vertical + out[0].navigationRules = NAVIGATION_PAIRS.VERTICAL as DimensionNavigationRules + // we want to start with left/right always, so we pop this out to add after our current dimension + popped = out.pop() + } + } + const d = navigableDimensions[navigableChartType][s.type] + const dimension: DimensionDatum = { + dimensionKey: s.domain.fields[0], + type: d.type, + // these are operations we perform when creating the dimension + operations: { + createNumericalSubdivisions: d.createNumericalSubdivisions, + compressSparseDivisions: true + }, + // these are props for setting structural behavior patterns (which influence navigation) + behavior: { + extents: "circular", + childmostNavigation + }, + // here we specify a function to create unique division ids + divisionOptions: { + divisionNodeIds: (dimensionKey, keyValue, i) => "_" + keyValue + "_key_" + dimensionKey + i + }, + // here we set the navigation rules + navigationRules: navigationRules as DimensionNavigationRules + } + out.push(dimension) + if (popped) { + out.push(popped) + } + } + } + }) +} + +export const buildNavigationStructure = (data, props, chartLayers) : Structure => { + const layers = props.list ? "" : chartLayers; + const structureOptions : StructureOptions = { + data, + idKey: NAVIGATION_ID_KEY, + // addIds: true, + // keysForIdGeneration: [], + dimensions: { + values: layers + } + } + + // now we build with data navigator: + return DataNavigator.structure(structureOptions) +} + +export const buildStructureHandler = (structure: Structure, navigationRules: NavigationRules, dimensions: Dimensions) => { + return DataNavigator.input({ + structure, + navigationRules, + entryPoint: dimensions[Object.keys(dimensions)[0]].nodeId, + // exitPoint + }) +} + export const removeUnusedScales = produce((spec) => { spec.scales = spec.scales?.filter((scale) => { return !('domain' in scale && scale.domain && 'fields' in scale.domain && scale.domain.fields.length === 0); diff --git a/src/types/Chart.ts b/src/types/Chart.ts index 02cb50c90..c617b7ec5 100644 --- a/src/types/Chart.ts +++ b/src/types/Chart.ts @@ -13,6 +13,7 @@ import { JSXElementConstructor, MutableRefObject, ReactElement, ReactNode } from import { GROUP_DATA, INTERACTION_MODE, MARK_ID, SERIES_ID, TRENDLINE_VALUE } from '@constants'; import { Config, Data, FontWeight, Locale, NumberLocale, Padding, Spec, SymbolShape, TimeLocale, View } from 'vega'; +import { DimensionList } from '../../node_modules/data-navigator/dist/src/data-navigator' import { Icon, IconProps } from '@adobe/react-spectrum'; import { IconPropsWithoutChildren } from '@react-spectrum/icon'; @@ -93,6 +94,7 @@ export interface SpecProps { highlightedSeries?: string | number; /** Data key that contains a unique ID for each data point in the array. */ idKey?: string; + } type SpecPropsWithDefaults = @@ -109,6 +111,7 @@ type SpecPropsWithDefaults = export interface SanitizedSpecProps extends PartiallyRequired { /** Children with all non-RSC components removed */ children: ChartChildElement[]; + chartLayers: DimensionList; data?: ChartData[]; } diff --git a/src/types/index.ts b/src/types/index.ts index 91ea2d04f..4049e0df1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,4 +14,5 @@ export * from './Chart'; export * from './specBuilderTypes'; export * from './SpectrumVizColors'; export * from './supplementalVegaTypes'; +export * from './navigationTypes'; export * from './locales'; diff --git a/src/types/navigationTypes.ts b/src/types/navigationTypes.ts new file mode 100644 index 000000000..ab197459c --- /dev/null +++ b/src/types/navigationTypes.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export type Navigation = { + transform: string; + buttonTransform: string; + current: CurrentNodeDetails; +} + +export type NavigationEvent = { + // focus and blur should be self-explanatory + // selection emits if spacebar is pressed + // enter is an event that only happens when the "enter" button is run, this emits BEFORE focus + // exit is an event that only happens when the "exit" key is pressed, this emits BEFORE blur + // help is an event that only happens when the "help" key is pressed (if set) + eventType: "focus" | "blur" | "selection" | "enter" | "exit" | "help"; + // the ID of the node being focused, blurred, exited from, selected, etc + nodeId: string; + // the vega-compatible ID of the node being focused, blurred, exited from, selected, etc + vegaId: string; + // these correspond to the 3 layers within data navigator + // dimensions are like the keys used in the data (eg "country") + // divisions are the collections of values within that dimension (eg "USA" or "1-50" if numerical) + // child is the lowest level of a dimension, the children of divisions + // if divisions only ever have 1 child each, they are skipped and the level goes straight from + // dimension to child (this is what happens in a basic bar chart) + nodeLevel: "dimension" | "division" | "child"; +} + +export type SpatialProperties = { + height?: string; + width?: string; + left?: string; + top?: string; +} + +export type CurrentNodeDetails = { + id: string; + figureRole?: "figure"; + imageRole?: "img"; + hasInteractivity?: boolean; + spatialProperties?: SpatialProperties; + semantics?: { + label: string + }; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d5f8e92a6..3eccefb72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6269,6 +6269,11 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +data-navigator@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/data-navigator/-/data-navigator-2.2.0.tgz#68ad8e7500cdbbe672203d229179d0cf3906ab90" + integrity sha512-hKxREE/q5qBdzb4WrH1cILLVTvSV/r6Iu1t14ds026cvMYUgGXVIvtAYP3YypZKmR59jrygZUeA0tDSVdpB6gQ== + data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143"