diff --git a/src/court/court-svg.js b/src/court/court-svg.js index 3e99d9d..7157bc7 100644 --- a/src/court/court-svg.js +++ b/src/court/court-svg.js @@ -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 += ``; + svg += ``; svg += ``; } diff --git a/src/editor/context-menu.js b/src/editor/context-menu.js index a35eb2b..2f7f414 100644 --- a/src/editor/context-menu.js +++ b/src/editor/context-menu.js @@ -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); diff --git a/src/editor/editor.js b/src/editor/editor.js index 84ddfe2..17f39e7 100644 --- a/src/editor/editor.js +++ b/src/editor/editor.js @@ -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'); } diff --git a/src/editor/interaction.js b/src/editor/interaction.js index 3bf80cc..176dcee 100644 --- a/src/editor/interaction.js +++ b/src/editor/interaction.js @@ -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); @@ -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(); diff --git a/style.css b/style.css index 225483e..59983e0 100644 --- a/style.css +++ b/style.css @@ -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 { diff --git a/tests/regressions.spec.js b/tests/regressions.spec.js index 8b1f04f..b16d546 100644 --- a/tests/regressions.spec.js +++ b/tests/regressions.spec.js @@ -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'); + }); +}); diff --git a/tests/renderer.spec.js b/tests/renderer.spec.js index e5208f3..bd71e61 100644 --- a/tests/renderer.spec.js +++ b/tests/renderer.spec.js @@ -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'); + }); });