Dev Investigation Notes
Why does a square grid turn into rectangles when you draw it in 0–1 normalized coordinates?
This is a translated article. Read the original in Korean ↗
I was building a web app (formation) for dance studios. Several people each watch the same choreography video on their own phones and line up formations and movement paths together. The app overlays a grid on the video so you can mark where each dancer stands. When I tried to snap the coordinates to that grid, someone pointed out that the placements didn't line up with the grid and looked skewed.
Conclusion
A grid cell can be a perfect square in screen pixels on the canvas. But if you normalize x and y to 0–1 independently, then in normalized coordinates the horizontal and vertical spacing differ by the video's aspect ratio. This project defined the grid as cell = gridSize × (w/800) . The video was 1920×1080 (16:9), so the horizontal spacing was 50/800 = 0.0625 and the vertical spacing was 0.0625 × (16/9) ≈ 0.1111. That's almost a 2× difference . On screen the cells are identical squares, but the coordinate numbers describe a completely different shape.
The vertical spacing has to be multiplied by the aspect ratio (w/h) for the on-screen squares to match the grid in coordinate space. If you leave that out, grids, circles and squares all get computed as stretched shapes. The same cause can bring this bug back anywhere you split width and height into 0–1 independently: Canvas2D, UV coordinates in WebGL and shaders, SVG viewBox, normalized world coordinates in game engines, and so on.
Situation
formation is a browser app that overlays a grid on dance practice videos and lets you mark each dancer's position at each point in time (keyframe), so you can visualize and edit formations and movement paths. Coordinates are stored relative to the stage, with both x and y normalized to 0–1 . The video resolution is 1920×1080 (16:9), and the canvas is drawn to match the video's aspect ratio .
Before this, the session had been working on removing coordinate jitter and snapping alignments (formation2.json v1). Then the user raised a new issue: "Can you check the grid size too? I'm trying to place things on the grid, but right now they don't line up with it and look a bit skewed." That's when the grid investigation began .
Symptom
The choreography data had 12 dancers and 83 keyframes, and its coordinates didn't sit cleanly on the grid. They were off. At first it looked like plain human error, as if the choreographer had placed points by eye. But measurements showed a median deviation from the grid lines of 4% in x and 7% in y . The horizontal and vertical errors were different enough in size that writing it off as random error felt wrong.
Cause
Two causes were stacked on top of each other.
1) Grid spacing mismatch from the aspect ratio. Work the grid definition cell = gridSize × (w/800) out in normalized coordinates and the horizontal spacing stays fixed at gridSize/800, while the vertical spacing is that value multiplied by the aspect ratio (w/h). For a 16:9 video that's 0.0625 horizontally and 0.1111 vertically. The cells are identical squares on screen, but in coordinate-space numbers the vertical spacing is nearly double . If you miss this and check the snapping against a full-cell grid with equal horizontal and vertical spacing, even correct coordinates look misaligned.
2) The editor had no drag snapping. With the grid spacing recalculated correctly, an error of 4% in x and 7% in y still remained. The distribution showed the values clustering around half-cell positions, not full cells . Measured against a half-cell grid (0.03125 horizontal, 0.0556 vertical), 91% of points fit, compared with 55% against the full-cell grid . A look at the editor code (formation7.html:120) showed there was no snapping at all when dragging . So the choreographer had been placing points by eye on the grid lines and cell centers visible on screen, and they drifted slightly in the process.
On top of that, the data file used the old format (a bare array), which doesn't store the gridSize that was actually used. So the grid size had to be worked out statistically by scanning the whole 20–100 range for the best fit . The best fit was the half-cell of on-screen gridSize 50 (which equals a gridSize 25 grid, 0.03125 horizontal / 0.05556 vertical), at 90% fit in x and 91% in y .
Fix
- Re-measured against a corrected grid that accounts for the video's 16:9 aspect ratio, multiplying the vertical grid spacing by
w/h. - Scanned gridSize 20–100 to work out and lock in the grid actually used for placement (half-grid, 0.03125 horizontal / 0.05556 vertical) .
- Snapped each dancer's coordinates to the nearest grid point, and moved any dancers that landed in the same cell to the nearest empty cell. In the end all 1034 points landed on the grid. 7 points moved more than 3% of the stage (max 3.6%), and 6 collisions were resolved .
- After a follow-up request to "try placing them more densely," the data was saved in the new
{gridSize, keyframes}format with the grid narrowed from 50 to 25 (half the snap spacing), and snapping was re-applied. The maximum move dropped from 3.6% to 1.7%, and moves over 3% dropped to zero .
In code, the core problem was that the vertical spacing was missing the aspect-ratio correction.
// Wrong assumption: compare as if horizontal and vertical spacing are equal
stepX = gridSize / canvasWidthNorm // 0.0625
stepY = gridSize / canvasWidthNorm // compared as the same 0.0625 → doesn't match reality
// Correct calculation: multiply the vertical by the aspect ratio (w/h)
stepX = gridSize / 800 // 0.0625
stepY = (gridSize / 800) * (w / h) // 0.0625 * 16/9 ≈ 0.1111
Bonus — a side lesson from the same incident
The same grid inference and snapping process was applied to another choreography file (환상동화.json, "Fantasy Fairy Tale"). It matched the same half-grid (98% fit in x, 93% in y), and all 927 points landed, with a max move of 1.4% and zero collisions. That finished even more cleanly than formation . But nowhere in this session does the user explicitly say they opened the comparison viewer, checked the result with their own eyes, and approved it. At every step the assistant reported completion itself, with lines like "Perfect" and "all 927 points landed" . The user just moved on to the next instruction. The assistant asked "Should I overwrite the original?" and got no answer before the conversation moved on to setting up a mobile hotspot . It's a good example of how an implementer's "done" report and a user's actual confirmation are two separate things.
Reporter's Notes — where did the square get stretched?
cell = gridSize × (w/800) .gridSize/800, but the vertical has to be multiplied by the aspect ratio. It's 16:9, so the vertical spacing is almost twice as wide .