Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/specBuilder/marks/markUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ export const getOpacityProductionRule = (opacity: OpacityFacet | DualFacet): { s
return { value: opacity.value };
};

export const getSymbolSizeProductionRule = (symbolSize: SymbolSizeFacet): NumericValueRef | undefined => {
export const getSymbolSizeProductionRule = (symbolSize: SymbolSizeFacet): NumericValueRef => {
// key reference for setting symbol size
if (typeof symbolSize === 'string') {
return { scale: 'symbolSize', field: symbolSize };
Expand Down
22 changes: 21 additions & 1 deletion src/specBuilder/scatter/scatterMarkUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import { ChartTooltip } from '@components/ChartTooltip';
import { Trendline } from '@components/Trendline';
import { DEFAULT_OPACITY_RULE, MARK_ID } from '@constants';
import { GroupMark } from 'vega';
import { addScatterMarks, getOpacity, getScatterHoverMarks } from './scatterMarkUtils';
import { addScatterMarks, getOpacity, getScatterHoverMarks, getSelectRingSize } from './scatterMarkUtils';
import { defaultScatterProps } from './scatterTestUtils';
import { ChartPopover } from '@components/ChartPopover';

describe('addScatterMarks()', () => {
test('should add the scatter group with the symbol marks', () => {
Expand Down Expand Up @@ -52,6 +53,12 @@ describe('getOpacity()', () => {
expect(opacity).toHaveLength(2);
expect(opacity[0]).toHaveProperty('test', `scatter0_hoveredId && scatter0_hoveredId !== datum.${MARK_ID}`);
});
test('should include select rule if popover exists', () => {
const opacity = getOpacity({ ...defaultScatterProps, children: [createElement(ChartPopover)] });
expect(opacity).toHaveLength(3);
expect(opacity[0]).toHaveProperty('test', `scatter0_hoveredId && scatter0_hoveredId !== datum.${MARK_ID}`);
expect(opacity[1]).toHaveProperty('test', `scatter0_selectedId && scatter0_selectedId !== datum.${MARK_ID}`);
});
});

describe('getScatterHoverMarks()', () => {
Expand All @@ -63,3 +70,16 @@ describe('getScatterHoverMarks()', () => {
expect(marks[0].name).toBe('scatter0_voronoi');
});
});

describe('getScatterSelectMarks()', () => {
test('should return a staic value if a static value is provided', () => {
const ringSize = getSelectRingSize({ value: 10 });
// sqrt of 196 is 14 which is 4 pixels larger than 10;
expect(ringSize).toHaveProperty('value', 196);
});
test('should return a signal if data key is provided', () => {
const sizeKey = 'weight';
const ringSize = getSelectRingSize(sizeKey);
expect(ringSize).toHaveProperty('signal', `pow(sqrt(scale('symbolSize', datum.${sizeKey})) + 4, 2)`);
});
});
80 changes: 72 additions & 8 deletions src/specBuilder/scatter/scatterMarkUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { FILTERED_TABLE, MARK_ID, DEFAULT_OPACITY_RULE } from '@constants';
import { DEFAULT_OPACITY_RULE, FILTERED_TABLE, HIGHLIGHT_CONTRAST_RATIO, MARK_ID } from '@constants';
import {
getColorProductionRule,
getHighlightOpacityValue,
getLineWidthProductionRule,
getOpacityProductionRule,
getStrokeDashProductionRule,
getSymbolSizeProductionRule,
getVoronoiPath,
getXProductionRule,
hasInteractiveChildren,
hasPopover,
} from '@specBuilder/marks/markUtils';
import { getTrendlineMarks } from '@specBuilder/trendline';
import { spectrumColors } from '@themes';
import { produce } from 'immer';
import { ScatterSpecProps } from 'types';
import { ScatterSpecProps, SymbolSizeFacet } from 'types';
import { GroupMark, Mark, NumericValueRef, PathMark, SymbolMark } from 'vega';

export const addScatterMarks = produce<Mark[], [ScatterSpecProps]>((marks, props) => {
Expand All @@ -32,7 +33,7 @@ export const addScatterMarks = produce<Mark[], [ScatterSpecProps]>((marks, props
const scatterGroup: GroupMark = {
name: `${name}_group`,
type: 'group',
marks: [getScatterMark(props), ...getScatterHoverMarks(props)],
marks: [getScatterMark(props), ...getScatterHoverMarks(props), ...getScatterSelectMarks(props)],
};

marks.push(scatterGroup);
Expand Down Expand Up @@ -88,15 +89,25 @@ export const getOpacity = ({ children, name }: ScatterSpecProps): ({ test?: stri
if (!hasInteractiveChildren(children)) {
return [DEFAULT_OPACITY_RULE];
}
// if a point is hovered, all other points should be reduced opacity
// if a point is hovered or selected, all other points should be reduced opacity
const hoverSignal = `${name}_hoveredId`;
return [
const selectSignal = `${name}_selectedId`;
const fadedValue = 1 / HIGHLIGHT_CONTRAST_RATIO;

const rules = [
{
test: `${hoverSignal} && ${hoverSignal} !== datum.${MARK_ID}`,
...getHighlightOpacityValue(),
value: fadedValue,
},
DEFAULT_OPACITY_RULE,
];
if (hasPopover(children)) {
rules.push({
test: `${selectSignal} && ${selectSignal} !== datum.${MARK_ID}`,
value: fadedValue,
});
}

return [...rules, DEFAULT_OPACITY_RULE];
};

/**
Expand All @@ -110,3 +121,56 @@ export const getScatterHoverMarks = ({ children, name }: ScatterSpecProps): Path
}
return [getVoronoiPath(children, name, name)];
};

const getScatterSelectMarks = ({
children,
dimension,
dimensionScaleType,
metric,
name,
size,
}: ScatterSpecProps): SymbolMark[] => {
if (!hasPopover(children)) {
return [];
}
return [
{
name: `${name}_selectRing`,
type: 'symbol',
from: {
data: `${name}_selectedData`,
},
encode: {
enter: {
fill: { value: 'transparent' },
shape: { value: 'circle' },
size: getSelectRingSize(size),
strokeWidth: { value: 2 },
stroke: { value: spectrumColors.light['static-blue'] },
},
update: {
x: getXProductionRule(dimensionScaleType, dimension),
y: { scale: 'yLinear', field: metric },
},
},
},
];
};

/**
* Gets the size of the select ring based on the size of the scatter points
* @param size SymbolSizeFacet
* @returns NumericValueRef
*/
export const getSelectRingSize = (size: SymbolSizeFacet): NumericValueRef => {
const baseSize = getSymbolSizeProductionRule(size);
if ('value' in baseSize && typeof baseSize.value === 'number') {
// the select ring is 4px widr and taller
// to calculate: (sqrt(baseSize) + 4)^2
return { value: Math.pow(Math.sqrt(baseSize.value) + 4, 2) };
}
if ('scale' in baseSize && 'field' in baseSize) {
return { signal: `pow(sqrt(scale('${baseSize.scale}', datum.${baseSize.field})) + 4, 2)` };
}
return baseSize;
};
23 changes: 20 additions & 3 deletions src/specBuilder/scatter/scatterSpecBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { initializeSpec } from '@specBuilder/specUtils';
import { addData, addSignals, setScales } from './scatterSpecBuilder';
import { defaultScatterProps } from './scatterTestUtils';
import { ChartTooltip } from '@components/ChartTooltip';
import { ChartPopover } from '@components/ChartPopover';

describe('addData()', () => {
test('should add time transform is dimensionScaleType === "time"', () => {
Expand All @@ -26,6 +27,14 @@ describe('addData()', () => {
expect(data[0].transform).toHaveLength(2);
expect(data[0].transform?.[1].type).toBe('timeunit');
});
test('should add selectedData if popover exists', () => {
const data = addData(initializeSpec().data ?? [], {
...defaultScatterProps,
children: [createElement(ChartPopover)],
});
expect(data).toHaveLength(3);
expect(data[2].name).toBe('scatter0_selectedData');
});
test('should add trendline data if trendline exists as a child', () => {
const data = addData(initializeSpec().data ?? [], {
...defaultScatterProps,
Expand All @@ -44,16 +53,24 @@ describe('addSignals()', () => {
children: [createElement(ChartTooltip)],
});
expect(signals).toHaveLength(1);
expect(signals[0]).toHaveProperty('name', 'scatter0_hoveredId');
expect(signals[0].name).toBe('scatter0_hoveredId');
});
test('should add selectedId signal if popover exists', () => {
const signals = addSignals([], {
...defaultScatterProps,
children: [createElement(ChartPopover)],
});
expect(signals).toHaveLength(2);
expect(signals[1].name).toBe('scatter0_selectedId');
});
test('should add trendline signals if trendline exists as a child', () => {
const signals = addSignals([], {
...defaultScatterProps,
children: [createElement(Trendline, { displayOnHover: true })],
});
expect(signals).toHaveLength(2);
expect(signals[0]).toHaveProperty('name', 'scatter0_hoveredSeries');
expect(signals[1]).toHaveProperty('name', 'scatter0_hoveredId');
expect(signals[0].name).toBe('scatter0_hoveredSeries');
expect(signals[1].name).toBe('scatter0_hoveredId');
});
});

Expand Down
27 changes: 24 additions & 3 deletions src/specBuilder/scatter/scatterSpecBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ import {
DEFAULT_DIMENSION_SCALE_TYPE,
DEFAULT_LINEAR_DIMENSION,
DEFAULT_METRIC,
FILTERED_TABLE,
MARK_ID,
} from '@constants';
import { addTimeTransform, getTableData } from '@specBuilder/data/dataUtils';
import { getInteractiveMarkName } from '@specBuilder/line/lineUtils';
import { hasInteractiveChildren } from '@specBuilder/marks/markUtils';
import { hasInteractiveChildren, hasPopover } from '@specBuilder/marks/markUtils';
import {
addContinuousDimensionScale,
addFieldToFacetScaleDomain,
addMetricScale,
} from '@specBuilder/scale/scaleSpecBuilder';
import { getUncontrolledHoverSignal, hasSignalByName } from '@specBuilder/signal/signalSpecBuilder';
import { getGenericSignal, getUncontrolledHoverSignal, hasSignalByName } from '@specBuilder/signal/signalSpecBuilder';
import { addTrendlineData, getTrendlineScales, getTrendlineSignals } from '@specBuilder/trendline';
import { sanitizeMarkChildren, toCamelCase } from '@utils';
import { produce } from 'immer';
Expand Down Expand Up @@ -85,11 +87,23 @@ export const addScatter = produce<Spec, [ScatterProps & { colorScheme?: ColorSch
);

export const addData = produce<Data[], [ScatterSpecProps]>((data, props) => {
const { dimension, dimensionScaleType } = props;
const { children, dimension, dimensionScaleType, name } = props;
if (dimensionScaleType === 'time') {
const tableData = getTableData(data);
tableData.transform = addTimeTransform(tableData.transform ?? [], dimension);
}
if (hasPopover(children)) {
data.push({
name: `${name}_selectedData`,
source: FILTERED_TABLE,
transform: [
{
type: 'filter',
expr: `${name}_selectedId === datum.${MARK_ID}`,
},
],
});
}
addTrendlineData(data, props);
});

Expand All @@ -106,8 +120,15 @@ export const addSignals = produce<Signal[], [ScatterSpecProps]>((signals, props)
if (!hasInteractiveChildren(children)) return;
// interactive signals
if (!hasSignalByName(signals, `${name}_hoveredId`)) {
// hover signal
signals.push(getUncontrolledHoverSignal(`${name}`, true, `${name}_voronoi`));
}
if (hasPopover(children)) {
if (!hasSignalByName(signals, `${name}_selectedId`)) {
// select signal
signals.push(getGenericSignal(`${name}_selectedId`));
}
}
});

/**
Expand Down
54 changes: 28 additions & 26 deletions src/stories/components/Scatter/Scatter.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { ReactElement } from 'react';
import { ReactElement, createElement } from 'react';

import { Content, Flex } from '@adobe/react-spectrum';
import useChartProps from '@hooks/useChartProps';
import { Axis, Chart, ChartProps, ChartTooltip, Datum, Legend, LegendProps, Scatter, ScatterProps, Title } from '@rsc';
import {
Axis,
Chart,
ChartPopover,
ChartProps,
ChartTooltip,
Datum,
Legend,
LegendProps,
Scatter,
ScatterProps,
Title,
} from '@rsc';
import { characterData } from '@stories/data/marioKartData';
import { StoryFn } from '@storybook/react';
import { bindWithProps } from '@test-utils';
Expand Down Expand Up @@ -98,41 +110,22 @@ const ScatterStory: StoryFn<typeof Scatter> = (args): ReactElement => {
<Axis position="bottom" grid ticks baseline title={marioKeyTitle[args.dimension as MarioDataKey]} />
<Axis position="left" grid ticks baseline title={marioKeyTitle[args.metric as MarioDataKey]} />
<Scatter {...args} />
<Legend {...legendProps} />
<Title text="Mario Kart 8 Character Data" />
</Chart>
);
};

const ScatterTooltipStory: StoryFn<typeof Scatter> = (args): ReactElement => {
const chartProps = useChartProps(defaultChartProps);
const legendProps = getLegendProps(args);
const dimension = args.dimension as MarioDataKey;
const metric = args.metric as MarioDataKey;

return (
<Chart {...chartProps}>
<Axis position="bottom" grid ticks baseline title={marioKeyTitle[dimension]} />
<Axis position="left" grid ticks baseline title={marioKeyTitle[metric]} />
<Scatter {...args}>
<ChartTooltip>{(item) => dialog(item, dimension, metric)}</ChartTooltip>
</Scatter>
<Legend {...legendProps} highlight />
<Title text="Mario Kart 8 Character Data" />
</Chart>
);
};

const dialog = (item: Datum, dimension: MarioDataKey, metric: MarioDataKey) => {
const dialog = (item: Datum) => {
return (
<Content>
<Flex direction="column">
<div style={{ fontWeight: 'bold' }}>{(item.character as string[]).join(', ')}</div>
<div>
{marioKeyTitle[dimension]}: {item[dimension]}
{marioKeyTitle.speedNormal}: {item.speedNormal}
</div>
<div>
{marioKeyTitle[metric]}: {item[metric]}
{marioKeyTitle.handlingNormal}: {item.handlingNormal}
</div>
</Flex>
</Content>
Expand Down Expand Up @@ -168,18 +161,27 @@ Opacity.args = {
metric: 'handlingNormal',
};

const Popover = bindWithProps(ScatterStory);
Popover.args = {
color: 'weightClass',
dimension: 'speedNormal',
metric: 'handlingNormal',
children: [createElement(ChartTooltip, {}, dialog), createElement(ChartPopover, { width: 200 }, dialog)],
};

const Size = bindWithProps(ScatterStory);
Size.args = {
size: 'weight',
dimension: 'speedNormal',
metric: 'handlingNormal',
};

const Tooltip = bindWithProps(ScatterTooltipStory);
const Tooltip = bindWithProps(ScatterStory);
Tooltip.args = {
color: 'weightClass',
dimension: 'speedNormal',
metric: 'handlingNormal',
children: [createElement(ChartTooltip, {}, dialog)],
};

export { Basic, Color, LineType, Opacity, Size, Tooltip };
export { Basic, Color, LineType, Opacity, Popover, Size, Tooltip };
Loading