Skip to content

Commit

Permalink
feat(zoom): add logic to zoom towards the cursor on scroll
Browse files Browse the repository at this point in the history
  • Loading branch information
MrYuion committed Sep 29, 2020
1 parent dce05fa commit 2d9948f
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 113 deletions.
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export async function updateViewer(viewer: string | Viewer, options: HashMap, re
if (_update_timers[viewer.id]) {
clearTimeout(_update_timers[viewer.id]);
}
_update_timers[viewer.id] = <any>setTimeout(() => updateViewer(updated_viewer, {}), 50);
_update_timers[viewer.id] = <any>setTimeout(() => updateViewer(updated_viewer, {}), 16);
}
if (render) {
renderView(updated_viewer);
Expand Down
29 changes: 29 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ export function coordinatesForElement(viewer: Viewer, id: string, svg_box?: Clie
return { x: -9, y: -9 };
}

export function coordinatesForPoint(viewer: Viewer, point: Point, svg_box?: ClientRect) {
const svg_el = viewer.element?.querySelector(`svg`);
if (svg_el) {
const box = svg_box || svg_el.getBoundingClientRect();
const coords = {
x: (point.x - box.left) / box.width,
y: (point.y - box.top) / box.height,
};
return coords;
} else {
log('DOM', `Unable to find SVG element`, undefined, 'warn');
}
return { x: 0, y: 0 };
}

export function relativeSizeOfElement(viewer: Viewer, id: string, svg_box?: ClientRect) {
const svg_el = viewer.element?.querySelector(`svg`);
const element = svg_el?.querySelector(`#${cleanCssSelector(id)}`);
Expand All @@ -99,3 +114,17 @@ export function relativeSizeOfElement(viewer: Viewer, id: string, svg_box?: Clie
}
return { w: 0, h: 0 };
}

export function calculateCenterFromZoomOffset(zoom_change: number, point: Point, center: Point) {
// const dir = zoom_change >= 1 ? 1 : -1;
// if (dir > 0) {
// return {
// x: point.x - (point.x - center.x) / zoom_change,
// y: point.y - (point.y - center.y) / zoom_change,
// }
// }
return {
x: Math.round((point.x + (center.x - point.x) / zoom_change) * 10000) / 10000,
y: Math.round((point.y + (center.y - point.y) / zoom_change) * 10000) / 10000,
}
}
262 changes: 152 additions & 110 deletions src/input.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { getViewer, updateViewer } from './api';
import { coordinatesForElement, eventToPoint, log } from './helpers';
import { subscription, unsubscribe, unsubscribeWith } from './async';
import {
calculateCenterFromZoomOffset,
coordinatesForElement,
coordinatesForPoint,
eventToPoint,
log,
} from './helpers';

import { HashMap, Point } from './types';
import { Viewer } from './viewer.class';
Expand All @@ -25,8 +32,6 @@ export interface ViewerAction {
const _view_actions = new BehaviorSubject<HashMap<ViewerAction[]>>({});
/** Emitter for view events */
const _action_emitter = new Subject<ViewerEvent>();
/** Mapping of viewers to the event subscriptions */
const _subscriptions: HashMap<Subscription> = {};
/** Mapping of custom action hash to viewer */
const _custom_action_map: HashMap<string> = {};
/** Mapping of custom action hash to viewer */
Expand All @@ -50,7 +55,6 @@ let _ignore_actions: string[] = [];
export function focusOnFeature(viewer: Viewer) {
const _focus_string = JSON.stringify(viewer.focus);
if (viewer.focus && _focus_string !== _focus_feature_map[viewer.id]) {

let coordinates = { x: 0, y: 0 };
const zoom = Math.max(1, Math.min(10, viewer.focus.zoom_level || 1));
if (typeof viewer.focus.location === 'string') {
Expand All @@ -59,7 +63,10 @@ export function focusOnFeature(viewer: Viewer) {
coordinates = viewer.focus.location;
}
_focus_feature_map[viewer.id] = _focus_string;
updateViewer(viewer, { desired_center: { x: 1 - coordinates.x, y: 1 - coordinates.y }, desired_zoom: zoom });
updateViewer(viewer, {
desired_center: { x: 1 - coordinates.x, y: 1 - coordinates.y },
desired_zoom: zoom,
});
}
}

Expand All @@ -76,135 +83,172 @@ export function listenForViewActions(viewer: Viewer, actions: string[] = DEFAULT
for (const type of actions) {
const action: ViewerAction = {
type,
fn: (e: Event) => _ignore_actions.includes(type) ? '' : emitter.next({ id: viewer.id, type, event: e }),
fn: (e: Event) =>
_ignore_actions.includes(type)
? ''
: emitter.next({ id: viewer.id, type, event: e }),
};
element.addEventListener(type, action.fn);
action_list.push(action);
}
action_map[viewer.id] = action_list;
_view_actions.next(action_map);
const view_emitter = emitter.pipe(filter((_) => !!_ && _.id === viewer.id));
listenForViewClick(viewer, view_emitter as any);
listenForViewDoubleClick(viewer, view_emitter as any);
listenForViewPanStart(viewer, view_emitter as any);
listenForViewScrolling(viewer, view_emitter as any);
return;
}

export function listenForViewClick(viewer: Viewer, emitter: Observable<ViewerEvent>) {
subscription(
`${viewer.id}-click`,
emitter
.pipe(
filter((_) => _.type === 'click'),
map((e) => e.event)
)
.subscribe((e: any) => {
log('INPUT', 'Resetting zoom level and center position...');
const view = getViewer(viewer.id);
if (view) {
log('INPUT', `Clicked:`, coordinatesForPoint(viewer, eventToPoint(e)));
}
})
);
}

export function listenForViewDoubleClick(viewer: Viewer, emitter: Observable<ViewerEvent>) {
if (_subscriptions[`${viewer.id}-dblclick`]) {
_subscriptions[`${viewer.id}-dblclick`].unsubscribe();
}
_subscriptions[`${viewer.id}-dblclick`] = emitter
.pipe(
filter((_) => _.type === 'dblclick'),
map((e) => e.event)
)
.subscribe(_ => {
log('INPUT', 'Resetting zoom level and center position...');
const view = getViewer(viewer.id);
if (view) {
updateViewer(view, { desired_zoom: 1, desired_center: { x: .5, y: .5 } });
}
});
subscription(
`${viewer.id}-dblclick`,
emitter
.pipe(
filter((_) => _.type === 'dblclick'),
map((e) => e.event)
)
.subscribe((_) => {
log('INPUT', 'Resetting zoom level and center position...');
const view = getViewer(viewer.id);
if (view) {
updateViewer(view, { desired_zoom: 1, desired_center: { x: 0.5, y: 0.5 } });
}
})
);
}

export function listenForViewPanStart(viewer: Viewer, emitter: Observable<ViewerEvent>) {
if (_subscriptions[`${viewer.id}-pan`]) {
_subscriptions[`${viewer.id}-pan`].unsubscribe();
}
_subscriptions[`${viewer.id}-pan`] = emitter
.pipe(
filter((_) => _.type === 'touchstart' || _.type === 'mousedown'),
map((e) => e.event)
)
.subscribe((e: any) => {
log('INPUT', 'Starting panning...');
const view = getViewer(viewer.id);
if (view) {
const start_point = eventToPoint(e);
listenForViewPanning(view, emitter, start_point);
listenForViewPanEnd(view, emitter);
}
});
subscription(
`${viewer.id}-pan`,
emitter
.pipe(
filter((_) => _.type === 'touchstart' || _.type === 'mousedown'),
map((e) => e.event)
)
.subscribe((e: any) => {
log('INPUT', 'Starting panning...');
const view = getViewer(viewer.id);
if (view) {
const start_point = eventToPoint(e);
listenForViewPanning(view, emitter, start_point);
listenForViewPanEnd(view, emitter);
}
})
);
}

export function listenForViewPanning(
viewer: Viewer,
emitter: Observable<ViewerEvent>,
start: Point
) {
if (_subscriptions[`${viewer.id}-panning`]) {
_subscriptions[`${viewer.id}-panning`].unsubscribe();
}
_subscriptions[`${viewer.id}-panning`] = emitter
.pipe(
filter((_) => _.type === 'touchmove' || _.type === 'mousemove'),
map((e) => e.event)
)
.subscribe((e: any) => {
const view = getViewer(viewer.id);
if (view) {
const point = eventToPoint(e);
const diff = Math.abs((point.x - start.x) + (point.y - start.y));
if (!_ignore_actions.includes('click') && diff > 1) { _ignore_actions.push('click'); }
const center = {
x: Math.max(
0,
Math.min(
1,
(point.x - start.x) / view.box.width / view.desired_zoom + view.center.x
)
),
y: Math.max(
0,
Math.min(
1,
(point.y - start.y) / view.box.height / view.desired_zoom + view.center.y
)
),
};
start = point;
updateViewer(view, { center, desired_center: center });
}
});
subscription(
`${viewer.id}-panning`,
emitter
.pipe(
filter((_) => _.type === 'touchmove' || _.type === 'mousemove'),
map((e) => e.event)
)
.subscribe((e: any) => {
const view = getViewer(viewer.id);
if (view) {
const point = eventToPoint(e);
const diff = Math.abs(point.x - start.x + (point.y - start.y));
if (!_ignore_actions.includes('click') && diff > 1) {
_ignore_actions.push('click');
}
const center = {
x: Math.max(
0,
Math.min(
1,
(point.x - start.x) / view.box.width / view.desired_zoom +
view.center.x
)
),
y: Math.max(
0,
Math.min(
1,
(point.y - start.y) / view.box.height / view.desired_zoom +
view.center.y
)
),
};
start = point;
updateViewer(view, { center, desired_center: center });
}
})
);
}

export function listenForViewPanEnd(viewer: Viewer, emitter: Observable<ViewerEvent>) {
_subscriptions[`${viewer.id}-pan_end`] = emitter
.pipe(filter((_) => _.type === 'touchend' || _.type === 'mouseup'))
.subscribe(() => {
subscription(
`${viewer.id}-pan_end`,
emitter.pipe(filter((_) => _.type === 'touchend' || _.type === 'mouseup')).subscribe(() => {
log('INPUT', 'Ending panning...');
if (_subscriptions[`${viewer.id}-pan_end`]) {
_subscriptions[`${viewer.id}-pan_end`].unsubscribe();
}
if (_subscriptions[`${viewer.id}-panning`]) {
_subscriptions[`${viewer.id}-panning`].unsubscribe();
}
unsubscribe(`${viewer.id}-pan_end`);
unsubscribe(`${viewer.id}-panning`);
setTimeout(() => {
_ignore_actions = _ignore_actions.filter(i => i !== 'click');
_ignore_actions = _ignore_actions.filter((i) => i !== 'click');
}, 100);
});
})
);
}

export function listenForViewScrolling(viewer: Viewer, emitter: Observable<ViewerEvent>) {
if (_subscriptions[`${viewer.id}-scrolling`]) {
_subscriptions[`${viewer.id}-scrolling`].unsubscribe();
}
_subscriptions[`${viewer.id}-scrolling`] = emitter
.pipe(
filter((_) => _.type === 'mousewheel' || _.type === 'wheel'),
map((e) => e.event)
)
.subscribe((e: any) => {
const event = e as WheelEvent;
event.preventDefault(); // Prevent viewport scrolling
const view = getViewer(viewer.id);
if (view) {
const delta = -event.deltaY / 100;
const desired_zoom = Math.min(10, Math.max(1, view.desired_zoom + delta / 5));
updateViewer(view, { desired_zoom });
}
});
subscription(
`${viewer.id}-scrolling`,
emitter
.pipe(
filter((_) => _.type === 'mousewheel' || _.type === 'wheel'),
map((e) => e.event)
)
.subscribe((e: any) => {
const event = e as WheelEvent;
event.preventDefault(); // Prevent viewport scrolling
const view = getViewer(viewer.id);
if (view) {
const delta = event.deltaY >= 0 ? -0.02 : 0.02;
const zoom = Math.min(10, Math.max(1, view.zoom * (1 + delta)));
const box = view.element
?.querySelector('.render-container')
?.getBoundingClientRect();
const cursor_point = coordinatesForPoint(view, eventToPoint(e), box);
const point = { x: 1 - cursor_point.x, y: 1 - cursor_point.y };
const center =
zoom === 1 || zoom === 10 || zoom === view.zoom
? view.center
: calculateCenterFromZoomOffset(1 + delta, point, view.center);
updateViewer(view, {
zoom,
center,
desired_zoom: zoom,
desired_center: center,
});
}
})
);
}

export function listenForCustomViewActions(
Expand All @@ -213,19 +257,17 @@ export function listenForCustomViewActions(
) {
const actions_string = JSON.stringify(viewer.actions);
if (_custom_action_map[viewer.id] !== actions_string) {
const keys = Object.keys(_subscriptions).filter((key) => key.includes(`${viewer.id}_`));
for (const key of keys) {
_subscriptions[key].unsubscribe();
}
unsubscribeWith(`${viewer.id}_`);
for (const action of viewer.actions) {
_subscriptions[`${viewer.id}_${action.id}-${action.action}`] = emitter
.pipe(filter((e) => e && e.type === action.action))
.subscribe((e) => {
subscription(
`${viewer.id}_${action.id}-${action.action}`,
emitter.pipe(filter((e) => e && e.type === action.action)).subscribe((e) => {
const el: HTMLElement = e.event.target as any;
if (el.id === action.id) {
action.callback(e.event);
}
});
})
);
}
}
}
Loading

0 comments on commit 2d9948f

Please sign in to comment.