diff --git a/src/components/Viewport.tsx b/src/components/Viewport.tsx index 72973d6..8222998 100644 --- a/src/components/Viewport.tsx +++ b/src/components/Viewport.tsx @@ -4,9 +4,17 @@ export default function Viewport( props: { children: unknown, + clampTop?: number, + clampBottom?: number, } ) { - return
+ const viewportProps: any = {}; + if (props.clampTop !== undefined) viewportProps["data-clamp-top"] = String(props.clampTop); + if (props.clampBottom !== undefined) viewportProps["data-clamp-bottom"] = String(props.clampBottom); + return
{ props.children }
} diff --git a/web/viewport.js b/web/viewport.js index a61d374..20aa3c6 100644 --- a/web/viewport.js +++ b/web/viewport.js @@ -32,6 +32,23 @@ function getWindowHeight(event) { return window.innerHeight + heightOffset; } +/** + Returns x rescaled such that the values in the range a-b are mapped to the range c-d. +*/ +function rescaleRange(x, a, b, c, d){ + return c + (x - a) * (d - c) / (b - a); +} + +/** + If value is not undefined return it parsed as a float, otherwise returns undefined. +*/ +function maybeFloat(value) { + if (value === undefined) { + return undefined; + } + return Number.parseFloat(value); +} + function setViewportOffset(viewport, windowHeight) { /* @@ -57,13 +74,46 @@ function setViewportOffset(viewport, windowHeight) { */ const documentRect = document.documentElement.getBoundingClientRect(); const endOffset = viewportRect.height * 0.25; - const origin = Math.min( + let origin = Math.min( (viewportRect.bottom - documentRect.top) - endOffset, Math.max( windowHeight / 2, windowHeight - (documentRect.bottom - viewportRect.top) + endOffset, ) ); + + /* + In some cases it's undesirable to let the perspective origin move too far + away in one or both directions as it causes elements inside the 3d view to + become obscured by the top/bottom of the viewport. To aid with this + optionally clamp how far the perspective origin can move relative to the top + and bottom. + + This is done by calculating how far the origin can go un-clamped while the + viewport is visible on the screen and then rescaling the range of the + perspective origin from it's un-clamped range to its clamped range. + */ + const clampTop = maybeFloat(viewport.dataset.clampTop); + const clampBottom = maybeFloat(viewport.dataset.clampBottom); + if (clampBottom !== undefined || clampTop !== undefined) { + const rootFontSize = Number.parseFloat(getComputedStyle(document.documentElement).fontSize); + const topOrigin = Math.min( + viewportRect.bottom - endOffset, + Math.max(viewportRect.top, documentRect.top + windowHeight) - windowHeight / 2, + ) + const topClampPosition = rootFontSize * (clampTop ?? -Infinity) + viewportRect.top; + const topLimit = Math.max(topOrigin, topClampPosition); + + const bottomOrigin = Math.max( + viewportRect.top + endOffset, + Math.min(viewportRect.bottom, documentRect.bottom - windowHeight) + windowHeight / 2, + ) + const bottomClampPosition = rootFontSize * (clampBottom ?? +Infinity) + viewportRect.bottom; + const bottomLimit = Math.min(bottomOrigin, bottomClampPosition); + + origin = rescaleRange(origin, topOrigin, bottomOrigin, topLimit, bottomLimit); + } + const yOffset = origin - viewportRect.top; viewport.style.setProperty('perspective-origin', `50% ${yOffset}px`); }