import {Widget} from '@deck.gl/core'; import type {Position, Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core'; import {render} from 'preact'; import {RangeInput, type RangeInputDecoration} from './lib/components/range-input'; export type ScrollbarOrientation = 'vertical' | 'horizontal'; export type ContentBounds = [min: Position, max: Position]; export type ScrollbarDecoration = { contentBounds: ContentBounds; color: string; title?: string; /** Callback when the decoration is clicked. Return `true` to mark the event as handled, and prevent the default behavior. */ onClick?: (evt: MouseEvent) => boolean; }; const clamp = (value: number, min: number, max: number) => { if (value < min) { return min; } if (value > max) { return max; } return value; }; export type ScrollbarWidgetProps = WidgetProps & { /** The full extent of the scrollable content, in world coordinates. * The widget relies on this value to calculate the position and size of the slider button and track. * If not supplied, the scrollbar will always be hidden. */ contentBounds?: ContentBounds | null; placement?: WidgetPlacement; viewId?: string | null; /** Direction of the scrollbar. `'horizontal'` scrolls the camera along the X axis, and `'vertical'` scrolls the camera along the Y axis. * @default 'vertical' */ orientation?: ScrollbarOrientation; /* Pixels scrolled when clicked on the step buttons. * @default 1/10 of the viewport size */ stepSize?: number | null; /* Pixels scrolled when clicked on the track. * @default 100% of the viewport size */ pageSize?: number | null; /** Label of the step button at the start. * @default 'Scroll left' | 'Scroll up' */ startButtonAriaLabel?: string; /** Label of the end button at the start. * @default 'Scroll right' | 'Scroll down' */ endButtonAriaLabel?: string; /** If `true`, mouse wheel events over the canvas will be intercepted by this scrollbar. * Useful when simulating the native scrollbar's behavior. * @default false */ captureWheel?: boolean; /** Custom markers to overlay on the track. */ decorations?: ScrollbarDecoration[]; }; type ScrollbarWidgetRequiredProps = Required; /** A scrollbar widget to be used with OrthographicView */ export class ScrollbarWidget extends Widget { static override defaultProps: ScrollbarWidgetRequiredProps = { ...Widget.defaultProps, contentBounds: null, placement: 'top-right', viewId: null, orientation: 'vertical', stepSize: null, pageSize: null, startButtonAriaLabel: '', endButtonAriaLabel: '', captureWheel: false, decorations: [] }; override className = 'deck-widget-scrollbar'; placement: WidgetPlacement = 'fill'; private viewport?: Viewport; private contentSize = 0; private viewportSize = 0; private scrollOffset = 0; constructor(props: ScrollbarWidgetProps) { const resolved = { ...ScrollbarWidget.defaultProps, ...props } as ScrollbarWidgetRequiredProps; super(resolved); this.viewId = resolved.viewId ?? null; } override onViewportChange(viewport: Viewport) { this.viewport = viewport; this.onRenderHTML(); } override onRenderHTML(): void { const element = this.rootElement; if (!element) { return; } element.dataset.placement = this.props.placement; const viewport = this.viewport; this.updateViewport(viewport); const clampedOffset = this.getClampedOffset(); const wheelTarget = this.getWheelEventTarget(this.props.captureWheel ? 'global' : 'local'); const decorations = this.getDecorations(viewport); const isVertical = this.isVertical(); const startLabel = this.props.startButtonAriaLabel ?? (isVertical ? 'Scroll up' : 'Scroll left'); const endLabel = this.props.endButtonAriaLabel ?? (isVertical ? 'Scroll down' : 'Scroll right'); const ui = ( ); render(ui, element); } override onRemove(): void { if (this.rootElement) { render(null, this.rootElement); } super.onRemove(); } private getContentBounds(viewId: string): ContentBounds | null { return this.props.contentBounds ?? this.deck?.getView(viewId)?.controller?.maxBounds ?? null; } private updateViewport(viewport?: Viewport): void { if (!viewport) { this.contentSize = 0; this.scrollOffset = 0; this.viewportSize = 0; return; } const contentBounds = this.getContentBounds(viewport.id); const isVertical = this.isVertical(); const projectedBounds = contentBounds ? projectBounds(contentBounds, viewport, isVertical) : ([0, 0] as [number, number]); this.contentSize = projectedBounds[1] - projectedBounds[0]; this.scrollOffset = -projectedBounds[0]; this.viewportSize = isVertical ? viewport.height : viewport.width; } private getDecorations(viewport?: Viewport): RangeInputDecoration[] { const {decorations = []} = this.props; if (!viewport || decorations.length === 0) { return []; } const contentBounds = this.getContentBounds(viewport.id); if (!contentBounds) { return []; } const isVertical = this.isVertical(); const [contentStart] = projectBounds(contentBounds, viewport, isVertical); return decorations.map(decoration => { const [start, end] = projectBounds(decoration.contentBounds, viewport, isVertical); const onClick = decoration.onClick ? (e: MouseEvent) => { const handled = decoration.onClick?.(e); if (handled) { e.stopPropagation(); e.preventDefault(); } } : undefined; return { position: [start - contentStart, end - contentStart], element: (
) }; }); } private getWheelEventTarget(mode: 'global' | 'local' | null) { if (mode === null) return null; if (mode === 'local') return this.rootElement; return this.deck?.props.parent || this.deck?.getCanvas()?.parentElement || this.rootElement; } private getMaxScroll(): number { return Math.max(0, this.contentSize - this.viewportSize); } private getClampedOffset(): number { const maxScroll = this.getMaxScroll(); return clamp(this.scrollOffset, 0, maxScroll); } private isVertical(): boolean { return this.props.orientation !== 'horizontal'; } private getEffectiveStep(): number { if (typeof this.props.stepSize === 'number' && !Number.isNaN(this.props.stepSize)) { return this.props.stepSize; } return Math.max(1, this.viewportSize / 10 || 1); } private getEffectivePage(): number { if (typeof this.props.pageSize === 'number' && !Number.isNaN(this.props.pageSize)) { return this.props.pageSize; } return this.viewportSize; } private emitScroll(next: number): void { const maxScroll = this.getMaxScroll(); const target = clamp(Math.round(next), 0, maxScroll); const viewport = this.viewport; if (viewport && target !== this.getClampedOffset()) { const pixel = viewport.project(viewport.position) as [number, number]; if (this.isVertical()) { pixel[1] -= target - this.scrollOffset; } else { pixel[0] -= target - this.scrollOffset; } const {target: newTarget} = viewport.panByPosition(viewport.position, pixel); // @ts-expect-error Using private method temporary until there's a public one this.deck._onViewStateChange({ viewId: viewport.id, viewState: { ...this.getViewState(viewport.id), target: newTarget }, interactionState: {} }); } } private handleRangeChange = (nextValue: [number, number]) => { this.emitScroll(nextValue[0]); }; } function projectBounds( bounds: ContentBounds, viewport: Viewport, isVertical: boolean ): [min: number, max: number] { return bounds .map(([x, y]) => viewport.project([x, y, 0])) .reduce( (range: [number, number], [x, y]) => { const value = isVertical ? y : x; range[0] = Math.min(range[0], value); range[1] = Math.max(range[1], value); return range; }, [Infinity, -Infinity] ); }