web based infinite canvas
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: grid sizing for snapping

+207 -2
+4
apps/web/src/lib/status.ts
··· 180 180 return false; 181 181 } 182 182 183 + /** 184 + * IMPORTANT: Default gridSize must match DEFAULT_GRID_SIZE renderer 185 + * to ensure grid lines and snapping positions align correctly 186 + */ 183 187 export function createSnapStore(initial?: Partial<SnapSettings>): SnapStore { 184 188 const defaults: SnapSettings = { snapEnabled: false, gridEnabled: true, gridSize: 25 }; 185 189 let value: SnapSettings = { ...defaults, ...initial };
+194
packages/core/tests/grid-snap-alignment.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + 3 + /** 4 + * Tests to verify grid rendering and snapping alignment 5 + * 6 + * These tests ensure that: 7 + * 1. Snapping positions align with grid line positions 8 + * 2. The default grid size is consistent across the system 9 + * 3. Grid lines are drawn at correct multiples of gridSize 10 + */ 11 + describe("Grid and snap alignment", () => { 12 + const DEFAULT_GRID_SIZE = 25; 13 + 14 + describe("Grid line positioning", () => { 15 + it("should position grid lines at exact multiples of gridSize", () => { 16 + const gridSize = 25; 17 + const testPositions = [0, 25, 50, 75, 100, 125, 150]; 18 + 19 + for (const pos of testPositions) { 20 + expect(pos % gridSize).toBe(0); 21 + 22 + const calculatedPosition = Math.floor(pos / gridSize) * gridSize; 23 + expect(calculatedPosition).toBe(pos); 24 + } 25 + }); 26 + 27 + it("should calculate start position correctly for any viewport", () => { 28 + const gridSize = 25; 29 + 30 + const testCases = [ 31 + { topLeft: 0, expectedStart: 0 }, 32 + { topLeft: 10, expectedStart: 0 }, 33 + { topLeft: 23, expectedStart: 0 }, 34 + { topLeft: 25, expectedStart: 25 }, 35 + { topLeft: 37, expectedStart: 25 }, 36 + { topLeft: 50, expectedStart: 50 }, 37 + { topLeft: -10, expectedStart: -25 }, 38 + { topLeft: -23, expectedStart: -25 }, 39 + ]; 40 + 41 + for (const { topLeft, expectedStart } of testCases) { 42 + const startX = Math.floor(topLeft / gridSize) * gridSize; 43 + expect(startX).toBe(expectedStart); 44 + } 45 + }); 46 + }); 47 + 48 + describe("Snapping calculation", () => { 49 + it("should snap to nearest grid line", () => { 50 + const gridSize = 25; 51 + 52 + const testCases = [ 53 + { input: 0, expected: 0 }, 54 + { input: 10, expected: 0 }, 55 + { input: 12, expected: 0 }, 56 + { input: 13, expected: 25 }, 57 + { input: 20, expected: 25 }, 58 + { input: 25, expected: 25 }, 59 + { input: 30, expected: 25 }, 60 + { input: 37, expected: 25 }, 61 + { input: 38, expected: 50 }, 62 + { input: 50, expected: 50 }, 63 + { input: -10, expected: 0 }, 64 + { input: -12, expected: 0 }, 65 + { input: -13, expected: -25 }, 66 + ]; 67 + 68 + for (const { input, expected } of testCases) { 69 + const snapped = Math.round(input / gridSize) * gridSize; 70 + const normalizedSnapped = snapped === 0 ? 0 : snapped; 71 + const normalizedExpected = expected === 0 ? 0 : expected; 72 + expect(normalizedSnapped).toBe(normalizedExpected); 73 + } 74 + }); 75 + 76 + it("should produce positions that align with grid lines", () => { 77 + const gridSize = 25; 78 + 79 + for (let i = 0; i < 100; i++) { 80 + const randomPos = Math.random() * 1000 - 500; 81 + const snapped = Math.round(randomPos / gridSize) * gridSize; 82 + 83 + expect(Math.abs(snapped % gridSize)).toBe(0); 84 + 85 + expect(Math.abs(snapped - randomPos)).toBeLessThanOrEqual(gridSize / 2); 86 + } 87 + }); 88 + }); 89 + 90 + describe("Grid/snap consistency", () => { 91 + it("should use the same default grid size", () => { 92 + const snapStoreDefault = 25; 93 + const rendererDefault = 25; 94 + 95 + expect(snapStoreDefault).toBe(DEFAULT_GRID_SIZE); 96 + expect(rendererDefault).toBe(DEFAULT_GRID_SIZE); 97 + }); 98 + 99 + it("should align snapped positions with grid lines", () => { 100 + const gridSize = 25; 101 + 102 + const inputs = [17, 42, 63, 88, 112, 137, 163, 188]; 103 + 104 + for (const input of inputs) { 105 + const snappedX = Math.round(input / gridSize) * gridSize; 106 + 107 + const startX = Math.floor(input / gridSize) * gridSize; 108 + const nextGridLine = startX + gridSize; 109 + 110 + expect(snappedX === startX || snappedX === nextGridLine).toBe(true); 111 + 112 + expect(snappedX % gridSize).toBe(0); 113 + } 114 + }); 115 + }); 116 + 117 + describe("Edge cases", () => { 118 + it("should handle zero correctly", () => { 119 + const gridSize = 25; 120 + const snapped = Math.round(0 / gridSize) * gridSize; 121 + expect(snapped).toBe(0); 122 + }); 123 + 124 + it("should handle exact grid positions correctly", () => { 125 + const gridSize = 25; 126 + const exactPositions = [0, 25, 50, 75, 100, -25, -50]; 127 + 128 + for (const pos of exactPositions) { 129 + const snapped = Math.round(pos / gridSize) * gridSize; 130 + expect(snapped).toBe(pos); 131 + } 132 + }); 133 + 134 + it("should handle midpoints consistently", () => { 135 + const gridSize = 25; 136 + const midpoint1 = 12.5; 137 + const snapped1 = Math.round(midpoint1 / gridSize) * gridSize; 138 + expect(snapped1).toBe(25); 139 + expect(Math.round(37.5 / 25) * 25).toBe(50); 140 + 141 + const negSnapped = Math.round(-12.5 / 25) * 25; 142 + expect(negSnapped === 0 ? 0 : negSnapped).toBe(0); 143 + }); 144 + 145 + it("should handle different grid sizes", () => { 146 + const testGridSizes = [10, 20, 25, 50, 100]; 147 + 148 + for (const gridSize of testGridSizes) { 149 + const input = 123; 150 + const snapped = Math.round(input / gridSize) * gridSize; 151 + 152 + expect(snapped % gridSize).toBe(0); 153 + 154 + const quotient = input / gridSize; 155 + const nearestMultiple = Math.round(quotient); 156 + expect(snapped).toBe(nearestMultiple * gridSize); 157 + } 158 + }); 159 + }); 160 + 161 + describe("Zoom independence", () => { 162 + it("should maintain alignment regardless of zoom level", () => { 163 + const gridSize = 25; 164 + const worldPosition = 137; 165 + const snapped = Math.round(worldPosition / gridSize) * gridSize; 166 + expect(snapped).toBe(125); 167 + 168 + const startGrid = Math.floor(worldPosition / gridSize) * gridSize; 169 + expect(startGrid).toBe(125); 170 + }); 171 + 172 + it("should draw grid lines at consistent world positions", () => { 173 + const gridSize = 25; 174 + 175 + const viewportCases = [{ topLeft: 0, zoom: 1 }, { topLeft: 100, zoom: 1 }, { topLeft: 0, zoom: 2 }, { 176 + topLeft: 50, 177 + zoom: 0.5, 178 + }]; 179 + 180 + for (const { topLeft } of viewportCases) { 181 + const startX = Math.floor(topLeft / gridSize) * gridSize; 182 + 183 + const gridLines = []; 184 + for (let x = startX; x <= startX + 200; x += gridSize) { 185 + gridLines.push(x); 186 + } 187 + 188 + for (const line of gridLines) { 189 + expect(line % gridSize).toBe(0); 190 + } 191 + } 192 + }); 193 + }); 194 + });
+9 -2
packages/renderer/src/index.ts
··· 207 207 } 208 208 209 209 /** 210 + * Default grid size in world units 211 + * This must match the default in the snap store to ensure grid lines and snapping align 212 + */ 213 + const DEFAULT_GRID_SIZE = 25; 214 + 215 + /** 210 216 * Draw grid/graph paper background 211 217 * 212 218 * Draws a subtle grid that helps with spatial awareness and alignment. ··· 216 222 if (snapSettings && !snapSettings.gridEnabled) { 217 223 return; 218 224 } 219 - const gridSize = snapSettings?.gridSize ?? 50; 225 + const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; 220 226 const minorGridColor = "rgba(128, 128, 128, 0.1)"; 221 227 const majorGridColor = "rgba(128, 128, 128, 0.2)"; 222 228 ··· 267 273 return; 268 274 } 269 275 270 - const gridSize = snapSettings.gridSize || 1; 276 + const gridSize = snapSettings?.gridSize ?? DEFAULT_GRID_SIZE; 271 277 const guideWorld = pointerState.snappedWorld ?? cursorState?.cursorWorld; 272 278 if (!guideWorld) { 273 279 return; 274 280 } 281 + 275 282 const snappedX = pointerState.snappedWorld 276 283 ? pointerState.snappedWorld.x 277 284 : Math.round(guideWorld.x / gridSize) * gridSize;