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
6 changes: 4 additions & 2 deletions src/court/court-svg.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,12 @@ export function renderCourtSVG(ruleset, courtType, transform) {

const cc = t.toSvg(0, 0);
const ccr = t.s(d.centerCircleR);
// Arc from left to right, sweeping AWAY from court (upward in SVG = sweep=0)
// Only the half that lies INSIDE the court is drawn (the half-court view cuts
// the circle at midcourt). That half bulges toward the basket = upward in
// SVG, which is sweep=1 going left→right.
const L = t.toSvg(-d.centerCircleR, 0);
const R = t.toSvg( d.centerCircleR, 0);
svg += `<path d="M ${f(L.x)},${f(L.y)} A ${f(ccr)},${f(ccr)} 0 0,0 ${f(R.x)},${f(R.y)}" fill="none" stroke="${LC}" stroke-width="${LW}"/>`;
svg += `<path d="M ${f(L.x)},${f(L.y)} A ${f(ccr)},${f(ccr)} 0 0,1 ${f(R.x)},${f(R.y)}" fill="none" stroke="${LC}" stroke-width="${LW}"/>`;
svg += `<circle cx="${f(cc.x)}" cy="${f(cc.y)}" r="3" fill="${LC}"/>`;
}

Expand Down
6 changes: 5 additions & 1 deletion src/editor/context-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ export class ContextMenu {
btn.appendChild(label);
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.state.setTool(tool.id);
// Start the line at the selected player so it becomes the first
// waypoint (e.g. dribble/pass begins from that player).
const key = this.state.selectedEntityKey;
if (key) this.state.startLineTool(tool.id, key);
else this.state.setTool(tool.id);
this._hide();
});
toolGroup.appendChild(btn);
Expand Down
23 changes: 23 additions & 0 deletions src/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,29 @@ export class EditorState {
setTool(tool) {
this.activeTool = tool;
this.lineWaypoints = [];
this._lineFromEntity = undefined;
this._lineToEntity = undefined;
this.notify('tool');
}

/**
* Activate a line tool that starts at a given entity. The entity's current
* position becomes the first waypoint (so e.g. "Dribble" started on a player
* draws from that player), which is the intuitive behavior.
*/
startLineTool(tool, entityKey) {
this.activeTool = tool;
this.lineWaypoints = [];
this._lineFromEntity = undefined;
this._lineToEntity = undefined;
const pos = resolveEntityPositions(this.doc, this.currentFrameIndex).get(entityKey);
if (pos) {
this.lineWaypoints.push({
x: Math.round(pos.x * 100) / 100,
y: Math.round(pos.y * 100) / 100,
});
this._lineFromEntity = entityKey;
}
this.notify('tool');
}

Expand Down
17 changes: 13 additions & 4 deletions src/editor/interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,17 @@ export class InteractionManager {
return t.toCourt(svgPt.x, svgPt.y);
}

/** Find which entity is at a given SVG position. */
_hitTestEntity(svgX, svgY) {
/**
* Find which entity is at a given position (in viewBox units).
* The radius is in viewBox units; since the court is scaled to fit, a touch
* needs a much larger radius than a mouse to be comfortable on a phone.
*/
_hitTestEntity(svgX, svgY, radius = 22) {
const t = this.getTransform();
if (!t) return null;
const positions = resolveEntityPositions(this.state.doc, this.state.currentFrameIndex);
let closest = null;
let closestDist = 15; // hit radius in pixels
let closestDist = radius;

for (const [key, pos] of positions) {
const eSvg = t.toSvg(pos.x, pos.y);
Expand Down Expand Up @@ -120,12 +124,17 @@ export class InteractionManager {
return best;
}

/** Generous hit radius (viewBox units) for coarse pointers (finger) vs mouse. */
_hitRadius(e) {
return e && e.pointerType === 'touch' ? 42 : 22;
}

_onPointerDown(e) {
if (e.button !== 0) return;
if (this.state.activeTool !== 'select') return;

const svgPt = this._svgPoint(e);
const hitKey = this._hitTestEntity(svgPt.x, svgPt.y);
const hitKey = this._hitTestEntity(svgPt.x, svgPt.y, this._hitRadius(e));

if (hitKey) {
const t = this.getTransform();
Expand Down
3 changes: 3 additions & 0 deletions style.css
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,9 @@ body {
height: 100%;
display: block;
cursor: default;
/* Stop the browser from claiming touch gestures (scroll/pan/zoom) so that
pointermove is delivered for dragging entities instead of pointercancel. */
touch-action: none;
}

#svg-container.action-mode {
Expand Down
95 changes: 95 additions & 0 deletions tests/regressions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,3 +292,98 @@ test.describe('Regression: touch dragging on a small screen', () => {
expect(moved.y).toBeCloseTo(3, 1);
});
});

// ─── Mobile: touch-action + larger hit radius + dribble start ────────────────────

test.describe('Regression: court allows touch dragging (touch-action)', () => {
test('the court SVG sets touch-action:none so the browser does not steal drags', async ({ page }) => {
await openEditor(page);
const ta = await page.evaluate(
() => getComputedStyle(document.getElementById('court-svg')).touchAction,
);
// Without this the browser treats a finger drag as scroll/pan and fires
// pointercancel, so entities could never be dragged on a phone.
expect(ta).toBe('none');
});
});

test.describe('Regression: generous touch hit radius', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });

test('a finger tap slightly off the token still grabs and drags it', async ({ page }) => {
await openEditor(page);
await page.evaluate(() => {
window.OCFEditor.editorState.addEntity({ type: 'offense', nr: 1, x: 0, y: 7 });
});

const moved = await page.evaluate(() => {
const svg = document.getElementById('court-svg');
const t = window.OCFEditor.getCurrentTransform();
const ctm = svg.getScreenCTM();
const toClientVB = (vx, vy) => {
const p = svg.createSVGPoint();
p.x = vx; p.y = vy;
const s = p.matrixTransform(ctm);
return { x: s.x, y: s.y };
};
const center = t.toSvg(0, 7); // player center in viewBox units
// Tap 28 viewBox units away — outside the old 15-unit radius, inside the
// new 42-unit touch radius.
const tap = toClientVB(center.x + 28, center.y);
const target = toClientVB(center.x + 28 + 60, center.y); // drag right
const fire = (type, c, buttons) => svg.dispatchEvent(new PointerEvent(type, {
bubbles: true, cancelable: true, view: window,
pointerId: 1, pointerType: 'touch', isPrimary: true,
clientX: c.x, clientY: c.y, button: 0, buttons,
}));
fire('pointerdown', tap, 1);
fire('pointermove', target, 1);
fire('pointerup', target, 0);
const p = window.OCFEditor.editorState.doc.entities.find(e => e.type === 'offense');
return { x: p.x, y: p.y, selected: window.OCFEditor.editorState.selectedEntityKey };
});

expect(moved.selected).toBe('offense_1'); // the off-center tap still grabbed it
expect(moved.x).toBeGreaterThan(0.5); // and it actually moved right
});
});

test.describe('Regression: drawing a line starts at the selected player', () => {
test('startLineTool seeds the player as the first waypoint', async ({ page }) => {
await openEditor(page);
const res = await page.evaluate(() => {
const s = window.OCFEditor.editorState;
s.addEntity({ type: 'offense', nr: 1, x: 2, y: 6 });
s.select('offense_1');
s.startLineTool('line_dribbling', 'offense_1');
return {
tool: s.activeTool,
first: s.lineWaypoints[0],
count: s.lineWaypoints.length,
from: s._lineFromEntity,
};
});
expect(res.tool).toBe('line_dribbling');
expect(res.count).toBe(1);
expect(res.first).toEqual({ x: 2, y: 6 });
expect(res.from).toBe('offense_1');
});

test('the context-menu Dribble button begins the line from that player', async ({ page }) => {
await openEditor(page);
await page.evaluate(() => {
const s = window.OCFEditor.editorState;
s.addEntity({ type: 'offense', nr: 1, x: -1, y: 5 });
s.select('offense_1');
});
// The floating action menu is shown for the selected player.
await page.locator('.ctx-menu .ctx-btn[data-tool="line_dribbling"]').click();
const res = await page.evaluate(() => {
const s = window.OCFEditor.editorState;
return { count: s.lineWaypoints.length, first: s.lineWaypoints[0], from: s._lineFromEntity };
});
expect(res.count).toBe(1);
expect(res.first).toEqual({ x: -1, y: 5 });
expect(res.from).toBe('offense_1');
});
});
22 changes: 22 additions & 0 deletions tests/renderer.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,26 @@ test.describe('court + lines', () => {
).svgContent;
expect(svg).toMatch(/stroke-dasharray="6,4"/);
});

test('half-court center-circle arc bulges into the court (not outside it)', () => {
// The half-court view cuts the center circle at the midcourt line; only the
// half lying inside the court should be drawn. Locate the arc by its
// endpoints sitting on the midcourt line (court y=0) and assert it bulges
// toward the basket (smaller SVG y = into the court).
const { svgContent, transform } = renderOCF(doc([]), 0, 700, 760);
const midY = transform.toSvg(0, 0).y; // midcourt line in SVG coords

// Every "M x1,y1 A rx,ry 0 large,sweep x2,y2" arc in the output.
const arcs = [...svgContent.matchAll(
/M\s*([\d.]+),([\d.]+)\s*A\s*[\d.]+,[\d.]+\s+0\s+\d+,(\d)\s+([\d.]+),([\d.]+)/g,
)].map(m => ({
y1: +m[2], sweep: m[3], y2: +m[5],
}));

// The center-circle arc: both endpoints lie on the midcourt line.
const cc = arcs.find(a => Math.abs(a.y1 - midY) < 0.5 && Math.abs(a.y2 - midY) < 0.5);
expect(cc, 'expected a center-circle arc on the midcourt line').toBeTruthy();
// sweep=1 makes a left→right arc bulge upward (into the court) in SVG coords.
expect(cc.sweep).toBe('1');
});
});
Loading