Event context from data, not adjacent feed lines
The buildup timeline mostly surfaced unrelated events (a substitution before a goal explains nothing). Replaced with the sources that actually describe the event: - goals match to their captured shot: situation (corner / counter / set piece / penalty), distance to goal, xG as a plain-language chance percentage, and xGOT for the finish quality - the attack-momentum curve judges whether the goal came with or against the run of play (prior five minutes, only when clear-cut) - VAR / video-review feed lines still surface on the matching event — the one slice of play-by-play that genuinely explains a decision - the feed's action description stays as the de-emphasized last line, with the redundant scorer prefix stripped; booking reasons unchanged - name matching shared from the lineup pitch (diacritic/order-proof), with a unique same-side-and-minute fallback Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
import { parseEvents } from '@/lib/matchEvents';
|
||||
import { nameMatch, parseEvents } from '@/lib/matchEvents';
|
||||
import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types';
|
||||
|
||||
/**
|
||||
@@ -17,22 +17,6 @@ import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib
|
||||
const W = 84;
|
||||
const H = 132;
|
||||
|
||||
/** Diacritic/case/punctuation-insensitive name comparison (token sets), so
|
||||
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-'.]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
function nameMatch(a: string, b: string): boolean {
|
||||
const na = norm(a);
|
||||
const nb = norm(b);
|
||||
if (!na || !nb) return false;
|
||||
if (na === nb) return true;
|
||||
const ta = na.split(' ');
|
||||
const tb = nb.split(' ');
|
||||
const sa = new Set(ta);
|
||||
const sb = new Set(tb);
|
||||
return ta.every((x) => sb.has(x)) || tb.every((x) => sa.has(x));
|
||||
}
|
||||
|
||||
interface Slot {
|
||||
/** Who currently occupies the position. */
|
||||
player: PitchPlayer;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useState, type KeyboardEvent } from 'react';
|
||||
import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
import { buildup, eventContext, parseEvents, halfTimeScore, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
|
||||
import type { MatchLiveEvent } from '@/lib/types';
|
||||
import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
|
||||
import type { MatchLiveEvent, RichShot } from '@/lib/types';
|
||||
|
||||
/** Crisp event markers instead of emoji: ball icon for goals, card-shaped
|
||||
* color chips for bookings, arrows for substitutions. */
|
||||
@@ -57,23 +57,46 @@ function expandable(ctx: string | null, onToggle: () => void) {
|
||||
}
|
||||
|
||||
|
||||
/** The expanded story: feed lines leading into the moment, then the event's
|
||||
* own description. */
|
||||
function ContextPanel({ pre, ctx }: { pre: CommentaryLine[]; ctx: string }) {
|
||||
interface RichContext {
|
||||
shots: RichShot[];
|
||||
momentum: { minute: number; value: number }[];
|
||||
commentary: CommentaryLine[];
|
||||
}
|
||||
|
||||
/** The expanded story, from data we trust: the chance behind a goal (xG,
|
||||
* situation, distance), whether it came against the run of play, any VAR
|
||||
* line for the decision — then the feed's own description of the action. */
|
||||
function ContextPanel({ e, ctx, shots, momentum, commentary }: {
|
||||
e: ParsedEvent;
|
||||
ctx: string;
|
||||
shots: RichShot[];
|
||||
momentum: { minute: number; value: number }[];
|
||||
commentary: CommentaryLine[];
|
||||
}) {
|
||||
const t = useT();
|
||||
const shot = goalShot(e, shots);
|
||||
const verdict = momentumVerdict(e, momentum);
|
||||
const vars = varLines(e, commentary);
|
||||
const situations = t.match.situations as Record<string, string>;
|
||||
const chance = shot && shot.xg != null
|
||||
? fmt(shot.distM != null ? t.match.insight.chance : t.match.insight.chanceNoDist, {
|
||||
situation: situations[shot.situation] ?? shot.situation,
|
||||
dist: shot.distM ?? 0,
|
||||
xg: shot.xg.toFixed(2),
|
||||
pct: Math.round(shot.xg * 100),
|
||||
}) + (shot.xgot != null ? ` · ${shot.xgot.toFixed(2)} xGOT` : '')
|
||||
: null;
|
||||
return (
|
||||
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
|
||||
{pre.map((c, j) => (
|
||||
<p key={j} className="text-faint">
|
||||
{c.minute != null && <span className="tnum">{c.minute}' </span>}
|
||||
{c.text}
|
||||
</p>
|
||||
))}
|
||||
<p className="text-muted">{ctx}</p>
|
||||
{chance && <p className="font-medium text-ink-soft">{chance}</p>}
|
||||
{verdict && <p className="text-muted">{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}</p>}
|
||||
{vars.map((v, j) => <p key={j} className="text-gold">{v}</p>)}
|
||||
<p className="text-faint">{ctx}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalCard({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; pre: CommentaryLine[]; open: boolean; onToggle: () => void }) {
|
||||
function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; rich: RichContext; open: boolean; onToggle: () => void }) {
|
||||
const t = useT();
|
||||
const away = e.side === 'away';
|
||||
// Side-colored edge faces the spine on desktop; always left on mobile.
|
||||
@@ -107,12 +130,12 @@ function GoalCard({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mob
|
||||
</div>
|
||||
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>{e.title}</div>
|
||||
{detailText && <div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>{detailText}</div>}
|
||||
{ctx && open && <ContextPanel pre={pre} ctx={ctx} />}
|
||||
{ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinorChip({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; pre: CommentaryLine[]; open: boolean; onToggle: () => void }) {
|
||||
function MinorChip({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; rich: RichContext; open: boolean; onToggle: () => void }) {
|
||||
const side = mobile
|
||||
? e.side === 'away' ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent'
|
||||
: '';
|
||||
@@ -128,7 +151,7 @@ function MinorChip({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mo
|
||||
{e.subOut && <span className="truncate text-[13.5px] text-faint">{e.subOut}</span>}
|
||||
{ctx && <ChevronDown size={12} className={`shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||
</span>
|
||||
{ctx && open && <ContextPanel pre={pre} ctx={ctx} />}
|
||||
{ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,15 +183,19 @@ function DividerPill({ row }: { row: Extract<Row, { t: 'divider' }> }) {
|
||||
|
||||
export interface MatchTimelineProps {
|
||||
events: MatchLiveEvent[];
|
||||
/** Full play-by-play feed — buildup lines for expanded events. */
|
||||
/** Play-by-play feed — only its VAR lines surface, on the matching event. */
|
||||
commentary?: CommentaryLine[];
|
||||
/** Captured shots — the data story behind each goal. */
|
||||
shots?: RichShot[];
|
||||
/** Attack-momentum curve (positive = home pressure). */
|
||||
momentum?: { minute: number; value: number }[];
|
||||
status: 'scheduled' | 'live' | 'finished';
|
||||
minute?: number | null;
|
||||
homeScore?: number | null;
|
||||
awayScore?: number | null;
|
||||
}
|
||||
|
||||
export function MatchTimeline({ events, commentary = [], status, minute, homeScore, awayScore }: MatchTimelineProps) {
|
||||
export function MatchTimeline({ events, commentary = [], shots = [], momentum = [], status, minute, homeScore, awayScore }: MatchTimelineProps) {
|
||||
const t = useT();
|
||||
// Tap-to-expand context per row (multiple rows can stay open).
|
||||
const [open, setOpen] = useState<ReadonlySet<number>>(new Set());
|
||||
@@ -239,10 +266,10 @@ export function MatchTimeline({ events, commentary = [], status, minute, homeSco
|
||||
);
|
||||
}
|
||||
const ctx = eventContext(e);
|
||||
const pre = open.has(i) ? buildup(e, commentary) : [];
|
||||
const rich = { shots, momentum, commentary };
|
||||
const content = e.kind === 'goal'
|
||||
? <GoalCard e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} />;
|
||||
? <GoalCard e={e} ctx={ctx} rich={rich} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} ctx={ctx} rich={rich} open={open.has(i)} onToggle={() => toggle(i)} />;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -276,8 +303,8 @@ export function MatchTimeline({ events, commentary = [], status, minute, homeSco
|
||||
{e.side === null
|
||||
? <NeutralChip e={e} showMinute={false} />
|
||||
: e.kind === 'goal'
|
||||
? <GoalCard e={e} mobile ctx={eventContext(e)} pre={open.has(i) ? buildup(e, commentary) : []} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} mobile ctx={eventContext(e)} pre={open.has(i) ? buildup(e, commentary) : []} open={open.has(i)} onToggle={() => toggle(i)} />}
|
||||
? <GoalCard e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary }} open={open.has(i)} onToggle={() => toggle(i)} />
|
||||
: <MinorChip e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary }} open={open.has(i)} onToggle={() => toggle(i)} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -370,7 +370,7 @@ export function MatchPreviewPage() {
|
||||
{tab === 'timeline' && preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="px-2 sm:px-4">
|
||||
<MatchTimeline events={preview.events} commentary={preview.commentary} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
|
||||
<MatchTimeline events={preview.events} commentary={preview.commentary} shots={rich?.shots ?? []} momentum={rich?.momentum ?? []} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -131,6 +131,22 @@ export const de: Dict = {
|
||||
noGoalsYet: "Noch keine Tore.",
|
||||
subbedOff: "Ausgewechselt",
|
||||
subbedOn: "Eingewechselt",
|
||||
situations: {
|
||||
RegularPlay: "Aus dem Spiel heraus",
|
||||
FromCorner: "Nach einer Ecke",
|
||||
SetPiece: "Nach einem Standard",
|
||||
FreeKick: "Freistoß",
|
||||
Penalty: "Elfmeter",
|
||||
FastBreak: "Im Konter",
|
||||
IndividualPlay: "Einzelaktion",
|
||||
ThrowInSetPiece: "Nach einem Einwurf",
|
||||
},
|
||||
insight: {
|
||||
chance: "{situation} · {dist} m vor dem Tor · {xg} xG — eine {pct}-%-Chance",
|
||||
chanceNoDist: "{situation} · {xg} xG — eine {pct}-%-Chance",
|
||||
withRun: "Fiel mit dem Momentum — das Team war am Drücker.",
|
||||
againstRun: "Fiel gegen den Spielverlauf.",
|
||||
},
|
||||
predictedLineups: "Voraussichtliche Aufstellungen",
|
||||
predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.",
|
||||
predictedVsActual: "Modell-Prognose vor Anstoß: {ph}–{pa} · Endstand {ah}–{aa}",
|
||||
|
||||
@@ -130,6 +130,22 @@ export const en = {
|
||||
noGoalsYet: "No goals yet.",
|
||||
subbedOff: "Subbed off",
|
||||
subbedOn: "Subbed on",
|
||||
situations: {
|
||||
RegularPlay: "Open play",
|
||||
FromCorner: "From a corner",
|
||||
SetPiece: "From a set piece",
|
||||
FreeKick: "Free kick",
|
||||
Penalty: "Penalty",
|
||||
FastBreak: "On the counter",
|
||||
IndividualPlay: "Individual effort",
|
||||
ThrowInSetPiece: "From a throw-in",
|
||||
},
|
||||
insight: {
|
||||
chance: "{situation} · {dist} m out · {xg} xG — a {pct}% chance",
|
||||
chanceNoDist: "{situation} · {xg} xG — a {pct}% chance",
|
||||
withRun: "Scored with the momentum — their side had been pressing.",
|
||||
againstRun: "Scored against the run of play.",
|
||||
},
|
||||
predictedLineups: "Predicted lineups",
|
||||
predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.",
|
||||
predictedVsActual: "Model's pre-match prediction: {ph}–{pa} · final score {ah}–{aa}",
|
||||
|
||||
+37
-29
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildup, eventContext, goalEvents, halfTimeScore, parseEvents } from './matchEvents';
|
||||
import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, varLines } from './matchEvents';
|
||||
import type { MatchLiveEvent } from './types';
|
||||
|
||||
// Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia).
|
||||
@@ -88,12 +88,12 @@ describe('parseEvents', () => {
|
||||
});
|
||||
|
||||
describe('eventContext', () => {
|
||||
it('strips the score sentence from goal commentary', () => {
|
||||
it('strips the score sentence and the scorer prefix from goal commentary', () => {
|
||||
const [g] = goalEvents([{
|
||||
minute: 59, type: 'Goal - Header', team: 'away',
|
||||
text: 'Goal! Korea Republic 0, Czechia 1. Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.',
|
||||
}]);
|
||||
expect(eventContext(g!)).toBe('Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.');
|
||||
expect(eventContext(g!)).toBe('Header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.');
|
||||
});
|
||||
|
||||
it('extracts the booking reason', () => {
|
||||
@@ -127,37 +127,45 @@ describe('eventContext', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildup', () => {
|
||||
describe('goal insight', () => {
|
||||
const goalText = 'Goal! Korea Republic 0, Czechia 1. Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal.';
|
||||
const commentary = [
|
||||
{ minute: null, text: 'First Half begins.' },
|
||||
{ minute: 53, text: 'Foul by Tomás Soucek (Czechia).' },
|
||||
{ minute: 56, text: 'Attempt saved. Son Heung-Min (Korea Republic) left footed shot is saved.' },
|
||||
{ minute: 56, text: 'Corner, Korea Republic. Conceded by Matej Kovár.' },
|
||||
{ minute: 59, text: goalText },
|
||||
{ minute: 61, text: 'Attempt missed. Lee Jae-Sung (Korea Republic) header misses to the left.' },
|
||||
];
|
||||
const [goal] = goalEvents([{ minute: 59, type: 'Goal - Header', team: 'away', text: goalText }]);
|
||||
|
||||
it('returns the lines leading into the event, oldest first', () => {
|
||||
expect(buildup(goal!, commentary).map((c) => c.minute)).toEqual([56, 56]);
|
||||
});
|
||||
|
||||
it('ignores lines older than three minutes before the event', () => {
|
||||
const lines = buildup(goal!, commentary);
|
||||
expect(lines.some((c) => c.minute === 53)).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the number of lines', () => {
|
||||
const noisy = [
|
||||
...Array.from({ length: 8 }, (_, i) => ({ minute: 58, text: `Pass ${i}.` })),
|
||||
{ minute: 59, text: goalText },
|
||||
const shots = [
|
||||
{ isHome: true, player: 'Heung-Min Son', x: 90, y: 30, min: 56, xg: 0.12, xgot: 0.2, type: 'AttemptSaved', situation: 'RegularPlay' },
|
||||
{ isHome: false, player: 'Ladislav Krejci', x: 100.26, y: 31.1, min: 59, xg: 0.215, xgot: null, type: 'Goal', situation: 'ThrowInSetPiece' },
|
||||
];
|
||||
expect(buildup(goal!, noisy, 3)).toHaveLength(3);
|
||||
|
||||
it('matches the goal to its shot despite diacritics and computes distance', () => {
|
||||
const s = goalShot(goal!, shots);
|
||||
expect(s).toMatchObject({ xg: 0.215, situation: 'ThrowInSetPiece' });
|
||||
expect(s?.distM).toBe(6);
|
||||
});
|
||||
|
||||
it('returns nothing when the event opens the feed', () => {
|
||||
expect(buildup(goal!, [{ minute: 59, text: goalText }])).toEqual([]);
|
||||
it('falls back to the unique same-side goal shot when names diverge', () => {
|
||||
const renamed = [{ ...shots[1]!, player: 'L. Krejci-Smith' }];
|
||||
expect(goalShot(goal!, renamed)?.situation).toBe('ThrowInSetPiece');
|
||||
});
|
||||
|
||||
it('strips the scorer prefix from the goal prose', () => {
|
||||
expect(eventContext(goal!)).toBe('Header from very close range to the bottom right corner. Assisted by Vladimír Coufal.');
|
||||
});
|
||||
|
||||
it('judges momentum: away goal during home pressure = against the run', () => {
|
||||
const momentum = Array.from({ length: 5 }, (_, i) => ({ minute: 54 + i, value: 60 }));
|
||||
expect(momentumVerdict(goal!, momentum)).toBe('against');
|
||||
const awayPressure = momentum.map((p) => ({ ...p, value: -60 }));
|
||||
expect(momentumVerdict(goal!, awayPressure)).toBe('with');
|
||||
const mixed = momentum.map((p, i) => ({ ...p, value: i % 2 ? 10 : -10 }));
|
||||
expect(momentumVerdict(goal!, mixed)).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces only VAR lines near the event', () => {
|
||||
const commentary = [
|
||||
{ minute: 58, text: 'Corner, Korea Republic.' },
|
||||
{ minute: 59, text: 'VAR Decision: Goal Czechia — onside confirmed.' },
|
||||
{ minute: 75, text: 'VAR Decision: something far away.' },
|
||||
];
|
||||
expect(varLines(goal!, commentary)).toEqual(['VAR Decision: Goal Czechia — onside confirmed.']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+65
-21
@@ -1,4 +1,4 @@
|
||||
import type { MatchLiveEvent } from '@/lib/types';
|
||||
import type { MatchLiveEvent, RichShot } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Turns ESPN's flat commentary events into structured timeline items.
|
||||
@@ -12,6 +12,24 @@ import type { MatchLiveEvent } from '@/lib/types';
|
||||
|
||||
export type EventKind = 'goal' | 'yellow' | 'red' | 'sub' | 'other';
|
||||
|
||||
/** Diacritic/case/punctuation-insensitive name comparison (token sets), so
|
||||
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */
|
||||
export const norm = (s: string): string =>
|
||||
s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-'.]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
|
||||
export function nameMatch(a: string, b: string): boolean {
|
||||
const na = norm(a);
|
||||
const nb = norm(b);
|
||||
if (!na || !nb) return false;
|
||||
if (na === nb) return true;
|
||||
const ta = na.split(' ');
|
||||
const tb = nb.split(' ');
|
||||
const sa = new Set(ta);
|
||||
const sb = new Set(tb);
|
||||
return ta.every((x) => sb.has(x)) || tb.every((x) => sa.has(x));
|
||||
}
|
||||
|
||||
|
||||
export type EventDetail =
|
||||
| { type: 'assist'; name: string }
|
||||
| { type: 'penalty' }
|
||||
@@ -133,6 +151,9 @@ export function eventContext(e: ParsedEvent): string | null {
|
||||
// Drop the leading "Goal! Home 1, Away 0." — the card shows the score chip.
|
||||
const m = /^(?:Goal!|Own Goal by[^.]*\.)[^.]*?\d+\s*[,.][^.]*?\.\s*(.+)$/.exec(raw);
|
||||
ctx = m?.[1]?.trim() ?? raw;
|
||||
// The card already names the scorer — drop the "Name (Team) " prefix.
|
||||
const p = /^[^.()]{2,45}\(([^)]+)\)\s+(.{10,})$/.exec(ctx);
|
||||
if (p?.[2]) ctx = p[2].charAt(0).toUpperCase() + p[2].slice(1);
|
||||
} else if (e.kind === 'yellow' || e.kind === 'red') {
|
||||
const m = /shown the (?:yellow|red|second yellow) card\s+(.+?)\.?\s*$/.exec(raw);
|
||||
const reason = m?.[1]?.trim();
|
||||
@@ -147,31 +168,54 @@ export function eventContext(e: ParsedEvent): string | null {
|
||||
|
||||
export interface CommentaryLine { minute: number | null; text: string }
|
||||
|
||||
/** Data behind a goal, matched from the captured shot list. */
|
||||
export interface GoalShotInsight {
|
||||
xg: number | null;
|
||||
xgot: number | null;
|
||||
situation: string;
|
||||
/** Distance to the goal center in meters (FotMob pitch coords). */
|
||||
distM: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The feed lines leading into an event — the buildup story shown when a
|
||||
* timeline item is expanded (the saved attempt before a counter-goal, the
|
||||
* foul before a card, a VAR check). Anchored on the entry that IS the event
|
||||
* (same text), else the last entry at the event's minute; returns up to
|
||||
* `max` preceding lines no older than three minutes.
|
||||
* Find the shot that IS this goal: scorer name match within ±2 minutes,
|
||||
* else the only goal-shot for that side within ±1 minute (the sources
|
||||
* romanize names differently, so the name can miss).
|
||||
*/
|
||||
export function buildup(e: ParsedEvent, commentary: CommentaryLine[], max = 4): CommentaryLine[] {
|
||||
if (e.minute == null || !commentary.length) return [];
|
||||
let anchor = commentary.findIndex((c) => c.text === e.raw);
|
||||
if (anchor === -1) {
|
||||
for (let i = 0; i < commentary.length; i++) {
|
||||
const m = commentary[i]!.minute;
|
||||
if (m != null && m <= e.minute) anchor = i;
|
||||
if (m != null && m > e.minute) break;
|
||||
export function goalShot(e: ParsedEvent, shots: RichShot[]): GoalShotInsight | null {
|
||||
if (e.kind !== 'goal' || e.minute == null) return null;
|
||||
const goals = shots.filter((s) => s.type === 'Goal' && s.min != null);
|
||||
let m = goals.find((s) => Math.abs(s.min! - e.minute!) <= 2 && nameMatch(s.player, e.title));
|
||||
if (!m) {
|
||||
const near = goals.filter((s) => Math.abs(s.min! - e.minute!) <= 1 && (e.side === null || s.isHome === (e.side === 'home')));
|
||||
if (near.length === 1) m = near[0];
|
||||
}
|
||||
if (!m) return null;
|
||||
const distM = m.x != null && m.y != null ? Math.round(Math.hypot(105 - m.x, 34 - m.y)) : null;
|
||||
return { xg: m.xg, xgot: m.xgot, situation: m.situation, distM };
|
||||
}
|
||||
if (anchor <= 0) return [];
|
||||
const out: CommentaryLine[] = [];
|
||||
for (let i = anchor - 1; i >= 0 && out.length < max; i--) {
|
||||
const c = commentary[i]!;
|
||||
if (c.minute == null || c.minute < e.minute - 3) break;
|
||||
out.unshift(c);
|
||||
|
||||
/** Was the goal scored with or against the momentum of the prior 5 minutes?
|
||||
* Null when the pressure was mixed or there isn't enough curve. */
|
||||
export function momentumVerdict(
|
||||
e: ParsedEvent,
|
||||
momentum: { minute: number; value: number }[],
|
||||
): 'with' | 'against' | null {
|
||||
if (e.minute == null || e.side == null) return null;
|
||||
const window = momentum.filter((p) => p.minute >= e.minute! - 5 && p.minute < e.minute!);
|
||||
if (window.length < 3) return null;
|
||||
const avg = window.reduce((s, p) => s + p.value, 0) / window.length;
|
||||
if (Math.abs(avg) < 25) return null;
|
||||
return (e.side === 'home') === avg > 0 ? 'with' : 'against';
|
||||
}
|
||||
return out;
|
||||
|
||||
/** VAR / video-review feed lines belonging to this event (±1 minute) —
|
||||
* the one slice of the play-by-play that genuinely explains a decision. */
|
||||
export function varLines(e: ParsedEvent, commentary: CommentaryLine[]): string[] {
|
||||
if (e.minute == null) return [];
|
||||
return commentary
|
||||
.filter((c) => c.minute != null && Math.abs(c.minute - e.minute!) <= 1 && /\b(VAR|video review|video assistant)\b/i.test(c.text))
|
||||
.map((c) => c.text);
|
||||
}
|
||||
|
||||
/** Score at half-time: last goal score at minute ≤ 45 (0–0 when none). */
|
||||
|
||||
Reference in New Issue
Block a user