Skip to content

Commit

Permalink
Add zoom and translations limits (options.fit)
Browse files Browse the repository at this point in the history
  • Loading branch information
charlespwd committed Aug 9, 2016
1 parent c940da3 commit 6bc1566
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 27 deletions.
13 changes: 8 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
}

#world {
width: 500px;
width: 1000px;
height: 500px;
}

Expand Down Expand Up @@ -51,7 +51,7 @@
<body>

<div id="container" class="container">
<div style="border: 1px solid black; width: 500px; height: 500px">
<div style="border: 1px solid black; width: 1000px; height: 500px">
<svg id="world">
<rect width="100" height="100"></rect>
<rect width="15" height="15" fill="yellow"></rect>
Expand All @@ -72,9 +72,12 @@
node.style.transform = transform
}

var view = new worldviewjs(render);
view.debug.setContainerSize(1000, 1000)
view.debug.setWorldSize(500, 500)
var view = new worldviewjs(render, {
containerSize: [1000, 1000],
worldSize: [1000, 500],
}, {
fit: true,
});
view.debug.setContainerOrigin(50, 50)
global.view = view;

Expand Down
7 changes: 5 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ const validateEventPosition = (method, e) => {
}
}

export default function PublicWorldView(render, opts) {
const view = worldView(opts);
export default function PublicWorldView(render, initialState, opts) {
const view = worldView(initialState, opts);
const state = {
isPanning: false,
panStart: null,
Expand All @@ -30,6 +30,9 @@ export default function PublicWorldView(render, opts) {
...view,
}

// initial render (after fitting, etc.)
publish()

function zoomAtMouse(wheelDelta, e = { pageX: undefined, pageY: undefined }) {
const change = wheelDelta > 0 ? 0.03 : -0.03; // %
const pointer_document = typeof e.pageX === 'number' ? fromEventToVector(e) : undefined
Expand Down
89 changes: 87 additions & 2 deletions src/transform-world.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { setState } from './utils/functional'
// Transformations are simple WorldState -> WorldState mappings.
// The simplest world transformation: change a value.
export const set = (key, value) => (state) => setState(state, key, value)
export const identity = (state) => state

// Give me a set of transformations and a state and I'll give you a
// new state. Transformations are composable.
export const reduce = (transforms = [], initialState) => (
transforms.reduce((state, transform) => transform(state), initialState)
);

/// Fixed point zoom transformation
// # Fixed point zoom transformation
// For {}_i and {}_f denotes initial and final transformation, we have
//
// 1. A constant rotation,
Expand Down Expand Up @@ -59,7 +60,7 @@ export const statelessZoom = (zoom_f, pointer_container) => (state) => {
}
}

/// Fixed point pan transformation
// # Fixed point pan transformation
// For {}_i and {}_f denotes initial and final transformation, we have
//
// 1. A constant rotation,
Expand Down Expand Up @@ -138,3 +139,87 @@ export const statelessRotateBy = (degrees, pivot_container = [0, 0]) => (state)
world_container: world_container_f,
}
}

// # Fitting to the container
// We have two limits, the first one is on the zoom, the second one is on the
// translation vector.
//
// The Goal: Any points visible within the container are points within the
// world.
//
// ## Part 1, the zoom limit
//
// We have
// 1. The size of the world
// W_w = wlimit_w - 0_w .. (1)
// 2. The size of the container
// C_c = climit_c - 0_c .. (2)
// 3. The coordinate transformation
// x_c = z*x_w + t_c .. (3)
// 4. From (3) and (1)
// W_c = z*W_w
//
// for z is the zoom, t_c the translation vector, wlimit the vector pointing to
// the limiting corner, climit the vector pointing to the corner limit of the
// container, and 0_x the zero vector.
//
// When fitting at the limit, one of the world size component is equal to the
// container size.
//
// Thus we have
// C_c_x = W_c_x = k_limit*W_w_X ... or ... C_c_y = W_c_y = k*W_w_y
//
// Therefore
// k_limit <= k
// k_limit = max(C_c_x / W_w_x, C_c_y / W_w_y) <= k
//
// ## Part 2, the translation limit
//
// We have
// 1. A container domain `C` defined as
// C = { x_c : 0_c <= x_c <= C_c } .. (1)
// 2. The world domain `W` defined as
// W = { x_w : 0_w <= x_w <= W_w } .. (2)
// 3. The coordinate transformations
// x_c = z*x_w + t_c .. (3)
// x_w = 1/z*(x_c - t_c) .. (4)
//
// Since all points in the container must be contained by the world, we have
// C ⊆ W ... (5)
//
// In an effort to obtain limits on t_c, we transform W into the container's
// coordinate system.
// 0_w = 1/z * (x_c_l - t_c) which implies x_c_l = t_c
// W_w = 1/z * (x_c_r - t_c) which implies x_c_r = z*W_w + t_c
// W = { x_c: x_c_l <= x_c <= x_c_r }
// W = { x_c: t_c <= x_c <= z*W_w + t_c }
//
// Since C ⊆ W, we get
// t_c <= 0 <= x_c <= C_c <= z*W_w + t_c
//
// Limit #1
// t_c <= 0
//
// Limit #2
// C_c - z*W_w <= t_c
//
// Therefore,
// C_c - z*W_w <= t_c <= 0
export const fit = (state) => {
const { worldSize, containerSize } = state
const zoomlimit_x = containerSize[0] / worldSize[0]
const zoomlimit_y = containerSize[1] / worldSize[1]
const zoom = Math.max(zoomlimit_x, zoomlimit_y, state.zoom)
return ({
...state,
zoom,
world_container: vector.bounded(
vector.sub(
containerSize,
vector.scale(zoom, worldSize)
),
state.world_container,
vector.zero
),
})
}
56 changes: 42 additions & 14 deletions src/worldview.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ import { center_container } from './centers'
import { setState } from './utils/functional'
import { fromDocumentToContainer } from './transform-vector'
import {
statelessZoom,
fit,
identity,
reduce,
set,
statelessPanBy,
statelessRotateBy,
statelessZoom,
} from './transform-world'

export default function WorldView(/* opts */) {
export default function WorldView(initialState, opts) {
/// State
let state = {
// scale level
Expand All @@ -23,10 +27,23 @@ export default function WorldView(/* opts */) {
world_container: [0, 0],

// The unscaled size of the world
worldSize: [0, 0],
worldSize: [1, 1],

// The size of the container (viewport)
containerSize: [0, 0],
containerSize: [1, 1],

...initialState,
}

const options = {
// Fit the world to the container when zooming and panning
fit: false,

...opts,
}

if (options.fit) {
state = fit(state)
}

/// Public API
Expand All @@ -45,46 +62,57 @@ export default function WorldView(/* opts */) {
zoomBy,
}

function withFit(transformation) {
return [
transformation,
options.fit ? fit : identity,
];
}

function setWorldSize(width, height) {
state = setState(state, 'worldSize', [width, height])
const transformations = withFit(set('worldSize', [width, height]))
state = reduce(transformations, state);
}

function setContainerSize(width, height) {
state = setState(state, 'containerSize', [width, height])
const transformations = withFit(set('containerSize', [width, height]))
state = reduce(transformations, state)
}

function setContainerOrigin(x_document, y_document) {
state = setState(state, 'container_document', [x_document, y_document])
}

function setWorldOrigin(x_container, y_container) {
state = setState(state, 'world_container', [x_container, y_container])
const transformations = withFit(set('world_container', [x_container, y_container]))
state = reduce(transformations, state)
}

function setTheta(degrees) {
state = setState(state, 'theta', degrees)
}

function setZoom(scale) {
state = setState(state, 'zoom', scale)
const transformations = withFit(set('zoom', scale))
state = reduce(transformations, state)
}

function zoomBy(change = 0, pointer_document) {
const newZoom = state.zoom * (1 + change);
zoomTo(newZoom, pointer_document);
const newZoom = state.zoom * (1 + change)
zoomTo(newZoom, pointer_document)
}

function zoomTo(newZoom, pointer_document) {
const pointer_container = pointer_document instanceof Array
? fromDocumentToContainer(state, pointer_document)
: center_container(state)
const transformation = statelessZoom(newZoom, pointer_container)
state = transformation(state)
const transformations = withFit(statelessZoom(newZoom, pointer_container))
state = reduce(transformations, state)
}

function panBy(translation_container) {
const transformation = statelessPanBy(translation_container)
state = transformation(state)
const transformations = withFit(statelessPanBy(translation_container))
state = reduce(transformations, state)
}

function rotateBy(degrees, pivot_container) {
Expand Down
2 changes: 1 addition & 1 deletion test/utils/vector.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('Module: vector', () => {
expect(vector.norm(vector.normalize([0, 5]))).to.eql(1)
});

it('should be possible to recover the initial vector after scaling a normalized vector by the norm', () => {
it('should recover the initial vector after scaling a normalized vector by the norm', () => {
const initialVector = [3, 4];
const norm = 5
expect(vector.scale(norm, vector.normalize(initialVector))).to.almost.eql(initialVector)
Expand Down
64 changes: 61 additions & 3 deletions test/worldview.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ describe('Module: WorldView', () => {
let transform

beforeEach(() => {
view = new WorldView()
view.setWorldSize(100, 100)
view.setContainerSize(100, 100)
view = new WorldView({
worldSize: [100, 100],
containerSize: [100, 100],
})
transform = undefined
})

Expand Down Expand Up @@ -127,4 +128,61 @@ describe('Module: WorldView', () => {
expect(view.transform.translate).to.eql([0, 0]);
});
});

describe('With options.fit', () => {
beforeEach(() => {
view = new WorldView({
containerSize: [100, 100],
worldSize: [50, 50],
}, {
fit: true,
})
transform = undefined
})

it('should fit', () => {
expect(view.state.zoom).to.be.eql(2);
});

it('should not let you unzoom past the limit', () => {
view.zoomTo(1)
expect(view.state.zoom).to.be.eql(2);
view.zoomTo(3)
expect(view.state.zoom).to.be.eql(3);
});

it('should not allow to pan at zoom = zoomlimit', () => {
const initialPan = view.state.world_container
view.panBy([1, 1])
expect(view.state.world_container).to.be.eql(initialPan);
view.panBy([-1, -1])
expect(view.state.world_container).to.be.eql(initialPan);
});

it('should allow you to pan within limits after zooming in', () => {
view.zoomBy(1) // now world is twice as big as container
view.setWorldOrigin(0, 0) // start at 0,0
expect(view.state.world_container).to.be.eql([0, 0]);

// Testing right limit
view.panBy([1, 1]) // shouldn't be able to do this
expect(view.state.world_container).to.be.eql([0, 0]);

// Testing you can pan within the domain
view.panBy([-1, -1]) // should be able to do this
expect(view.state.world_container).to.be.eql([-1, -1]);
view.setWorldOrigin(0, 0) // reset at 0,0

// Testing you'll reach the limit at some point and max out there.
view.panBy([-10000, -10000]) // should limit you to the max pan
expect(view.state.world_container).to.be.eql([-100, -100]);
view.setWorldOrigin(0, 0) // reset at 0,0
});

it('should not allow you to set world_document outside of domain', () => {
view.zoomBy(1) // now world is twice as big as container
view.setWorldOrigin(-10000, -10000) // should limit you to the max pan
expect(view.state.world_container).to.be.eql([-100, -100]);
});
});
})

0 comments on commit 6bc1566

Please sign in to comment.