Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Add configurable distance calculation methods #138

Merged
merged 4 commits into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

# [2.2.0]
## Added
- New init config option `distanceCalculationMethod` that allows switching between edge-based, center-based and corner-based (default) distance calculations.
- Support for a custom distance calculation function via the `customDistanceCalculationFunction` option, enabling custom logic for determining distances between focusable components. This will override the `getSecondaryAxisDistance` method.


# [2.1.0]
## Added
- new `init` config option `shouldUseNativeEvents` that enables the use of native events for triggering actions, such as clicks or key presses.
- new `init` config option `rtl` that changes focus behavior for layouts in right-to-left (RTL) languages such as Arabic and Hebrew.
- new `init` config option `rtl` that changes focus behavior for layouts in right-to-left (RTL) languages such as Arabic and Hebrew.

# [2.0.2]
## Added
Expand Down
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,64 @@ init({
});
```

## New Distance Calculation Configuration
Starting from version `2.2.0`, you can configure the method used for distance calculations between focusable components. This can be set during initialization using the `distanceCalculationMethod` option.

### How to use | Available Options
* `edges`: Calculates distances using the closest edges of the components.
* `center`: Calculates distances using the center points of the components for size-agnostic comparisons. Ideal for non-uniform elements between siblings.
* `corners`: Calculates distances using the corners of the components, between the nearest corners. This is the default value.

```jsx
import { init } from '@noriginmedia/norigin-spatial-navigation';

init({
// options
distanceCalculationMethod: 'center', // or 'edges' or 'corners' (default)
});
```

## Custom Distance Calculation Function
In addition to the predefined distance calculation methods, you can define your own custom distance calculation function. This will override the `getSecondaryAxisDistance` method.

You can pass your custom distance calculation function during initialization using the customDistanceCalculationFunction option. This function will override the built-in methods.

### How to use | Available Options
The custom distance calculation function should follow the DistanceCalculationFunction type signature:

```jsx
type DistanceCalculationFunction = (
refCorners: Corners,
siblingCorners: Corners,
isVerticalDirection: boolean,
distanceCalculationMethod: DistanceCalculationMethod
) => number;
```

### Example
```jsx
import { init } from '@noriginmedia/norigin-spatial-navigation';

// Define a custom distance calculation function
const myCustomDistanceCalculationFunction = (refCorners, siblingCorners, isVerticalDirection, distanceCalculationMethod) => {
// Custom logic for distance calculation
const { a: refA, b: refB } = refCorners;
const { a: siblingA, b: siblingB } = siblingCorners;
const coordinate = isVerticalDirection ? 'x' : 'y';

const refCoordinateCenter = (refA[coordinate] + refB[coordinate]) / 2;
const siblingCoordinateCenter = (siblingA[coordinate] + siblingB[coordinate]) / 2;

return Math.abs(refCoordinateCenter - siblingCoordinateCenter);
};

// Initialize with custom distance calculation function
init({
// options
customDistanceCalculationFunction: myCustomDistanceCalculationFunction,
});
```

## Making your component focusable
Most commonly you will have Leaf Focusable components. (See [Tree Hierarchy](#tree-hierarchy-of-focusable-components))
Leaf component is the one that doesn't have focusable children.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@noriginmedia/norigin-spatial-navigation",
"version": "2.1.0",
"version": "2.2.0",
"description": "React hooks based Spatial Navigation solution",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
36 changes: 29 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ const logo = require('../logo.png').default;

init({
debug: false,
visualDebug: false
visualDebug: false,
distanceCalculationMethod: 'center'
});

const rows = shuffle([
Expand Down Expand Up @@ -182,12 +183,15 @@ const AssetWrapper = styled.div`
`;

interface AssetBoxProps {
index: number;
isShuffleSize: boolean;
focused: boolean;
color: string;
}

const AssetBox = styled.div<AssetBoxProps>`
width: 225px;
width: ${({ isShuffleSize, index }) =>
isShuffleSize ? `${80 + index * 30}px` : '225px'};
height: 127px;
background-color: ${({ color }) => color};
border-color: white;
Expand All @@ -206,6 +210,8 @@ const AssetTitle = styled.div`
`;

interface AssetProps {
index: number;
isShuffleSize: boolean;
title: string;
color: string;
onEnterPress: (props: object, details: KeyPressDetails) => void;
Expand All @@ -216,7 +222,14 @@ interface AssetProps {
) => void;
}

function Asset({ title, color, onEnterPress, onFocus }: AssetProps) {
function Asset({
title,
color,
onEnterPress,
onFocus,
isShuffleSize,
index
}: AssetProps) {
const { ref, focused } = useFocusable({
onEnterPress,
onFocus,
Expand All @@ -228,7 +241,12 @@ function Asset({ title, color, onEnterPress, onFocus }: AssetProps) {

return (
<AssetWrapper ref={ref}>
<AssetBox color={color} focused={focused} />
<AssetBox
index={index}
color={color}
focused={focused}
isShuffleSize={isShuffleSize}
/>
<AssetTitle>{title}</AssetTitle>
</AssetWrapper>
);
Expand Down Expand Up @@ -261,6 +279,7 @@ const ContentRowScrollingContent = styled.div`
`;

interface ContentRowProps {
isShuffleSize: boolean;
title: string;
onAssetPress: (props: object, details: KeyPressDetails) => void;
onFocus: (
Expand All @@ -273,7 +292,8 @@ interface ContentRowProps {
function ContentRow({
title: rowTitle,
onAssetPress,
onFocus
onFocus,
isShuffleSize
}: ContentRowProps) {
const { ref, focusKey } = useFocusable({
onFocus
Expand All @@ -297,13 +317,14 @@ function ContentRow({
<ContentRowTitle>{rowTitle}</ContentRowTitle>
<ContentRowScrollingWrapper ref={scrollingRef}>
<ContentRowScrollingContent>
{assets.map(({ title, color }) => (
{assets.map(({ title, color }, index) => (
<Asset
key={title}
index={index}
title={title}
color={color}
onEnterPress={onAssetPress}
onFocus={onAssetFocus}
isShuffleSize={isShuffleSize}
/>
))}
</ContentRowScrollingContent>
Expand Down Expand Up @@ -403,6 +424,7 @@ function Content() {
title={title}
onAssetPress={onAssetPress}
onFocus={onRowFocus}
isShuffleSize={Math.random() < 0.5} // Rows will have children assets of different sizes, randomly setting it to true or false.
/>
))}
</div>
Expand Down
84 changes: 75 additions & 9 deletions src/SpatialNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ const KEY_ENTER = 'enter';

export type Direction = 'up' | 'down' | 'left' | 'right';

type DistanceCalculationMethod = 'center' | 'edges' | 'corners';

type DistanceCalculationFunction = (
refCorners: Corners,
siblingCorners: Corners,
isVerticalDirection: boolean,
distanceCalculationMethod: DistanceCalculationMethod
) => number;

const DEFAULT_KEY_MAP = {
[DIRECTION_LEFT]: [37, 'ArrowLeft'],
[DIRECTION_UP]: [38, 'ArrowUp'],
Expand Down Expand Up @@ -241,6 +250,10 @@ class SpatialNavigationService {

private writingDirection: WritingDirection;

private distanceCalculationMethod: DistanceCalculationMethod;

private customDistanceCalculationFunction?: DistanceCalculationFunction;

/**
* Used to determine the coordinate that will be used to filter items that are over the "edge"
*/
Expand Down Expand Up @@ -408,8 +421,19 @@ class SpatialNavigationService {
static getSecondaryAxisDistance(
refCorners: Corners,
siblingCorners: Corners,
isVerticalDirection: boolean
isVerticalDirection: boolean,
distanceCalculationMethod: DistanceCalculationMethod,
customDistanceCalculationFunction?: DistanceCalculationFunction
) {
if (customDistanceCalculationFunction) {
return customDistanceCalculationFunction(
refCorners,
siblingCorners,
isVerticalDirection,
distanceCalculationMethod
);
}

const { a: refA, b: refB } = refCorners;
const { a: siblingA, b: siblingB } = siblingCorners;
const coordinate = isVerticalDirection ? 'x' : 'y';
Expand All @@ -419,13 +443,44 @@ class SpatialNavigationService {
const siblingCoordinateA = siblingA[coordinate];
const siblingCoordinateB = siblingB[coordinate];

const distancesToCompare = [];
if (distanceCalculationMethod === 'center') {
const refCoordinateCenter = (refCoordinateA + refCoordinateB) / 2;
const siblingCoordinateCenter =
(siblingCoordinateA + siblingCoordinateB) / 2;
return Math.abs(refCoordinateCenter - siblingCoordinateCenter);
}
if (distanceCalculationMethod === 'edges') {
// 1. Find the minimum and maximum coordinates for both ref and sibling
const refCoordinateEdgeMin = Math.min(refCoordinateA, refCoordinateB);
const siblingCoordinateEdgeMin = Math.min(
siblingCoordinateA,
siblingCoordinateB
);
const refCoordinateEdgeMax = Math.max(refCoordinateA, refCoordinateB);
const siblingCoordinateEdgeMax = Math.max(
siblingCoordinateA,
siblingCoordinateB
);

// 2. Calculate the distances between the closest edges
const minEdgeDistance = Math.abs(
refCoordinateEdgeMin - siblingCoordinateEdgeMin
);
const maxEdgeDistance = Math.abs(
refCoordinateEdgeMax - siblingCoordinateEdgeMax
);

distancesToCompare.push(Math.abs(siblingCoordinateA - refCoordinateA));
distancesToCompare.push(Math.abs(siblingCoordinateA - refCoordinateB));
distancesToCompare.push(Math.abs(siblingCoordinateB - refCoordinateA));
distancesToCompare.push(Math.abs(siblingCoordinateB - refCoordinateB));
// 3. Return the smallest distance between the edges
return Math.min(minEdgeDistance, maxEdgeDistance);
}

// Default to corners
const distancesToCompare = [
Math.abs(siblingCoordinateA - refCoordinateA),
Math.abs(siblingCoordinateA - refCoordinateB),
Math.abs(siblingCoordinateB - refCoordinateA),
Math.abs(siblingCoordinateB - refCoordinateB)
];
return Math.min(...distancesToCompare);
}

Expand Down Expand Up @@ -473,12 +528,16 @@ class SpatialNavigationService {
const primaryAxisDistance = primaryAxisFunction(
refCorners,
siblingCorners,
isVerticalDirection
isVerticalDirection,
this.distanceCalculationMethod,
this.customDistanceCalculationFunction
);
const secondaryAxisDistance = secondaryAxisFunction(
refCorners,
siblingCorners,
isVerticalDirection
isVerticalDirection,
this.distanceCalculationMethod,
this.customDistanceCalculationFunction
);

/**
Expand Down Expand Up @@ -586,6 +645,8 @@ class SpatialNavigationService {
this.visualDebugger = null;

this.logIndex = 0;

this.distanceCalculationMethod = 'corners';
}

init({
Expand All @@ -597,7 +658,9 @@ class SpatialNavigationService {
useGetBoundingClientRect = false,
shouldFocusDOMNode = false,
shouldUseNativeEvents = false,
rtl = false
rtl = false,
distanceCalculationMethod = 'corners' as DistanceCalculationMethod,
customDistanceCalculationFunction = undefined as DistanceCalculationFunction
} = {}) {
if (!this.enabled) {
this.enabled = true;
Expand All @@ -607,6 +670,9 @@ class SpatialNavigationService {
this.shouldFocusDOMNode = shouldFocusDOMNode && !nativeMode;
this.shouldUseNativeEvents = shouldUseNativeEvents;
this.writingDirection = rtl ? WritingDirection.RTL : WritingDirection.LTR;
this.distanceCalculationMethod = distanceCalculationMethod;
this.customDistanceCalculationFunction =
customDistanceCalculationFunction;

this.debug = debug;

Expand Down