Skip to content

Instantly share code, notes, and snippets.

@john-yuan
Last active April 30, 2026 08:58
Show Gist options
  • Select an option

  • Save john-yuan/a80e774402b22fc2e86bef3828cf3ded to your computer and use it in GitHub Desktop.

Select an option

Save john-yuan/a80e774402b22fc2e86bef3828cf3ded to your computer and use it in GitHub Desktop.
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)
})
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