-
Notifications
You must be signed in to change notification settings - Fork 1.7k
feat: add the ability to brush historical #7247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v5
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { G2Spec, PLOT_CLASS_NAME } from '../../../src'; | ||
| import { brush, dblclick } from './penguins-point-brush'; | ||
|
|
||
| export function penguinsPointBrushFilterWithHistory(): G2Spec { | ||
| return { | ||
| type: 'point', | ||
| data: { | ||
| type: 'fetch', | ||
| value: 'data/penguins.csv', | ||
| }, | ||
| axis: { x: { animate: false }, y: { animate: false } }, | ||
| encode: { | ||
| color: 'species', | ||
| x: 'culmen_length_mm', | ||
| y: 'culmen_depth_mm', | ||
| }, | ||
| state: { | ||
| inactive: { stroke: 'gray' }, | ||
| }, | ||
| interaction: { | ||
| brushFilter: { | ||
| history: true, | ||
| }, | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| penguinsPointBrushFilterWithHistory.steps = ({ canvas }) => { | ||
| const { document } = canvas; | ||
| const plot = document.getElementsByClassName(PLOT_CLASS_NAME)[0]; | ||
| return [ | ||
| { | ||
| changeState: () => { | ||
| brush(plot, 100, 100, 300, 300); | ||
| }, | ||
| }, | ||
| { | ||
| changeState: () => { | ||
| brush(plot, 100, 100, 300, 300); | ||
| }, | ||
| }, | ||
| { | ||
| changeState: () => { | ||
| dblclick(plot); | ||
| }, | ||
| }, | ||
| ]; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -94,6 +94,7 @@ It can also be configured at the View level. Interactions declared on the view w | |
| | Property | Description | Type | Default | Required | | ||
| | -------- | ------------------------ | ------------- | ----------------- | -------- | | ||
| | reverse | Whether to reverse brush | boolean | false | | | ||
| | history | Whether to histroy brush | boolean | false | | | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| | mask | Style of brush area mask | [mask](#mask) | See [mask](#mask) | | | ||
|
|
||
| ### mask | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -81,7 +81,12 @@ export function brushFilter( | |
| }; | ||
| } | ||
|
|
||
| export function BrushFilter({ hideX = true, hideY = true, ...rest }) { | ||
| export function BrushFilter({ | ||
| hideX = true, | ||
| hideY = true, | ||
| history = false, | ||
| ...rest | ||
| }) { | ||
| return (target, viewInstances, emitter) => { | ||
| const { container, view, options: viewOptions, update, setState } = target; | ||
| const plotArea = selectPlotArea(container); | ||
|
|
@@ -96,8 +101,36 @@ export function BrushFilter({ hideX = true, hideY = true, ...rest }) { | |
| let filtered = false; | ||
| let filtering = false; | ||
| let newView = view; | ||
|
|
||
| const brushHistory = []; | ||
| const { scale, coordinate } = view; | ||
| const updateScale = (options, domainX, domainY) => { | ||
| const { marks } = options; | ||
| const newMarks = marks.map((mark) => | ||
| deepMix( | ||
| { | ||
| // Hide label to keep smooth transition. | ||
| axis: { | ||
| ...(hideX && { x: { transform: [{ type: 'hide' }] } }), | ||
| ...(hideY && { y: { transform: [{ type: 'hide' }] } }), | ||
| }, | ||
| }, | ||
| mark, | ||
| { | ||
| // Set nice to false to avoid modify domain. | ||
| scale: { | ||
| x: { domain: domainX, nice: false }, | ||
| y: { domain: domainY, nice: false }, | ||
| }, | ||
| }, | ||
| ), | ||
| ); | ||
|
|
||
| return { | ||
| ...viewOptions, | ||
| marks: newMarks, | ||
| clip: true, // Clip shapes out of plot area. | ||
| }; | ||
| }; | ||
| return brushFilter(plotArea, { | ||
| brushRegion: (x, y, x1, y1) => [x, y, x1, y1], | ||
| selection: (x, y, x1, y1) => { | ||
|
|
@@ -112,62 +145,75 @@ export function BrushFilter({ hideX = true, hideY = true, ...rest }) { | |
| // Update the domain of x and y scale to filter data. | ||
| const [domainX, domainY] = selection; | ||
|
|
||
| setState('brushFilter', (options) => { | ||
| const { marks } = options; | ||
| const newMarks = marks.map((mark) => | ||
| deepMix( | ||
| { | ||
| // Hide label to keep smooth transition. | ||
| axis: { | ||
| ...(hideX && { x: { transform: [{ type: 'hide' }] } }), | ||
| ...(hideY && { y: { transform: [{ type: 'hide' }] } }), | ||
| }, | ||
| }, | ||
| mark, | ||
| { | ||
| // Set nice to false to avoid modify domain. | ||
| scale: { | ||
| x: { domain: domainX, nice: false }, | ||
| y: { domain: domainY, nice: false }, | ||
| }, | ||
| }, | ||
| ), | ||
| ); | ||
|
|
||
| return { | ||
| ...viewOptions, | ||
| marks: newMarks, | ||
| clip: true, // Clip shapes out of plot area. | ||
| }; | ||
| }); | ||
|
|
||
| setState('brushFilter', (options) => | ||
| updateScale(options, domainX, domainY), | ||
| ); | ||
| if (history) { | ||
| brushHistory.push({ | ||
| domain: selection, | ||
| view: newView, | ||
| }); | ||
| } | ||
| // Emit event. | ||
| emitter.emit('brush:filter', { | ||
| ...event, | ||
| data: { selection: [domainX, domainY] }, | ||
| data: { | ||
| ...(history ? { history: brushHistory } : {}), | ||
| selection: [domainX, domainY], | ||
| }, | ||
| }); | ||
|
|
||
| const newState = await update(); | ||
| newView = newState.view; | ||
| filtering = false; | ||
| filtered = true; | ||
| }, | ||
| reset: (event) => { | ||
| reset: async (event) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is now // in brushFilter(...)
async function click(e) {
if (isDblclick(e)) {
e.nativeEvent = true;
await reset(e);
}
}Although this change is outside the diff, it's critical for the correctness of this feature. |
||
| if (filtering || !filtered) return; | ||
| event.nativeEvent = true; | ||
|
|
||
| // Emit event. | ||
| const { scale } = view; | ||
| const { x: scaleX, y: scaleY } = scale; | ||
| const domainX = scaleX.getOptions().domain; | ||
| const domainY = scaleY.getOptions().domain; | ||
| emitter.emit('brush:filter', { | ||
| ...event, | ||
| data: { selection: [domainX, domainY] }, | ||
| }); | ||
| filtered = false; | ||
| newView = view; | ||
| setState('brushFilter'); | ||
| update(); | ||
| // Restore previous brush state | ||
| if (brushHistory.length > 1) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The logic A more conventional undo behavior would be to pop from history on every Consider refactoring the logic to handle undo consistently for all levels of history. For example: if (history && brushHistory.length > 0) {
brushHistory.pop();
if (brushHistory.length > 0) {
// Restore previous state
} else {
// Reset to initial state
}
} else {
// Reset to initial state (if no history)
}This would provide a more predictable multi-level undo experience. |
||
| brushHistory.pop(); | ||
|
|
||
| // Get the last brush state | ||
| const { domain, view: lastView } = | ||
| brushHistory[brushHistory.length - 1]; | ||
|
|
||
| const [domainX, domainY] = domain; | ||
| newView = lastView; | ||
|
|
||
| // update scale | ||
| setState('brushFilter', (options) => | ||
| updateScale(options, domainX, domainY), | ||
| ); | ||
|
|
||
| emitter.emit('brush:filter', { | ||
| ...event, | ||
| data: { | ||
| ...(history ? { history: brushHistory } : {}), | ||
| selection: [domainX, domainY], | ||
| }, | ||
| }); | ||
|
|
||
| await update(); | ||
| } else { | ||
| // Reset to initial state | ||
| brushHistory.length = 0; | ||
| emitter.emit('brush:filter', { | ||
| ...event, | ||
| data: { | ||
| ...(history ? { history: brushHistory } : {}), | ||
| selection: [ | ||
| scale.x.getOptions().domain, | ||
| scale.y.getOptions().domain, | ||
| ], | ||
| }, | ||
| }); | ||
| setState('brushFilter'); | ||
| newView = view; | ||
| filtered = false; | ||
| await update(); | ||
| } | ||
| }, | ||
| extent: undefined, | ||
| emitter, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test for the history feature seems incomplete and could be improved:
dblclickto trigger the undo functionality, but the corresponding snapshot (step3.svg) is missing from the pull request. This is the most critical part of the test for this feature and should be included to verify the undo behavior.brushactions use the same coordinates (100, 100, 300, 300). While this tests the history mechanism, using different coordinates for the second brush would make the visual change betweenstep1andstep2clearer, and would provide a more robust verification whenstep3reverts to the state ofstep1.