From b6336af115e2c70ca07e7476871c3eb6e79be7dc Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 15 Dec 2024 09:53:52 -0500 Subject: [PATCH 01/37] add data-navigator dependency --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 2a2623e32..063dc784f 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ }, "dependencies": { "d3-format": "^3.1.0", + "data-navigator": "^2.0.2", "immer": ">= 9.0.0", "uuid": ">= 9.0.0", "vega-embed": ">= 6.27.0", diff --git a/yarn.lock b/yarn.lock index d5f8e92a6..ba72a0734 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.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/data-navigator/-/data-navigator-2.0.2.tgz#082d65166bf950e9634340346aa0ca7cae96570a" + integrity sha512-K9OleAyYZpPSuYnFLdZG8VueI8PY1HxhhmS43hKYdfW0fjbarfBmeqxra1TqhFtG4TYAURY7w4pWzWSzjs8Tcg== + data-urls@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-3.0.2.tgz#9cf24a477ae22bcef5cd5f6f0bfbc1d2d3be9143" From 500791e8e5f9b9bc194fbb3a33d0dbd17f727d7d Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 15 Dec 2024 09:54:25 -0500 Subject: [PATCH 02/37] add basic dimension navigation to charts --- src/Navigator.tsx | 152 ++++++++++++++++++++++++++++ src/RscChart.tsx | 17 +++- src/VegaChart.tsx | 38 +++++++ src/hooks/useSpec.tsx | 2 + src/specBuilder/chartSpecBuilder.ts | 115 +++++++++++++++++++++ src/types/Chart.ts | 3 + src/types/index.ts | 1 + src/types/navigationTypes.ts | 32 ++++++ 8 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 src/Navigator.tsx create mode 100644 src/types/navigationTypes.ts diff --git a/src/Navigator.tsx b/src/Navigator.tsx new file mode 100644 index 000000000..7ba3a5457 --- /dev/null +++ b/src/Navigator.tsx @@ -0,0 +1,152 @@ +/* + * 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, useEffect, useRef, useState, MutableRefObject } from 'react' +import { DimensionList } from '../node_modules/data-navigator/dist/src/data-navigator' +import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/chartSpecBuilder'; +import { Navigation, CurrentNodeDetails, ChartData} from './types' +import { View } from 'vega'; + +export interface NavigationProps { + data: ChartData; + chartView: MutableRefObject; + chartLayers: DimensionList; +} + +export const Navigator: FC = ({data, chartView, chartLayers}) => { + const focusedElement = useRef({ + id: "" + } as CurrentNodeDetails) + const willFocusAfterRender = useRef(false); + const firstRef = useRef(null); + const secondRef = useRef(null); + + const navigationStructure = buildNavigationStructure(data, {}, chartLayers) + const structureNavigationHandler = buildStructureHandler( + { + nodes: navigationStructure.nodes, + edges: navigationStructure.edges, + }, + navigationStructure.navigationRules || {}, + navigationStructure.dimensions || {} + ) + + const entryPoint = structureNavigationHandler.enter() + const [navigation, setNavigation] = useState({ + transform: "", + buttonTransform: "", + current: { + id: entryPoint.id, + figureRole: "figure", + imageRole: "image", + hasInteractivity: true, + spatialProperties: { + width: "", + height: "", + left: "", + top: "" + }, + semantics: { + label: entryPoint.semantics?.label || "initial element test" + } + } + }) + + const setNavigationElement = (target) => { + console.log("changing to new target",target.id) + // console.log("navigationStructure.navigationRules",navigationStructure.navigationRules) + setSpatialProperties() + setNavigation({ + transform: "", + buttonTransform: "", + current: { + id: target.id, + figureRole: "figure", + imageRole: "image", + hasInteractivity: true, + spatialProperties: { + width: "", + height: "", + left: "", + top: "" + }, + semantics: { + label: "testing semantics!" + } + } + }) + } + // setNavigationElement(entryPoint) + + useEffect(()=>{ + if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { + focusedElement.current = {id: navigation.current.id} + willFocusAfterRender.current = false + console.log("firstRef",firstRef) + console.log("secondRef",secondRef) + // console.log("navigation.current.id",navigation) + // console.log("focusedElement",focusedElement) + if (firstRef.current?.id === navigation.current.id) { + firstRef.current.focus() + } else if (secondRef.current?.id === navigation.current.id) { + secondRef.current.focus() + } + } + }, [navigation]) + + const handleFocus = (e) => { + console.log("focused",e) + focusedElement.current = {id: e.target.id} + } + const handleBlur = (e) => { + console.log("bluring at parent",e) + focusedElement.current = {id: ""} + } + const handleKeydown = (e) => { + // console.log("keydown",e) + const direction = structureNavigationHandler.keydownValidator(e); + console.log("direction",direction) + if (direction) { + e.preventDefault(); + const nextNode = structureNavigationHandler.move(e.target.id, direction); + if (nextNode) { + setNavigationElement(nextNode); + willFocusAfterRender.current = true + } + } + } + const setSpatialProperties = () => { + console.log(chartView?.current) + } + + 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 + + return ( + <> +
+ {/* */} +
+
+
+
+
+
+
+ + ) +} diff --git a/src/RscChart.tsx b/src/RscChart.tsx index a6f956521..ec59ee73f 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -45,6 +45,8 @@ import { ActionButton, Dialog, DialogTrigger, View as SpectrumView } from '@adob import './Chart.css'; import { VegaChart } from './VegaChart'; +import { Navigator } from 'Navigator'; + import { ChartHandle, ColorScheme, @@ -53,7 +55,7 @@ import { MarkBounds, RscChartProps, TooltipAnchor, - TooltipPlacement, + TooltipPlacement } from './types'; interface ChartDialogProps { @@ -111,7 +113,7 @@ export const RscChart = forwardRef( const [isPopoverOpen, setIsPopoverOpen] = useState(false); // tracks the open/close state of the popover const sanitizedChildren = sanitizeRscChartChildren(props.children); - + const chartLayers = []; // THE MAGIC, builds our spec const spec = useSpec({ backgroundColor, @@ -130,6 +132,7 @@ export const RscChart = forwardRef( opacities, colorScheme, title, + chartLayers, UNSAFE_vegaSpec, }); @@ -149,7 +152,7 @@ export const RscChart = forwardRef( }, [isPopoverOpen]); useChartImperativeHandle(forwardedRef, { chartView, title }); - + const { legendHiddenSeries, setLegendHiddenSeries, @@ -309,6 +312,14 @@ export const RscChart = forwardRef( popover={popover} /> ))} + ); } diff --git a/src/VegaChart.tsx b/src/VegaChart.tsx index 1095e9f1f..293946919 100644 --- a/src/VegaChart.tsx +++ b/src/VegaChart.tsx @@ -103,6 +103,44 @@ export const VegaChart: FC = ({ tooltip, width, }).then(({ view }) => { + // c/onsole.log("~~~~") + // c/onsole.log("~~~~") + // c/onsole.log("view!", view) + // c/onsole.log("specCopy!", specCopy) + // c/onsole.log("tableData!", tableData) + // c/onsole.log("config!", config) + // c/onsole.log("containerRef.current",containerRef.current) + // view._scenegraph.root.items[0].items[3].source.value[0].datum // + // items[0] of 0 // not sure what this level is + // items[2] of 4 // role === "mark", name === "bar0_background" + // items[3] of 4 // role === "mark", name === "bar0" + // source.value // this is an array of the bars in a bar chart now + // value[i].datum // this contains the data used to render the bar + // value[i].x // & y, width, & height are the rendered mark's info + /* + browser: "Chrome" + downloads: 27000 + downloads0: 0 + downloads1: 27000 + percentLabel: "53.1%" + rscMarkId: 1 // this is the id + rscStackId: "Chrome" // this id is used for x axis grouping (if stacking?) + Symbol(vega_id): 13367 + */ + /* + goals: + - use tableData to construct dimensions: dimension, metric, and color are all used as encoding props. Note: dimension can sometimes be numerical or categorical + - figure out how to intercept which dimensions are used to encode the chart, use only those + - create structure: grouped or list based on encoding type, include rendered info + - view._scenegraph.root.items[0].items[3].source.value[0].datum is where x/y/width/eight are stored + - what about other mark types? + - create semantics for axes, legends, groups, elements, etc + - export the structure and semantics to ghost element renderer + - add alt text to root chart element + - on user input: + - render ghost element + - show signal in chart + */ chartView.current = view; onNewView(view); view.resize(); 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/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index a141fe163..4ece60309 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -34,6 +34,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} from '../../node_modules/data-navigator/dist/src/data-navigator' import { AreaElement, @@ -98,6 +100,7 @@ export function buildSpec(props: SanitizedSpecProps) { opacities, symbolShapes, symbolSizes, + chartLayers, title, } = props; let spec = initializeSpec(null, { backgroundColor, colorScheme, description, title }); @@ -119,6 +122,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) => { @@ -134,18 +138,25 @@ export function buildSpec(props: SanitizedSpecProps) { switch (cur.type.displayName) { case Area.displayName: areaCount++; + addLayer(chartLayers, {acc,cur,index:areaCount}) return addArea(acc, { ...(cur as AreaElement).props, ...specProps, index: areaCount }); case Axis.displayName: axisCount++; + // console.log("AXIS! What do we do here?") + // addLayer(chartLayers, {acc,cur,index:axisCount}) return addAxis(acc, { ...(cur as AxisElement).props, ...specProps, index: axisCount }); case Bar.displayName: barCount++; + addLayer(chartLayers, {acc,cur,index:barCount}) return addBar(acc, { ...(cur as BarElement).props, ...specProps, index: barCount }); case Donut.displayName: donutCount++; + addLayer(chartLayers, {acc,cur,index:donutCount}) return addDonut(acc, { ...(cur as DonutElement).props, ...specProps, index: donutCount }); case Legend.displayName: legendCount++; + // console.log("LEGEND! What do we do here?") + // addLayer(chartLayers, {acc,cur,index:legendCount}) return addLegend(acc, { ...(cur as LegendElement).props, ...specProps, @@ -155,18 +166,24 @@ export function buildSpec(props: SanitizedSpecProps) { }); case Line.displayName: lineCount++; + addLayer(chartLayers, {acc,cur,index:lineCount}) return addLine(acc, { ...(cur as LineElement).props, ...specProps, index: lineCount }); case Scatter.displayName: scatterCount++; + addLayer(chartLayers, {acc,cur,index:scatterCount}) return addScatter(acc, { ...(cur as ScatterElement).props, ...specProps, index: scatterCount }); case Title.displayName: // No title count. There can only be one title. + // console.log("TITLE! What do we do here?") + // addLayer(chartLayers, {acc,cur,index:0}) return addTitle(acc, { ...(cur as TitleElement).props }); case BigNumber.displayName: // Do nothing and do not throw an error return acc; case Combo.displayName: comboCount++; + // console.log("COMBO! What do we do here?") + // addLayer(chartLayers, {acc,cur,index:comboCount}) return addCombo(acc, { ...(cur as ComboElement).props, ...specProps, index: comboCount }); default: console.error(`Invalid component type: ${cur.type.displayName} is not a supported child`); @@ -189,6 +206,104 @@ export function buildSpec(props: SanitizedSpecProps) { return spec; } +export const addLayer = (chartLayers: DimensionList, newLayer) => { + if (newLayer.cur.type.displayName === Line.displayName) { + // console.log("we have a line! how do we want to deal with this??") + } + const dimensions: Record = { + dimension: { + dimensionKey: "", + operations: { + createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 + }, + behavior: { + extents: "circular" + } + }, + metric: { + dimensionKey: "", + operations: { + createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 + }, + type: "numerical", + behavior: { + extents: "terminal" + } + }, + color: { + dimensionKey: "", + type: "categorical", + behavior: { + extents: "circular" + } + } + } + if (newLayer.cur?.props) { + const dimensionKeys = Object.keys(dimensions) + if (newLayer.cur.props.metric_start || newLayer.cur.props.metric_end) { + // console.log("uh oh! a range!") + } + dimensionKeys.forEach(k => { + const dimensionKey = newLayer.cur.props[k] + if (dimensionKey) { + const newDimension: DimensionDatum = { + ...dimensions[k], + dimensionKey, + navigationRules: { + parent_child: ["parent-"+dimensionKey,"child"], + sibling_sibling: ["previous-"+dimensionKey,"next-"+dimensionKey] + }, + divisionOptions: { + // sortFunction: , // no idea if we want to sort or not + divisionNodeIds: (dimensionKey, keyValue, i) => dimensionKey + keyValue + i + } + }; + chartLayers.push(newDimension) + } + }) + } +} + +export const buildNavigationStructure = (data, props, chartLayers) : Structure => { + // to do: + // if "props.list" then we create a new dimension here + // figure out keysForIdGeneration + // set dimensions options (below) + // use data + vega lite view to finalize structure and render info + // generate descriptions + // build structure+input in RscChart + // append element after in RscChart for navigation (the rendered thing) + // append an exit element within the appended parent element for our navigation stuff + const layers = props.list ? "" : chartLayers + const structureOptions : StructureOptions = { + data, + idKey: "dnNodeId"/*(d) => { + console.log("trying to find Id",d) + if (d && d.rscMarkId) { + return "rscMarkId" + } + return "rscStackId" + }*/, + 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..4b411253f --- /dev/null +++ b/src/types/navigationTypes.ts @@ -0,0 +1,32 @@ +/* + * 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 CurrentNodeDetails = { + id: string; + figureRole?: "figure"; + imageRole?: "image"; + hasInteractivity?: boolean; + spatialProperties?: { + height?: string; + width?: string; + left?: string; + top?: string; + }; + semantics?: { + label: string + }; +} \ No newline at end of file From e1e6124dc887e7e34f0bf97425f7987fc6575bae Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 15 Dec 2024 19:02:02 -0500 Subject: [PATCH 03/37] load view into Navigator, remove excess comments --- src/Navigator.tsx | 80 +++++++++++++++++++++++++++++++++--- src/RscChart.tsx | 3 -- src/VegaChart.tsx | 38 ----------------- src/types/navigationTypes.ts | 14 ++++--- 4 files changed, 83 insertions(+), 52 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 7ba3a5457..016079061 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -10,13 +10,15 @@ * governing permissions and limitations under the License. */ import { FC, useEffect, useRef, useState, MutableRefObject } from 'react' -import { DimensionList } from '../node_modules/data-navigator/dist/src/data-navigator' +import { DimensionList, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator' +import { describeNode } from '../node_modules/data-navigator/dist/utilities.js' import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/chartSpecBuilder'; -import { Navigation, CurrentNodeDetails, ChartData} from './types' -import { View } from 'vega'; +import { Navigation, CurrentNodeDetails, ChartData, SpatialProperties} from './types' +import { View } from 'vega-view' +import { Scenegraph } from 'vega-scenegraph'; export interface NavigationProps { - data: ChartData; + data: ChartData[]; chartView: MutableRefObject; chartLayers: DimensionList; } @@ -102,8 +104,55 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } }, [navigation]) + const roles = ["mark", "legend", "axis", "scope"] + const marktypes = ["rect", "group", "arc"] + const checkForSpatialProperties = (id: string): SpatialProperties | void => { + const nodeToCheck: NodeObject = navigationStructure.nodes[id] + if (!nodeToCheck.spatialProperties) { + console.log("nodeToCheck",nodeToCheck) + if (!chartView.current) { + // I want to do this, but will leave it out for now + // window.setTimeout(()=>{ + // checkForSpatialProperties(id) + // }, 500) + } else { + const root: Scenegraph = chartView.current.scenegraph().root + console.log(root) + const items = root.items + if (items.length !== 1) { + console.log("what is in items??",items) + } + if (root.items[0]?.items?.length) { + root.items[0].items.forEach((i) => { + console.log(i.marktype, i.role, i.name, i) + // needs a name + // name should not have "_background" added + // marktypes === group: legend, axis, scatter, line + // role === scope: scatter, area, line or line(metric group aka name === "line0MetricRange0_group") + // marktype === rect | arc: bar or pie (easy) + // ** if scatter: i.items[0].items[0].items + // ** if line: i.items are lines, forEach(l) : + // l.items[0].items + + /* each child datum looks something like this: + browser: "Chrome" + downloads: 27000 + downloads0: 0 + downloads1: 27000 + percentLabel: "53.1%" + rscMarkId: 1 // this is the id + rscStackId: "Chrome" // this id is used for x axis grouping (if stacking?) + Symbol(vega_id): 13367 + */ + }) + } + } + } + } + const handleFocus = (e) => { console.log("focused",e) + checkForSpatialProperties(e.target.id) focusedElement.current = {id: e.target.id} } const handleBlur = (e) => { @@ -111,7 +160,6 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = focusedElement.current = {id: ""} } const handleKeydown = (e) => { - // console.log("keydown",e) const direction = structureNavigationHandler.keydownValidator(e); console.log("direction",direction) if (direction) { @@ -124,7 +172,29 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } } const setSpatialProperties = () => { + /* + goals: + - fix bugs in line, combo, and area + - [dn work] create dataNavigator functions to: + - compress divisions if all divisions of a dimension only have a single child (as an option), aka make a "list" within that dimension (this is good for regular bar charts, as an example) + - create a function to hide a particular division/dimension parent? this would be helpful for some cases like a bar chart, where you want sorted numerical nav at the child level, but not division/dimension parents + - nest n divisions within divisions (make this a much smarter process) + - add type for what Input returns, improve consistency of those functions + - create semantics for axes, legends, groups, elements, etc + - create spatialProperties for axes, legends, groups, elements, etc + - add alt text to root chart element + - possibly also hide vega's stuff + */ console.log(chartView?.current) + console.log(navigationStructure.nodes) + console.log(describeNode) + + if (chartView.current) { + console.log("we got room with a view",chartView.current) + } else { + console.log("describeNode",describeNode) + console.log("hmm",chartView) + } } const dummySpecs: Navigation = { diff --git a/src/RscChart.tsx b/src/RscChart.tsx index ec59ee73f..0098b09fc 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -313,12 +313,9 @@ export const RscChart = forwardRef( /> ))} ); diff --git a/src/VegaChart.tsx b/src/VegaChart.tsx index 293946919..1095e9f1f 100644 --- a/src/VegaChart.tsx +++ b/src/VegaChart.tsx @@ -103,44 +103,6 @@ export const VegaChart: FC = ({ tooltip, width, }).then(({ view }) => { - // c/onsole.log("~~~~") - // c/onsole.log("~~~~") - // c/onsole.log("view!", view) - // c/onsole.log("specCopy!", specCopy) - // c/onsole.log("tableData!", tableData) - // c/onsole.log("config!", config) - // c/onsole.log("containerRef.current",containerRef.current) - // view._scenegraph.root.items[0].items[3].source.value[0].datum // - // items[0] of 0 // not sure what this level is - // items[2] of 4 // role === "mark", name === "bar0_background" - // items[3] of 4 // role === "mark", name === "bar0" - // source.value // this is an array of the bars in a bar chart now - // value[i].datum // this contains the data used to render the bar - // value[i].x // & y, width, & height are the rendered mark's info - /* - browser: "Chrome" - downloads: 27000 - downloads0: 0 - downloads1: 27000 - percentLabel: "53.1%" - rscMarkId: 1 // this is the id - rscStackId: "Chrome" // this id is used for x axis grouping (if stacking?) - Symbol(vega_id): 13367 - */ - /* - goals: - - use tableData to construct dimensions: dimension, metric, and color are all used as encoding props. Note: dimension can sometimes be numerical or categorical - - figure out how to intercept which dimensions are used to encode the chart, use only those - - create structure: grouped or list based on encoding type, include rendered info - - view._scenegraph.root.items[0].items[3].source.value[0].datum is where x/y/width/eight are stored - - what about other mark types? - - create semantics for axes, legends, groups, elements, etc - - export the structure and semantics to ghost element renderer - - add alt text to root chart element - - on user input: - - render ghost element - - show signal in chart - */ chartView.current = view; onNewView(view); view.resize(); diff --git a/src/types/navigationTypes.ts b/src/types/navigationTypes.ts index 4b411253f..a96d5f0b9 100644 --- a/src/types/navigationTypes.ts +++ b/src/types/navigationTypes.ts @@ -15,17 +15,19 @@ export type Navigation = { current: CurrentNodeDetails; } +export type SpatialProperties = { + height?: string; + width?: string; + left?: string; + top?: string; +} + export type CurrentNodeDetails = { id: string; figureRole?: "figure"; imageRole?: "image"; hasInteractivity?: boolean; - spatialProperties?: { - height?: string; - width?: string; - left?: string; - top?: string; - }; + spatialProperties?: SpatialProperties; semantics?: { label: string }; From be94d2455f9dcd3487a7a999f9678e5c8aa7185c Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 15 Dec 2024 21:25:41 -0500 Subject: [PATCH 04/37] add rendering property initialization for navigation --- src/Chart.css | 30 ++++++ src/Navigator.tsx | 162 ++++++++++++++++------------ src/RscChart.tsx | 14 +++ src/constants.ts | 3 + src/specBuilder/chartSpecBuilder.ts | 14 +-- 5 files changed, 148 insertions(+), 75 deletions(-) diff --git a/src/Chart.css b/src/Chart.css index abbbe5ad9..46d6a7554 100644 --- a/src/Chart.css +++ b/src/Chart.css @@ -60,3 +60,33 @@ 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 { + position: absolute; + padding: 0px; + margin: 0px; + overflow: visible; +} +.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: relative; + top: -21px; +} \ No newline at end of file diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 016079061..fa970a2cb 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -16,6 +16,7 @@ import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/ch import { Navigation, CurrentNodeDetails, ChartData, SpatialProperties} from './types' import { View } from 'vega-view' import { Scenegraph } from 'vega-scenegraph'; +import { NAVIGATION_ID_KEY } from '@constants' export interface NavigationProps { data: ChartData[]; @@ -31,7 +32,7 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = const firstRef = useRef(null); const secondRef = useRef(null); - const navigationStructure = buildNavigationStructure(data, {}, chartLayers) + const navigationStructure = buildNavigationStructure(data, {NAVIGATION_ID_KEY}, chartLayers) const structureNavigationHandler = buildStructureHandler( { nodes: navigationStructure.nodes, @@ -62,10 +63,11 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } }) + const [childrenInitialized, setInitialization] = useState(false) + const setNavigationElement = (target) => { - console.log("changing to new target",target.id) + console.log("changing to new target",target.id,target) // console.log("navigationStructure.navigationRules",navigationStructure.navigationRules) - setSpatialProperties() setNavigation({ transform: "", buttonTransform: "", @@ -74,14 +76,14 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = figureRole: "figure", imageRole: "image", hasInteractivity: true, - spatialProperties: { + spatialProperties: target.spatialProperties || { width: "", height: "", left: "", top: "" }, semantics: { - label: "testing semantics!" + label: target.semantics?.label || "no label yet" } } }) @@ -92,8 +94,8 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { focusedElement.current = {id: navigation.current.id} willFocusAfterRender.current = false - console.log("firstRef",firstRef) - console.log("secondRef",secondRef) + // console.log("firstRef",firstRef) + // console.log("secondRef",secondRef) // console.log("navigation.current.id",navigation) // console.log("focusedElement",focusedElement) if (firstRef.current?.id === navigation.current.id) { @@ -104,55 +106,88 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } }, [navigation]) - const roles = ["mark", "legend", "axis", "scope"] - const marktypes = ["rect", "group", "arc"] - const checkForSpatialProperties = (id: string): SpatialProperties | void => { + const initializeRenderingProperties = (id: string): SpatialProperties | void => { const nodeToCheck: NodeObject = navigationStructure.nodes[id] if (!nodeToCheck.spatialProperties) { - console.log("nodeToCheck",nodeToCheck) + console.log("INITIALIZING PROPERTIES!") if (!chartView.current) { // I want to do this, but will leave it out for now // window.setTimeout(()=>{ - // checkForSpatialProperties(id) + // initializeRenderingProperties(id) // }, 500) } else { const root: Scenegraph = chartView.current.scenegraph().root - console.log(root) const items = root.items 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 || "") + }) root.items[0].items.forEach((i) => { - console.log(i.marktype, i.role, i.name, i) - // needs a name - // name should not have "_background" added - // marktypes === group: legend, axis, scatter, line - // role === scope: scatter, area, line or line(metric group aka name === "line0MetricRange0_group") - // marktype === rect | arc: bar or pie (easy) - // ** if scatter: i.items[0].items[0].items - // ** if line: i.items are lines, forEach(l) : - // l.items[0].items - - /* each child datum looks something like this: - browser: "Chrome" - downloads: 27000 - downloads0: 0 - downloads1: 27000 - percentLabel: "53.1%" - rscMarkId: 1 // this is the id - rscStackId: "Chrome" // this id is used for x axis grouping (if stacking?) - Symbol(vega_id): 13367 - */ + if (i.marktype === "rect" && i.role === "mark" && i.name.indexOf("_background") === -1) { + // these are the bars in a bar chart! + // console.log("bars",i) + i.items.forEach(bar => { + // console.log("bar", bar, bar.datum) + const datum = {} + keysToMatch.forEach(key => { + datum[key] = bar.datum[key] + }) + if (bar.datum[NAVIGATION_ID_KEY] && navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]) { + const correspondingNode = navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]; + + correspondingNode.spatialProperties = { + width: `${bar.width}px`, + height: `${bar.height}px`, + left: `${bar.x}px`, + top: `${bar.y}px`, + } + correspondingNode.semantics = { + label: describeNode(datum, {semanticLabel: "Bar."}) + } + console.log("correspondingNode",correspondingNode) + } + }) + } 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 + } }) } } } } + const enterChart = () => { + initializeRenderingProperties(navigation.current.id) + setInitialization(true) + setNavigationElement(navigationStructure.nodes[navigation.current.id]); + willFocusAfterRender.current = true + // document.getElementById(navigation.current.id)?.focus() + } const handleFocus = (e) => { console.log("focused",e) - checkForSpatialProperties(e.target.id) + initializeRenderingProperties(e.target.id) focusedElement.current = {id: e.target.id} } const handleBlur = (e) => { @@ -171,31 +206,6 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } } } - const setSpatialProperties = () => { - /* - goals: - - fix bugs in line, combo, and area - - [dn work] create dataNavigator functions to: - - compress divisions if all divisions of a dimension only have a single child (as an option), aka make a "list" within that dimension (this is good for regular bar charts, as an example) - - create a function to hide a particular division/dimension parent? this would be helpful for some cases like a bar chart, where you want sorted numerical nav at the child level, but not division/dimension parents - - nest n divisions within divisions (make this a much smarter process) - - add type for what Input returns, improve consistency of those functions - - create semantics for axes, legends, groups, elements, etc - - create spatialProperties for axes, legends, groups, elements, etc - - add alt text to root chart element - - possibly also hide vega's stuff - */ - console.log(chartView?.current) - console.log(navigationStructure.nodes) - console.log(describeNode) - - if (chartView.current) { - console.log("we got room with a view",chartView.current) - } else { - console.log("describeNode",describeNode) - console.log("hmm",chartView) - } - } const dummySpecs: Navigation = { ...navigation @@ -206,16 +216,36 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = 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: + - fix over-initialization (on every focus) + - fix off placement of focus + - fix bugs in line, combo, and area + - [dn work] create dataNavigator functions to: + - compress divisions if all divisions of a dimension only have a single child (as an option), aka make a "list" within that dimension (this is good for regular bar charts, as an example) + - create a function to hide a particular division/dimension parent? this would be helpful for some cases like a bar chart, where you want sorted numerical nav at the child level, but not division/dimension parents + - nest n divisions within divisions (make this a much smarter process) + - add type for what Input returns, improve consistency of those functions + - create semantics for axes, legends, groups, etc + - create spatialProperties for axes, legends, groups, etc + - add alt text to root chart element + - possibly also hide vega's stuff + */ return ( <>
- {/* */} -
-
-
-
-
-
+ + {childrenInitialized ? figures : null}
) diff --git a/src/RscChart.tsx b/src/RscChart.tsx index 0098b09fc..524be5061 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -19,6 +19,7 @@ import { SELECTED_ITEM, SELECTED_SERIES, SERIES_ID, + NAVIGATION_ID_KEY } from '@constants'; import useChartImperativeHandle from '@hooks/useChartImperativeHandle'; import useLegend from '@hooks/useLegend'; @@ -40,6 +41,7 @@ import { import { renderToStaticMarkup } from 'react-dom/server'; import { Item } from 'vega'; import { Handler, Position, Options as TooltipOptions } from 'vega-tooltip'; +import { addSimpleDataIDs as addNavigationIds } from '../node_modules/data-navigator/dist/structure.js' import { ActionButton, Dialog, DialogTrigger, View as SpectrumView } from '@adobe/react-spectrum'; @@ -113,7 +115,19 @@ export const RscChart = forwardRef( const [isPopoverOpen, setIsPopoverOpen] = useState(false); // tracks the open/close state of the popover 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, diff --git a/src/constants.ts b/src/constants.ts index e0c2ba786..03f843480 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -116,3 +116,6 @@ 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" diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index 4ece60309..8da5ab4f5 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -27,6 +27,7 @@ import { SYMBOL_SHAPE_SCALE, SYMBOL_SIZE_SCALE, TABLE, + NAVIGATION_ID_KEY } from '@constants'; import { Area, Axis, Bar, Legend, Line, Scatter, Title } from '@rsc'; import { Combo } from '@rsc/alpha'; @@ -275,17 +276,12 @@ export const buildNavigationStructure = (data, props, chartLayers) : Structure = // append element after in RscChart for navigation (the rendered thing) // append an exit element within the appended parent element for our navigation stuff const layers = props.list ? "" : chartLayers + const structureOptions : StructureOptions = { data, - idKey: "dnNodeId"/*(d) => { - console.log("trying to find Id",d) - if (d && d.rscMarkId) { - return "rscMarkId" - } - return "rscStackId" - }*/, - addIds: true, - keysForIdGeneration: [], + idKey: NAVIGATION_ID_KEY, + // addIds: true, + // keysForIdGeneration: [], dimensions: { values: layers } From 5289911766820cf5bb4b46de22b0104cb4d211ab Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Mon, 16 Dec 2024 16:31:39 -0500 Subject: [PATCH 05/37] use latest data-navigator, with dimension compression --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 063dc784f..8ed91b2a8 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ }, "dependencies": { "d3-format": "^3.1.0", - "data-navigator": "^2.0.2", + "data-navigator": "^2.1.0", "immer": ">= 9.0.0", "uuid": ">= 9.0.0", "vega-embed": ">= 6.27.0", diff --git a/yarn.lock b/yarn.lock index ba72a0734..ff5950e19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6269,10 +6269,10 @@ 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.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/data-navigator/-/data-navigator-2.0.2.tgz#082d65166bf950e9634340346aa0ca7cae96570a" - integrity sha512-K9OleAyYZpPSuYnFLdZG8VueI8PY1HxhhmS43hKYdfW0fjbarfBmeqxra1TqhFtG4TYAURY7w4pWzWSzjs8Tcg== +data-navigator@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/data-navigator/-/data-navigator-2.1.0.tgz#b80816ae659891e177a45f70ec39218102edf291" + integrity sha512-yDCoZxWu31qn0M6nEj19ucdcnzbeF9os0xsrENXKrqzSe5o/PmqmeA3qX0mvxwaVL8E1maA/eoYbr0G01g6XzA== data-urls@^3.0.2: version "3.0.2" From 6b090c377d85c1338e1d80f58b060e4895088ffb Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Mon, 16 Dec 2024 16:34:14 -0500 Subject: [PATCH 06/37] add spatial properties and semantics for dimensions and divisions --- src/Navigator.tsx | 118 ++++++++++++++++++----- src/constants.ts | 89 +++++++++++++++++ src/specBuilder/chartSpecBuilder.test.ts | 1 + src/specBuilder/chartSpecBuilder.ts | 38 ++++---- 4 files changed, 201 insertions(+), 45 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index fa970a2cb..cbe8648e5 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -10,13 +10,13 @@ * governing permissions and limitations under the License. */ import { FC, useEffect, useRef, useState, MutableRefObject } from 'react' -import { DimensionList, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator' +import { DimensionList, NavigationRules, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator' import { describeNode } from '../node_modules/data-navigator/dist/utilities.js' import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/chartSpecBuilder'; import { Navigation, CurrentNodeDetails, ChartData, SpatialProperties} from './types' import { View } from 'vega-view' import { Scenegraph } from 'vega-scenegraph'; -import { NAVIGATION_ID_KEY } from '@constants' +import { NAVIGATION_ID_KEY, NAVIGATION_RULES, NAVIGATION_SEMANTICS } from '@constants' export interface NavigationProps { data: ChartData[]; @@ -38,7 +38,7 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = nodes: navigationStructure.nodes, edges: navigationStructure.edges, }, - navigationStructure.navigationRules || {}, + NAVIGATION_RULES as NavigationRules, navigationStructure.dimensions || {} ) @@ -67,7 +67,6 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = const setNavigationElement = (target) => { console.log("changing to new target",target.id,target) - // console.log("navigationStructure.navigationRules",navigationStructure.navigationRules) setNavigation({ transform: "", buttonTransform: "", @@ -88,16 +87,11 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } }) } - // setNavigationElement(entryPoint) useEffect(()=>{ if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { focusedElement.current = {id: navigation.current.id} willFocusAfterRender.current = false - // console.log("firstRef",firstRef) - // console.log("secondRef",secondRef) - // console.log("navigation.current.id",navigation) - // console.log("focusedElement",focusedElement) if (firstRef.current?.id === navigation.current.id) { firstRef.current.focus() } else if (secondRef.current?.id === navigation.current.id) { @@ -109,7 +103,6 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = const initializeRenderingProperties = (id: string): SpatialProperties | void => { const nodeToCheck: NodeObject = navigationStructure.nodes[id] if (!nodeToCheck.spatialProperties) { - console.log("INITIALIZING PROPERTIES!") if (!chartView.current) { // I want to do this, but will leave it out for now // window.setTimeout(()=>{ @@ -118,6 +111,7 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = } else { const root: Scenegraph = chartView.current.scenegraph().root const items = root.items + const offset = -root.bounds.x1 if (items.length !== 1) { console.log("what is in items??",items) } @@ -129,12 +123,77 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = 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: 0, + x2: 0, + y1: 0, + y2: 0 + } + 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.x1 + spatialBounds.x2}px`, + height: `${spatialBounds.y1 + spatialBounds.y2}px`, + left: `${spatialBounds.x1 + offset}px`, + top: `${spatialBounds.y1}px`, + } + divisionNode.semantics = { + label: `${NAVIGATION_SEMANTICS[semanticKey].DIVISION} of ${navigationStructure.dimensions?.[d].dimensionKey}. Contains ${childrenCount} ${NAVIGATION_SEMANTICS[semanticKey].CHILD}${isPlural}. Press ${NAVIGATION_RULES.child.key} key to navigate.` + } + }) + } + } + }) + } root.items[0].items.forEach((i) => { if (i.marktype === "rect" && i.role === "mark" && i.name.indexOf("_background") === -1) { // these are the bars in a bar chart! - // console.log("bars",i) + setDimensionSpatialProperties(i, "BAR") i.items.forEach(bar => { - // console.log("bar", bar, bar.datum) const datum = {} keysToMatch.forEach(key => { datum[key] = bar.datum[key] @@ -145,15 +204,15 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = correspondingNode.spatialProperties = { width: `${bar.width}px`, height: `${bar.height}px`, - left: `${bar.x}px`, + left: `${bar.x + offset}px`, top: `${bar.y}px`, } correspondingNode.semantics = { - label: describeNode(datum, {semanticLabel: "Bar."}) + label: describeNode(datum, {semanticLabel: NAVIGATION_SEMANTICS.BAR.CHILD + '.'}) } - console.log("correspondingNode",correspondingNode) } }) + setDivisionSpatialProperties(i, "BAR") } else if (i.marktype === "arc" && i.role === "mark") { // this is a pie chart! // console.log("pie slices",i) @@ -173,25 +232,35 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = // 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 - // document.getElementById(navigation.current.id)?.focus() } const handleFocus = (e) => { - console.log("focused",e) + console.log("focused",e,e.target) initializeRenderingProperties(e.target.id) focusedElement.current = {id: e.target.id} } const handleBlur = (e) => { - console.log("bluring at parent",e) + console.log("blurring at parent",e) focusedElement.current = {id: ""} } const handleKeydown = (e) => { @@ -228,18 +297,19 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = ) /* goals: - - fix over-initialization (on every focus) - - fix off placement of focus + - add *real* focus indicators (using signals in vega) + - add event handling between vega and navigator - fix bugs in line, combo, and area - [dn work] create dataNavigator functions to: - - compress divisions if all divisions of a dimension only have a single child (as an option), aka make a "list" within that dimension (this is good for regular bar charts, as an example) - - create a function to hide a particular division/dimension parent? this would be helpful for some cases like a bar chart, where you want sorted numerical nav at the child level, but not division/dimension parents - nest n divisions within divisions (make this a much smarter process) - add type for what Input returns, improve consistency of those functions - - create semantics for axes, legends, groups, etc - - create spatialProperties for axes, legends, groups, etc + - create semantics for axes, legends, etc + - create spatialProperties for axes, legends, etc - 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 and "return to chart" button? + - add handling for resizing/etc */ return ( <> diff --git a/src/constants.ts b/src/constants.ts index 03f843480..b37c07000 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -119,3 +119,92 @@ 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: 'Period', direction: 'source'}, + "right": {key: 'Comma', direction: 'target'}, + "child": {key: 'Enter', direction: 'target'}, + // dimension + "previous-dimension": {direction: 'source', key: 'ArrowLeft'}, + "next-dimension": {direction: 'target', key: 'ArrowRight'}, + "parent-dimension": {direction: 'source', key: 'Backspace'}, + // metric + "previous-metric": {direction: 'source', key: 'ArrowUp'}, + "next-metric": {direction: 'target', key: 'ArrowDown'}, + "parent-metric": {direction: 'source', key: 'Slash'}, + // color + "previous-color": {direction: 'source', key: 'BracketLeft'}, + "next-color": {direction: 'target', key: 'BracketRight'}, + "parent-color": {direction: 'source', key: 'BackSlash'} +} +export const NAVIGATION_PAIRS = { + DIMENSION: { + parent_child: ["parent-dimension","child"], + sibling_sibling: ["previous-dimension","next-dimension"], + }, + METRIC: { + parent_child: ["parent-metric","child"], + sibling_sibling: ["previous-metric","next-metric"], + }, + COLOR: { + parent_child: ["parent-color","child"], + sibling_sibling: ["previous-color","next-color"], + } +} +/* + 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/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 8da5ab4f5..6348b8249 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -27,7 +27,8 @@ import { SYMBOL_SHAPE_SCALE, SYMBOL_SIZE_SCALE, TABLE, - NAVIGATION_ID_KEY + NAVIGATION_ID_KEY, + NAVIGATION_PAIRS } from '@constants'; import { Area, Axis, Bar, Legend, Line, Scatter, Title } from '@rsc'; import { Combo } from '@rsc/alpha'; @@ -36,7 +37,7 @@ 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} from '../../node_modules/data-navigator/dist/src/data-navigator' +import {StructureOptions, DimensionList, DimensionDatum, Structure, NavigationRules, Dimensions, DimensionNavigationRules} from '../../node_modules/data-navigator/dist/src/data-navigator' import { AreaElement, @@ -215,28 +216,36 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { dimension: { dimensionKey: "", operations: { - createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 + createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1, + compressSparseDivisions: true }, behavior: { extents: "circular" - } + }, + navigationRules: NAVIGATION_PAIRS.DIMENSION as DimensionNavigationRules }, metric: { dimensionKey: "", operations: { - createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 + createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1, + compressSparseDivisions: true }, type: "numerical", behavior: { extents: "terminal" - } + }, + navigationRules: NAVIGATION_PAIRS.METRIC as DimensionNavigationRules }, color: { dimensionKey: "", type: "categorical", behavior: { extents: "circular" - } + }, + operations: { + compressSparseDivisions: true + }, + navigationRules: NAVIGATION_PAIRS.COLOR as DimensionNavigationRules } } if (newLayer.cur?.props) { @@ -249,11 +258,7 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { if (dimensionKey) { const newDimension: DimensionDatum = { ...dimensions[k], - dimensionKey, - navigationRules: { - parent_child: ["parent-"+dimensionKey,"child"], - sibling_sibling: ["previous-"+dimensionKey,"next-"+dimensionKey] - }, + dimensionKey, divisionOptions: { // sortFunction: , // no idea if we want to sort or not divisionNodeIds: (dimensionKey, keyValue, i) => dimensionKey + keyValue + i @@ -266,15 +271,6 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { } export const buildNavigationStructure = (data, props, chartLayers) : Structure => { - // to do: - // if "props.list" then we create a new dimension here - // figure out keysForIdGeneration - // set dimensions options (below) - // use data + vega lite view to finalize structure and render info - // generate descriptions - // build structure+input in RscChart - // append element after in RscChart for navigation (the rendered thing) - // append an exit element within the appended parent element for our navigation stuff const layers = props.list ? "" : chartLayers const structureOptions : StructureOptions = { From fdea7db0c9abe3986634ff2784bc975bb988876d Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 14 Feb 2025 14:43:30 -0500 Subject: [PATCH 07/37] reduce nav dimensions to 2, limit nav to bar --- src/specBuilder/chartSpecBuilder.ts | 57 ++++++++++++++++++----------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index 6348b8249..0b948f7bc 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -140,7 +140,7 @@ export function buildSpec(props: SanitizedSpecProps) { switch (cur.type.displayName) { case Area.displayName: areaCount++; - addLayer(chartLayers, {acc,cur,index:areaCount}) + // addLayer(chartLayers, {acc,cur,index:areaCount}) return addArea(acc, { ...(cur as AreaElement).props, ...specProps, index: areaCount }); case Axis.displayName: axisCount++; @@ -153,7 +153,7 @@ export function buildSpec(props: SanitizedSpecProps) { return addBar(acc, { ...(cur as BarElement).props, ...specProps, index: barCount }); case Donut.displayName: donutCount++; - addLayer(chartLayers, {acc,cur,index:donutCount}) + // addLayer(chartLayers, {acc,cur,index:donutCount}) return addDonut(acc, { ...(cur as DonutElement).props, ...specProps, index: donutCount }); case Legend.displayName: legendCount++; @@ -168,11 +168,11 @@ export function buildSpec(props: SanitizedSpecProps) { }); case Line.displayName: lineCount++; - addLayer(chartLayers, {acc,cur,index:lineCount}) + // addLayer(chartLayers, {acc,cur,index:lineCount}) return addLine(acc, { ...(cur as LineElement).props, ...specProps, index: lineCount }); case Scatter.displayName: scatterCount++; - addLayer(chartLayers, {acc,cur,index:scatterCount}) + // addLayer(chartLayers, {acc,cur,index:scatterCount}) return addScatter(acc, { ...(cur as ScatterElement).props, ...specProps, index: scatterCount }); case Title.displayName: // No title count. There can only be one title. @@ -209,34 +209,31 @@ export function buildSpec(props: SanitizedSpecProps) { } export const addLayer = (chartLayers: DimensionList, newLayer) => { - if (newLayer.cur.type.displayName === Line.displayName) { - // console.log("we have a line! how do we want to deal with this??") - } + // this function adds new traversable dimensions to data navigator + // "dimensions" is an API in data navigator, not to be confused with RSC's "dimension" + // that being said, the default "dimension" for every chart does seem to be based on + // the prop with the same name + const subdivisions = newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 + console.log("new layer",newLayer) const dimensions: Record = { dimension: { dimensionKey: "", operations: { - createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1, + createNumericalSubdivisions: subdivisions, compressSparseDivisions: true }, behavior: { extents: "circular" }, navigationRules: NAVIGATION_PAIRS.DIMENSION as DimensionNavigationRules - }, - metric: { - dimensionKey: "", - operations: { - createNumericalSubdivisions: newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1, - compressSparseDivisions: true - }, - type: "numerical", - behavior: { - extents: "terminal" - }, - navigationRules: NAVIGATION_PAIRS.METRIC as DimensionNavigationRules - }, - color: { + } + } + // We only want 2 traversable dimensions at most, so this conditional tries to figure out which to use based on chart type + // this will need to be expanded as navigation is added to additional charts in the library + if (newLayer.cur.type.displayName === Bar.displayName) { + // for bar (at least, and probably other charts?), the "color" becomes the second dimension + // we would want to traverse, after "dimension" + dimensions.color = { dimensionKey: "", type: "categorical", behavior: { @@ -247,11 +244,27 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { }, navigationRules: NAVIGATION_PAIRS.COLOR as DimensionNavigationRules } + } else if (newLayer.cur.type.displayName === Scatter.displayName) { + // for scatter at least (and likely also line chart), "metric" becomes the second dimension + // we would want to traverse, after "dimension" + dimensions.metric = { + dimensionKey: "", + operations: { + createNumericalSubdivisions: subdivisions, + compressSparseDivisions: true + }, + type: "numerical", + behavior: { + extents: "terminal" + }, + navigationRules: NAVIGATION_PAIRS.METRIC as DimensionNavigationRules + } } if (newLayer.cur?.props) { const dimensionKeys = Object.keys(dimensions) if (newLayer.cur.props.metric_start || newLayer.cur.props.metric_end) { // console.log("uh oh! a range!") + // we are currently not handling metric start/end ranges right now } dimensionKeys.forEach(k => { const dimensionKey = newLayer.cur.props[k] From 62da48b1c4ed3beccb5331481b626141a68acb01 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 14 Feb 2025 14:44:23 -0500 Subject: [PATCH 08/37] add conditional render of navigator if nav layers exist --- src/RscChart.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/RscChart.tsx b/src/RscChart.tsx index 524be5061..0a9cf38e5 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -149,7 +149,7 @@ export const RscChart = forwardRef( chartLayers, UNSAFE_vegaSpec, }); - + // see controlledHoveredIdSignal for pattern to update focus signal const { controlledHoveredIdSignal, controlledHoveredGroupSignal } = useSpecProps(spec); const chartConfig = useMemo(() => getChartConfig(config, colorScheme), [config, colorScheme]); @@ -241,6 +241,7 @@ export const RscChart = forwardRef( if (legendIsToggleable) { signals.hiddenSeries = legendHiddenSeries; } + // see "selected" signals[SELECTED_ITEM] = selectedData?.[idKey] ?? null; signals[SELECTED_SERIES] = selectedData?.[SERIES_ID] ?? null; @@ -291,6 +292,7 @@ export const RscChart = forwardRef( if (legendIsToggleable) { view.signal('hiddenSeries', legendHiddenSeries); } + // this is where the magic happens setSelectedSignals({ idKey, selectedData: selectedData.current, @@ -326,11 +328,11 @@ export const RscChart = forwardRef( popover={popover} /> ))} - + > :
} ); } From bb18dc0134c0a4c6c82ae5ae3883ea23584b1f61 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 16 Feb 2025 18:04:52 -0500 Subject: [PATCH 09/37] add navigation event callback to navigator --- src/Navigator.tsx | 61 ++++++++++++++++++++++++++---------- src/RscChart.tsx | 6 ++++ src/types/navigationTypes.ts | 18 +++++++++++ 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index cbe8648e5..f7b148acc 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -13,7 +13,7 @@ import { FC, useEffect, useRef, useState, MutableRefObject } from 'react' import { DimensionList, NavigationRules, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator' import { describeNode } from '../node_modules/data-navigator/dist/utilities.js' import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/chartSpecBuilder'; -import { Navigation, CurrentNodeDetails, ChartData, SpatialProperties} from './types' +import { Navigation, NavigationEvent, CurrentNodeDetails, ChartData, SpatialProperties} from './types' import { View } from 'vega-view' import { Scenegraph } from 'vega-scenegraph'; import { NAVIGATION_ID_KEY, NAVIGATION_RULES, NAVIGATION_SEMANTICS } from '@constants' @@ -22,9 +22,10 @@ export interface NavigationProps { data: ChartData[]; chartView: MutableRefObject; chartLayers: DimensionList; + navigationEventCallback?: (navEvent: NavigationEvent) => void; } -export const Navigator: FC = ({data, chartView, chartLayers}) => { +export const Navigator: FC = ({data, chartView, chartLayers, navigationEventCallback}) => { const focusedElement = useRef({ id: "" } as CurrentNodeDetails) @@ -41,7 +42,6 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = NAVIGATION_RULES as NavigationRules, navigationStructure.dimensions || {} ) - const entryPoint = structureNavigationHandler.enter() const [navigation, setNavigation] = useState({ transform: "", @@ -66,7 +66,6 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = const [childrenInitialized, setInitialization] = useState(false) const setNavigationElement = (target) => { - console.log("changing to new target",target.id,target) setNavigation({ transform: "", buttonTransform: "", @@ -113,7 +112,7 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = const items = root.items const offset = -root.bounds.x1 if (items.length !== 1) { - console.log("what is in items??",items) + // console.log("what is in items??",items) } if (root.items[0]?.items?.length) { // const roles = ["mark", "legend", "axis", "scope"] @@ -253,27 +252,62 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = setInitialization(true) setNavigationElement(navigationStructure.nodes[navigation.current.id]); willFocusAfterRender.current = true + if (navigationEventCallback) { + const node = navigationStructure.nodes[navigation.current.id] + navigationEventCallback({ + nodeId: navigation.current.id, + eventType: "enter", + nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + }) + } } const handleFocus = (e) => { - console.log("focused",e,e.target) initializeRenderingProperties(e.target.id) focusedElement.current = {id: e.target.id} + if (navigationEventCallback) { + const node = navigationStructure.nodes[e.target.id] + navigationEventCallback({ + nodeId: e.target.id, + eventType: "focus", + nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + }) + } } - const handleBlur = (e) => { - console.log("blurring at parent",e) + const handleBlur = () => { + const blurredId = navigation.current.id focusedElement.current = {id: ""} + if (navigationEventCallback) { + const node = navigationStructure.nodes[blurredId] + navigationEventCallback({ + nodeId: blurredId, + eventType: "blur", + nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + }) + } } const handleKeydown = (e) => { const direction = structureNavigationHandler.keydownValidator(e); - console.log("direction",direction) + console.log("e.code",e.code,"direction",direction) + const target = e.target as HTMLElement if (direction) { e.preventDefault(); - const nextNode = structureNavigationHandler.move(e.target.id, direction); + 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] + navigationEventCallback({ + nodeId: target.id, + eventType: "selection", + nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + }) + } + } } const dummySpecs: Navigation = { @@ -297,12 +331,7 @@ export const Navigator: FC = ({data, chartView, chartLayers}) = ) /* goals: - - add *real* focus indicators (using signals in vega) - - add event handling between vega and navigator - - fix bugs in line, combo, and area - - [dn work] create dataNavigator functions to: - - nest n divisions within divisions (make this a much smarter process) - - add type for what Input returns, improve consistency of those functions + - add exit event + handling - create semantics for axes, legends, etc - create spatialProperties for axes, legends, etc - add alt text to root chart element diff --git a/src/RscChart.tsx b/src/RscChart.tsx index 0a9cf38e5..3fc3e8e4d 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -55,6 +55,7 @@ import { Datum, LegendDescription, MarkBounds, + NavigationEvent, RscChartProps, TooltipAnchor, TooltipPlacement @@ -248,6 +249,10 @@ export const RscChart = forwardRef( return signals; }, [colorScheme, idKey, legendHiddenSeries, legendIsToggleable]); + const navigationEventCallback = (navData: NavigationEvent) => { + console.log("RSC chart can use this navData to set signals", navData) + // set signals here! + } return ( <>
( data={data} chartView={chartView} chartLayers={chartLayers} + navigationEventCallback={navigationEventCallback} > :
} ); diff --git a/src/types/navigationTypes.ts b/src/types/navigationTypes.ts index a96d5f0b9..997d0ea25 100644 --- a/src/types/navigationTypes.ts +++ b/src/types/navigationTypes.ts @@ -15,6 +15,24 @@ export type Navigation = { 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; + // 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; From c4d8c7a7bdb90bd42293c71f3a4c493d9267e66c Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 16 Feb 2025 18:29:46 -0500 Subject: [PATCH 10/37] emit valid vegaId within nav event callback --- src/Navigator.tsx | 30 +++++++++++++++++++++++++---- src/specBuilder/chartSpecBuilder.ts | 19 +++++++++++++++++- src/types/navigationTypes.ts | 2 ++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index f7b148acc..c29b834c0 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -18,6 +18,19 @@ import { View } from 'vega-view' import { Scenegraph } from 'vega-scenegraph'; import { NAVIGATION_ID_KEY, NAVIGATION_RULES, NAVIGATION_SEMANTICS } from '@constants' +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; @@ -34,6 +47,7 @@ export const Navigator: FC = ({data, chartView, chartLayers, na const secondRef = useRef(null); const navigationStructure = buildNavigationStructure(data, {NAVIGATION_ID_KEY}, chartLayers) + console.log("navigationStructure",navigationStructure) const structureNavigationHandler = buildStructureHandler( { nodes: navigationStructure.nodes, @@ -254,10 +268,12 @@ export const Navigator: FC = ({data, chartView, chartLayers, na 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", - nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel }) } } @@ -266,10 +282,12 @@ export const Navigator: FC = ({data, chartView, chartLayers, na 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", - nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel }) } } @@ -278,10 +296,12 @@ export const Navigator: FC = ({data, chartView, chartLayers, na 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", - nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel }) } } @@ -301,10 +321,12 @@ export const Navigator: FC = ({data, chartView, chartLayers, na 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", - nodeLevel: node.dimensionLevel === 1 ? "dimension" : node.dimensionLevel === 2 ? "division" : "child" + vegaId: convertId(navigation.current.id, nodeLevel), + nodeLevel }) } } diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index 0b948f7bc..f3e9ba0b2 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -209,10 +209,27 @@ export function buildSpec(props: SanitizedSpecProps) { } export const addLayer = (chartLayers: DimensionList, newLayer) => { + // note: check "funnel" for stack + dodge example + // series, dimension, and item + // dimension, division, and child + // for childmost ids: they are indexed, starting at 1, in the order of the data + // need to emit division ids (values in a series) or dimension ids (the key) + + // color, opacity, line-type, etc - these "facets" are + // all ways to create a series id + // (after spec) - go to data, see if series transform happened (aka "as": "rscSeriesId", look for "expr": "datum.operatingSystem") + // this function adds new traversable dimensions to data navigator // "dimensions" is an API in data navigator, not to be confused with RSC's "dimension" // that being said, the default "dimension" for every chart does seem to be based on // the prop with the same name + + // look at "react-aria" - see if this is the right approach + // for first pass (POC) use callback + // "useFocus" passes into data navigator + // setFocused(child/division/dimension) + // add marshallpete to repo + const subdivisions = newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 console.log("new layer",newLayer) const dimensions: Record = { @@ -274,7 +291,7 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { dimensionKey, divisionOptions: { // sortFunction: , // no idea if we want to sort or not - divisionNodeIds: (dimensionKey, keyValue, i) => dimensionKey + keyValue + i + divisionNodeIds: (dimensionKey, keyValue, i) => "_" + keyValue + "_key_" + dimensionKey + i // dimensionKey + keyValue + i } }; chartLayers.push(newDimension) diff --git a/src/types/navigationTypes.ts b/src/types/navigationTypes.ts index 997d0ea25..225a50ab7 100644 --- a/src/types/navigationTypes.ts +++ b/src/types/navigationTypes.ts @@ -24,6 +24,8 @@ export type NavigationEvent = { 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) From 0b2eb420e14ed6c0c8791e924287576f12faf0f1 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 18 Feb 2025 15:01:10 -0500 Subject: [PATCH 11/37] convert navigation to generics for up, down, left, right --- src/constants.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index b37c07000..fcd30b876 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -167,34 +167,35 @@ export const NAVIGATION_RULES = { "help": {key: 'Quote', direction: 'target'}, "undo": {key: 'SemiColon', direction: 'target'}, // generics - "left": {key: 'Period', direction: 'source'}, - "right": {key: 'Comma', direction: 'target'}, + "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 - "previous-dimension": {direction: 'source', key: 'ArrowLeft'}, - "next-dimension": {direction: 'target', key: 'ArrowRight'}, "parent-dimension": {direction: 'source', key: 'Backspace'}, - // metric - "previous-metric": {direction: 'source', key: 'ArrowUp'}, - "next-metric": {direction: 'target', key: 'ArrowDown'}, - "parent-metric": {direction: 'source', key: 'Slash'}, // color - "previous-color": {direction: 'source', key: 'BracketLeft'}, - "next-color": {direction: 'target', key: 'BracketRight'}, - "parent-color": {direction: 'source', key: 'BackSlash'} + "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: ["previous-dimension","next-dimension"], + sibling_sibling: ["left","right"], }, METRIC: { parent_child: ["parent-metric","child"], - sibling_sibling: ["previous-metric","next-metric"], + sibling_sibling: ["up","down"], }, COLOR: { parent_child: ["parent-color","child"], - sibling_sibling: ["previous-color","next-color"], + sibling_sibling: ["up","down"], } } /* From 6044ba9b3a974ff5d6b988fe0f167ec452532d2a Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 18 Feb 2025 20:37:14 -0500 Subject: [PATCH 12/37] fix placement for stacked bar divisions --- src/Navigator.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index c29b834c0..cca5e64d8 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -170,10 +170,10 @@ export const Navigator: FC = ({data, chartView, chartLayers, na const divisionNode = navigationStructure.nodes[division.id] const isPlural = childrenCount === 1 ? "" : "s" const spatialBounds = { - x1: 0, - x2: 0, - y1: 0, - y2: 0 + x1: Infinity, + x2: -Infinity, + y1: Infinity, + y2: -Infinity } children.forEach(c => { const child = navigationStructure.nodes[c] @@ -189,9 +189,9 @@ export const Navigator: FC = ({data, chartView, chartLayers, na } }) divisionNode.spatialProperties = { - width: `${spatialBounds.x1 + spatialBounds.x2}px`, - height: `${spatialBounds.y1 + spatialBounds.y2}px`, - left: `${spatialBounds.x1 + offset}px`, + width: `${spatialBounds.x2 - spatialBounds.x1}px`, + height: `${spatialBounds.y2 - spatialBounds.y1}px`, + left: `${spatialBounds.x1}px`, top: `${spatialBounds.y1}px`, } divisionNode.semantics = { From cfa72ba2c1d82956326376a6e9c00f0996c8dede Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 18 Feb 2025 21:10:18 -0500 Subject: [PATCH 13/37] fix focus indication location for dodged bar --- src/Navigator.tsx | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index cca5e64d8..34d7d48c5 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -124,7 +124,7 @@ export const Navigator: FC = ({data, chartView, chartLayers, na } else { const root: Scenegraph = chartView.current.scenegraph().root const items = root.items - const offset = -root.bounds.x1 + let offset = -root.bounds.x1 if (items.length !== 1) { // console.log("what is in items??",items) } @@ -204,7 +204,7 @@ export const Navigator: FC = ({data, chartView, chartLayers, na } root.items[0].items.forEach((i) => { if (i.marktype === "rect" && i.role === "mark" && i.name.indexOf("_background") === -1) { - // these are the bars in a bar chart! + // these are the bars in a bar chart or stacked bar chart! setDimensionSpatialProperties(i, "BAR") i.items.forEach(bar => { const datum = {} @@ -226,6 +226,36 @@ export const Navigator: FC = ({data, chartView, chartLayers, na } }) setDivisionSpatialProperties(i, "BAR") + } else if (i.name && i.name.indexOf("bar0_group") !== -1) { + // these are the bars in a dodged bar chart! + setDimensionSpatialProperties(i, "BAR") + i.items.forEach((bg) => { + offset = -root.bounds.x1 + bg.bounds.x1 + bg.items.forEach((bg_i) => { + if (bg_i.marktype === "rect" && bg_i.role === "mark" && bg_i.name.indexOf("_background") === -1) { + bg_i.items.forEach(bar => { + const datum = {} + keysToMatch.forEach(key => { + datum[key] = bar.datum[key] + }) + if (bar.datum[NAVIGATION_ID_KEY] && navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]) { + const correspondingNode = navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]; + correspondingNode.spatialProperties = { + width: `${bar.width}px`, + height: `${bar.height}px`, + left: `${bar.x + offset}px`, + top: `${bar.y}px`, + } + correspondingNode.semantics = { + label: describeNode(datum, {semanticLabel: NAVIGATION_SEMANTICS.BAR.CHILD + '.'}) + } + } + }) + } + + }) + }) + setDivisionSpatialProperties(i, "BAR") } else if (i.marktype === "arc" && i.role === "mark") { // this is a pie chart! // console.log("pie slices",i) From 0588cce543fe54347f0760cb8fae541315c722da Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 18 Feb 2025 21:15:46 -0500 Subject: [PATCH 14/37] make function to set child spatial properties generic --- src/Navigator.tsx | 53 ++++++++++++++++++----------------------------- 1 file changed, 20 insertions(+), 33 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 34d7d48c5..59deb588c 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -202,28 +202,30 @@ export const Navigator: FC = ({data, chartView, chartLayers, na } }) } + 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.marktype === "rect" && i.role === "mark" && i.name.indexOf("_background") === -1) { // these are the bars in a bar chart or stacked bar chart! setDimensionSpatialProperties(i, "BAR") i.items.forEach(bar => { - const datum = {} - keysToMatch.forEach(key => { - datum[key] = bar.datum[key] - }) - if (bar.datum[NAVIGATION_ID_KEY] && navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]) { - const correspondingNode = navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]; - - correspondingNode.spatialProperties = { - width: `${bar.width}px`, - height: `${bar.height}px`, - left: `${bar.x + offset}px`, - top: `${bar.y}px`, - } - correspondingNode.semantics = { - label: describeNode(datum, {semanticLabel: NAVIGATION_SEMANTICS.BAR.CHILD + '.'}) - } - } + setChildSpatialProperties(bar, "BAR") }) setDivisionSpatialProperties(i, "BAR") } else if (i.name && i.name.indexOf("bar0_group") !== -1) { @@ -234,22 +236,7 @@ export const Navigator: FC = ({data, chartView, chartLayers, na bg.items.forEach((bg_i) => { if (bg_i.marktype === "rect" && bg_i.role === "mark" && bg_i.name.indexOf("_background") === -1) { bg_i.items.forEach(bar => { - const datum = {} - keysToMatch.forEach(key => { - datum[key] = bar.datum[key] - }) - if (bar.datum[NAVIGATION_ID_KEY] && navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]) { - const correspondingNode = navigationStructure.nodes[bar.datum[NAVIGATION_ID_KEY]]; - correspondingNode.spatialProperties = { - width: `${bar.width}px`, - height: `${bar.height}px`, - left: `${bar.x + offset}px`, - top: `${bar.y}px`, - } - correspondingNode.semantics = { - label: describeNode(datum, {semanticLabel: NAVIGATION_SEMANTICS.BAR.CHILD + '.'}) - } - } + setChildSpatialProperties(bar, "BAR") }) } From 7a779a9a7d004c8d20492d5c1deeac9749a5b6b9 Mon Sep 17 00:00:00 2001 From: Marshall Peterson Date: Thu, 20 Feb 2025 13:18:51 -0700 Subject: [PATCH 15/37] feat: add signals and styling for focus --- src/hooks/useChartProps.tsx | 7 +- src/specBuilder/bar/barSpecBuilder.ts | 27 +++++- src/specBuilder/bar/dodgedBarUtils.ts | 122 +++++++++++++++++-------- src/specBuilder/bar/stackedBarUtils.ts | 29 ++++++ 4 files changed, 146 insertions(+), 39 deletions(-) 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/specBuilder/bar/barSpecBuilder.ts b/src/specBuilder/bar/barSpecBuilder.ts index c35520b95..33deada02 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('focussedItem')); + signals.push(getGenericValueSignal('focussedDimension')); + signals.push(getGenericValueSignal('focussedRegion')); 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: "focussedRegion === '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..2ac290bc1 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: `focussedDimension === 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: `focussedItem === 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..67608011e 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'; @@ -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( @@ -104,6 +108,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: `focussedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], + }, + }, + }; +}; + export const getStackedDimensionEncodings = (props: BarSpecProps): RectEncodeEntry => { const { dimension, orientation } = props; if (isDodgedAndStacked(props)) { From 02cdad3f3e5c1fff960838f677674a9aa1811bbd Mon Sep 17 00:00:00 2001 From: Marshall Peterson Date: Thu, 20 Feb 2025 13:20:17 -0700 Subject: [PATCH 16/37] fix: remove debug --- src/hooks/useChartProps.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useChartProps.tsx b/src/hooks/useChartProps.tsx index 1d52fd61b..dc1a12bc1 100644 --- a/src/hooks/useChartProps.tsx +++ b/src/hooks/useChartProps.tsx @@ -20,5 +20,5 @@ import { ChartProps } from '../types'; */ export default function useChartProps(props: ChartProps): ChartProps { const darkMode = useDarkMode(); - return { colorScheme: darkMode ? 'dark' : 'light', debug: true, ...props }; + return { colorScheme: darkMode ? 'dark' : 'light', ...props }; } From c6872f3725388b84dfa497c3527bb42c77216144 Mon Sep 17 00:00:00 2001 From: Marshall Peterson Date: Thu, 20 Feb 2025 14:48:39 -0700 Subject: [PATCH 17/37] feat: add focus to stacked bar dimensions --- src/Navigator.tsx | 762 ++++++++++++++----------- src/RscChart.tsx | 54 +- src/hooks/useChartProps.tsx | 2 +- src/specBuilder/bar/barSpecBuilder.ts | 8 +- src/specBuilder/bar/dodgedBarUtils.ts | 4 +- src/specBuilder/bar/stackedBarUtils.ts | 40 +- 6 files changed, 493 insertions(+), 377 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 59deb588c..6c1d053c9 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -9,366 +9,415 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { FC, useEffect, useRef, useState, MutableRefObject } from 'react' -import { DimensionList, NavigationRules, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator' -import { describeNode } from '../node_modules/data-navigator/dist/utilities.js' +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 { Navigation, NavigationEvent, CurrentNodeDetails, ChartData, SpatialProperties} from './types' -import { View } from 'vega-view' import { Scenegraph } from 'vega-scenegraph'; -import { NAVIGATION_ID_KEY, NAVIGATION_RULES, NAVIGATION_SEMANTICS } from '@constants' +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 -} + 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; + 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); +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); + console.log('navigationStructure', navigationStructure); + 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: 'image', + hasInteractivity: true, + spatialProperties: { + width: '', + height: '', + left: '', + top: '', + }, + semantics: { + label: entryPoint.semantics?.label || 'initial element test', + }, + }, + }); - const navigationStructure = buildNavigationStructure(data, {NAVIGATION_ID_KEY}, chartLayers) - console.log("navigationStructure",navigationStructure) - 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: "image", - hasInteractivity: true, - spatialProperties: { - width: "", - height: "", - left: "", - top: "" - }, - semantics: { - label: entryPoint.semantics?.label || "initial element test" - } - } - }) + const [childrenInitialized, setInitialization] = useState(false); - const [childrenInitialized, setInitialization] = useState(false) + const setNavigationElement = (target) => { + setNavigation({ + transform: '', + buttonTransform: '', + current: { + id: target.id, + figureRole: 'figure', + imageRole: 'image', + hasInteractivity: true, + spatialProperties: target.spatialProperties || { + width: '', + height: '', + left: '', + top: '', + }, + semantics: { + label: target.semantics?.label || 'no label yet', + }, + }, + }); + }; - const setNavigationElement = (target) => { - setNavigation({ - transform: "", - buttonTransform: "", - current: { - id: target.id, - figureRole: "figure", - imageRole: "image", - hasInteractivity: true, - spatialProperties: target.spatialProperties || { - width: "", - height: "", - left: "", - top: "" - }, - semantics: { - label: target.semantics?.label || "no label yet" - } - } - }) - } - - useEffect(()=>{ - if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { - focusedElement.current = {id: navigation.current.id} - willFocusAfterRender.current = false - if (firstRef.current?.id === navigation.current.id) { - firstRef.current.focus() - } else if (secondRef.current?.id === navigation.current.id) { - secondRef.current.focus() - } - } - }, [navigation]) + useEffect(() => { + if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { + focusedElement.current = { id: navigation.current.id }; + willFocusAfterRender.current = false; + if (firstRef.current?.id === navigation.current.id) { + firstRef.current.focus(); + } else if (secondRef.current?.id === navigation.current.id) { + secondRef.current.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`, - } - divisionNode.semantics = { - label: `${NAVIGATION_SEMANTICS[semanticKey].DIVISION} of ${navigationStructure.dimensions?.[d].dimensionKey}. 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.marktype === "rect" && i.role === "mark" && i.name.indexOf("_background") === -1) { - // these are the bars in a bar chart or stacked bar chart! - setDimensionSpatialProperties(i, "BAR") - i.items.forEach(bar => { - setChildSpatialProperties(bar, "BAR") - }) - setDivisionSpatialProperties(i, "BAR") - } else if (i.name && i.name.indexOf("bar0_group") !== -1) { - // these are the bars in a dodged bar chart! - setDimensionSpatialProperties(i, "BAR") - i.items.forEach((bg) => { - offset = -root.bounds.x1 + bg.bounds.x1 - bg.items.forEach((bg_i) => { - if (bg_i.marktype === "rect" && bg_i.role === "mark" && bg_i.name.indexOf("_background") === -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 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`, + }; + divisionNode.semantics = { + label: `${NAVIGATION_SEMANTICS[semanticKey].DIVISION} of ${navigationStructure.dimensions?.[d].dimensionKey}. 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.marktype === 'rect' && i.role === 'mark' && i.name.indexOf('_background') === -1) { + // these are the bars in a bar chart or stacked bar chart! + setDimensionSpatialProperties(i, 'BAR'); + i.items.forEach((bar) => { + setChildSpatialProperties(bar, 'BAR'); + }); + setDivisionSpatialProperties(i, 'BAR'); + } else if (i.name && i.name.indexOf('bar0_group') !== -1) { + // these are the bars in a dodged bar chart! + setDimensionSpatialProperties(i, 'BAR'); + i.items.forEach((bg) => { + offset = -root.bounds.x1 + bg.bounds.x1; + bg.items.forEach((bg_i) => { + if ( + bg_i.marktype === 'rect' && + bg_i.role === 'mark' && + bg_i.name.indexOf('_background') === -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 = () => { - /* + 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); - console.log("e.code",e.code,"direction",direction) - 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 - }) - } - } - } + 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); + console.log('e.code', e.code, 'direction', direction); + 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 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 = ( -
-
-
-
-
-
-
-
- ) - /* + const figures = ( +
+
+
+
+
+
+
+
+ ); + /* goals: - add exit event + handling - create semantics for axes, legends, etc @@ -380,11 +429,26 @@ export const Navigator: FC = ({data, chartView, chartLayers, na - add handling for resizing/etc */ return ( - <> -
- - {childrenInitialized ? figures : null} -
- - ) -} + <> +
+ + {childrenInitialized ? figures : null} +
+ + ); +}; diff --git a/src/RscChart.tsx b/src/RscChart.tsx index 3fc3e8e4d..934f82c25 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -16,10 +16,10 @@ import { FILTERED_TABLE, GROUP_DATA, LEGEND_TOOLTIP_DELAY, + NAVIGATION_ID_KEY, SELECTED_ITEM, SELECTED_SERIES, SERIES_ID, - NAVIGATION_ID_KEY } from '@constants'; import useChartImperativeHandle from '@hooks/useChartImperativeHandle'; import useLegend from '@hooks/useLegend'; @@ -38,17 +38,16 @@ 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 { addSimpleDataIDs as addNavigationIds } from '../node_modules/data-navigator/dist/structure.js' 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 { Navigator } from 'Navigator'; - import { ChartHandle, ColorScheme, @@ -58,7 +57,7 @@ import { NavigationEvent, RscChartProps, TooltipAnchor, - TooltipPlacement + TooltipPlacement, } from './types'; interface ChartDialogProps { @@ -127,8 +126,8 @@ export const RscChart = forwardRef( addNavigationIds({ idKey: NAVIGATION_ID_KEY, data, - addIds:true - }) + addIds: true, + }); // THE MAGIC, builds our spec const spec = useSpec({ backgroundColor, @@ -167,7 +166,7 @@ export const RscChart = forwardRef( }, [isPopoverOpen]); useChartImperativeHandle(forwardedRef, { chartView, title }); - + const { legendHiddenSeries, setLegendHiddenSeries, @@ -250,9 +249,28 @@ export const RscChart = forwardRef( }, [colorScheme, idKey, legendHiddenSeries, legendIsToggleable]); const navigationEventCallback = (navData: NavigationEvent) => { - console.log("RSC chart can use this navData to set signals", navData) + if (chartView.current) { + console.log('RSC chart can use this navData to set signals', navData); + 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(); + } // set signals here! - } + }; return ( <>
( popover={popover} /> ))} - {chartLayers.length ? :
} + {chartLayers.length ? ( + + ) : ( +
+ )} ); } diff --git a/src/hooks/useChartProps.tsx b/src/hooks/useChartProps.tsx index dc1a12bc1..1d52fd61b 100644 --- a/src/hooks/useChartProps.tsx +++ b/src/hooks/useChartProps.tsx @@ -20,5 +20,5 @@ import { ChartProps } from '../types'; */ 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/specBuilder/bar/barSpecBuilder.ts b/src/specBuilder/bar/barSpecBuilder.ts index 33deada02..bc0a954ce 100644 --- a/src/specBuilder/bar/barSpecBuilder.ts +++ b/src/specBuilder/bar/barSpecBuilder.ts @@ -114,9 +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('focussedItem')); - signals.push(getGenericValueSignal('focussedDimension')); - signals.push(getGenericValueSignal('focussedRegion')); + signals.push(getGenericValueSignal('focusedItem')); + signals.push(getGenericValueSignal('focusedDimension')); + signals.push(getGenericValueSignal('focusedRegion')); if (!children.length) { return; @@ -309,7 +309,7 @@ export const addMarks = produce((marks, props) => { x2: { signal: 'width' }, y: { value: 0 }, y2: { signal: 'height' }, - opacity: [{ test: "focussedRegion === 'chart'", value: 1 }, { value: 0 }], + opacity: [{ test: "focusedRegion === 'chart'", value: 1 }, { value: 0 }], }, }, }); diff --git a/src/specBuilder/bar/dodgedBarUtils.ts b/src/specBuilder/bar/dodgedBarUtils.ts index 2ac290bc1..1135fb85d 100644 --- a/src/specBuilder/bar/dodgedBarUtils.ts +++ b/src/specBuilder/bar/dodgedBarUtils.ts @@ -85,7 +85,7 @@ export const getDodgedMark = (props: BarSpecProps): [GroupMark, RectMark] => { x2: { signal: 'datum.bounds.x2 + 2' }, y: { signal: 'datum.bounds.y1 - 2' }, y2: { signal: 'datum.bounds.y2 + 2' }, - opacity: [{ test: `focussedDimension === datum.datum.${dimension}`, value: 1 }, { value: 0 }], + opacity: [{ test: `focusedDimension === datum.datum.${dimension}`, value: 1 }, { value: 0 }], }, }, }, @@ -111,7 +111,7 @@ export const getBarFocusRing = (props: BarSpecProps): RectMark => { x2: { signal: 'datum.bounds.x2 + 2' }, y: { signal: 'datum.bounds.y1 - 2' }, y2: { signal: 'datum.bounds.y2 + 2' }, - opacity: [{ test: `focussedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], + opacity: [{ test: `focusedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], }, }, }; diff --git a/src/specBuilder/bar/stackedBarUtils.ts b/src/specBuilder/bar/stackedBarUtils.ts index 67608011e..cc4fc5a93 100644 --- a/src/specBuilder/bar/stackedBarUtils.ts +++ b/src/specBuilder/bar/stackedBarUtils.ts @@ -27,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 @@ -49,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 => { @@ -74,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: { @@ -93,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: { @@ -127,7 +157,7 @@ export const getStackedBarFocusRing = (props: BarSpecProps): RectMark => { x2: { signal: 'datum.bounds.x2 + 2' }, y: { signal: 'datum.bounds.y1 - 2' }, y2: { signal: 'datum.bounds.y2 + 2' }, - opacity: [{ test: `focussedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], + opacity: [{ test: `focusedItem === datum.datum.${idKey}`, value: 1 }, { value: 0 }], }, }, }; From 796e4d16ca88d009758a634146164cc26d2a4da4 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 21 Feb 2025 21:35:13 -0500 Subject: [PATCH 18/37] fix nav element location between dodge, stack, and regular bar --- src/Navigator.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index 6c1d053c9..ebd928e8e 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -53,7 +53,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const secondRef = useRef(null); const navigationStructure = buildNavigationStructure(data, { NAVIGATION_ID_KEY }, chartLayers); - console.log('navigationStructure', navigationStructure); const structureNavigationHandler = buildStructureHandler( { nodes: navigationStructure.nodes, @@ -238,23 +237,19 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n } }; root.items[0].items.forEach((i) => { - if (i.marktype === 'rect' && i.role === 'mark' && i.name.indexOf('_background') === -1) { - // these are the bars in a bar chart or stacked bar chart! - setDimensionSpatialProperties(i, 'BAR'); - i.items.forEach((bar) => { - setChildSpatialProperties(bar, 'BAR'); - }); - setDivisionSpatialProperties(i, 'BAR'); - } else if (i.name && i.name.indexOf('bar0_group') !== -1) { - // these are the bars in a dodged bar chart! + if (i.name && i.name.indexOf('bar0_group') !== -1 && i.name.indexOf('focus') === -1) { + // these are bars! setDimensionSpatialProperties(i, 'BAR'); i.items.forEach((bg) => { - offset = -root.bounds.x1 + bg.bounds.x1; + // 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('_background') === -1 && + bg_i.name.indexOf('focus') === -1 ) { bg_i.items.forEach((bar) => { setChildSpatialProperties(bar, 'BAR'); From ed3982a5eda833815f9795a846c00f6401ecd732 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 21 Feb 2025 21:38:22 -0500 Subject: [PATCH 19/37] remove redundant focus indicator on nav element --- src/Chart.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Chart.css b/src/Chart.css index 46d6a7554..3dc9781b4 100644 --- a/src/Chart.css +++ b/src/Chart.css @@ -76,6 +76,9 @@ this removes transitions in the vega tooltip margin: 0px; overflow: visible; } +.dn-node:focus, .dn-node:focus-visible { + outline: none; +} .dn-node-svg { position: absolute; pointer-events: none; From 785505390200d6722963292b05b4b26b6ba578c4 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 23 Feb 2025 20:47:01 -0500 Subject: [PATCH 20/37] add 2 new elements for mobile fallback --- src/Navigator.tsx | 121 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 7 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index ebd928e8e..d44fbfef2 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -16,7 +16,7 @@ import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/ch import { Scenegraph } from 'vega-scenegraph'; import { View } from 'vega-view'; -import { DimensionList, NavigationRules, NodeObject } from '../node_modules/data-navigator/dist/src/data-navigator'; +import { DimensionList, NavigationRules, NodeObject, Nodes } 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'; @@ -37,6 +37,28 @@ const convertId = (id, nodeLevel) => { : +id.substring(1) + 1; }; +const buildChildNodeLookup = (nodes: Nodes) => { + const keys = Object.keys(nodes) + const lookup = {}; + let previous = ""; + keys.forEach(key => { + if (!nodes[key].dimensionLevel) { + // childmost nodes do not have a dimension level (they are not dimensions or divisions), + // that means that these are the child nodes we care about + lookup[nodes[key].id] = { + id: nodes[key].id, + next: "", + previous: previous || "" + } + if (previous) { + lookup[previous].next = nodes[key].id + } + previous = nodes[key].id + } + }) + return lookup +} + export interface NavigationProps { data: ChartData[]; chartView: MutableRefObject; @@ -51,6 +73,8 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const willFocusAfterRender = useRef(false); const firstRef = useRef(null); const secondRef = useRef(null); + const mobileFallbackPreviousRef = useRef(null); + const mobileFallbackNextRef = useRef(null); const navigationStructure = buildNavigationStructure(data, { NAVIGATION_ID_KEY }, chartLayers); const structureNavigationHandler = buildStructureHandler( @@ -61,6 +85,9 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n NAVIGATION_RULES as NavigationRules, navigationStructure.dimensions || {} ); + // we create a child-only lookup with indexes, this is for our mobile fallback nodes + const navigationChildren = buildChildNodeLookup(navigationStructure.nodes) + const entryPoint = structureNavigationHandler.enter(); const [navigation, setNavigation] = useState({ transform: '', @@ -77,7 +104,7 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n top: '', }, semantics: { - label: entryPoint.semantics?.label || 'initial element test', + label: entryPoint.semantics?.label || '', }, }, }); @@ -100,12 +127,59 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n top: '', }, semantics: { - label: target.semantics?.label || 'no label yet', + label: target.semantics?.label || '', }, }, - }); + } as Navigation); }; + const setMobileFallbackElement = (nodeId: string, direction: 'previous' | 'next'): Navigation => { + console.log(nodeId) + const mobileFallbackData = { + transform: '', + buttonTransform: '', + current: { + id: '_no_' + direction + '_for_' + nodeId, + hasInteractivity: false, + spatialProperties: { + width: '', + height: '', + left: '', + top: '', + }, + semantics: { + label: '' + } + }, + } as Navigation + + // we need to find a fallback node for our mobile users + // first we check if the current node is a child element, if so, we find the previous or next child + // but if the current node isn't a child element, then we simply make both of the previous/next elements the first child node + // the reasoning for this is that the chart initially might render with a non-child element, + // so we want mobile users to encounter the first child element whether they nav from above or below + // (hence why both will be this element at first) + const target = navigationChildren[nodeId] ? navigationStructure.nodes[navigationChildren[nodeId][direction]] : navigationStructure.nodes[Object.keys(navigationChildren)[0]] + if (target) { + mobileFallbackData.current = { + id: target.id, + figureRole: 'figure', + imageRole: 'image', + hasInteractivity: true, + spatialProperties: target.spatialProperties || { + width: '', + height: '', + left: '', + top: '', + }, + semantics: { + label: target.semantics?.label || '' + } + } + } + return mobileFallbackData + } + useEffect(() => { if (willFocusAfterRender.current && focusedElement.current.id !== navigation.current.id) { focusedElement.current = { id: navigation.current.id }; @@ -376,8 +450,27 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const firstProps = !firstRef.current || focusedElement.current.id !== firstRef.current.id ? navigation : dummySpecs; const secondProps = firstProps.current.id === navigation.current.id ? dummySpecs : navigation; + const mobileFallbackPreviousProps = setMobileFallbackElement(navigation.current.id, 'previous') + const mobileFallbackNextProps = setMobileFallbackElement(navigation.current.id, 'next') + const figures = (
+
+
+
= ({ data, chartView, chartLayers, n aria-label={secondProps.current.semantics?.label || undefined} >
+
+
+
); /* goals: - add exit event + handling - - create semantics for axes, legends, etc - - create spatialProperties for axes, legends, etc - 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 and "return to chart" button? + - add help menu/popup on "help" command? - add handling for resizing/etc */ return ( From b28b36d286c49d4ad5cb25e4d36f03a2751c686a Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sun, 23 Feb 2025 21:25:58 -0500 Subject: [PATCH 21/37] fix: stop pointer-events on navigation nodes --- src/Chart.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Chart.css b/src/Chart.css index 3dc9781b4..7fae54615 100644 --- a/src/Chart.css +++ b/src/Chart.css @@ -71,6 +71,7 @@ this removes transitions in the vega tooltip left: 0px; } .dn-node { + pointer-events: none; position: absolute; padding: 0px; margin: 0px; From 6e842c45bc6ff5278b217b463ec81d3eeadedc7c Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 28 Feb 2025 20:49:37 -0500 Subject: [PATCH 22/37] remove console log --- src/specBuilder/chartSpecBuilder.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index f3e9ba0b2..c89f8fc29 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -231,7 +231,6 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { // add marshallpete to repo const subdivisions = newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 - console.log("new layer",newLayer) const dimensions: Record = { dimension: { dimensionKey: "", From 589fb359b7a4123131c4d5342f7e15157ddaf469 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 28 Feb 2025 20:50:31 -0500 Subject: [PATCH 23/37] use divs for better mobile detection, improve division labels --- src/Navigator.tsx | 114 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 89 insertions(+), 25 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index d44fbfef2..d3a6ad6be 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -71,10 +71,10 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n id: '', } as CurrentNodeDetails); const willFocusAfterRender = useRef(false); - const firstRef = useRef(null); - const secondRef = useRef(null); - const mobileFallbackPreviousRef = useRef(null); - const mobileFallbackNextRef = useRef(null); + const firstRef = useRef(null); + const secondRef = useRef(null); + const mobileFallbackPreviousRef = useRef(null); + const mobileFallbackNextRef = useRef(null); const navigationStructure = buildNavigationStructure(data, { NAVIGATION_ID_KEY }, chartLayers); const structureNavigationHandler = buildStructureHandler( @@ -134,7 +134,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n }; const setMobileFallbackElement = (nodeId: string, direction: 'previous' | 'next'): Navigation => { - console.log(nodeId) const mobileFallbackData = { transform: '', buttonTransform: '', @@ -282,8 +281,10 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n 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: `${NAVIGATION_SEMANTICS[semanticKey].DIVISION} of ${navigationStructure.dimensions?.[d].dimensionKey}. Contains ${childrenCount} ${NAVIGATION_SEMANTICS[semanticKey].CHILD}${isPlural}. Press ${NAVIGATION_RULES.child.key} key to navigate.`, + label: `${divisionType}, ${NAVIGATION_SEMANTICS[semanticKey].DIVISION} of ${key}. Contains ${childrenCount} ${NAVIGATION_SEMANTICS[semanticKey].CHILD}${isPlural}. Press ${NAVIGATION_RULES.child.key} key to navigate.`, }; }); } @@ -415,7 +416,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n }; const handleKeydown = (e) => { const direction = structureNavigationHandler.keydownValidator(e); - console.log('e.code', e.code, 'direction', direction); const target = e.target as HTMLElement; if (direction) { e.preventDefault(); @@ -441,6 +441,29 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n } }; + const handleFallbackFocus = (e) => { + + // initializeRenderingProperties(e.target.id); + setNavigationElement(e.target.id); + willFocusAfterRender.current = true; + + // 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 handleFallbackKeydown = (e) => { + console.log("how does this even happen?",e) + } + const dummySpecs: Navigation = { ...navigation, }; @@ -455,23 +478,34 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const figures = (
-
-
-
*/} +
+ {/*
= ({ data, chartView, chartLayers, n className="dn-node-text" aria-label={firstProps.current.semantics?.label || undefined} >
- -
*/} +
+ {/*
= ({ data, chartView, chartLayers, n className="dn-node-text" aria-label={secondProps.current.semantics?.label || undefined} >
- -
*/} +
+
+ + {/*
-
-
+ +
*/}
); /* From a6caf049f93cb9f74b908d23dedbcd5935fb78d8 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Fri, 28 Feb 2025 20:56:41 -0500 Subject: [PATCH 24/37] remove console log --- src/RscChart.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/RscChart.tsx b/src/RscChart.tsx index 934f82c25..a201b8523 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -250,7 +250,6 @@ export const RscChart = forwardRef( const navigationEventCallback = (navData: NavigationEvent) => { if (chartView.current) { - console.log('RSC chart can use this navData to set signals', navData); chartView.current.signal('focusedItem', null); chartView.current.signal('focusedDimension', null); chartView.current.signal('focusedRegion', null); From 7aa9ccc143a84650601e4327ad72054b3f291d02 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sat, 1 Mar 2025 19:23:25 -0500 Subject: [PATCH 25/37] fix: navigation now maintains direction at childmost level --- package.json | 2 +- src/specBuilder/chartSpecBuilder.ts | 9 ++++++--- yarn.lock | 8 ++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 8ed91b2a8..bc816bf3c 100644 --- a/package.json +++ b/package.json @@ -150,7 +150,7 @@ }, "dependencies": { "d3-format": "^3.1.0", - "data-navigator": "^2.1.0", + "data-navigator": "^2.2.0", "immer": ">= 9.0.0", "uuid": ">= 9.0.0", "vega-embed": ">= 6.27.0", diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index c89f8fc29..f9aae526a 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -239,7 +239,8 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { compressSparseDivisions: true }, behavior: { - extents: "circular" + extents: "circular", + childmostNavigation: "across" }, navigationRules: NAVIGATION_PAIRS.DIMENSION as DimensionNavigationRules } @@ -253,7 +254,8 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { dimensionKey: "", type: "categorical", behavior: { - extents: "circular" + extents: "circular", + childmostNavigation: "across" }, operations: { compressSparseDivisions: true @@ -271,7 +273,8 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { }, type: "numerical", behavior: { - extents: "terminal" + extents: "terminal", + childmostNavigation: "within" }, navigationRules: NAVIGATION_PAIRS.METRIC as DimensionNavigationRules } diff --git a/yarn.lock b/yarn.lock index ff5950e19..3eccefb72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6269,10 +6269,10 @@ 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.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/data-navigator/-/data-navigator-2.1.0.tgz#b80816ae659891e177a45f70ec39218102edf291" - integrity sha512-yDCoZxWu31qn0M6nEj19ucdcnzbeF9os0xsrENXKrqzSe5o/PmqmeA3qX0mvxwaVL8E1maA/eoYbr0G01g6XzA== +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" From dd90d05db89a520c08189c6d9f14436864c946fb Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Sat, 1 Mar 2025 19:24:26 -0500 Subject: [PATCH 26/37] fix: use the same dimension behavior for all navigation --- src/specBuilder/chartSpecBuilder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index f9aae526a..c2c40f97f 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -273,8 +273,8 @@ export const addLayer = (chartLayers: DimensionList, newLayer) => { }, type: "numerical", behavior: { - extents: "terminal", - childmostNavigation: "within" + extents: "circular", + childmostNavigation: "across" }, navigationRules: NAVIGATION_PAIRS.METRIC as DimensionNavigationRules } From 2cf6f1558f4cc11c1e97848bcf1f47e00abdb9aa Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 13:22:39 -0500 Subject: [PATCH 27/37] add spatial names for navigation constants --- src/constants.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/constants.ts b/src/constants.ts index fcd30b876..b0dd8cba0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -196,6 +196,14 @@ export const NAVIGATION_PAIRS = { 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"], } } /* From 1e896a4d69ed2d1fe6d01de2e43ed346826a1d66 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 13:23:18 -0500 Subject: [PATCH 28/37] refactor: build navigation downstream using spec --- src/specBuilder/chartSpecBuilder.ts | 202 ++++++++++++++-------------- 1 file changed, 102 insertions(+), 100 deletions(-) diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index c2c40f97f..bae18e620 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -37,7 +37,7 @@ 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} from '../../node_modules/data-navigator/dist/src/data-navigator' +import {StructureOptions, DimensionList, DimensionDatum, Structure, NavigationRules, Dimensions, DimensionNavigationRules, ChildmostNavigationStrategy} from '../../node_modules/data-navigator/dist/src/data-navigator' import { AreaElement, @@ -140,25 +140,18 @@ export function buildSpec(props: SanitizedSpecProps) { switch (cur.type.displayName) { case Area.displayName: areaCount++; - // addLayer(chartLayers, {acc,cur,index:areaCount}) return addArea(acc, { ...(cur as AreaElement).props, ...specProps, index: areaCount }); case Axis.displayName: axisCount++; - // console.log("AXIS! What do we do here?") - // addLayer(chartLayers, {acc,cur,index:axisCount}) return addAxis(acc, { ...(cur as AxisElement).props, ...specProps, index: axisCount }); case Bar.displayName: barCount++; - addLayer(chartLayers, {acc,cur,index:barCount}) return addBar(acc, { ...(cur as BarElement).props, ...specProps, index: barCount }); case Donut.displayName: donutCount++; - // addLayer(chartLayers, {acc,cur,index:donutCount}) return addDonut(acc, { ...(cur as DonutElement).props, ...specProps, index: donutCount }); case Legend.displayName: legendCount++; - // console.log("LEGEND! What do we do here?") - // addLayer(chartLayers, {acc,cur,index:legendCount}) return addLegend(acc, { ...(cur as LegendElement).props, ...specProps, @@ -168,24 +161,18 @@ export function buildSpec(props: SanitizedSpecProps) { }); case Line.displayName: lineCount++; - // addLayer(chartLayers, {acc,cur,index:lineCount}) return addLine(acc, { ...(cur as LineElement).props, ...specProps, index: lineCount }); case Scatter.displayName: scatterCount++; - // addLayer(chartLayers, {acc,cur,index:scatterCount}) return addScatter(acc, { ...(cur as ScatterElement).props, ...specProps, index: scatterCount }); case Title.displayName: // No title count. There can only be one title. - // console.log("TITLE! What do we do here?") - // addLayer(chartLayers, {acc,cur,index:0}) return addTitle(acc, { ...(cur as TitleElement).props }); case BigNumber.displayName: // Do nothing and do not throw an error return acc; case Combo.displayName: comboCount++; - // console.log("COMBO! What do we do here?") - // addLayer(chartLayers, {acc,cur,index:comboCount}) return addCombo(acc, { ...(cur as ComboElement).props, ...specProps, index: comboCount }); default: console.error(`Invalid component type: ${cur.type.displayName} is not a supported child`); @@ -205,106 +192,120 @@ 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 addLayer = (chartLayers: DimensionList, newLayer) => { - // note: check "funnel" for stack + dodge example - // series, dimension, and item - // dimension, division, and child - // for childmost ids: they are indexed, starting at 1, in the order of the data - // need to emit division ids (values in a series) or dimension ids (the key) - - // color, opacity, line-type, etc - these "facets" are - // all ways to create a series id - // (after spec) - go to data, see if series transform happened (aka "as": "rscSeriesId", look for "expr": "datum.operatingSystem") - - // this function adds new traversable dimensions to data navigator - // "dimensions" is an API in data navigator, not to be confused with RSC's "dimension" - // that being said, the default "dimension" for every chart does seem to be based on - // the prop with the same name - - // look at "react-aria" - see if this is the right approach - // for first pass (POC) use callback - // "useFocus" passes into data navigator - // setFocused(child/division/dimension) - // add marshallpete to repo - - const subdivisions = newLayer.cur.type.displayName === Scatter.displayName ? 4 : 1 - const dimensions: Record = { - dimension: { - dimensionKey: "", - operations: { - createNumericalSubdivisions: subdivisions, - compressSparseDivisions: true - }, - behavior: { - extents: "circular", - childmostNavigation: "across" - }, - navigationRules: NAVIGATION_PAIRS.DIMENSION as DimensionNavigationRules - } - } - // We only want 2 traversable dimensions at most, so this conditional tries to figure out which to use based on chart type - // this will need to be expanded as navigation is added to additional charts in the library - if (newLayer.cur.type.displayName === Bar.displayName) { - // for bar (at least, and probably other charts?), the "color" becomes the second dimension - // we would want to traverse, after "dimension" - dimensions.color = { - dimensionKey: "", +export const buildNavigationDimensions = (spec, children, out: DimensionList) => { + let popped: DimensionDatum | undefined = undefined; + let navigableChartType = ""; + let isDodged = false; + const navigableDimensions = {} + console.log("children",children) + console.log("children.length",children.length) + const childArray = [...children] + let count = 0; + + const dimensionTypes = { + "categorical": { type: "categorical", - behavior: { - extents: "circular", - childmostNavigation: "across" - }, - operations: { - compressSparseDivisions: true - }, - navigationRules: NAVIGATION_PAIRS.COLOR as DimensionNavigationRules - } - } else if (newLayer.cur.type.displayName === Scatter.displayName) { - // for scatter at least (and likely also line chart), "metric" becomes the second dimension - // we would want to traverse, after "dimension" - dimensions.metric = { - dimensionKey: "", - operations: { - createNumericalSubdivisions: subdivisions, - compressSparseDivisions: true - }, + createNumericalSubdivisions: 1 + }, + // curently we don't support any numerical scales, but later (for scatter) you'll want to + "numerical": { type: "numerical", - behavior: { - extents: "circular", - childmostNavigation: "across" - }, - navigationRules: NAVIGATION_PAIRS.METRIC as DimensionNavigationRules + createNumericalSubdivisions: 4 // we should *at least* divide numerical dimensions into navigable quartiles } } - if (newLayer.cur?.props) { - const dimensionKeys = Object.keys(dimensions) - if (newLayer.cur.props.metric_start || newLayer.cur.props.metric_end) { - // console.log("uh oh! a range!") - // we are currently not handling metric start/end ranges right now + + 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 + } } - dimensionKeys.forEach(k => { - const dimensionKey = newLayer.cur.props[k] - if (dimensionKey) { - const newDimension: DimensionDatum = { - ...dimensions[k], - dimensionKey, - divisionOptions: { - // sortFunction: , // no idea if we want to sort or not - divisionNodeIds: (dimensionKey, keyValue, i) => "_" + keyValue + "_key_" + dimensionKey + i // dimensionKey + keyValue + i + } + 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" } - }; - chartLayers.push(newDimension) + + 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 - + console.log("props",props,NAVIGATION_ID_KEY) + console.log('chartLayers',chartLayers) const structureOptions : StructureOptions = { data, idKey: NAVIGATION_ID_KEY, @@ -320,6 +321,7 @@ export const buildNavigationStructure = (data, props, chartLayers) : Structure = } export const buildStructureHandler = (structure: Structure, navigationRules: NavigationRules, dimensions: Dimensions) => { + console.log("dimensions", dimensions) return DataNavigator.input({ structure, navigationRules, From 5ee8183b44533a38c15ce045e26f54c990109329 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 13:28:44 -0500 Subject: [PATCH 29/37] remove unused logs --- src/specBuilder/chartSpecBuilder.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/specBuilder/chartSpecBuilder.ts b/src/specBuilder/chartSpecBuilder.ts index bae18e620..dbe900ed3 100644 --- a/src/specBuilder/chartSpecBuilder.ts +++ b/src/specBuilder/chartSpecBuilder.ts @@ -201,9 +201,7 @@ export const buildNavigationDimensions = (spec, children, out: DimensionList) => let popped: DimensionDatum | undefined = undefined; let navigableChartType = ""; let isDodged = false; - const navigableDimensions = {} - console.log("children",children) - console.log("children.length",children.length) + const navigableDimensions = {}; const childArray = [...children] let count = 0; @@ -303,9 +301,7 @@ export const buildNavigationDimensions = (spec, children, out: DimensionList) => } export const buildNavigationStructure = (data, props, chartLayers) : Structure => { - const layers = props.list ? "" : chartLayers - console.log("props",props,NAVIGATION_ID_KEY) - console.log('chartLayers',chartLayers) + const layers = props.list ? "" : chartLayers; const structureOptions : StructureOptions = { data, idKey: NAVIGATION_ID_KEY, @@ -321,7 +317,6 @@ export const buildNavigationStructure = (data, props, chartLayers) : Structure = } export const buildStructureHandler = (structure: Structure, navigationRules: NavigationRules, dimensions: Dimensions) => { - console.log("dimensions", dimensions) return DataNavigator.input({ structure, navigationRules, From 3cbcc7e95be9c5417db351ff5a0e9cc41b075956 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 15:43:07 -0500 Subject: [PATCH 30/37] fix: typo on image role semantics --- src/types/navigationTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/navigationTypes.ts b/src/types/navigationTypes.ts index 225a50ab7..ab197459c 100644 --- a/src/types/navigationTypes.ts +++ b/src/types/navigationTypes.ts @@ -45,7 +45,7 @@ export type SpatialProperties = { export type CurrentNodeDetails = { id: string; figureRole?: "figure"; - imageRole?: "image"; + imageRole?: "img"; hasInteractivity?: boolean; spatialProperties?: SpatialProperties; semantics?: { From e4e5a7f9023584fca8c55b569e5e26ee8e9e47b4 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 15:44:02 -0500 Subject: [PATCH 31/37] fix: add state for mobile fallback elements --- src/Navigator.tsx | 85 ++++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index d3a6ad6be..a35ec2ebb 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -37,10 +37,15 @@ const convertId = (id, nodeLevel) => { : +id.substring(1) + 1; }; -const buildChildNodeLookup = (nodes: Nodes) => { +const buildChildNodeLookup = (nodes: Nodes, entryPoint) => { const keys = Object.keys(nodes) const lookup = {}; - let previous = ""; + lookup[entryPoint.id] = { + id: entryPoint.id, + next: "", + previous: "" + } + let previous = entryPoint.id; keys.forEach(key => { if (!nodes[key].dimensionLevel) { // childmost nodes do not have a dimension level (they are not dimensions or divisions), @@ -56,6 +61,7 @@ const buildChildNodeLookup = (nodes: Nodes) => { previous = nodes[key].id } }) + console.log(entryPoint, lookup) return lookup } @@ -85,17 +91,19 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n NAVIGATION_RULES as NavigationRules, navigationStructure.dimensions || {} ); - // we create a child-only lookup with indexes, this is for our mobile fallback nodes - const navigationChildren = buildChildNodeLookup(navigationStructure.nodes) const entryPoint = structureNavigationHandler.enter(); + + // we create a child-only lookup with indexes, this is for our mobile fallback nodes + const navigationChildren = buildChildNodeLookup(navigationStructure.nodes, entryPoint) + const [navigation, setNavigation] = useState({ transform: '', buttonTransform: '', current: { id: entryPoint.id, figureRole: 'figure', - imageRole: 'image', + imageRole: 'img', hasInteractivity: true, spatialProperties: { width: '', @@ -109,30 +117,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n }, }); - const [childrenInitialized, setInitialization] = useState(false); - - const setNavigationElement = (target) => { - setNavigation({ - transform: '', - buttonTransform: '', - current: { - id: target.id, - figureRole: 'figure', - imageRole: 'image', - hasInteractivity: true, - spatialProperties: target.spatialProperties || { - width: '', - height: '', - left: '', - top: '', - }, - semantics: { - label: target.semantics?.label || '', - }, - }, - } as Navigation); - }; - const setMobileFallbackElement = (nodeId: string, direction: 'previous' | 'next'): Navigation => { const mobileFallbackData = { transform: '', @@ -163,7 +147,7 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n mobileFallbackData.current = { id: target.id, figureRole: 'figure', - imageRole: 'image', + imageRole: 'img', hasInteractivity: true, spatialProperties: target.spatialProperties || { width: '', @@ -178,15 +162,49 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n } return mobileFallbackData } + const [mobileFallbackPreviousProps, setMobileFallbackPrevious] = useState(setMobileFallbackElement(navigation.current.id, 'previous')) + + const [mobileFallbackNextProps, setMobileFallbackNext] = useState(setMobileFallbackElement(navigation.current.id, 'next')) + + 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); + + setMobileFallbackPrevious(setMobileFallbackElement(target.id, 'previous')) + setMobileFallbackNext(setMobileFallbackElement(target.id, 'next')) + }; 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) { - firstRef.current.focus(); + refToFocus = firstRef.current; } else if (secondRef.current?.id === navigation.current.id) { - secondRef.current.focus(); + refToFocus = secondRef.current + } + if (refToFocus) { + refToFocus.focus() } } }, [navigation]); @@ -473,9 +491,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const firstProps = !firstRef.current || focusedElement.current.id !== firstRef.current.id ? navigation : dummySpecs; const secondProps = firstProps.current.id === navigation.current.id ? dummySpecs : navigation; - const mobileFallbackPreviousProps = setMobileFallbackElement(navigation.current.id, 'previous') - const mobileFallbackNextProps = setMobileFallbackElement(navigation.current.id, 'next') - const figures = (
{/*
Date: Tue, 4 Mar 2025 15:51:43 -0500 Subject: [PATCH 32/37] clean up commented blocks --- src/Navigator.tsx | 61 ----------------------------------------------- 1 file changed, 61 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index a35ec2ebb..a1e4dda91 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -493,22 +493,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const figures = (
- {/*
-
-
*/}
= ({ data, chartView, chartLayers, n role={mobileFallbackPreviousProps.current.imageRole || 'presentation'} aria-label={mobileFallbackPreviousProps.current.semantics?.label || undefined} >
- {/*
-
-
*/}
= ({ data, chartView, chartLayers, n role={firstProps.current.imageRole || 'presentation'} aria-label={firstProps.current.semantics?.label || undefined} >
- {/*
-
-
*/}
= ({ data, chartView, chartLayers, n role={mobileFallbackNextProps.current.imageRole || 'presentation'} aria-label={mobileFallbackNextProps.current.semantics?.label || undefined} >
- - {/*
- -
*/}
); /* From 2f601eb1702dac8c7eeaf30a0777683b5554451c Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 15:57:27 -0500 Subject: [PATCH 33/37] fix: remove mobile fallback elements --- src/Navigator.tsx | 132 +--------------------------------------------- 1 file changed, 1 insertion(+), 131 deletions(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index a1e4dda91..c0cf931cb 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -16,7 +16,7 @@ import { buildNavigationStructure, buildStructureHandler } from '@specBuilder/ch import { Scenegraph } from 'vega-scenegraph'; import { View } from 'vega-view'; -import { DimensionList, NavigationRules, NodeObject, Nodes } from '../node_modules/data-navigator/dist/src/data-navigator'; +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'; @@ -37,34 +37,6 @@ const convertId = (id, nodeLevel) => { : +id.substring(1) + 1; }; -const buildChildNodeLookup = (nodes: Nodes, entryPoint) => { - const keys = Object.keys(nodes) - const lookup = {}; - lookup[entryPoint.id] = { - id: entryPoint.id, - next: "", - previous: "" - } - let previous = entryPoint.id; - keys.forEach(key => { - if (!nodes[key].dimensionLevel) { - // childmost nodes do not have a dimension level (they are not dimensions or divisions), - // that means that these are the child nodes we care about - lookup[nodes[key].id] = { - id: nodes[key].id, - next: "", - previous: previous || "" - } - if (previous) { - lookup[previous].next = nodes[key].id - } - previous = nodes[key].id - } - }) - console.log(entryPoint, lookup) - return lookup -} - export interface NavigationProps { data: ChartData[]; chartView: MutableRefObject; @@ -79,8 +51,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const willFocusAfterRender = useRef(false); const firstRef = useRef(null); const secondRef = useRef(null); - const mobileFallbackPreviousRef = useRef(null); - const mobileFallbackNextRef = useRef(null); const navigationStructure = buildNavigationStructure(data, { NAVIGATION_ID_KEY }, chartLayers); const structureNavigationHandler = buildStructureHandler( @@ -94,9 +64,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const entryPoint = structureNavigationHandler.enter(); - // we create a child-only lookup with indexes, this is for our mobile fallback nodes - const navigationChildren = buildChildNodeLookup(navigationStructure.nodes, entryPoint) - const [navigation, setNavigation] = useState({ transform: '', buttonTransform: '', @@ -117,55 +84,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n }, }); - const setMobileFallbackElement = (nodeId: string, direction: 'previous' | 'next'): Navigation => { - const mobileFallbackData = { - transform: '', - buttonTransform: '', - current: { - id: '_no_' + direction + '_for_' + nodeId, - hasInteractivity: false, - spatialProperties: { - width: '', - height: '', - left: '', - top: '', - }, - semantics: { - label: '' - } - }, - } as Navigation - - // we need to find a fallback node for our mobile users - // first we check if the current node is a child element, if so, we find the previous or next child - // but if the current node isn't a child element, then we simply make both of the previous/next elements the first child node - // the reasoning for this is that the chart initially might render with a non-child element, - // so we want mobile users to encounter the first child element whether they nav from above or below - // (hence why both will be this element at first) - const target = navigationChildren[nodeId] ? navigationStructure.nodes[navigationChildren[nodeId][direction]] : navigationStructure.nodes[Object.keys(navigationChildren)[0]] - if (target) { - mobileFallbackData.current = { - id: target.id, - figureRole: 'figure', - imageRole: 'img', - hasInteractivity: true, - spatialProperties: target.spatialProperties || { - width: '', - height: '', - left: '', - top: '', - }, - semantics: { - label: target.semantics?.label || '' - } - } - } - return mobileFallbackData - } - const [mobileFallbackPreviousProps, setMobileFallbackPrevious] = useState(setMobileFallbackElement(navigation.current.id, 'previous')) - - const [mobileFallbackNextProps, setMobileFallbackNext] = useState(setMobileFallbackElement(navigation.current.id, 'next')) - const [childrenInitialized, setInitialization] = useState(false); const setNavigationElement = (target) => { @@ -188,9 +106,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n }, }, } as Navigation); - - setMobileFallbackPrevious(setMobileFallbackElement(target.id, 'previous')) - setMobileFallbackNext(setMobileFallbackElement(target.id, 'next')) }; useEffect(() => { @@ -459,29 +374,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n } }; - const handleFallbackFocus = (e) => { - - // initializeRenderingProperties(e.target.id); - setNavigationElement(e.target.id); - willFocusAfterRender.current = true; - - // 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 handleFallbackKeydown = (e) => { - console.log("how does this even happen?",e) - } - const dummySpecs: Navigation = { ...navigation, }; @@ -493,17 +385,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n const figures = (
-
= ({ data, chartView, chartLayers, n role={secondProps.current.imageRole || 'presentation'} aria-label={secondProps.current.semantics?.label || undefined} >
-
); /* From bb7afca737e8ef7376f57abb6981a6bcec1bd5d6 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 16:06:12 -0500 Subject: [PATCH 34/37] hide vega chart from screen readers --- src/VegaChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ; }; From f4af8870c0127510c3e026d562f36a94f293a035 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 16:13:18 -0500 Subject: [PATCH 35/37] fix: hide entry button until focused --- src/Chart.css | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Chart.css b/src/Chart.css index 7fae54615..90ea98617 100644 --- a/src/Chart.css +++ b/src/Chart.css @@ -91,6 +91,12 @@ this removes transitions in the vega tooltip transform: translateY(2px); } .dn-entry-button { - position: relative; + 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 From 9ba7cda1c09dd8c4f818bd98b819cb1322f79a41 Mon Sep 17 00:00:00 2001 From: Frank Elavsky Date: Tue, 4 Mar 2025 16:15:37 -0500 Subject: [PATCH 36/37] fix: remove repeated id usage from navigator --- src/Navigator.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Navigator.tsx b/src/Navigator.tsx index c0cf931cb..d7a5acce5 100644 --- a/src/Navigator.tsx +++ b/src/Navigator.tsx @@ -421,7 +421,6 @@ export const Navigator: FC = ({ data, chartView, chartLayers, n return ( <>
Date: Tue, 4 Mar 2025 16:31:21 -0500 Subject: [PATCH 37/37] feat: add blur handling to navigation events --- src/RscChart.tsx | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/RscChart.tsx b/src/RscChart.tsx index a201b8523..c6379f761 100644 --- a/src/RscChart.tsx +++ b/src/RscChart.tsx @@ -250,23 +250,31 @@ export const RscChart = forwardRef( const navigationEventCallback = (navData: NavigationEvent) => { if (chartView.current) { - 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; + 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! } - chartView.current.runAsync(); } // set signals here! };