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');
+ });
});