@luceosports/play-rendering 2.6.1 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,536 +1,570 @@
1
- import _ from 'lodash';
2
- import Bezier from '../math/Bezier';
3
- import { animationProgress, transformShapeTypeToImportKey } from '../helpers/common';
4
- import { distanceBetweenPoints, splitBezierCurveAtTVal } from '../math/LineDrawingMath';
5
- import CourtLayer from '../layers/CourtLayer';
6
- import LineLayer from '../layers/LineLayer';
7
- import PlayerLayer from '../layers/PlayerLayer';
8
- import ShapeLayer from '../layers/ShapeLayer';
9
- import NoteLayer from '../layers/NoteLayer';
10
- import LineControlPointLayer from '../layers/LineControlPointLayer';
11
- import ShapeControlPointLayer from '../layers/ShapeControlPointLayer';
12
- import LineModel, { LinePartAdjusted } from './LineModel';
13
- import PlayerModel from './PlayerModel';
14
- import NoteModel from './NoteModel';
15
- import * as ShapeModels from './ShapeModels';
16
-
17
- import {
18
- COURT_TYPE_FOOTBALL_HIGH_SCHOOL,
19
- COURT_TYPE_FOOTBALL_HIGH_SCHOOL_LEGACY,
20
- SPORT_TYPE_BASKETBALL,
21
- SPORT_TYPE_FOOTBALL
22
- } from '../constants';
23
-
24
- import PlayModel, { PlayModelOptions, PlayStaticData } from './PlayModel';
25
- import { AnimationMode, Court, CourtPoint, SportType } from '../types';
26
- import ShapeModel from './ShapeModel';
27
- import { getEffectiveTimeline, getLineAnimationChunks, getTimelineKey, keyTimesToChunks } from '../helpers/animation';
28
-
29
- export type FrameData = {
30
- sport: SportType;
31
- court: Court;
32
- lines: LineModel[];
33
- players: PlayerModel[];
34
- shapes: ShapeModel[];
35
- notes: NoteModel[];
36
- };
37
-
38
- export type FrameDataOptions = PlayModelOptions & {
39
- staticData: PlayStaticData;
40
- scale: number;
41
- animationGlobalProgress: number;
42
- playAnimationDuration: number;
43
- };
44
-
45
- type ClosestObjectDistance<T extends PlayerModel | ShapeModel | NoteModel | LineModel> = {
46
- object: T;
47
- objectPoint: CourtPoint;
48
- distance: number;
49
- };
50
-
51
- type InferEntityFromObjectType<T> = T extends 'player'
52
- ? PlayerModel
53
- : T extends 'note'
54
- ? NoteModel
55
- : T extends 'shape'
56
- ? ShapeModel
57
- : LineModel;
58
-
59
- export default class FrameModel {
60
- private ctx: CanvasRenderingContext2D | null;
61
- private animationGlobalProgressPrev: number;
62
-
63
- constructor(private play: PlayModel, private phase = 1, private animationGlobalProgress = 0) {
64
- this.animationGlobalProgressPrev = animationGlobalProgress;
65
- this.ctx = null;
66
- }
67
-
68
- get sport(): SportType {
69
- const { court, sport } = this.play.playData;
70
- if (sport) return sport;
71
- return [COURT_TYPE_FOOTBALL_HIGH_SCHOOL_LEGACY, COURT_TYPE_FOOTBALL_HIGH_SCHOOL].includes(court.type)
72
- ? SPORT_TYPE_FOOTBALL
73
- : SPORT_TYPE_BASKETBALL;
74
- }
75
-
76
- get court(): Court {
77
- const { court } = this.play.playData;
78
- return {
79
- ...court,
80
- type: court.type === COURT_TYPE_FOOTBALL_HIGH_SCHOOL_LEGACY ? COURT_TYPE_FOOTBALL_HIGH_SCHOOL : court.type
81
- };
82
- }
83
-
84
- get frameWidth() {
85
- return this.play.width;
86
- }
87
-
88
- get frameHeight() {
89
- return this.play.height;
90
- }
91
-
92
- get frameData() {
93
- return {
94
- sport: this.sport,
95
- court: this.court,
96
- lines: this.lines,
97
- players: this.players,
98
- shapes: this.shapes,
99
- notes: this.notes
100
- };
101
- }
102
-
103
- get frameDataOptions(): FrameDataOptions {
104
- return {
105
- ...this.play.options,
106
- staticData: this.play.staticData,
107
- scale: this.play.scale,
108
- animationGlobalProgress: this.animationGlobalProgress,
109
- playAnimationDuration: this.playAnimationDuration
110
- };
111
- }
112
-
113
- get playAnimationDuration() {
114
- const mode = this.play.options.animationMode;
115
- return this.play.playData.lines.reduce<number>((result, l) => {
116
- const line = new LineModel(l);
117
- const lineLastAnimEndTime = line.lastAnimEndTime(mode)!;
118
- return result > lineLastAnimEndTime ? result : lineLastAnimEndTime;
119
- }, 0);
120
- }
121
-
122
- get lines() {
123
- return this.animationGlobalProgress ? this.animationProgressLines : this.currentPhaseLines;
124
- }
125
-
126
- get players() {
127
- return this.animationGlobalProgress ? this.animationProgressPlayers : this.currentPhasePlayers;
128
- }
129
-
130
- get shapes(): ShapeModel[] {
131
- if (!this.play.playData.shapes) return [];
132
- return this.play.playData.shapes
133
- .filter(s => {
134
- return ShapeModels[transformShapeTypeToImportKey(s.type) as keyof typeof ShapeModels] !== undefined;
135
- })
136
- .map(s => {
137
- return new ShapeModels[transformShapeTypeToImportKey(s.type) as keyof typeof ShapeModels](s);
138
- });
139
- }
140
-
141
- get notes(): NoteModel[] {
142
- if (!this.play.playData.notes) return [];
143
- return this.play.playData.notes
144
- .filter(n => {
145
- return !n.displayModes || (n.displayModes as string[]).includes('onCourt');
146
- })
147
- .map(n => {
148
- return new NoteModel(n);
149
- });
150
- }
151
-
152
- get currentPhasePlayers() {
153
- const { currentPhaseLines } = this;
154
- const { huddleMode, magnetMode, animationMode } = this.play.options;
155
-
156
- const lines = huddleMode || magnetMode ? this.playDataLines : this.prevPhaseLines;
157
-
158
- const passLines = lines.filter(l => ['PASS', 'HANDOFF'].includes(l.type));
159
-
160
- return this.play.playData.players.map(p => {
161
- const player = new PlayerModel(p);
162
- player.setPossession(passLines);
163
-
164
- const playerPrevLines = lines
165
- .filter(l => {
166
- if (!huddleMode) return true;
167
- if (this.phase < l.phase) return false;
168
- return !currentPhaseLines.find(item => item.id === l.id); // exclude last 2 lines in huddle mode
169
- })
170
- .filter(l => l.playerPositionOrigin === player.position && l.playerPositionTerminus === player.position);
171
-
172
- const playerCurrentPhaseLines = currentPhaseLines.filter(
173
- l => magnetMode && l.playerPositionOrigin === player.position && l.playerPositionTerminus === player.position
174
- );
175
-
176
- const playerLines = playerCurrentPhaseLines.length ? playerCurrentPhaseLines : playerPrevLines;
177
- if (playerLines.length) {
178
- const maxLine = _.maxBy(playerLines, l => l.lastAnimEndTime(animationMode));
179
- if (maxLine) {
180
- const lastPoint = maxLine.lastLinePartControlPoint;
181
- if (lastPoint) {
182
- player.location = lastPoint;
183
- }
184
- }
185
- }
186
- return player;
187
- });
188
- }
189
-
190
- get currentPhaseLines() {
191
- let lines = this.play.playData.lines.filter(l => l.phase === this.phase);
192
- if (this.play.options.huddleMode) {
193
- lines = _.takeRight(lines, 2);
194
- }
195
- return lines.map(l => {
196
- return new LineModel(l);
197
- });
198
- }
199
-
200
- get animationProgressPlayers() {
201
- const passLines = this.prevAnimationLines.filter(l => ['PASS', 'HANDOFF', 'SHOT'].includes(l.type));
202
- const { animationMode } = this.play.options;
203
-
204
- // TODO MAYBE WE WILL SUSPEND PLAYER ANIMATIONS COMPLETELY AND RELY ONLY ON LINE ANIMATIONS TO HAVE SINGE SOURCE OF TRUTH
205
-
206
- return this.play.playData.players.map(p => {
207
- const player = new PlayerModel(p);
208
- player.setPossession(passLines);
209
-
210
- if (!player.animations.length) return player;
211
-
212
- const timeline = getTimelineKey(animationMode);
213
-
214
- player.animations.forEach(animation => {
215
- const keyTimes = getEffectiveTimeline(animation, timeline);
216
- const keyTimesChunks = keyTimesToChunks(keyTimes);
217
-
218
- keyTimesChunks.forEach(([start, end], index) => {
219
- if (_.inRange(this.animationGlobalProgress, start, end)) {
220
- const animProgress = this.animationProgress(start, end);
221
- const bezier = new Bezier(
222
- ...(animation.lineParts[index].controlPoints as ConstructorParameters<typeof Bezier>)
223
- );
224
- player.location = {
225
- x: bezier.x(animProgress),
226
- y: bezier.y(animProgress)
227
- };
228
- }
229
- });
230
- });
231
-
232
- if (this.animationGlobalProgress >= player.lastAnimEndTime(animationMode)!) {
233
- player.location = player.lastAnimationLastLinePartControlPoint!;
234
- }
235
-
236
- return player;
237
- });
238
- }
239
-
240
- get animationProgressLines() {
241
- const { linesDisplayOnMoveOnly, animationMode } = this.play.options;
242
-
243
- return this.play.playData.lines
244
- .map(l => {
245
- const line = new LineModel(l);
246
-
247
- const chunks = getLineAnimationChunks({
248
- mode: animationMode,
249
- line: l,
250
- players: this.play.playData.players
251
- });
252
-
253
- const linePartsAdjusted: LinePartAdjusted[] = [];
254
- chunks.forEach(([start, end], index) => {
255
- if (this.animationGlobalProgressPrev < end && this.animationGlobalProgress >= end) {
256
- window.dispatchEvent(
257
- new CustomEvent('@luceosports/play-rendering:lineAnimationFinished', { detail: { id: line.id } })
258
- );
259
- }
260
-
261
- if (_.inRange(this.animationGlobalProgress, start, end)) {
262
- if (line.type === 'DRIBBLE') {
263
- return linePartsAdjusted.push({ ...line.getLineParts()[index] });
264
- }
265
-
266
- const linePathSplitted = splitBezierCurveAtTVal(
267
- line.getLineParts()[index].controlPoints,
268
- this.animationProgress(start, end)
269
- ) as LinePartAdjusted['controlPoints'][];
270
-
271
- linePartsAdjusted.push({
272
- controlPoints: linePathSplitted[0],
273
- animationSegment: 'processed'
274
- });
275
-
276
- linePartsAdjusted.push({
277
- controlPoints: linePathSplitted[1],
278
- animationSegment: 'active'
279
- });
280
- } else if (this.animationGlobalProgress > end) {
281
- linePartsAdjusted.push({
282
- ...line.getLineParts()[index],
283
- animationSegment: 'processed'
284
- });
285
- } else {
286
- linePartsAdjusted.push({ ...line.getLineParts()[index] });
287
- }
288
- });
289
-
290
- line.setLinePartsAdjusted(linePartsAdjusted);
291
-
292
- if (this.animationGlobalProgress > line.lastAnimEndTime(animationMode)!) {
293
- line.color = { ...line.color, alpha: 0.1 }; // To adjust line cap opacity
294
- }
295
-
296
- return line;
297
- })
298
- .filter(l => {
299
- const [firstAnimation] = l.animations;
300
- const keyTimes =
301
- animationMode === AnimationMode.SEQUENTIAL
302
- ? firstAnimation.keyTimesSeq || firstAnimation.keyTimes
303
- : firstAnimation.keyTimes;
304
-
305
- const [start] = keyTimes;
306
- const [end] = [...keyTimes].reverse();
307
-
308
- const lineOnMove = _.inRange(this.animationGlobalProgress, start, end);
309
- if (linesDisplayOnMoveOnly) return lineOnMove;
310
- return lineOnMove || this.animationGlobalProgress > end || l.phase === this.phase;
311
- });
312
- }
313
-
314
- get playDataLines() {
315
- return this.play.playData.lines.map(l => {
316
- return new LineModel(l);
317
- });
318
- }
319
-
320
- get prevPhaseLines() {
321
- return this.playDataLines.filter(l => l.phase < this.phase);
322
- }
323
-
324
- get prevAnimationLines() {
325
- const mode = this.play.options.animationMode;
326
- return this.playDataLines.filter(l => this.animationGlobalProgress >= l.lastAnimEndTime(mode)!);
327
- }
328
-
329
- draw() {
330
- if (!this.ctx) throw new Error('Canvas context is not provided. Please use setContext() method.');
331
-
332
- // console.time('draw');
333
-
334
- this.ctx.clearRect(0, 0, this.frameWidth, this.frameHeight);
335
-
336
- this._init();
337
-
338
- const { frameData, frameDataOptions } = this;
339
- new CourtLayer(this.ctx, frameData, frameDataOptions).apply();
340
- new ShapeLayer(this.ctx, frameData, frameDataOptions).apply();
341
- new LineLayer(this.ctx, frameData, frameDataOptions).apply();
342
- new PlayerLayer(this.ctx, frameData, frameDataOptions).apply();
343
- new LineControlPointLayer(this.ctx, frameData, frameDataOptions).apply();
344
- new ShapeControlPointLayer(this.ctx, frameData, frameDataOptions).apply();
345
- new NoteLayer(this.ctx, frameData, frameDataOptions).apply();
346
-
347
- // console.timeEnd('draw');
348
-
349
- return this;
350
- }
351
-
352
- _init() {
353
- if (!this.ctx) throw new Error('Canvas context is not provided. Please use setContext() method.');
354
-
355
- this.ctx.canvas.width = this.frameWidth;
356
- this.ctx.canvas.height = this.frameHeight;
357
-
358
- this.ctx.scale(this.play.scale, this.play.scale);
359
- this.ctx.translate(-this.court.courtRect.origin.x, -this.court.courtRect.origin.y);
360
- if (this.play.options.mirror) {
361
- this.ctx.scale(-1, 1);
362
- this.ctx.translate(Math.abs(this.court.courtRect.origin.x) * 2 - this.court.courtRect.size.width, 0);
363
- }
364
- }
365
-
366
- setContext(ctx: CanvasRenderingContext2D) {
367
- this.ctx = ctx;
368
- return this;
369
- }
370
-
371
- setPhase(newPhase: number) {
372
- this.phase = newPhase;
373
- return this;
374
- }
375
-
376
- setAnimationGlobalProgress(newProgress: number) {
377
- this.animationGlobalProgressPrev = this.animationGlobalProgress;
378
- this.animationGlobalProgress = newProgress;
379
- return this;
380
- }
381
-
382
- animationProgress(start: number, end: number) {
383
- return animationProgress(this.animationGlobalProgress, start, end);
384
- }
385
-
386
- closestObjectToPoint<T extends 'player' | 'shape' | 'note' | 'line' | 'lineVertex'>(
387
- viewPoint: CourtPoint,
388
- objectType: T,
389
- needConversion = true,
390
- filterCallback?: (subject: InferEntityFromObjectType<T>) => boolean
391
- ): ClosestObjectDistance<InferEntityFromObjectType<T>> | null {
392
- const courtPoint = needConversion ? this.convertViewPointToCourtPoint(viewPoint) : viewPoint;
393
- if (objectType === 'player') {
394
- return this.closestPlayerToPoint(
395
- courtPoint,
396
- filterCallback as (subject: PlayerModel) => boolean
397
- ) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
398
- }
399
- if (objectType === 'shape') {
400
- return this.closestShapeToPoint(courtPoint) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
401
- }
402
- if (objectType === 'note') {
403
- return this.closestNoteToPoint(courtPoint) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
404
- }
405
- if (objectType === 'line') {
406
- return this.closestLineToPoint(
407
- courtPoint,
408
- filterCallback as (subject: LineModel) => boolean
409
- ) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
410
- }
411
- if (objectType === 'lineVertex') {
412
- return this.closestLineVertexToPoint(
413
- courtPoint,
414
- filterCallback as (subject: LineModel) => boolean
415
- ) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
416
- }
417
- return null;
418
- }
419
-
420
- closestLineToPoint(courtPoint: CourtPoint, filterCallback?: (subject: LineModel) => boolean) {
421
- const lineDistances = this.lines
422
- .filter(item => {
423
- return filterCallback ? filterCallback(item) : true;
424
- })
425
- .reduce<ClosestObjectDistance<LineModel>[]>((result, item) => {
426
- item.lineParts.forEach(linePart => {
427
- const bezier = new Bezier(...(linePart.controlPoints as ConstructorParameters<typeof Bezier>));
428
- for (let i = 1; i <= 100; i++) {
429
- const tVal = i / 100;
430
- const pointAtTVal = this.prepareCourtPoint({
431
- x: bezier.x(tVal),
432
- y: bezier.y(tVal)
433
- });
434
- result.push({
435
- object: item,
436
- objectPoint: pointAtTVal,
437
- distance: distanceBetweenPoints(pointAtTVal, courtPoint)
438
- });
439
- }
440
- });
441
- return result;
442
- }, []);
443
-
444
- return _.minBy(lineDistances, 'distance');
445
- }
446
-
447
- closestLineVertexToPoint(courtPoint: CourtPoint, filterCallback?: (subject: LineModel) => boolean) {
448
- const lineVertexDistances = this.lines
449
- .filter(item => {
450
- return filterCallback ? filterCallback(item) : true;
451
- })
452
- .reduce<ClosestObjectDistance<LineModel>[]>((result, item) => {
453
- if (!item.id.length) return result;
454
-
455
- const firstVertex = this.prepareCourtPoint(item.firstLinePartControlPoint);
456
- const lastVertex = this.prepareCourtPoint(item.lastLinePartControlPoint);
457
- result.push({
458
- object: item,
459
- objectPoint: firstVertex,
460
- distance: distanceBetweenPoints(firstVertex, courtPoint)
461
- });
462
- result.push({
463
- object: item,
464
- objectPoint: lastVertex,
465
- distance: distanceBetweenPoints(lastVertex, courtPoint)
466
- });
467
- return result;
468
- }, []);
469
- return _.minBy(lineVertexDistances, 'distance');
470
- }
471
-
472
- closestPlayerToPoint(courtPoint: CourtPoint, filterCallback?: (subject: PlayerModel) => boolean) {
473
- const playerDistances = this.players
474
- .filter(item => {
475
- return filterCallback ? filterCallback(item) : true;
476
- })
477
- .reduce<ClosestObjectDistance<PlayerModel>[]>((result, item) => {
478
- const playerLocation = this.prepareCourtPoint(item.location);
479
- result.push({
480
- object: item,
481
- objectPoint: playerLocation,
482
- distance: distanceBetweenPoints(playerLocation, courtPoint)
483
- });
484
- return result;
485
- }, []);
486
- return _.minBy(playerDistances, 'distance');
487
- }
488
-
489
- closestShapeToPoint(courtPoint: CourtPoint) {
490
- const shapeDistances = this.shapes
491
- .filter(shape => shape.rectWrapperContains(courtPoint))
492
- .reduce<ClosestObjectDistance<ShapeModel>[]>((result, item) => {
493
- const shapeLocation = this.prepareCourtPoint(item.location);
494
- result.push({
495
- object: item,
496
- objectPoint: shapeLocation,
497
- distance: distanceBetweenPoints(shapeLocation, courtPoint)
498
- });
499
- return result;
500
- }, []);
501
- return _.minBy(shapeDistances, 'distance');
502
- }
503
-
504
- closestNoteToPoint(courtPoint: CourtPoint) {
505
- const noteDistances = this.notes
506
- .filter(note => note.noteWrapperContains(courtPoint))
507
- .reduce<ClosestObjectDistance<NoteModel>[]>((result, item) => {
508
- const noteLocation = this.prepareCourtPoint(item.location);
509
- result.push({
510
- object: item,
511
- objectPoint: noteLocation,
512
- distance: distanceBetweenPoints(noteLocation, courtPoint)
513
- });
514
- return result;
515
- }, []);
516
- return noteDistances.length ? _.minBy(noteDistances, 'distance') : null;
517
- }
518
-
519
- prepareCourtPoint(courtPoint: CourtPoint) {
520
- const { mirror } = this.play.options;
521
- const { width } = this.play.playData.court.courtRect.size;
522
- const { origin } = this.play.playData.court.courtRect;
523
- return {
524
- x: mirror ? width + origin.x - (courtPoint.x - origin.x) : courtPoint.x,
525
- y: courtPoint.y
526
- };
527
- }
528
-
529
- convertViewPointToCourtPoint(viewPoint: CourtPoint) {
530
- const { x: ox, y: oy } = this.play.playData.court.courtRect.origin;
531
- return {
532
- x: viewPoint.x / this.play.scale + ox,
533
- y: viewPoint.y / this.play.scale + oy
534
- };
535
- }
536
- }
1
+ import _ from 'lodash';
2
+ import Bezier from '../math/Bezier';
3
+ import { animationProgress, isBallTransferLineType, transformShapeTypeToImportKey } from '../helpers/common';
4
+ import { distanceBetweenPoints, splitBezierCurveAtTVal } from '../math/LineDrawingMath';
5
+ import CourtLayer from '../layers/CourtLayer';
6
+ import LineLayer from '../layers/LineLayer';
7
+ import PlayerLayer from '../layers/PlayerLayer';
8
+ import ShapeLayer from '../layers/ShapeLayer';
9
+ import NoteLayer from '../layers/NoteLayer';
10
+ import LineControlPointLayer from '../layers/LineControlPointLayer';
11
+ import ShapeControlPointLayer from '../layers/ShapeControlPointLayer';
12
+ import LineModel, { LinePartAdjusted } from './LineModel';
13
+ import PlayerModel from './PlayerModel';
14
+ import NoteModel from './NoteModel';
15
+ import * as ShapeModels from './ShapeModels';
16
+
17
+ import {
18
+ COURT_TYPE_FOOTBALL_HIGH_SCHOOL,
19
+ COURT_TYPE_FOOTBALL_HIGH_SCHOOL_LEGACY,
20
+ SPORT_TYPE_BASKETBALL,
21
+ SPORT_TYPE_FOOTBALL
22
+ } from '../constants';
23
+
24
+ import PlayModel, { PlayModelOptions, PlayStaticData } from './PlayModel';
25
+ import { AnimationMode, Court, CourtPoint, SportType } from '../types';
26
+ import ShapeModel from './ShapeModel';
27
+ import { getEffectiveTimeline, getLineAnimationChunks, getTimelineKey, keyTimesToChunks } from '../helpers/animation';
28
+
29
+ export type FrameData = {
30
+ sport: SportType;
31
+ court: Court;
32
+ lines: LineModel[];
33
+ players: PlayerModel[];
34
+ shapes: ShapeModel[];
35
+ notes: NoteModel[];
36
+ };
37
+
38
+ export type FrameDataOptions = PlayModelOptions & {
39
+ staticData: PlayStaticData;
40
+ scale: number;
41
+ animationGlobalProgress: number;
42
+ playAnimationDuration: number;
43
+ };
44
+
45
+ type ClosestObjectDistance<T extends PlayerModel | ShapeModel | NoteModel | LineModel> = {
46
+ object: T;
47
+ objectPoint: CourtPoint;
48
+ distance: number;
49
+ };
50
+
51
+ type InferEntityFromObjectType<T> = T extends 'player'
52
+ ? PlayerModel
53
+ : T extends 'note'
54
+ ? NoteModel
55
+ : T extends 'shape'
56
+ ? ShapeModel
57
+ : LineModel;
58
+
59
+ export default class FrameModel {
60
+ private ctx: CanvasRenderingContext2D | null;
61
+ private animationGlobalProgressPrev: number;
62
+
63
+ constructor(private play: PlayModel, private phase = 1, private animationGlobalProgress = 0) {
64
+ this.animationGlobalProgressPrev = animationGlobalProgress;
65
+ this.ctx = null;
66
+ }
67
+
68
+ get sport(): SportType {
69
+ const { court, sport } = this.play.playData;
70
+ if (sport) return sport;
71
+ return [COURT_TYPE_FOOTBALL_HIGH_SCHOOL_LEGACY, COURT_TYPE_FOOTBALL_HIGH_SCHOOL].includes(court.type)
72
+ ? SPORT_TYPE_FOOTBALL
73
+ : SPORT_TYPE_BASKETBALL;
74
+ }
75
+
76
+ get court(): Court {
77
+ const { court } = this.play.playData;
78
+ return {
79
+ ...court,
80
+ type: court.type === COURT_TYPE_FOOTBALL_HIGH_SCHOOL_LEGACY ? COURT_TYPE_FOOTBALL_HIGH_SCHOOL : court.type
81
+ };
82
+ }
83
+
84
+ get frameWidth() {
85
+ return this.play.width;
86
+ }
87
+
88
+ get frameHeight() {
89
+ return this.play.height;
90
+ }
91
+
92
+ get frameData() {
93
+ return {
94
+ sport: this.sport,
95
+ court: this.court,
96
+ lines: this.lines,
97
+ players: this.players,
98
+ shapes: this.shapes,
99
+ notes: this.notes
100
+ };
101
+ }
102
+
103
+ get frameDataOptions(): FrameDataOptions {
104
+ return {
105
+ ...this.play.options,
106
+ staticData: this.play.staticData,
107
+ scale: this.play.scale,
108
+ animationGlobalProgress: this.animationGlobalProgress,
109
+ playAnimationDuration: this.playAnimationDuration
110
+ };
111
+ }
112
+
113
+ get playAnimationDuration() {
114
+ const mode = this.play.options.animationMode;
115
+ return this.play.playData.lines.reduce<number>((result, l) => {
116
+ const line = new LineModel(l);
117
+ const lineLastAnimEndTime = line.lastAnimEndTime(mode)!;
118
+ return result > lineLastAnimEndTime ? result : lineLastAnimEndTime;
119
+ }, 0);
120
+ }
121
+
122
+ get lines() {
123
+ return this.animationGlobalProgress ? this.animationProgressLines : this.currentPhaseLines;
124
+ }
125
+
126
+ get players() {
127
+ return this.animationGlobalProgress ? this.animationProgressPlayers : this.currentPhasePlayers;
128
+ }
129
+
130
+ get shapes(): ShapeModel[] {
131
+ if (!this.play.playData.shapes) return [];
132
+ return this.play.playData.shapes
133
+ .filter(s => {
134
+ return ShapeModels[transformShapeTypeToImportKey(s.type) as keyof typeof ShapeModels] !== undefined;
135
+ })
136
+ .map(s => {
137
+ return new ShapeModels[transformShapeTypeToImportKey(s.type) as keyof typeof ShapeModels](s);
138
+ });
139
+ }
140
+
141
+ get notes(): NoteModel[] {
142
+ if (!this.play.playData.notes) return [];
143
+ return this.play.playData.notes
144
+ .filter(n => {
145
+ return !n.displayModes || (n.displayModes as string[]).includes('onCourt');
146
+ })
147
+ .map(n => {
148
+ return new NoteModel(n);
149
+ });
150
+ }
151
+
152
+ get currentPhasePlayers() {
153
+ const { currentPhaseLines } = this;
154
+ const { huddleMode, magnetMode, animationMode } = this.play.options;
155
+
156
+ const lines = huddleMode || magnetMode ? this.playDataLines : this.prevPhaseLines;
157
+
158
+ const passLines = lines.filter(l => ['PASS', 'HANDOFF'].includes(l.type));
159
+
160
+ return this.play.playData.players.map(p => {
161
+ const player = new PlayerModel(p);
162
+ player.setPossession(passLines);
163
+
164
+ const playerPrevLines = lines
165
+ .filter(l => {
166
+ if (!huddleMode) return true;
167
+ if (this.phase < l.phase) return false;
168
+ return !currentPhaseLines.find(item => item.id === l.id); // exclude last 2 lines in huddle mode
169
+ })
170
+ .filter(l => l.playerPositionOrigin === player.position && l.playerPositionTerminus === player.position);
171
+
172
+ const playerCurrentPhaseLines = currentPhaseLines.filter(
173
+ l => magnetMode && l.playerPositionOrigin === player.position && l.playerPositionTerminus === player.position
174
+ );
175
+
176
+ const playerLines = playerCurrentPhaseLines.length ? playerCurrentPhaseLines : playerPrevLines;
177
+ if (playerLines.length) {
178
+ const maxLine = _.maxBy(playerLines, l => l.lastAnimEndTime(animationMode));
179
+ if (maxLine) {
180
+ const lastPoint = maxLine.lastLinePartControlPoint;
181
+ if (lastPoint) {
182
+ player.location = lastPoint;
183
+ }
184
+ }
185
+ }
186
+ return player;
187
+ });
188
+ }
189
+
190
+ get currentPhaseLines() {
191
+ let lines = this.play.playData.lines.filter(l => l.phase === this.phase);
192
+ if (this.play.options.huddleMode) {
193
+ lines = _.takeRight(lines, 2);
194
+ }
195
+ return lines.map(l => {
196
+ return new LineModel(l);
197
+ });
198
+ }
199
+
200
+ get animationProgressPlayers() {
201
+ const ballTransferLines = this.prevAnimationLines.filter(l => isBallTransferLineType(l.type));
202
+ const { animationMode } = this.play.options;
203
+
204
+ // During playback, rely ONLY on line animations as the single source of truth for player movement.
205
+ // Player.animations are ignored here as we treat them as legacy data.
206
+
207
+ // Only "movement" lines affect player location (origin === terminus === player position).
208
+ // Ball transfer lines (PASS/HANDOFF/SHOT) are used for possession only.
209
+ const movementLines = this.play.playData.lines.filter(l => {
210
+ if (isBallTransferLineType(l.type)) return false;
211
+ return !!l.playerPositionOrigin && l.playerPositionOrigin === l.playerPositionTerminus;
212
+ });
213
+
214
+ const movementLinesByPlayer = _.groupBy(movementLines, l => l.playerPositionOrigin);
215
+
216
+ return this.play.playData.players.map(p => {
217
+ const player = new PlayerModel(p);
218
+ player.setPossession(ballTransferLines);
219
+
220
+ const playerMovementLines = movementLinesByPlayer[player.position] ?? [];
221
+ if (!playerMovementLines.length) return player;
222
+
223
+ // Find active movement line segment (if any), otherwise fall back to the last completed end point.
224
+ let activeLocation: CourtPoint | null = null;
225
+
226
+ for (const lineData of playerMovementLines) {
227
+ const chunks = getLineAnimationChunks({
228
+ mode: animationMode,
229
+ line: lineData,
230
+ players: this.play.playData.players
231
+ });
232
+
233
+ if (!chunks.length) continue;
234
+
235
+ const lineModel = new LineModel(lineData);
236
+ const parts = lineModel.getLineParts();
237
+
238
+ for (let index = 0; index < chunks.length; index += 1) {
239
+ const [start, end] = chunks[index];
240
+ if (!_.inRange(this.animationGlobalProgress, start, end)) continue;
241
+
242
+ const part = parts[index];
243
+ if (!part) continue;
244
+
245
+ const t = this.animationProgress(start, end);
246
+ const bezier = new Bezier(...(part.controlPoints as ConstructorParameters<typeof Bezier>));
247
+
248
+ activeLocation = { x: bezier.x(t), y: bezier.y(t) };
249
+ break;
250
+ }
251
+
252
+ if (activeLocation) break;
253
+ }
254
+
255
+ if (activeLocation) {
256
+ player.location = activeLocation;
257
+ return player;
258
+ }
259
+
260
+ // No active segment: put player at the end of the latest movement line that has finished.
261
+ const finishedMovementLines = playerMovementLines
262
+ .map(l => new LineModel(l))
263
+ .filter(lm => this.animationGlobalProgress >= lm.lastAnimEndTime(animationMode)!);
264
+
265
+ const lastFinished = _.maxBy(finishedMovementLines, lm => lm.lastAnimEndTime(animationMode)!);
266
+ if (lastFinished?.lastLinePartControlPoint) {
267
+ player.location = lastFinished.lastLinePartControlPoint;
268
+ }
269
+
270
+ return player;
271
+ });
272
+ }
273
+
274
+ get animationProgressLines() {
275
+ const { linesDisplayOnMoveOnly, animationMode } = this.play.options;
276
+
277
+ return this.play.playData.lines
278
+ .map(l => {
279
+ const line = new LineModel(l);
280
+
281
+ const chunks = getLineAnimationChunks({
282
+ mode: animationMode,
283
+ line: l,
284
+ players: this.play.playData.players
285
+ });
286
+
287
+ const linePartsAdjusted: LinePartAdjusted[] = [];
288
+ chunks.forEach(([start, end], index) => {
289
+ if (this.animationGlobalProgressPrev < end && this.animationGlobalProgress >= end) {
290
+ window.dispatchEvent(
291
+ new CustomEvent('@luceosports/play-rendering:lineAnimationFinished', { detail: { id: line.id } })
292
+ );
293
+ }
294
+
295
+ if (_.inRange(this.animationGlobalProgress, start, end)) {
296
+ if (line.type === 'DRIBBLE') {
297
+ return linePartsAdjusted.push({ ...line.getLineParts()[index] });
298
+ }
299
+
300
+ const linePathSplitted = splitBezierCurveAtTVal(
301
+ line.getLineParts()[index].controlPoints,
302
+ this.animationProgress(start, end)
303
+ ) as LinePartAdjusted['controlPoints'][];
304
+
305
+ linePartsAdjusted.push({
306
+ controlPoints: linePathSplitted[0],
307
+ animationSegment: 'processed'
308
+ });
309
+
310
+ linePartsAdjusted.push({
311
+ controlPoints: linePathSplitted[1],
312
+ animationSegment: 'active'
313
+ });
314
+ } else if (this.animationGlobalProgress > end) {
315
+ linePartsAdjusted.push({
316
+ ...line.getLineParts()[index],
317
+ animationSegment: 'processed'
318
+ });
319
+ } else {
320
+ linePartsAdjusted.push({ ...line.getLineParts()[index] });
321
+ }
322
+ });
323
+
324
+ line.setLinePartsAdjusted(linePartsAdjusted);
325
+
326
+ if (this.animationGlobalProgress > line.lastAnimEndTime(animationMode)!) {
327
+ line.color = { ...line.color, alpha: 0.1 }; // To adjust line cap opacity
328
+ }
329
+
330
+ return line;
331
+ })
332
+ .filter(l => {
333
+ const [firstAnimation] = l.animations;
334
+ const keyTimes =
335
+ animationMode === AnimationMode.SEQUENTIAL
336
+ ? firstAnimation.keyTimesSeq || firstAnimation.keyTimes
337
+ : firstAnimation.keyTimes;
338
+
339
+ const [start] = keyTimes;
340
+ const [end] = [...keyTimes].reverse();
341
+
342
+ const lineOnMove = _.inRange(this.animationGlobalProgress, start, end);
343
+ if (linesDisplayOnMoveOnly) return lineOnMove;
344
+ return lineOnMove || this.animationGlobalProgress > end || l.phase === this.phase;
345
+ });
346
+ }
347
+
348
+ get playDataLines() {
349
+ return this.play.playData.lines.map(l => {
350
+ return new LineModel(l);
351
+ });
352
+ }
353
+
354
+ get prevPhaseLines() {
355
+ return this.playDataLines.filter(l => l.phase < this.phase);
356
+ }
357
+
358
+ get prevAnimationLines() {
359
+ const mode = this.play.options.animationMode;
360
+ return this.playDataLines.filter(l => this.animationGlobalProgress >= l.lastAnimEndTime(mode)!);
361
+ }
362
+
363
+ draw() {
364
+ if (!this.ctx) throw new Error('Canvas context is not provided. Please use setContext() method.');
365
+
366
+ // console.time('draw');
367
+
368
+ this.ctx.clearRect(0, 0, this.frameWidth, this.frameHeight);
369
+
370
+ this._init();
371
+
372
+ const { frameData, frameDataOptions } = this;
373
+ new CourtLayer(this.ctx, frameData, frameDataOptions).apply();
374
+ new ShapeLayer(this.ctx, frameData, frameDataOptions).apply();
375
+ new LineLayer(this.ctx, frameData, frameDataOptions).apply();
376
+ new PlayerLayer(this.ctx, frameData, frameDataOptions).apply();
377
+ new LineControlPointLayer(this.ctx, frameData, frameDataOptions).apply();
378
+ new ShapeControlPointLayer(this.ctx, frameData, frameDataOptions).apply();
379
+ new NoteLayer(this.ctx, frameData, frameDataOptions).apply();
380
+
381
+ // console.timeEnd('draw');
382
+
383
+ return this;
384
+ }
385
+
386
+ _init() {
387
+ if (!this.ctx) throw new Error('Canvas context is not provided. Please use setContext() method.');
388
+
389
+ this.ctx.canvas.width = this.frameWidth;
390
+ this.ctx.canvas.height = this.frameHeight;
391
+
392
+ this.ctx.scale(this.play.scale, this.play.scale);
393
+ this.ctx.translate(-this.court.courtRect.origin.x, -this.court.courtRect.origin.y);
394
+ if (this.play.options.mirror) {
395
+ this.ctx.scale(-1, 1);
396
+ this.ctx.translate(Math.abs(this.court.courtRect.origin.x) * 2 - this.court.courtRect.size.width, 0);
397
+ }
398
+ }
399
+
400
+ setContext(ctx: CanvasRenderingContext2D) {
401
+ this.ctx = ctx;
402
+ return this;
403
+ }
404
+
405
+ setPhase(newPhase: number) {
406
+ this.phase = newPhase;
407
+ return this;
408
+ }
409
+
410
+ setAnimationGlobalProgress(newProgress: number) {
411
+ this.animationGlobalProgressPrev = this.animationGlobalProgress;
412
+ this.animationGlobalProgress = newProgress;
413
+ return this;
414
+ }
415
+
416
+ animationProgress(start: number, end: number) {
417
+ return animationProgress(this.animationGlobalProgress, start, end);
418
+ }
419
+
420
+ closestObjectToPoint<T extends 'player' | 'shape' | 'note' | 'line' | 'lineVertex'>(
421
+ viewPoint: CourtPoint,
422
+ objectType: T,
423
+ needConversion = true,
424
+ filterCallback?: (subject: InferEntityFromObjectType<T>) => boolean
425
+ ): ClosestObjectDistance<InferEntityFromObjectType<T>> | null {
426
+ const courtPoint = needConversion ? this.convertViewPointToCourtPoint(viewPoint) : viewPoint;
427
+ if (objectType === 'player') {
428
+ return this.closestPlayerToPoint(
429
+ courtPoint,
430
+ filterCallback as (subject: PlayerModel) => boolean
431
+ ) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
432
+ }
433
+ if (objectType === 'shape') {
434
+ return this.closestShapeToPoint(courtPoint) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
435
+ }
436
+ if (objectType === 'note') {
437
+ return this.closestNoteToPoint(courtPoint) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
438
+ }
439
+ if (objectType === 'line') {
440
+ return this.closestLineToPoint(
441
+ courtPoint,
442
+ filterCallback as (subject: LineModel) => boolean
443
+ ) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
444
+ }
445
+ if (objectType === 'lineVertex') {
446
+ return this.closestLineVertexToPoint(
447
+ courtPoint,
448
+ filterCallback as (subject: LineModel) => boolean
449
+ ) as ClosestObjectDistance<InferEntityFromObjectType<T>>;
450
+ }
451
+ return null;
452
+ }
453
+
454
+ closestLineToPoint(courtPoint: CourtPoint, filterCallback?: (subject: LineModel) => boolean) {
455
+ const lineDistances = this.lines
456
+ .filter(item => {
457
+ return filterCallback ? filterCallback(item) : true;
458
+ })
459
+ .reduce<ClosestObjectDistance<LineModel>[]>((result, item) => {
460
+ item.lineParts.forEach(linePart => {
461
+ const bezier = new Bezier(...(linePart.controlPoints as ConstructorParameters<typeof Bezier>));
462
+ for (let i = 1; i <= 100; i++) {
463
+ const tVal = i / 100;
464
+ const pointAtTVal = this.prepareCourtPoint({
465
+ x: bezier.x(tVal),
466
+ y: bezier.y(tVal)
467
+ });
468
+ result.push({
469
+ object: item,
470
+ objectPoint: pointAtTVal,
471
+ distance: distanceBetweenPoints(pointAtTVal, courtPoint)
472
+ });
473
+ }
474
+ });
475
+ return result;
476
+ }, []);
477
+
478
+ return _.minBy(lineDistances, 'distance');
479
+ }
480
+
481
+ closestLineVertexToPoint(courtPoint: CourtPoint, filterCallback?: (subject: LineModel) => boolean) {
482
+ const lineVertexDistances = this.lines
483
+ .filter(item => {
484
+ return filterCallback ? filterCallback(item) : true;
485
+ })
486
+ .reduce<ClosestObjectDistance<LineModel>[]>((result, item) => {
487
+ if (!item.id.length) return result;
488
+
489
+ const firstVertex = this.prepareCourtPoint(item.firstLinePartControlPoint);
490
+ const lastVertex = this.prepareCourtPoint(item.lastLinePartControlPoint);
491
+ result.push({
492
+ object: item,
493
+ objectPoint: firstVertex,
494
+ distance: distanceBetweenPoints(firstVertex, courtPoint)
495
+ });
496
+ result.push({
497
+ object: item,
498
+ objectPoint: lastVertex,
499
+ distance: distanceBetweenPoints(lastVertex, courtPoint)
500
+ });
501
+ return result;
502
+ }, []);
503
+ return _.minBy(lineVertexDistances, 'distance');
504
+ }
505
+
506
+ closestPlayerToPoint(courtPoint: CourtPoint, filterCallback?: (subject: PlayerModel) => boolean) {
507
+ const playerDistances = this.players
508
+ .filter(item => {
509
+ return filterCallback ? filterCallback(item) : true;
510
+ })
511
+ .reduce<ClosestObjectDistance<PlayerModel>[]>((result, item) => {
512
+ const playerLocation = this.prepareCourtPoint(item.location);
513
+ result.push({
514
+ object: item,
515
+ objectPoint: playerLocation,
516
+ distance: distanceBetweenPoints(playerLocation, courtPoint)
517
+ });
518
+ return result;
519
+ }, []);
520
+ return _.minBy(playerDistances, 'distance');
521
+ }
522
+
523
+ closestShapeToPoint(courtPoint: CourtPoint) {
524
+ const shapeDistances = this.shapes
525
+ .filter(shape => shape.rectWrapperContains(courtPoint))
526
+ .reduce<ClosestObjectDistance<ShapeModel>[]>((result, item) => {
527
+ const shapeLocation = this.prepareCourtPoint(item.location);
528
+ result.push({
529
+ object: item,
530
+ objectPoint: shapeLocation,
531
+ distance: distanceBetweenPoints(shapeLocation, courtPoint)
532
+ });
533
+ return result;
534
+ }, []);
535
+ return _.minBy(shapeDistances, 'distance');
536
+ }
537
+
538
+ closestNoteToPoint(courtPoint: CourtPoint) {
539
+ const noteDistances = this.notes
540
+ .filter(note => note.noteWrapperContains(courtPoint))
541
+ .reduce<ClosestObjectDistance<NoteModel>[]>((result, item) => {
542
+ const noteLocation = this.prepareCourtPoint(item.location);
543
+ result.push({
544
+ object: item,
545
+ objectPoint: noteLocation,
546
+ distance: distanceBetweenPoints(noteLocation, courtPoint)
547
+ });
548
+ return result;
549
+ }, []);
550
+ return noteDistances.length ? _.minBy(noteDistances, 'distance') : null;
551
+ }
552
+
553
+ prepareCourtPoint(courtPoint: CourtPoint) {
554
+ const { mirror } = this.play.options;
555
+ const { width } = this.play.playData.court.courtRect.size;
556
+ const { origin } = this.play.playData.court.courtRect;
557
+ return {
558
+ x: mirror ? width + origin.x - (courtPoint.x - origin.x) : courtPoint.x,
559
+ y: courtPoint.y
560
+ };
561
+ }
562
+
563
+ convertViewPointToCourtPoint(viewPoint: CourtPoint) {
564
+ const { x: ox, y: oy } = this.play.playData.court.courtRect.origin;
565
+ return {
566
+ x: viewPoint.x / this.play.scale + ox,
567
+ y: viewPoint.y / this.play.scale + oy
568
+ };
569
+ }
570
+ }