Instantly share code, notes, and snippets.
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save john-yuan/a80e774402b22fc2e86bef3828cf3ded to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import * as d3 from 'd3' | |
| import { navigatorX } from './navigator' | |
| const svg = d3.select('#chart') | |
| .append('svg') | |
| .attr('viewBox', '0 0 800 400') | |
| .style('width', '800px') | |
| const xNavigator = navigatorX().size(800, 54).move(0, 100) | |
| svg.append('g').call(xNavigator) | |
| xNavigator.on('change', (state) => { | |
| console.log(state) | |
| }) | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { type Selection } from 'd3' | |
| import { dispatch } from 'd3-dispatch' | |
| export interface NavigatorXState { | |
| /** | |
| * The x of the selected area. | |
| */ | |
| x: number | |
| /** | |
| * The width of the selected area. | |
| */ | |
| width: number | |
| /** | |
| * The total width. | |
| */ | |
| totalWidth: number | |
| } | |
| export type NavigatorXListener = (state: NavigatorXState) => void | |
| export interface NavigatorX { | |
| (group: Selection<SVGGElement, any, any, any>): void | |
| /** | |
| * Registers a listener for the specified event. | |
| * Currently, only the "change" event is supported. | |
| * When the selected area changes, a re-render is triggered, | |
| * and the "change" event is fired immediately | |
| * after rendering is complete. | |
| */ | |
| on(event: string, listener: NavigatorXListener): this | |
| /** | |
| * Specify the space the navigator takes. | |
| * | |
| * @param width The total width. | |
| * @param height The total height. | |
| */ | |
| size(width: number, height: number): this | |
| /** | |
| * Move the selected area to the specified position. | |
| * If the navigation is rendered, a "change" event will be triggered. | |
| * | |
| * @param x The x of the selected area. | |
| * @param width The width of the selected area. | |
| */ | |
| move(x: number, width: number): this | |
| /** | |
| * Get the total available width of the selection area. | |
| * Please note that the return value is less than the value specified | |
| * with `size(width: number, height: number)`. | |
| */ | |
| width(): number | |
| } | |
| export function navigatorX() { | |
| const state: { | |
| g?: Selection<SVGGElement, any, any, any> | |
| W: number | |
| H: number | |
| x: number | |
| w: number | |
| } = { W: 0, H: 0, x: 0, w: 0 } | |
| const events = dispatch('change') | |
| const emitChange = (g: Selection<SVGGElement, any, any, any>) => { | |
| navigator(g) | |
| events.call('change', undefined, { | |
| x: state.x, | |
| width: state.w, | |
| totalWidth: state.W | |
| } satisfies NavigatorXState) | |
| } | |
| const ns = (name: string) => 'navigator-x__' + name | |
| const onBarMousedown = (e: MouseEvent) => { | |
| if (e.currentTarget != e.target) { | |
| return | |
| } | |
| const { W, x, w, g } = state | |
| if (!g) { | |
| return | |
| } | |
| const x1 = e.clientX | |
| const maxRight = W - (x + w) | |
| const onmousemove = (e: MouseEvent) => { | |
| let movedX = e.clientX - x1 | |
| if (movedX > maxRight) { | |
| movedX = maxRight | |
| } | |
| if (movedX < -x) { | |
| movedX = -x | |
| } | |
| const newX = x + movedX | |
| if (newX != state.x) { | |
| state.x = newX | |
| emitChange(g) | |
| } | |
| } | |
| const onmouseup = () => { | |
| window.removeEventListener('mousemove', onmousemove, true) | |
| window.removeEventListener('mouseup', onmouseup, true) | |
| } | |
| window.addEventListener('mousemove', onmousemove, true) | |
| window.addEventListener('mouseup', onmouseup, true) | |
| } | |
| const createHandleGesture = (name: 'left' | 'right') => { | |
| return function mousedown(e: MouseEvent) { | |
| if (e.currentTarget != e.target) { | |
| return | |
| } | |
| const { g, x, w, W } = state | |
| if (!g) { | |
| return | |
| } | |
| const x1 = e.clientX | |
| let onmousemove: (e: MouseEvent) => void | |
| if (name == 'left') { | |
| onmousemove = (e: MouseEvent) => { | |
| const movedX = e.clientX - x1 | |
| const newX = Math.min(Math.max(0, x + movedX), W) | |
| const endX = x + w | |
| const newState = { x: 0, w: 0 } | |
| if (newX > endX) { | |
| newState.x = endX - 1 | |
| newState.w = newX - endX + 1 | |
| } else { | |
| newState.x = newX | |
| newState.w = endX - newX | |
| } | |
| if (!(state.x == newState.x && state.w == newState.w)) { | |
| state.x = newState.x | |
| state.w = newState.w | |
| emitChange(g) | |
| } | |
| } | |
| } else { | |
| onmousemove = (e: MouseEvent) => { | |
| const movedX = e.clientX - x1 | |
| const endX = Math.min(Math.max(x + w + movedX, 0), W) | |
| const newState = { x: 0, w: 0 } | |
| if (x < endX) { | |
| newState.x = x | |
| newState.w = endX - x | |
| } else { | |
| newState.x = endX | |
| newState.w = x - endX + 1 | |
| } | |
| if (!(state.x == newState.x && state.w == newState.w)) { | |
| state.x = newState.x | |
| state.w = newState.w | |
| emitChange(g) | |
| } | |
| } | |
| } | |
| const onmouseup = () => { | |
| window.removeEventListener('mousemove', onmousemove, true) | |
| window.removeEventListener('mouseup', onmouseup, true) | |
| } | |
| window.addEventListener('mousemove', onmousemove, true) | |
| window.addEventListener('mouseup', onmouseup, true) | |
| } | |
| } | |
| const onLeftHandleMousedown = createHandleGesture('left') | |
| const onRightHandleMousedown = createHandleGesture('right') | |
| const rect = (g: Selection<SVGGElement, any, any, any>, name: string) => { | |
| const className = ns(name) | |
| return g.selectAll(`rect.${className}`).data([0]).join('rect').attr('class', className) | |
| } | |
| const path = (g: Selection<SVGGElement, any, any, any>, name: string) => { | |
| const className = ns(name) | |
| return g.selectAll(`path.${className}`).data([0]).join('path').attr('class', className) | |
| } | |
| const handle = (g: Selection<SVGGElement, any, any, any>, name: string) => { | |
| return path(g, name) | |
| .attr( | |
| 'd', | |
| 'M 0.5 0.5 L 0.5 15.5 L 8.5 15.5 L 8.5 0.5 Z M 3.5 4.5 L 3.5 11.5 M 5.5 4.5 L 5.5 11.5' | |
| ) | |
| .attr('fill', '#eee') | |
| .style('cursor', 'ew-resize') | |
| } | |
| const render = function render(g: Selection<SVGGElement, any, any, any>) { | |
| const BORDER_COLOR = '#ccc' | |
| const { W, H, x, w } = state | |
| const AREA_HEIGHT = H - 14 | |
| rect(g, 'left-area') | |
| .attr('width', x) | |
| .attr('height', AREA_HEIGHT) | |
| .attr('fill', '#000') | |
| .attr('fill-opacity', '0') | |
| rect(g, 'right-area') | |
| .attr('x', x + w) | |
| .attr('width', W - x - w) | |
| .attr('height', AREA_HEIGHT) | |
| .attr('fill', '#000') | |
| .attr('fill-opacity', '0') | |
| rect(g, 'selected-area') | |
| .attr('x', x) | |
| .attr('width', w) | |
| .attr('height', AREA_HEIGHT) | |
| .attr('fill', 'rgb(102,122,255)') | |
| .attr('opacity', '0.3') | |
| .attr('stroke-width', '0') | |
| .style('cursor', 'ew-resize') | |
| .on('mousedown', onBarMousedown) | |
| path(g, 'border-top') | |
| .attr('d', `M 0 0.5 L ${W} 0.5`) | |
| .attr('stroke', BORDER_COLOR) | |
| .attr('fill', 'none') | |
| path(g, 'border-bottom') | |
| .attr('d', `M 0 ${AREA_HEIGHT - 0.5} L ${W} ${AREA_HEIGHT - 0.5}`) | |
| .attr('stroke', BORDER_COLOR) | |
| .attr('fill', 'none') | |
| path(g, 'outline') | |
| .attr( | |
| 'd', | |
| [ | |
| `M ${x + 0.5} 0 L ${x + 0.5} ${AREA_HEIGHT}`, | |
| `M ${x + w - 0.5} 0 L ${x + w - 0.5} ${AREA_HEIGHT}` | |
| ].join(' ') | |
| ) | |
| .attr('stroke', BORDER_COLOR) | |
| handle(g, 'left-handle') | |
| .attr('stroke', BORDER_COLOR) | |
| .attr('transform', `translate(${x - 4},${(AREA_HEIGHT - 16) / 2})`) | |
| .on('mousedown', onLeftHandleMousedown) | |
| handle(g, 'right-handle') | |
| .attr('stroke', BORDER_COLOR) | |
| .attr('transform', `translate(${x + w - 5},${(AREA_HEIGHT - 16) / 2})`) | |
| .on('mousedown', onRightHandleMousedown) | |
| rect(g, 'scrollbar-track') | |
| .attr('height', 10) | |
| .attr('width', W - 1) | |
| .attr('transform', `translate(0,${AREA_HEIGHT + 4})`) | |
| .attr('rx', 5) | |
| .attr('ry', 5) | |
| .attr('x', 0.5) | |
| .attr('y', -0.5) | |
| .attr('stroke', BORDER_COLOR) | |
| .attr('fill', 'none') | |
| rect(g, 'scrollbar-thumb') | |
| .attr('height', 10) | |
| .attr('width', Math.max(4, w)) | |
| .attr('x', w < 4 ? -1 : 0) | |
| .attr('rx', 5) | |
| .attr('ry', 5) | |
| .attr('fill', BORDER_COLOR) | |
| .attr('transform', `translate(${x},${AREA_HEIGHT + 3.5})`) | |
| .on('mousedown', onBarMousedown) | |
| } | |
| const navigator: NavigatorX = function navigatorX(g) { | |
| state.g = g | |
| rect(g, 'container') | |
| .attr('width', state.W + 8) | |
| .attr('height', state.H) | |
| .attr('fill', 'none') | |
| const className = ns('inner-group') | |
| let innerGroup = g.select<SVGGElement>(`g.${className}`) | |
| if (!innerGroup.nodes().length) { | |
| innerGroup = g.append('g').attr('class', className) | |
| } | |
| innerGroup.attr('transform', `translate(4,0)`) | |
| render(innerGroup) | |
| } | |
| navigator.size = function size(width, height) { | |
| state.W = width - 8 | |
| state.H = height | |
| state.x = 0 | |
| state.w = state.W | |
| return this | |
| } | |
| navigator.on = function on(event, listener) { | |
| events.on(event as any, listener) | |
| return this | |
| } | |
| navigator.width = function width() { | |
| return state.W | |
| } | |
| navigator.move = function (x: number, width: number) { | |
| const totalWidth = state.W | |
| const newX = Math.min(totalWidth - 1, Math.max(0, Math.floor(x))) | |
| let w = Math.max(1, Math.floor(width)) | |
| if (newX + w > totalWidth) { | |
| w = totalWidth - newX | |
| } | |
| state.w = w | |
| state.x = newX | |
| if (state.g) { | |
| emitChange(state.g) | |
| } | |
| return this | |
| } | |
| return navigator | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment