diff --git a/README.md b/README.md index c14e3ec..4cbaf48 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -## Getting Started - -First, run the development server: +## Running locally ```bash nvm use @@ -12,7 +10,7 @@ This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next ## Deploy -Deploying a static site (no server) using github actions. +Deployed via github actions. Sources: diff --git a/src/app/page.tsx b/src/app/page.tsx index 27688be..35c5ae4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -18,6 +18,8 @@ import { GlobalCss } from '../components/globalCss'; import { useTelemetryDeck } from '../hooks/useTelemetryDeck'; import { useIsFirefox } from '../hooks/useIsFirefox'; import { DarkModeProvider } from '../hooks/useDarkMode'; +import { Pagination } from '../components/pagination'; +import { startCameraPosArray } from '../components/verticalCenterWithMargin'; const DesktopFeaturesDiv = styled.div` text-align: left; @@ -81,33 +83,33 @@ const DesktopCanvasDiv = styled.div` top: 0; z-index: 0; touch-action: none; + * { + touch-action: none; + } `; const MobileCanvasDiv = styled.div` position: absolute; left: 0; - top: -50px; /// making room for features div - height: 100dvh; width: 100vw; z-index: 0; touch-action: none; + * { + touch-action: none; + } `; -const cameraDesktop = { - // position: [-77, -40.2, 242], - position: [-77, -30, 242], +const camera = { + position: startCameraPosArray, fov: 56, - near: 0.1, - far: 10000, // seems a bit much... TODO: double check + near: 120, + far: 450, } as const; const cameraMobile = { - // position: [-77, -40.2, 242], - position: [-77, -30, 242], + ...camera, fov: 60, - near: 0.1, - far: 10000, // seems a bit much... TODO: double check -} as const; +}; export default function Home() { const { width, height } = useWindowSize(); @@ -139,10 +141,16 @@ export default function Home() { - - + + + @@ -194,15 +202,10 @@ export default function Home() { height: '100dvh', }} > - + + diff --git a/src/components/about.tsx b/src/components/about.tsx index b6b1a43..213dd92 100644 --- a/src/components/about.tsx +++ b/src/components/about.tsx @@ -37,17 +37,30 @@ const FAQ = [ 'What data source are you using?', 'iOS404 uses [caniuse.com](https://caniuse.com) ([caniuse github](https://github.com/Fyrd/caniuse/tree/main)) data. In the future, hoping to expand to MDN data as well --- there are even more missing iOS features in MDN data.', ], + [ + 'What browsers can I compare to iOS WebKit?', + 'You can select from Chrome for Android, Firefox for Android, and/or Safari Desktop. Click on the browser icon to select or deselect a browser. The default comparison browser is Chrome for Android.', + ], + [ + 'How do I filter out non-standard specifications?', + 'Click on the filter icon to remove less-standard or non-standard specifications from your search.', + ], [ 'How do you determine if something is not supported?', - 'If the selected comparison browser(s) (Chrome for Android is the default but others may be selected) supports a feature more than iOS WebKit, then that feature is consider missing with special note if support is "Partial". You can also use the filtering icon to remove less-standard or non-standard specifications from your search.', + 'If the selected comparison browser(s) supports a feature more than iOS WebKit, then that feature is consider missing with special note if support is "Partial".', ], + [ - 'Who are you?', - "Hi! 👋 I'm Shalanah Dawson a developer that believes the web should be powerful, approachable, and fun. Find me on [Twitter](https://twitter.com/shalanahfaith), [GitHub](https://github.com/shalanah), or [LinkedIn](https://linkedin.com/in/shalanah).", + 'How do I report a bug or ask a feature for iOS404?', + 'Thank you for your help in making iOS404 better! Open an issue on [GitHub](https://github.com/shalanah/iOS404/issues).', ], [ 'Do you track me?', - 'iOS404 uses TelemetryDeck. TelemetryDeck does not collect any personally identifiable information. iOS404 sends a best guess on timezone and device type (phone|tablet|desktop) and on load sends which site feature and filters are active. This helps to understand how our users are using the website and how we can improve it. You can read more about [TelemetryDeck’s privacy policy](https://telemetrydeck.com/privacy)', + 'iOS404 uses TelemetryDeck. TelemetryDeck does not collect any personally identifiable information. iOS404 sends a best guess on timezone and device type ("phone", "tablet" or "desktop") and on load sends which site feature and specification filters are active. This helps to understand how our users are using the website and how we can improve it. You can read more about [TelemetryDeck’s privacy policy](https://telemetrydeck.com/privacy)', + ], + [ + 'Who are you?', + "Hi! 👋 I'm Shalanah Dawson a developer that believes the web should be powerful, approachable, and fun. Find me on [Twitter](https://twitter.com/shalanahfaith), [GitHub](https://github.com/shalanah), or [LinkedIn](https://linkedin.com/in/shalanah).", ], ['', 'Copyright 2024. All Rights Reserved - Shalanah Dawson'], ]; diff --git a/src/components/arrow.tsx b/src/components/arrow.tsx index 813ecd7..cc06972 100644 --- a/src/components/arrow.tsx +++ b/src/components/arrow.tsx @@ -2,8 +2,6 @@ import React from 'react'; import styled from 'styled-components'; const Span = styled.span` - width: 15px; - height: 15px; opacity: 1; transform-origin: center; border: 2px solid var(--titleFg); @@ -12,9 +10,11 @@ const Span = styled.span` `; export const Arrow = ({ + size = 15, left = false, right = false, }: { + size?: number; left?: boolean; right?: boolean; }) => { @@ -22,6 +22,8 @@ export const Arrow = ({ return ( */ - parent: Object3D; - /** The outmost container group of the
component */ - container: Object3D; - width: number; - height: number; - depth: number; - boundingBox: Box3; - boundingSphere: Sphere; - center: Vector3; - verticalAlignment: number; - horizontalAlignment: number; - depthAlignment: number; -}; - -export type CenterProps = { - top?: boolean; - right?: boolean; - bottom?: boolean; - left?: boolean; - front?: boolean; - back?: boolean; - /** Disable all axes */ - disable?: boolean; - /** Disable x-axis centering */ - disableX?: boolean; - /** Disable y-axis centering */ - disableY?: boolean; - /** Disable z-axis centering */ - disableZ?: boolean; - /** See https://threejs.org/docs/index.html?q=box3#api/en/math/Box3.setFromObject */ - precise?: boolean; - /** Callback, fires in the useLayoutEffect phase, after measurement */ - onCentered?: (props: OnCenterCallbackProps) => void; - /** Optional cacheKey to keep the component from recalculating on every render */ - cacheKey?: any; -}; - -export const Center: ForwardRefComponent< - JSX.IntrinsicElements['group'] & CenterProps, - Group -> = /* @__PURE__ */ React.forwardRef< - Group, - JSX.IntrinsicElements['group'] & CenterProps ->(function Center( - { - children, - disable, - disableX, - disableY, - disableZ, - left, - right, - top, - bottom, - front, - back, - onCentered, - precise = true, - cacheKey = 0, - ...props - }, - fRef -) { - const ref = React.useRef(null!); - const outer = React.useRef(null!); - const inner = React.useRef(null!); - React.useLayoutEffect(() => { - outer.current.matrixWorld.identity(); - const box3 = new Box3().setFromObject(inner.current, precise); - const center = new Vector3(); - const sphere = new Sphere(); - const width = box3.max.x - box3.min.x; - const height = box3.max.y - box3.min.y; - const depth = box3.max.z - box3.min.z; - box3.getCenter(center); - box3.getBoundingSphere(sphere); - const vAlign = top ? height / 2 : bottom ? -height / 2 : 0; - const hAlign = left ? -width / 2 : right ? width / 2 : 0; - const dAlign = front ? depth / 2 : back ? -depth / 2 : 0; - - outer.current.position.set( - disable || disableX ? 0 : -center.x + hAlign, - disable || disableY ? 0 : -center.y + vAlign, - disable || disableZ ? 0 : -center.z + dAlign - ); - - // Only fire onCentered if the bounding box has changed - if (typeof onCentered !== 'undefined') { - onCentered({ - parent: ref.current.parent!, - container: ref.current, - width, - height, - depth, - boundingBox: box3, - boundingSphere: sphere, - center: center, - verticalAlignment: vAlign, - horizontalAlignment: hAlign, - depthAlignment: dAlign, - }); - } - }, [ - cacheKey, - onCentered, - top, - left, - front, - disable, - disableX, - disableY, - disableZ, - precise, - right, - bottom, - back, - ]); - - React.useImperativeHandle(fRef, () => ref.current, []); - - return ( - - - {children} - - - ); -}); diff --git a/src/components/experience.tsx b/src/components/experience.tsx index 448d1ea..d424bc5 100644 --- a/src/components/experience.tsx +++ b/src/components/experience.tsx @@ -1,25 +1,40 @@ import { Suspense, useEffect, useRef } from 'react'; -import { Center } from '@react-three/drei'; import { Model } from './milkcarton'; import { MilkCartonText } from './milkcartontext'; import useCanIUseContext from '../hooks/useCanIUseContext'; import usePrevious from '../hooks/usePrevious'; import { a, useSpring } from '@react-spring/three'; -import { useDrag } from '@use-gesture/react'; -import { Html } from '@react-three/drei'; -import styled from 'styled-components'; -import { Arrow } from './arrow'; +import { useGesture } from '@use-gesture/react'; +import { VerticalCenterWithMargin } from './verticalCenterWithMargin'; -const Button = styled.button` - width: 40px; - height: 40px; - border-radius: 8px; - color: var(--fg); - flex-shrink: 0; - &:focus { - outline: 2.5px dotted currentColor; +const getTextRotationAndPosition = (turns: number) => { + let mod = turns % 4; + if (mod < 0) mod += 4; + switch (mod) { + case 0: + return { + position: [0.1, 58, 51], + rotation: [0, 0, 0], + }; + case 1: + return { + position: [51, 58, 0.1], + rotation: [0, Math.PI / 2, 0], + }; + + case 2: + return { + position: [0.1, 58, -51], + rotation: [0, Math.PI, 0], + }; + default: + case 3: + return { + position: [-51, 58, 0.1], + rotation: [0, (Math.PI * 3) / 2, 0], + }; } -`; +}; const config = { mass: 0.05, tension: 600, friction: 40 }; export default function Experience() { @@ -29,17 +44,15 @@ export default function Experience() { setNextFeature, filteredData, doNotRotate, + position: pos, } = useCanIUseContext(); const len = iOSMissingFeatures.length; const filteredLen = filteredData.length; const turns = useRef(0); const prevActiveIndex = usePrevious(activeIndex); - // @ts-ignore - const pos = filteredData.findIndex((v) => v.index === activeIndex); // actual position in list --- active index + prev active is out of the WHOLE non-filtered list - // @ts-ignore - const prevPos = filteredData.findIndex((v) => v.index === prevActiveIndex); if (activeIndex !== -1 && prevActiveIndex !== -1 && !doNotRotate) { + const prevPos = filteredData.findIndex((v) => v.index === prevActiveIndex); if (prevPos < pos) { const looping = pos === filteredLen - 1 && prevPos === 0; turns.current += looping ? -1 : 1; @@ -49,32 +62,8 @@ export default function Experience() { } } - let mod = turns.current % 4; - if (mod < 0) mod += 4; - - let position = [0.1, 58, 51]; - let rotation = [0, 0, 0]; - switch (mod) { - case 0: - position = [0.1, 58, 51]; - rotation = [0, 0, 0]; - break; - case 1: - position = [51, 58, 0.1]; - rotation = [0, Math.PI / 2, 0]; - break; - case 2: - position = [0.1, 58, -51]; - rotation = [0, Math.PI, 0]; - break; - case 3: - position = [-51, 58, 0.1]; - rotation = [0, (Math.PI * 3) / 2, 0]; - break; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - const rot = [0, (turns.current * -Math.PI) / 2 - Math.PI / 10, 0]; + const rot = [0, (turns.current * -Math.PI) / 2, 0]; const [spring, api] = useSpring(() => ({ rotation: rot, config, @@ -87,119 +76,57 @@ export default function Experience() { }); }, [api, rot]); - const bind = useDrag( - ({ movement: [mx], last }) => { - const value = Math.min(Math.abs(mx), 44.5); // clamping... - const sign = Math.sign(mx); - if (last) { - if (value > 35 && filteredData.length > 1) { - setNextFeature({ forwards: sign === -1, action: 'swipe' }); - return; - } else { - api.start({ - rotation: [0, (turns.current * -Math.PI) / 2 - Math.PI / 10, 0], - config, - }); - return; + const bind = useGesture( + { + onDrag: ({ movement: [mx], last }) => { + const value = Math.min(Math.abs(mx), 44.5); // clamping... + const sign = Math.sign(mx); + if (last) { + if (value > 35 && filteredData.length > 1) { + setNextFeature({ forwards: sign === -1, action: 'swipe' }); + return; + } else { + api.start({ + rotation: [0, (turns.current * -Math.PI) / 2, 0], + config, + }); + return; + } } - } - api.start({ - rotation: [ - rot[0], - rot[1] + (Math.PI / (360 * 2)) * value * sign, - rot[2], - ], - config, - }); + api.start({ + rotation: [ + rot[0], + rot[1] + (Math.PI / (360 * 2)) * value * sign, + rot[2], + ], + config, + }); + }, }, { - axis: 'x', - filterTaps: true, + drag: { + axis: 'x', + filterTaps: true, + }, } ); if (len === 0 || activeIndex === -1) return null; - let countText = - filteredLen && pos !== -1 ? ( - <> - {pos + 1}  /   - {filteredData.length} - - ) : ( - '' // couldn't find the active on in the filtered list - ); - return ( <> {/* */} -
+ - -
- - - {countText} - - -
-
-
+
); diff --git a/src/components/links.tsx b/src/components/links.tsx index baba3fb..c05052c 100644 --- a/src/components/links.tsx +++ b/src/components/links.tsx @@ -1,10 +1,15 @@ import React from 'react'; -import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { + InfoCircledIcon, + ZoomInIcon, + ZoomOutIcon, +} from '@radix-ui/react-icons'; import { About } from './about'; import styled from 'styled-components'; import { verticalViewWidth } from '../utils/constants'; import { DarkModeSwitch, defaultProperties } from 'react-toggle-dark-mode'; import useDarkMode from '../hooks/useDarkMode'; +import useCanIUseContext from '@/hooks/useCanIUseContext'; const DMS = styled(DarkModeSwitch)` vector-effect: non-scaling-stroke; @@ -32,24 +37,58 @@ const Div = styled.div` } `; +const buttonSize = 40; +const Button = styled.button` + border-radius: 8px; + width: ${buttonSize}px; + height: ${buttonSize}px; + &:disabled { + opacity: 0.2; + cursor: not-allowed; + } +`; + +export const scaleOpts = { + min: 0.9, + default: 1, + max: 1.5, + step: 0.05, +}; + +const iconSize = 30; +const iconStyle = { width: iconSize, height: iconSize }; + export const Links = () => { const { isDarkMode, setColorScheme } = useDarkMode(); + const { scale, setScale } = useCanIUseContext(); + return (

No affiliation with Apple or iOS

-
- + + + - - + } />
diff --git a/src/components/milkcarton.tsx b/src/components/milkcarton.tsx index 72f180e..d965e29 100644 --- a/src/components/milkcarton.tsx +++ b/src/components/milkcarton.tsx @@ -15,6 +15,9 @@ import useDarkMode from '../hooks/useDarkMode'; const textureUrlLight = '/milkcarton-texture-bake-light5.jpg'; const textureUrlDark = '/milkcarton-texture-bake-dark8.jpg'; +export const cartonSide = 50; // distance to edge of carton - from Blender model +export const cartonHeight = 172; // height of carton - from Blender model + type MilkCartonNodes = { nodes: { carton: Object3D & { geometry: BufferGeometry }; diff --git a/src/components/milkcartontext.tsx b/src/components/milkcartontext.tsx index 30c1485..6721733 100644 --- a/src/components/milkcartontext.tsx +++ b/src/components/milkcartontext.tsx @@ -23,11 +23,10 @@ function addCanIUseUrlBack() { } const Div = styled.div` - /* touch-action: none !important; - pointer-events: none !important; + touch-action: none; * { - touch-action: none !important; - } */ + touch-action: none; + } .description { text-align: left; text-transform: none; diff --git a/src/components/pagination.tsx b/src/components/pagination.tsx new file mode 100644 index 0000000..6116584 --- /dev/null +++ b/src/components/pagination.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import useCanIUseContext from '../hooks/useCanIUseContext'; +import styled from 'styled-components'; +import { Arrow } from './arrow'; +import { ZoomInIcon, ZoomOutIcon } from '@radix-ui/react-icons'; + +const Div = styled.div` + position: absolute; + left: 50%; + transform: translate3D(-50%, 100%, 0); + pointer-events: none; + bottom: 0; +`; +const Button = styled.button` + border-radius: 8px; + color: var(--fg); + flex-shrink: 0; + &:focus { + outline: 2.5px dotted currentColor; + } +`; +const Text = styled.span` + font-size: 0.75rem; + white-space: nowrap; + width: 6.5ch; + text-align: center; + font-variant-numeric: tabular-nums; + font-weight: 500; +`; + +export const Pagination = () => { + const { + setNextFeature, + iOSMissingFeatures, + filteredData, + activeIndex, + position: pos, + paginationHeight, + } = useCanIUseContext(); + + const len = iOSMissingFeatures.length; + const filteredLen = filteredData.length; + + if (len === 0 || activeIndex === -1 || paginationHeight === null) return null; + + let countText = + filteredLen && pos !== -1 ? ( + <> + {pos + 1}  /   + {filteredData.length} + + ) : ( + '' // couldn't find the active on in the filtered list + ); + + let large = paginationHeight > 90; + let arrowSize = large ? 15 : 12; + let buttonSize = large ? 40 : 25; + let bottom = large ? paginationHeight * 0.76 : paginationHeight / 2; + // center vertically if smaller + let transform = large + ? 'translate3D(-50%, 100%, 0)' + : 'translate3D(-50%, 50%, 0)'; + + return ( +
+
+ + {countText} + +
+
+ ); +}; diff --git a/src/components/verticalCenterWithMargin.tsx b/src/components/verticalCenterWithMargin.tsx new file mode 100644 index 0000000..3305408 --- /dev/null +++ b/src/components/verticalCenterWithMargin.tsx @@ -0,0 +1,81 @@ +import { Camera, Vector3 } from 'three'; +import React, { useLayoutEffect } from 'react'; +import { useThree } from '@react-three/fiber'; +import { cartonHeight, cartonSide } from './milkcarton'; +import useCanIUseContext from '@/hooks/useCanIUseContext'; +import { a, SpringProps, useSpring } from '@react-spring/three'; +import { scaleOpts } from './links'; + +type Props = { + children?: React.ReactNode; +}; + +const getYPosition = (verticalView: boolean) => { + const centerY = cartonHeight / 2; + // visual centering top part of milk carton less important + recedes + const verticalOffset = verticalView && window.innerHeight < 600 ? 10 : 0; + return -centerY + verticalOffset; +}; + +const config = { + mass: 0.25, + tension: 800, + friction: 30, +}; + +export const startCameraPosArray = [0, -30, 254] as const; // for around scale 1 +const startCameraPos = new Vector3(0, -40, 254); // for scale .9 +const endCameraPos = new Vector3(0, 0, 254); + +const setCameraPosition = (camera: Camera, scale: number) => { + const cameraPos = camera.position; + const percent = + ((scale - scaleOpts.min) * 1.5) / (scaleOpts.max - scaleOpts.min); // let's make it lerp faster 1.5x + const newPosition = startCameraPos + .clone() + .lerp(endCameraPos, Math.min(percent, 1)); + if (!newPosition.equals(cameraPos)) { + camera.position.copy(newPosition); + camera.lookAt(0, 0, 0); + } +}; + +export const VerticalCenterWithMargin = ({ children = null }: Props) => { + const { setPaginationHeight, paginationHeight, verticalView, scale } = + useCanIUseContext(); + const { camera, size } = useThree(); + + const y = getYPosition(verticalView); + const position = [0, y, -cartonSide * (scale - 1)]; + + const [spring] = useSpring( + () => ({ + scale, + position, + config, + onChange: { + scale: (v: number) => { + setCameraPosition(camera, v); + }, + }, + }), + [scale, position, config] + ); + + useLayoutEffect(() => { + // On first load set the camera position especially if we're not at scale 1 + setCameraPosition(camera, scale); + }, []); + + // Get pagination space - static from scale 1 (will update though with window resize) + useLayoutEffect(() => { + const cartonBottomPos = new Vector3(0, y, cartonSide); + const mouseCoords = cartonBottomPos.project(camera); // help figure out how much pagination space we have + const margin = Math.round(((mouseCoords.y + 1) / 2) * size.height); + if (margin !== paginationHeight) { + setPaginationHeight(margin); + } + }, [y, camera, size, paginationHeight, setPaginationHeight]); + + return {children}; +}; diff --git a/src/hooks/useCanIUseContext.tsx b/src/hooks/useCanIUseContext.tsx index 6499fb3..7ed416b 100644 --- a/src/hooks/useCanIUseContext.tsx +++ b/src/hooks/useCanIUseContext.tsx @@ -17,8 +17,12 @@ import { import cloneDeep from 'lodash/cloneDeep'; import { useCanIUseData } from './useCanIUseData'; import { useFilters, type FiltersType } from './useFilters'; +import { scaleOpts } from '../components/links'; // import { parseMdnData } from '../utils/parseMdnData'; +const clamp = (num: number, min: number, max: number) => + Math.min(Math.max(num, min), max); + // TODO: Continue type checking + CLEANUP (more sharing of types) interface CanIUseContextInterface { loading: boolean; @@ -42,8 +46,14 @@ interface CanIUseContextInterface { filteredData: IOSMissingFeaturesType; filteredByBrowserOnly: IOSMissingFeaturesType; activeInFilteredData: boolean; + position: number; actionType: ActionType; doNotRotate: boolean; + paginationHeight: number | null; + setPaginationHeight: Dispatch>; + verticalView: boolean; + scale: number; + setScale: Dispatch>; } // Game state... could probably be broken out into smaller files / hooks @@ -140,6 +150,18 @@ export const CanIUseContextProvider = ({ } }; + const [paginationHeight, setPaginationHeight] = useState(null); + const [scale, setScale] = useState(() => { + return clamp( + Number(localStorage.getItem('scale') || 1), + scaleOpts.min, + scaleOpts.max + ); // TODO: maybe also round via steps if they change in the future + }); + useEffect(() => { + localStorage.setItem('scale', String(scale)); // remember user's preference for scale + }, [scale]); + // MDN DATA: TODO: Later // If Safari brings up that caniuse data isn't up-to-date... // Maybe they should work on that --- who do they really have to blame? That's part of their job, right? Right? @@ -159,9 +181,13 @@ export const CanIUseContextProvider = ({ // }); // }, []); + const pos = filteredData.findIndex((v) => v.index === activeIndex); // actual position in list --- active index + prev active is out of the WHOLE non-filtered list + return ( v.index === activeIndex), + activeInFilteredData: filteredData && pos !== -1, + position: pos, actionType, doNotRotate: verticalView && actionType === 'button', // too distracting with drawer open + verticalView, + scale, + setScale, }} > {children}