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
36 changes: 16 additions & 20 deletions src/editor/context-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class ContextMenu {
this._ballBtn.appendChild(ballLabel);
this._ballBtn.addEventListener('click', (e) => {
e.stopPropagation();
this._toggleBall();
this._assignBall();
});

// Delete button
Expand Down Expand Up @@ -150,33 +150,29 @@ export class ContextMenu {
this.menu.style.left = left + 'px';
this.menu.style.top = top + 'px';

// Reflect has_ball state
const entity = this.state.doc.entities?.[entityKey];
if (this._ballBtn && entity) {
this._ballBtn.classList.toggle('active', !!entity.has_ball);
// Ball possession only applies to players. Reflect whether this player
// currently holds the ball (i.e. the ball entity sits on top of them).
if (this._ballBtn) {
const type = pos.entity?.type;
const isPlayer = type === 'offense' || type === 'defense' || type === 'coach';
this._ballBtn.style.display = isPlayer ? '' : 'none';
const ballPos = positions.get('ball');
const holds = isPlayer && ballPos
&& Math.abs(ballPos.x - pos.x) < 0.01
&& Math.abs(ballPos.y - pos.y) < 0.01;
this._ballBtn.classList.toggle('active', !!holds);
}
}

_hide() {
this.menu.classList.add('hidden');
}

_toggleBall() {
_assignBall() {
const key = this.state.selectedEntityKey;
if (!key) return;
const entity = this.state.doc.entities?.[key];
if (!entity) return;

this.state.saveUndo();
entity.has_ball = !entity.has_ball;

// Only one player can have the ball at a time
if (entity.has_ball) {
for (const [k, e] of Object.entries(this.state.doc.entities || {})) {
if (k !== key) e.has_ball = false;
}
}

this.state.notify('has-ball-changed');
// Possession is modeled by the ball entity's position (OCF has a single
// ball). assignBall creates/moves the ball onto the selected player.
this.state.assignBall(key);
}
}
48 changes: 44 additions & 4 deletions src/editor/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { createBlankDocument } from '../schema.js';
import { entityKey } from '../court/renderer.js';
import { entityKey, resolveEntityPositions } from '../court/renderer.js';

const MAX_UNDO = 30;

Expand Down Expand Up @@ -142,13 +142,53 @@ export class EditorState {
}
}

/** Get next available number for a given entity type. */
/**
* Get next available number for a given entity type.
* offense/defense are capped at 9 (schema limit); returns null if full.
*/
nextEntityNr(type) {
const existing = this.doc.entities.filter(e => e.type === type).map(e => e.nr || 0);
for (let i = 1; i <= 99; i++) {
const max = (type === 'offense' || type === 'defense') ? 9 : 99;
for (let i = 1; i <= max; i++) {
if (!existing.includes(i)) return i;
}
return existing.length + 1;
return null;
}

/**
* Assign ball possession to a player by moving the ball entity onto them.
* Creates the ball entity if it does not exist yet. Since there is only one
* ball, possession is inherently exclusive. Honors the current frame: on
* frame 0 the base position is updated, otherwise a frame delta is written.
*/
assignBall(playerKey) {
const positions = resolveEntityPositions(this.doc, this.currentFrameIndex);
const target = positions.get(playerKey);
if (!target) return;
const round = (v) => Math.round(v * 100) / 100;

this.saveUndo();

// Ensure a ball entity exists (base position seeded from frame 0).
let ball = this.doc.entities.find(e => e.type === 'ball');
if (!ball) {
const base = resolveEntityPositions(this.doc, 0).get(playerKey) || target;
ball = { type: 'ball', x: round(base.x), y: round(base.y) };
this.doc.entities.push(ball);
}

// Place the ball on the player for the current frame.
if (this.currentFrameIndex === 0) {
ball.x = round(target.x);
ball.y = round(target.y);
} else {
const frame = this.doc.frames[this.currentFrameIndex];
if (frame) {
if (!frame.entity_states) frame.entity_states = {};
frame.entity_states['ball'] = { x: round(target.x), y: round(target.y) };
}
}
this.notify('ball-assign');
}

// --- Line Operations ---
Expand Down
59 changes: 55 additions & 4 deletions src/editor/interaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,17 @@
*/

import { resolveEntityPositions, entityKey } from '../court/renderer.js';
import { findSnapPosition } from '../court/positions.js';
import { findSnapPosition, resolveCoordinate } from '../court/positions.js';

/** Shortest distance from point p to line segment a–b (in pixels). */
function distToSegment(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
let t = lenSq === 0 ? 0 : ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq;
t = Math.max(0, Math.min(1, t));
const cx = a.x + t * dx, cy = a.y + t * dy;
return Math.hypot(p.x - cx, p.y - cy);
}

export class InteractionManager {
constructor(svgElement, editorState, getTransform) {
Expand Down Expand Up @@ -65,6 +75,34 @@ export class InteractionManager {
return closest;
}

/** Find which line (in the current frame) is at a given SVG position. */
_hitTestLine(svgX, svgY) {
const t = this.getTransform();
if (!t) return null;
const frame = this.state.doc.frames[this.state.currentFrameIndex];
if (!frame || !frame.lines) return null;
const ruleset = this.state.doc.court?.ruleset || 'fiba';
const customPos = this.state.doc.named_positions?.custom || {};
const threshold = 8; // hit distance in pixels
let best = null;
let bestDist = threshold;

for (let i = 0; i < frame.lines.length; i++) {
const pts = (frame.lines[i].coords || []).map(c => {
const abs = resolveCoordinate(c, ruleset, customPos);
return t.toSvg(abs.x, abs.y);
});
for (let s = 0; s + 1 < pts.length; s++) {
const d = distToSegment({ x: svgX, y: svgY }, pts[s], pts[s + 1]);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
}
return best;
}

_onMouseDown(e) {
if (e.button !== 0) return;
if (this.state.activeTool !== 'select') return;
Expand Down Expand Up @@ -160,8 +198,13 @@ export class InteractionManager {
const hitKey = this._hitTestEntity(svgPt.x, svgPt.y);
if (hitKey) {
this.state.select(hitKey);
return;
}
// No entity hit — try lines, otherwise deselect.
const lineIdx = this._hitTestLine(svgPt.x, svgPt.y);
if (lineIdx !== null) {
this.state.selectLine(lineIdx);
} else if (!this.dragging || !this.dragging?.hasMoved) {
// Check if we clicked a line (approximate)
this.state.deselect();
}
}
Expand All @@ -188,11 +231,14 @@ export class InteractionManager {
}

_onContextMenu(e) {
// Suppress the browser menu and instead select the entity so the
// floating action menu (with its Delete button) appears. Right-click no
// longer deletes immediately, which was surprising and destructive.
e.preventDefault();
const svgPt = this._svgPoint(e);
const hitKey = this._hitTestEntity(svgPt.x, svgPt.y);
if (hitKey) {
this.state.removeEntity(hitKey);
this.state.select(hitKey);
}
}

Expand Down Expand Up @@ -228,7 +274,8 @@ export class InteractionManager {
entity = { type: 'station', nr: this.state.nextEntityNr('station'), x, y };
break;
}
if (entity) this.state.addEntity(entity);
// nextEntityNr returns null when the numbered slots are exhausted.
if (entity && entity.nr !== null) this.state.addEntity(entity);
}

_onKeyDown(e) {
Expand Down Expand Up @@ -287,6 +334,10 @@ export class InteractionManager {
this.snapIndicator.setAttribute('stroke-width', '2');
this.snapIndicator.setAttribute('stroke-dasharray', '3,2');
this.snapIndicator.setAttribute('pointer-events', 'none');
}
// renderEditor() resets svg.innerHTML on every move, detaching the
// indicator — re-append it whenever it is not currently in the DOM.
if (!this.snapIndicator.isConnected) {
this.svg.appendChild(this.snapIndicator);
}
this.snapIndicator.setAttribute('cx', svgPt.x);
Expand Down
3 changes: 2 additions & 1 deletion src/editor/palette.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ export class Palette {
entity = { type: 'station', nr: this.state.nextEntityNr('station'), x: pos.x + 2, y: pos.y };
break;
}
if (entity) this.state.addEntity(entity);
// nextEntityNr returns null when the numbered slots are exhausted.
if (entity && entity.nr !== null) this.state.addEntity(entity);
}
}

Expand Down
35 changes: 32 additions & 3 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@

import { renderOCF, resolveEntityPositions, entityKey } from './court/renderer.js';
import { createTransform } from './court/court-svg.js';
import { straightPath } from './court/curves.js';
import { straightPath, curvedPath } from './court/curves.js';
import { resolveCoordinate } from './court/positions.js';
import { FramePlayer } from './player/player.js';
import { EditorState } from './editor/editor.js';
import { Palette } from './editor/palette.js';
Expand All @@ -20,11 +21,16 @@ import { exportJSON, importJSON, toJSONString } from './export/json.js';
let currentMode = 'editor'; // 'editor' | 'viewer'
let currentTransform = null;

/** Current court transform (court units ↔ viewBox units). Exposed for tests. */
export function getCurrentTransform() {
return currentTransform;
}

// --- Editor State ---
const editorState = new EditorState({ ruleset: 'fiba', courtType: 'half_court', drillFocus: 'offense' });
export const editorState = new EditorState({ ruleset: 'fiba', courtType: 'half_court', drillFocus: 'offense' });

// --- Frame Player ---
const player = new FramePlayer({
export const player = new FramePlayer({
onFrameChange: (idx) => {
if (currentMode === 'viewer') renderViewer();
updatePlayerControls();
Expand Down Expand Up @@ -201,6 +207,29 @@ function renderEditor() {
}
}

// Selected line highlight
if (editorState.selectedLineIndex !== null && transform) {
const frame = editorState.doc.frames[editorState.currentFrameIndex];
const line = frame?.lines?.[editorState.selectedLineIndex];
if (line) {
const ruleset = editorState.doc.court?.ruleset || 'fiba';
const customPos = editorState.doc.named_positions?.custom || {};
const pts = (line.coords || []).map(c => {
const abs = resolveCoordinate(c, ruleset, customPos);
return transform.toSvg(abs.x, abs.y);
});
if (pts.length >= 2) {
const d = line.curved ? curvedPath(pts) : straightPath(pts);
if (d) {
overlay += `<path d="${d}" fill="none" stroke="#00aaff" stroke-width="6" stroke-opacity="0.35" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>`;
}
for (const pt of pts) {
overlay += `<circle cx="${pt.x}" cy="${pt.y}" r="4" fill="#00aaff" pointer-events="none"/>`;
}
}
}
}

// Line drawing preview
if (editorState.lineWaypoints.length > 0 && transform) {
const pts = editorState.lineWaypoints.map(p => transform.toSvg(p.x, p.y));
Expand Down
Loading
Loading