Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
110 changes: 35 additions & 75 deletions src/court/curves.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@
*/

/**
* Compute angle bisector control points for quadratic bezier interpolation.
* For waypoints P0, P1, ..., Pn:
* At each interior point Pi, compute angle bisector of incoming/outgoing vectors.
*
* Smooth quadratic spline through a set of waypoints.
*
* Each interior waypoint becomes the control point of a quadratic Bézier, and
* consecutive spans are joined at the segment midpoints so the curve passes
* smoothly *through* the waypoints and bulges toward them (the intuitive
* "outward" direction the author drew). The first span starts at P0 and the
* last span ends exactly on Pn, so endpoints are honored.
*
* @param {Array<{x:number, y:number}>} points - Waypoints
* @returns {string} SVG path data
*/
Expand All @@ -17,64 +21,19 @@ export function curvedPath(points) {
return `M ${points[0].x},${points[0].y} L ${points[1].x},${points[1].y}`;
}

const n = points.length;
let d = `M ${points[0].x},${points[0].y}`;

for (let i = 1; i < points.length - 1; i++) {
const prev = points[i - 1];
const curr = points[i];
const next = points[i + 1];

// Vectors from curr to prev and curr to next
const v1x = prev.x - curr.x;
const v1y = prev.y - curr.y;
const v2x = next.x - curr.x;
const v2y = next.y - curr.y;

const len1 = Math.sqrt(v1x * v1x + v1y * v1y);
const len2 = Math.sqrt(v2x * v2x + v2y * v2y);

if (len1 === 0 || len2 === 0) {
d += ` L ${curr.x},${curr.y}`;
continue;
}

// Normalized vectors
const n1x = v1x / len1, n1y = v1y / len1;
const n2x = v2x / len2, n2y = v2y / len2;

// Bisector direction (pointing "outward" from the angle)
const bx = n1x + n2x;
const by = n1y + n2y;
const bLen = Math.sqrt(bx * bx + by * by);

if (bLen < 0.001) {
// Points are collinear, use straight line to midpoints
const mid1x = (prev.x + curr.x) / 2;
const mid1y = (prev.y + curr.y) / 2;
d += ` L ${mid1x},${mid1y} L ${curr.x},${curr.y}`;
continue;
}

// Control point offset along bisector
const controlDist = Math.min(len1, len2) * 0.3;
const cx = curr.x + (bx / bLen) * controlDist;
const cy = curr.y + (by / bLen) * controlDist;

// Midpoints for smooth transitions
const mid1x = (prev.x + curr.x) / 2;
const mid1y = (prev.y + curr.y) / 2;

if (i === 1) {
d += ` Q ${cx},${cy} ${curr.x},${curr.y}`;
} else {
d += ` Q ${cx},${cy} ${curr.x},${curr.y}`;
}
for (let i = 1; i < n - 1; i++) {
const ctrl = points[i];
// End each span at the midpoint to the next waypoint, except the final span
// which lands on the last point.
const end = (i === n - 2)
? points[n - 1]
: { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 };
d += ` Q ${ctrl.x},${ctrl.y} ${end.x},${end.y}`;
}

// Last segment
const last = points[points.length - 1];
d += ` L ${last.x},${last.y}`;

return d;
}

Expand Down Expand Up @@ -125,27 +84,21 @@ export function dribblingPath(points, scale = 1) {
// Walk along path and create wave control points
let d = `M ${points[0].x},${points[0].y}`;
let walked = 0;
let segIdx = 0;
let segWalked = 0;
let side = 1;

for (let w = 0; w < numWaves; w++) {
const halfTarget = walked + segLen / 2;
const endTarget = walked + segLen;

// Find midpoint along path
// Midpoint of this wave, plus the tangent of whichever segment it lies on —
// so the perpendicular follows bends in the path instead of always using
// the first segment's direction.
const mid = pointAlongPath(segments, halfTarget);
const end = pointAlongPath(segments, endTarget);

// Direction at midpoint
const seg = segments[Math.min(segIdx, segments.length - 1)];
const dx = seg.to.x - seg.from.x;
const dy = seg.to.y - seg.from.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;

// Perpendicular
const px = -dy / len;
const py = dx / len;
// Perpendicular to the local tangent at the midpoint
const px = -mid.ty;
const py = mid.tx;

const cx = mid.x + px * amplitude * side;
const cy = mid.y + py * amplitude * side;
Expand All @@ -159,20 +112,27 @@ export function dribblingPath(points, scale = 1) {
}

/**
* Find a point at a given distance along a series of segments.
* Find a point at a given distance along a series of segments, together with the
* unit tangent (tx, ty) of the segment it falls on.
*/
function pointAlongPath(segments, distance) {
let remaining = distance;
for (const seg of segments) {
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
const dx = seg.to.x - seg.from.x;
const dy = seg.to.y - seg.from.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (remaining <= len || seg === segments[segments.length - 1]) {
const tx = len > 0 ? dx / len : 0;
const ty = len > 0 ? dy / len : 0;
if (remaining <= len || i === segments.length - 1) {
const t = len > 0 ? Math.min(remaining / len, 1) : 0;
return { x: seg.from.x + dx * t, y: seg.from.y + dy * t };
return { x: seg.from.x + dx * t, y: seg.from.y + dy * t, tx, ty };
}
remaining -= len;
}
const last = segments[segments.length - 1];
return { x: last.to.x, y: last.to.y };
const dx = last.to.x - last.from.x;
const dy = last.to.y - last.from.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return { x: last.to.x, y: last.to.y, tx: dx / len, ty: dy / len };
}
29 changes: 12 additions & 17 deletions src/court/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,25 +256,20 @@ function renderPlayer(pos, transform, scheme) {
svg += `<text x="${svgPos.x}" y="${svgPos.y}" text-anchor="middle" dominant-baseline="central" fill="${scheme.offense_stroke}" font-size="13" font-weight="bold" font-family="Arial, sans-serif">${e.nr}</text>`;
} else if (e.type === 'defense') {
const fill = e.color ? getColor(e.color, scheme) : scheme.defense_fill;
// Defense: FIBA-style symbol — horseshoe (open-top arc) + small inner circle
// Original FIBA path at scale 1 (width=40, height=24): outer arc + inner arc → closed horseshoe
// We scale to fit our radius r: scale = r*2/40 = r/20
const s = r / 20;
const n = (v) => Math.round(v * 10) / 10; // round to 1 decimal
const cx = svgPos.x, cy = svgPos.y;
const n = (v) => Math.round(v * 10) / 10;
// White background for contrast.
svg += `<circle cx="${cx}" cy="${cy}" r="${r + 2}" fill="white" opacity="0.9"/>`;
// Horseshoe: outer bezier arc + inner bezier arc (closed), centered at (cx, cy)
// Outer arc: M (cx-20s, cy+10s) C (cx-10s, cy-6s) (cx+10s, cy-6s) (cx+20s, cy+10s)
// Inner arc: C (cx+15s, cy-14s) (cx-15s, cy-14s) (cx-20s, cy+10s) Z
const ox1 = n(cx - 20*s), oy1 = n(cy + 10*s);
const ox2 = n(cx + 20*s), oy2 = n(cy + 10*s);
const oc1x = n(cx - 10*s), oc1y = n(cy - 6*s);
const oc2x = n(cx + 10*s), oc2y = n(cy - 6*s);
const ic1x = n(cx + 15*s), ic1y = n(cy - 14*s);
const ic2x = n(cx - 15*s), ic2y = n(cy - 14*s);
svg += `<path d="M ${ox1},${oy1} C ${oc1x},${oc1y} ${oc2x},${oc2y} ${ox2},${oy2} C ${ic1x},${ic1y} ${ic2x},${ic2y} ${ox1},${oy1} Z" fill="${fill}" stroke="none"/>`;
// Inner white circle
svg += `<circle cx="${cx}" cy="${cy}" r="${n(r * 0.45)}" fill="white" stroke="none"/>`;
// FIBA defender: a chunky horseshoe (open at the bottom) drawn as a single
// thick stroked arc — no self-intersecting fill — with the number inside.
const Rc = r * 0.72; // centerline radius of the band
const sw = Math.max(2.5, r * 0.42); // band thickness
const gap = 52 * Math.PI / 180; // half-angle of the bottom opening
const aStart = -Math.PI / 2 - gap; // lower-left leg
const aEnd = -Math.PI / 2 + gap; // lower-right leg
const pt = (a) => `${n(cx + Rc * Math.cos(a))},${n(cy - Rc * Math.sin(a))}`;
// Major arc over the top: large-arc-flag=1, sweep-flag=1.
svg += `<path d="M ${pt(aStart)} A ${n(Rc)},${n(Rc)} 0 1 1 ${pt(aEnd)}" fill="none" stroke="${fill}" stroke-width="${n(sw)}" stroke-linecap="round"/>`;
svg += `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central" fill="${fill}" font-size="11" font-weight="bold" font-family="Arial, sans-serif">${e.nr}</text>`;
} else if (e.type === 'coach') {
svg += `<circle cx="${svgPos.x}" cy="${svgPos.y}" r="${r + 2}" fill="white" opacity="0.9"/>`;
Expand Down
152 changes: 152 additions & 0 deletions tests/curves.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Geometric unit tests for the curve renderers (pure functions — no browser).
*
* Focus: the reported rendering bugs —
* - curved movement lines bulged toward the *inside* of a bend (and were
* half-straight) instead of passing smoothly through the waypoint;
* - dribbling waves used the first segment's direction for every wave, so
* after a bend the zig-zag pointed the wrong way.
*/

import { test, expect } from '@playwright/test';
import { curvedPath, straightPath, dribblingPath } from '../src/court/curves.js';

// ─── Tiny SVG-path sampler (supports the M / L / Q we emit) ─────────────────────

/** Parse "M/L/Q" path data into a list of commands with numeric args. */
function parsePath(d) {
const tokens = d.match(/[MLQ][^MLQ]*/g) || [];
return tokens.map(tok => {
const cmd = tok[0];
const nums = (tok.slice(1).match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
return { cmd, nums };
});
}

/** Collect the anchor points the path passes through (M start + each L/Q end). */
function pathAnchors(d) {
const pts = [];
for (const { cmd, nums } of parsePath(d)) {
if (cmd === 'M' || cmd === 'L') pts.push({ x: nums[0], y: nums[1] });
else if (cmd === 'Q') pts.push({ x: nums[2], y: nums[3] });
}
return pts;
}

/** Quadratic control points (cx,cy) of every Q command. */
function quadControls(d) {
return parsePath(d)
.filter(c => c.cmd === 'Q')
.map(c => ({ x: c.nums[0], y: c.nums[1] }));
}

/** Signed side of point p relative to directed line a→b (>0 left, <0 right). */
function sideOf(a, b, p) {
return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
}

/** Sample a single quadratic Bézier at parameter t. */
function quadAt(p0, c, p1, t) {
const u = 1 - t;
return {
x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x,
y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y,
};
}

// ─── curvedPath ─────────────────────────────────────────────────────────────────

test.describe('curvedPath', () => {
test('two points → a straight segment', () => {
const d = curvedPath([{ x: 0, y: 0 }, { x: 10, y: 0 }]);
expect(d).toBe('M 0,0 L 10,0');
});

test('three points → a single quadratic with the waypoint as control', () => {
const p0 = { x: 0, y: 0 }, p1 = { x: 5, y: 4 }, p2 = { x: 10, y: 0 };
const d = curvedPath([p0, p1, p2]);
const cmds = parsePath(d);
// Exactly: M p0 ; Q p1 p2 — no trailing straight "L" half.
expect(cmds.map(c => c.cmd)).toEqual(['M', 'Q']);
const q = cmds[1];
expect(q.nums).toEqual([5, 4, 10, 0]); // control = waypoint, ends on p2
});

test('curve bulges toward the waypoint (outward), not into the bend', () => {
const p0 = { x: 0, y: 0 }, p1 = { x: 5, y: 4 }, p2 = { x: 10, y: 0 };
const d = curvedPath([p0, p1, p2]);
const ctrl = quadControls(d)[0];
const apex = quadAt(p0, ctrl, p2, 0.5);
// Apex and the waypoint must be on the SAME side of the chord p0→p2.
const sApex = sideOf(p0, p2, apex);
const sWaypoint = sideOf(p0, p2, p1);
expect(Math.sign(sApex)).toBe(Math.sign(sWaypoint));
// And it must actually bow out (not be ~collinear / straight).
expect(Math.abs(apex.y)).toBeGreaterThan(1);
});

test('endpoints are preserved for a multi-point path', () => {
const pts = [
{ x: 0, y: 0 }, { x: 4, y: 3 }, { x: 8, y: -2 }, { x: 12, y: 1 },
];
const anchors = pathAnchors(curvedPath(pts));
expect(anchors[0]).toEqual(pts[0]); // starts at P0
expect(anchors[anchors.length - 1]).toEqual(pts[3]); // ends at Pn
});

test('interior waypoints are used as control points', () => {
const pts = [
{ x: 0, y: 0 }, { x: 4, y: 3 }, { x: 8, y: -2 }, { x: 12, y: 1 },
];
const controls = quadControls(curvedPath(pts));
expect(controls).toContainEqual({ x: 4, y: 3 });
expect(controls).toContainEqual({ x: 8, y: -2 });
});
});

// ─── dribblingPath ──────────────────────────────────────────────────────────────

test.describe('dribblingPath', () => {
test('starts at the first point and ends near the last', () => {
const pts = [{ x: 0, y: 0 }, { x: 20, y: 0 }];
const anchors = pathAnchors(dribblingPath(pts, 1));
expect(anchors[0]).toEqual({ x: 0, y: 0 });
const end = anchors[anchors.length - 1];
expect(end.x).toBeCloseTo(20, 5);
expect(end.y).toBeCloseTo(0, 5);
});

test('consecutive waves alternate sides of a straight path', () => {
const a = { x: 0, y: 0 }, b = { x: 30, y: 0 };
const controls = quadControls(dribblingPath([a, b], 1));
expect(controls.length).toBeGreaterThanOrEqual(2);
// On a horizontal path the wave controls sit above/below the x-axis in turn.
for (let i = 1; i < controls.length; i++) {
expect(Math.sign(controls[i].y)).toBe(-Math.sign(controls[i - 1].y));
expect(Math.abs(controls[i].y)).toBeGreaterThan(0.1);
}
});

test('waves follow a bend: displacement is perpendicular to the local segment', () => {
// L-shaped path: first leg goes +x, second leg goes +y.
const a = { x: 0, y: 0 }, b = { x: 20, y: 0 }, c = { x: 20, y: 20 };
const controls = quadControls(dribblingPath([a, b, c], 1));

// Controls whose midpoint lies on the vertical second leg must be displaced
// horizontally (perpendicular to +y). With the old bug they were displaced
// vertically (using the first segment's perpendicular) — i.e. x≈20.
const onSecondLeg = controls.filter(p => p.y > 1 && p.y < 19);
expect(onSecondLeg.length).toBeGreaterThan(0);
for (const p of onSecondLeg) {
// Perpendicular to the vertical leg ⇒ noticeable x offset from the line x=20.
expect(Math.abs(p.x - 20)).toBeGreaterThan(0.3);
}
});
});

// ─── straightPath (sanity) ──────────────────────────────────────────────────────

test('straightPath chains line segments through every point', () => {
const d = straightPath([{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 4 }]);
expect(d).toBe('M 0,0 L 1,2 L 3,4');
});
Loading
Loading