From fbea8bd7a70081e3acf0c2ed9143563b9bc19da3 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Sat, 6 Feb 2021 19:29:24 +1100 Subject: [PATCH] Move grid snapping to hook and added math for hex snapping --- src/components/Grid.js | 20 ++- src/components/map/MapToken.js | 13 +- src/components/note/Note.js | 12 +- src/contexts/GridContext.js | 14 +- src/helpers/Vector3.js | 55 ++++++++ src/helpers/grid.js | 238 ++++++++++++++++----------------- src/hooks/useGridSnapping.js | 92 +++++++++++++ 7 files changed, 293 insertions(+), 151 deletions(-) create mode 100644 src/helpers/Vector3.js create mode 100644 src/hooks/useGridSnapping.js diff --git a/src/components/Grid.js b/src/components/Grid.js index 9eded3d..0b2ffec 100644 --- a/src/components/Grid.js +++ b/src/components/Grid.js @@ -5,7 +5,9 @@ import { getCellLocation, gridClipFunction, shouldClipCell, + getNearestCellCoordinates, } from "../helpers/grid"; +import Vector2 from "../helpers/Vector2"; import { useGrid } from "../contexts/GridContext"; @@ -22,6 +24,8 @@ function Grid({ strokeWidth, stroke }) { return null; } + const negativeGridOffset = Vector2.multiply(gridOffset, -1); + const shapes = []; if (grid.type === "square") { for (let x = 1; x < grid.size.x; x++) { @@ -37,7 +41,7 @@ function Grid({ strokeWidth, stroke }) { stroke={stroke} strokeWidth={gridStrokeWidth * strokeWidth} opacity={0.5} - offset={gridOffset} + offset={negativeGridOffset} /> ); } @@ -54,7 +58,7 @@ function Grid({ strokeWidth, stroke }) { stroke={stroke} strokeWidth={gridStrokeWidth * strokeWidth} opacity={0.5} - offset={gridOffset} + offset={negativeGridOffset} /> ); } @@ -73,7 +77,7 @@ function Grid({ strokeWidth, stroke }) { } x={cellLocation.x} y={cellLocation.y} - offset={gridOffset} + offset={negativeGridOffset} > { + console.log( + getNearestCellCoordinates( + grid, + cellLocation.x, + cellLocation.y, + gridCellPixelSize + ) + ); + }} /> ); diff --git a/src/components/map/MapToken.js b/src/components/map/MapToken.js index c3e3dfd..a96c386 100644 --- a/src/components/map/MapToken.js +++ b/src/components/map/MapToken.js @@ -7,8 +7,7 @@ import Konva from "konva"; import useDataSource from "../../hooks/useDataSource"; import useDebounce from "../../hooks/useDebounce"; import usePrevious from "../../hooks/usePrevious"; - -import { snapNodeToGrid } from "../../helpers/grid"; +import useGridSnapping from "../../hooks/useGridSnapping"; import { useAuth } from "../../contexts/AuthContext"; import { useMapInteraction } from "../../contexts/MapInteractionContext"; @@ -52,6 +51,8 @@ function MapToken({ } }, [tokenSourceImage]); + const snapNodeToGrid = useGridSnapping(snappingThreshold); + function handleDragStart(event) { const tokenGroup = event.target; const tokenImage = imageRef.current; @@ -88,13 +89,7 @@ function MapToken({ const tokenGroup = event.target; // Snap to corners of grid if (map.snapToGrid) { - snapNodeToGrid( - map.grid, - mapWidth, - mapHeight, - tokenGroup, - snappingThreshold - ); + snapNodeToGrid(tokenGroup); } } diff --git a/src/components/note/Note.js b/src/components/note/Note.js index 1005286..2a45c97 100644 --- a/src/components/note/Note.js +++ b/src/components/note/Note.js @@ -6,10 +6,10 @@ import { useAuth } from "../../contexts/AuthContext"; import { useMapInteraction } from "../../contexts/MapInteractionContext"; import { useGrid } from "../../contexts/GridContext"; -import { snapNodeToGrid } from "../../helpers/grid"; import colors from "../../helpers/colors"; import usePrevious from "../../hooks/usePrevious"; +import useGridSnapping from "../../hooks/useGridSnapping"; const snappingThreshold = 1 / 5; @@ -31,6 +31,8 @@ function Note({ const noteHeight = noteWidth; const notePadding = noteWidth / 10; + const snapNodeToGrid = useGridSnapping(snappingThreshold); + function handleDragStart(event) { onNoteDragStart && onNoteDragStart(event, note.id); } @@ -39,13 +41,7 @@ function Note({ const noteGroup = event.target; // Snap to corners of grid if (map.snapToGrid) { - snapNodeToGrid( - map.grid, - mapWidth, - mapHeight, - noteGroup, - snappingThreshold - ); + snapNodeToGrid(noteGroup); } } diff --git a/src/contexts/GridContext.js b/src/contexts/GridContext.js index 215c231..13830a4 100644 --- a/src/contexts/GridContext.js +++ b/src/contexts/GridContext.js @@ -19,14 +19,14 @@ import { getGridPixelSize, getCellPixelSize, Grid } from "../helpers/grid"; */ const defaultValue = { grid: { - size: { x: 0, y: 0 }, - inset: { topLeft: { x: 0, y: 0 }, bottomRight: { x: 1, y: 1 } }, + size: new Vector2(0, 0), + inset: { topLeft: new Vector2(0, 0), bottomRight: new Vector2(1, 1) }, type: "square", }, gridPixelSize: new Size(0, 0), gridCellPixelSize: new Size(0, 0, 0), gridCellNormalizedSize: new Size(0, 0, 0), - gridOffset: { x: 0, y: 0 }, + gridOffset: new Vector2(0, 0), gridStrokeWidth: 0, }; @@ -53,10 +53,10 @@ export function GridProvider({ grid, width, height, children }) { gridCellPixelSize.width / gridPixelSize.width, gridCellPixelSize.height / gridPixelSize.height ); - const gridOffset = { - x: grid.inset.topLeft.x * width * -1, - y: grid.inset.topLeft.y * height * -1, - }; + const gridOffset = Vector2.multiply(grid.inset.topLeft, { + x: width, + y: height, + }); const gridStrokeWidth = (gridCellPixelSize.width < gridCellPixelSize.height ? gridCellPixelSize.width diff --git a/src/helpers/Vector3.js b/src/helpers/Vector3.js new file mode 100644 index 0000000..d29b8b0 --- /dev/null +++ b/src/helpers/Vector3.js @@ -0,0 +1,55 @@ +/** + * Vector class with x, y, z and static helper methods + */ +class Vector3 { + /** + * @type {number} x - X component of the vector + */ + x; + /** + * @type {number} y - Y component of the vector + */ + y; + /** + * @type {number} z - Z component of the vector + */ + z; + + /** + * @param {number} x + * @param {number} y + * @param {number} z + */ + constructor(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + } + + /** + * Round a Vector3 to the nearest integer while maintaining x + y + z = 0 + * @param {Vector3} cube + * @returns {Vector3} + */ + static cubeRound(cube) { + var rX = Math.round(cube.x); + var rY = Math.round(cube.y); + var rZ = Math.round(cube.z); + + var xDiff = Math.abs(rX - cube.x); + var yDiff = Math.abs(rY - cube.y); + var zDiff = Math.abs(rZ - cube.z); + + if (xDiff > yDiff && xDiff > zDiff) { + rX = -rY - rZ; + } else if (yDiff > zDiff) { + rY = -rX - rZ; + } else { + rZ = -rX - rY; + } + + return new Vector3(rX, rY, rZ); + } +} + +export default Vector3; diff --git a/src/helpers/grid.js b/src/helpers/grid.js index 29d5a44..e8707e9 100644 --- a/src/helpers/grid.js +++ b/src/helpers/grid.js @@ -1,4 +1,5 @@ import GridSizeModel from "../ml/gridSize/GridSizeModel"; +import Vector3 from "./Vector3"; import Vector2 from "./Vector2"; import Size from "./Size"; @@ -56,50 +57,81 @@ export function getCellPixelSize(grid, gridWidth, gridHeight) { } /** - * Find the location of cell in the grid + * Find the location of a cell in the grid. + * Hex is addressed in an offset coordinate system with even numbered columns/rows offset to the right * @param {Grid} grid - * @param {number} x X-axis location of the cell - * @param {number} y Y-axis location of the cell + * @param {number} col X-axis coordinate of the cell + * @param {number} row Y-axis coordinate of the cell * @param {Size} cellSize Cell size in pixels * @returns {Vector2} */ -export function getCellLocation(grid, x, y, cellSize) { +export function getCellLocation(grid, col, row, cellSize) { switch (grid.type) { case "square": - return { x: x * cellSize.width, y: y * cellSize.height }; + return { x: col * cellSize.width, y: row * cellSize.height }; case "hexVertical": return { - x: x * cellSize.width + (cellSize.width * (1 + (y % 2))) / 2, - y: y * cellSize.height * (3 / 4) + cellSize.radius, + x: col * cellSize.width + (cellSize.width * (1 + (row & 1))) / 2, + y: row * cellSize.height * (3 / 4) + cellSize.radius, }; case "hexHorizontal": return { - x: x * cellSize.width * (3 / 4) + cellSize.radius, - y: y * cellSize.height + (cellSize.height * (1 + (x % 2))) / 2, + x: col * cellSize.width * (3 / 4) + cellSize.radius, + y: row * cellSize.height + (cellSize.height * (1 + (col & 1))) / 2, }; default: throw GRID_TYPE_NOT_IMPLEMENTED; } } +/** + * Find the coordinates of the nearest cell in the grid to a point in pixels + * @param {Grid} grid + * @param {number} x X location to look for in pixels + * @param {number} y Y location to look for in pixels + * @param {Size} cellSize Cell size in pixels + * @returns {Vector2} + */ +export function getNearestCellCoordinates(grid, x, y, cellSize) { + switch (grid.type) { + case "square": + return Vector2.roundTo({ x, y }, cellSize); + case "hexVertical": + // Find nearest cell in cube coordinates the convert to offset coordinates + const cubeXVert = ((SQRT3 / 3) * x - (1 / 3) * y) / cellSize.radius; + const cubeZVert = ((2 / 3) * y) / cellSize.radius; + const cubeYVert = -cubeXVert - cubeZVert; + const cubeVert = new Vector3(cubeXVert, cubeYVert, cubeZVert); + return hexCubeToOffset(Vector3.cubeRound(cubeVert), "hexVertical"); + case "hexHorizontal": + const cubeXHorz = ((2 / 3) * x) / cellSize.radius; + const cubeZHorz = (-(1 / 3) * x + (SQRT3 / 3) * y) / cellSize.radius; + const cubeYHorz = -cubeXHorz - cubeZHorz; + const cubeHorz = new Vector3(cubeXHorz, cubeYHorz, cubeZHorz); + return hexCubeToOffset(Vector3.cubeRound(cubeHorz), "hexHorizontal"); + default: + throw GRID_TYPE_NOT_IMPLEMENTED; + } +} + /** * Whether the cell located at `x, y` is out of bounds of the grid * @param {Grid} grid - * @param {number} x X-axis location of the cell - * @param {number} y Y-axis location of the cell + * @param {number} col X-axis coordinate of the cell + * @param {number} row Y-axis coordinate of the cell * @returns {boolean} */ -export function shouldClipCell(grid, x, y) { - if (x < 0 || y < 0) { +export function shouldClipCell(grid, col, row) { + if (col < 0 || row < 0) { return true; } switch (grid.type) { case "square": return false; case "hexVertical": - return x === grid.size.x - 1 && y % 2 !== 0; + return col === grid.size.x - 1 && (row & 1) !== 0; case "hexHorizontal": - return y === grid.size.y - 1 && x % 2 !== 0; + return row === grid.size.y - 1 && (col & 1) !== 0; default: throw GRID_TYPE_NOT_IMPLEMENTED; } @@ -109,25 +141,33 @@ export function shouldClipCell(grid, x, y) { * Canvas clip function for culling hex cells that overshoot/undershoot the grid * @param {CanvasRenderingContext2D} context The canvas context of the clip function * @param {Grid} grid - * @param {number} x X-axis location of the cell - * @param {number} y Y-axis location of the cell + * @param {number} col X-axis coordinate of the cell + * @param {number} row Y-axis coordinate of the cell * @param {Size} cellSize Cell size in pixels */ -export function gridClipFunction(context, grid, x, y, cellSize) { +export function gridClipFunction(context, grid, col, row, cellSize) { // Clip the undershooting cells unless they are needed to fill out a specific grid type - if ((x < 0 && grid.type !== "hexVertical") || (x < 0 && y % 2 === 0)) { + if ( + (col < 0 && grid.type !== "hexVertical") || + (col < 0 && (row & 1) === 0) + ) { return; } - if ((y < 0 && grid.type !== "hexHorizontal") || (y < 0 && x % 2 === 0)) { + if ( + (row < 0 && grid.type !== "hexHorizontal") || + (row < 0 && (col & 1) === 0) + ) { return; } context.rect( - x < 0 ? 0 : -cellSize.radius, - y < 0 ? 0 : -cellSize.radius, - x > 0 && grid.type === "hexVertical" + col < 0 ? 0 : -cellSize.radius, + row < 0 ? 0 : -cellSize.radius, + col > 0 && grid.type === "hexVertical" ? cellSize.radius : cellSize.radius * 2, - y > 0 && grid.type === "hexVertical" ? cellSize.radius * 2 : cellSize.radius + row > 0 && grid.type === "hexVertical" + ? cellSize.radius * 2 + : cellSize.radius ); } @@ -184,6 +224,56 @@ export function getGridUpdatedInset(grid, mapWidth, mapHeight) { return inset; } +/** + * Get the max zoom for a grid + * @param {Grid} grid + * @returns {number} + */ +export function getGridMaxZoom(grid) { + if (!grid) { + return 10; + } + // Return max grid size / 2 + return Math.max(Math.max(grid.size.x, grid.size.y) / 2, 5); +} + +/** + * Convert from a 3D cube hex representation to a 2D offset one + * @param {Vector3} cube Cube representation of the hex cell + * @param {("hexVertical"|"hexHorizontal")} type + * @returns {Vector2} + */ +function hexCubeToOffset(cube, type) { + if (type === "hexVertical") { + const x = cube.x + (cube.z + (cube.z & 1)) / 2; + const y = cube.z; + return new Vector2(x, y); + } else { + const x = cube.x; + const y = cube.z + (cube.x + (cube.x & 1)) / 2; + return new Vector2(x, y); + } +} +/** + * Convert from a 2D offset hex representation to a 3D cube one + * @param {Vector2} offset Offset representation of the hex cell + * @param {("hexVertical"|"hexHorizontal")} type + * @returns {Vector3} + */ +function hexOffsetToCube(offset, type) { + if (type === "hexVertical") { + const x = offset.x - (offset.y - (offset.y & 1)) / 2; + const z = offset.y; + const y = -x - z; + return { x, y, z }; + } else { + const x = offset.x; + const z = offset.y - (offset.x - (offset.x & 1)) / 2; + const y = -x - z; + return { x, y, z }; + } +} + /** * Get all factors of a number * @param {number} n @@ -364,103 +454,3 @@ export async function getGridSizeFromImage(image) { return prediction; } - -/** - * Get the max zoom for a grid - * @param {Grid} grid - * @returns {number} - */ -export function getGridMaxZoom(grid) { - if (!grid) { - return 10; - } - // Return max grid size / 2 - return Math.max(Math.max(grid.size.x, grid.size.y) / 2, 5); -} - -/** - * Snap a Konva Node to a the closest grid cell - * @param {Grid} grid - * @param {number} mapWidth - * @param {number} mapHeight - * @param {Konva.Node} node - * @param {number} snappingThreshold 1 = Always snap, 0 = never snap - */ -export function snapNodeToGrid( - grid, - mapWidth, - mapHeight, - node, - snappingThreshold -) { - const offset = Vector2.multiply(grid.inset.topLeft, { - x: mapWidth, - y: mapHeight, - }); - const gridSize = { - x: - (mapWidth * (grid.inset.bottomRight.x - grid.inset.topLeft.x)) / - grid.size.x, - y: - (mapHeight * (grid.inset.bottomRight.y - grid.inset.topLeft.y)) / - grid.size.y, - }; - - const position = node.position(); - const halfSize = Vector2.divide({ x: node.width(), y: node.height() }, 2); - - // Offsets to tranform the centered position into the four corners - const cornerOffsets = [ - { x: 0, y: 0 }, - halfSize, - { x: -halfSize.x, y: -halfSize.y }, - { x: halfSize.x, y: -halfSize.y }, - { x: -halfSize.x, y: halfSize.y }, - ]; - - // Minimum distance from a corner to the grid - let minCornerGridDistance = Number.MAX_VALUE; - // Minimum component of the difference between the min corner and the grid - let minCornerMinComponent; - // Closest grid value - let minGridSnap; - - // Find the closest corner to the grid - for (let cornerOffset of cornerOffsets) { - const corner = Vector2.add(position, cornerOffset); - // Transform into offset space, round, then transform back - const gridSnap = Vector2.add( - Vector2.roundTo(Vector2.subtract(corner, offset), gridSize), - offset - ); - const gridDistance = Vector2.length(Vector2.subtract(gridSnap, corner)); - const minComponent = Vector2.min(gridSize); - if (gridDistance < minCornerGridDistance) { - minCornerGridDistance = gridDistance; - minCornerMinComponent = minComponent; - // Move the grid value back to the center - minGridSnap = Vector2.subtract(gridSnap, cornerOffset); - } - } - - // Snap to center of grid - // Subtract offset and half grid size to transform it into offset half space then transform it back - const halfGridSize = Vector2.multiply(gridSize, 0.5); - const centerSnap = Vector2.add( - Vector2.add( - Vector2.roundTo( - Vector2.subtract(Vector2.subtract(position, offset), halfGridSize), - gridSize - ), - halfGridSize - ), - offset - ); - const centerDistance = Vector2.length(Vector2.subtract(centerSnap, position)); - - if (minCornerGridDistance < minCornerMinComponent * snappingThreshold) { - node.position(minGridSnap); - } else if (centerDistance < Vector2.min(gridSize) * snappingThreshold) { - node.position(centerSnap); - } -} diff --git a/src/hooks/useGridSnapping.js b/src/hooks/useGridSnapping.js new file mode 100644 index 0000000..3336115 --- /dev/null +++ b/src/hooks/useGridSnapping.js @@ -0,0 +1,92 @@ +import Konva from "konva"; + +import Vector2 from "../helpers/Vector2"; +import { getCellLocation } from "../helpers/grid"; + +import { useGrid } from "../contexts/GridContext"; + +/** + * Returns a function that when called will snap a node to the current grid + * @param {number} snappingThreshold 1 = Always snap, 0 = never snap + */ +function useGridSnapping(snappingThreshold) { + const { gridOffset, gridCellPixelSize } = useGrid(); + + /** + * @param {Konva.Node} node The node to snap + */ + function snapNodeToGrid(node) { + const position = node.position(); + const halfSize = Vector2.divide({ x: node.width(), y: node.height() }, 2); + + // Offsets to tranform the centered position into the four corners + const cornerOffsets = [ + { x: 0, y: 0 }, + // halfSize, + // { x: -halfSize.x, y: -halfSize.y }, + // { x: halfSize.x, y: -halfSize.y }, + // { x: -halfSize.x, y: halfSize.y }, + ]; + + // Minimum distance from a corner to the grid + let minCornerGridDistance = Number.MAX_VALUE; + // Minimum component of the difference between the min corner and the grid + let minCornerMinComponent; + // Closest grid value + let minGridSnap; + + // Find the closest corner to the grid + for (let cornerOffset of cornerOffsets) { + const corner = Vector2.add(position, cornerOffset); + // Transform into gridOffset space, round, then transform back + const gridSnap = Vector2.add( + Vector2.roundTo( + Vector2.subtract(corner, gridOffset), + gridCellPixelSize + ), + gridOffset + ); + const gridDistance = Vector2.length(Vector2.subtract(gridSnap, corner)); + const minComponent = Vector2.min(gridCellPixelSize); + if (gridDistance < minCornerGridDistance) { + minCornerGridDistance = gridDistance; + minCornerMinComponent = minComponent; + // Move the grid value back to the center + minGridSnap = Vector2.subtract(gridSnap, cornerOffset); + } + } + + // Snap to center of grid + // Subtract gridOffset and half grid size to transform it into gridOffset half space then transform it back + const halfGridSize = Vector2.multiply(gridCellPixelSize, 0.5); + const centerSnap = Vector2.add( + Vector2.add( + Vector2.roundTo( + Vector2.subtract( + Vector2.subtract(position, gridOffset), + halfGridSize + ), + gridCellPixelSize + ), + halfGridSize + ), + gridOffset + ); + const centerDistance = Vector2.length( + Vector2.subtract(centerSnap, position) + ); + + if (minCornerGridDistance < minCornerMinComponent * snappingThreshold) { + node.position(minGridSnap); + } else if ( + centerDistance < + Vector2.min(gridCellPixelSize) * snappingThreshold + ) { + node.position(centerSnap); + } + } + + return snapNodeToGrid; +} + +export default useGridSnapping;