diff --git a/app/assets/2025/field.png b/app/assets/2025/field.png new file mode 100644 index 00000000..a2745ed2 Binary files /dev/null and b/app/assets/2025/field.png differ diff --git a/app/assets/2026/field.png b/app/assets/2026/field.png new file mode 100644 index 00000000..ee78b208 Binary files /dev/null and b/app/assets/2026/field.png differ diff --git a/app/assets/css/components.css b/app/assets/css/components.css index 8fa47509..42a6e20d 100644 --- a/app/assets/css/components.css +++ b/app/assets/css/components.css @@ -125,6 +125,321 @@ button.secondary:hover { color: var(--color-btn-secondary-text); } +/* + * Strategy canvas toolbar button. Overrides the default `button` padding + * to get a tighter, consistently-sized control. Scales modestly across + * breakpoints (matching typography.css) but NOT as aggressively as rem + * would — buttons are chrome, not content, and shouldn't blow up 1.78× + * between phone and desktop the way body text does. + */ +.strategy-toolbar-btn { + padding: 4px 12px; + min-height: 36px; +} + +@media screen and (min-width: 700px) and (min-height: 500px) { + .strategy-toolbar-btn { + padding: 6px 18px; + min-height: 44px; + } +} + +@media screen and (min-width: 1000px) and (min-height: 1000px) { + .strategy-toolbar-btn { + padding: 8px 22px; + min-height: 52px; + } +} + +/* + * Strategy canvas — drawing list (sidebar). + */ +.strategy-drawing-list h3 { + margin: 0.5rem 0; +} +.strategy-drawing-list ul { + list-style: none; + padding: 0; + margin: 0; +} +.strategy-drawing-list li { + padding: 0.5rem; + margin-bottom: 0.3rem; + border-radius: 0.4rem; + border: 2px solid transparent; + background: rgba(128, 128, 128, 0.08); + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} +.strategy-drawing-list li.is-active { + border-color: var(--color-btn-primary-bg, #38f); + background: rgba(56, 136, 255, 0.12); +} +.strategy-drawing-list .strategy-drawing-label { + flex: 1; + min-width: 0; +} +.strategy-drawing-list .strategy-drawing-title { + font-weight: 600; +} +.strategy-drawing-list .strategy-drawing-meta { + font-size: 0.75rem; + opacity: 0.7; +} +.strategy-drawing-list .strategy-drawing-delete { + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + background: transparent; + color: var(--color-text-primary); + border: 1px solid currentColor; +} +.strategy-drawing-list .strategy-drawing-add { + margin-top: 0.5rem; + width: 100%; +} + +/* + * Strategy canvas — robot-slot palette (six alliance colour swatches). + * Per-slot colour is provided inline via `--slot-color`; everything else + * sits here. + */ +.strategy-palette { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} +.strategy-palette .strategy-palette-slot { + background: transparent; + color: var(--slot-color); + border: 3px solid var(--slot-color); + border-radius: 0.5rem; + padding: 0.4rem 0.7rem; + min-width: 4.2rem; + font-weight: 700; + cursor: pointer; + opacity: 1; + text-transform: none; + letter-spacing: 0; +} +.strategy-palette .strategy-palette-slot.is-selected { + background: var(--slot-color); + color: #fff; +} +.strategy-palette .strategy-palette-slot.is-hidden { + border-style: dotted; + opacity: 0.4; +} +.strategy-palette .strategy-palette-slot:disabled { + cursor: not-allowed; + opacity: 0.5; +} +.strategy-palette .strategy-palette-slot-label { + font-size: 0.75rem; +} +.strategy-palette .strategy-palette-slot-team { + font-size: 1rem; +} + +/* + * Strategy tournament picker (home + matches pages). + */ +.strategy-tournament-list { + list-style: none; + padding: 0; +} +.strategy-tournament-list li { + margin: 0.5rem 0; +} +.strategy-tournament-list li a { + display: inline-block; +} +.strategy-season-hint { + opacity: 0.7; + font-size: 0.85rem; + margin-top: 0; +} + +/* + * Strategy match picker — grid of match-number buttons per level. + */ +.strategy-match-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(4rem, 1fr)); + gap: 0.4rem; +} +.strategy-match-grid a { + text-align: center; +} + +/* + * Strategy plan editor — page layout + sidebar + canvas-card chrome. + */ +.strategy-plan-grid { + display: grid; + grid-template-columns: minmax(18rem, 28%) 1fr; + gap: 1rem; + align-items: start; +} +.strategy-plan-grid.is-stacked { + grid-template-columns: 1fr; +} +/* + * Strategy sync-status icon button — bare icon (no button chrome), + * clickable to trigger a manual sync. Colour + rotation come from the + * nested Sync icon's status classes. + */ +.strategy-sync-btn { + margin-left: 0.8rem; + padding: 0; + background: transparent; + border: none; + cursor: pointer; + vertical-align: middle; + text-transform: none; + letter-spacing: 0; + font-weight: inherit; +} +.strategy-sync-btn:hover, +.strategy-sync-btn:active { + background: transparent; + transform: none; +} +.strategy-sync-btn:disabled { + cursor: wait; + opacity: 1; /* icon's own state colour is the visual cue */ +} +.strategy-sync-btn .icon { + width: 1.3rem; + height: 1.15rem; +} +.strategy-sync-countdown-wrap { +} +.page-header .strategy-sync-countdown-wrap { + margin-left: 0.8rem; +} +.strategy-sidebar-field { + display: block; + margin-bottom: 0.6rem; +} +.strategy-char-count, +.strategy-sync-countdown-wrap { + font-size: 0.75rem; + opacity: 0.7; +} +.strategy-sidebar-input-control { + width: 100%; + margin-top: 0.2rem; +} +.strategy-drawing-list-scroll { + max-height: 40vh; + overflow-y: auto; +} +/* + * Strategy fullscreen close button — standard toolbar-button box styled + * via the existing .btn-secondary + .strategy-toolbar-btn classes; the + * auto-margin here pushes it to the right edge of the metadata row. + */ +.strategy-close-fullscreen-btn { + margin-left: auto; +} + +/* + * Strategy canvas — windowed read-only preview. + */ +.strategy-drawing-title-static { + flex: 1 1 auto; + min-width: 0; + font-weight: 700; + color: var(--color-text-primary); +} +.strategy-drawing-title-heading { + flex: 0 1 14rem; + min-width: 0; + margin: 0; + font-size: 1rem; + font-weight: 700; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.strategy-canvas-click-to-fullscreen { + cursor: pointer; +} +.strategy-canvas-section.is-fullscreen { + position: fixed; + inset: 0; + z-index: 1000; + margin: 0; + border-radius: 0; + overflow: hidden; + background: var(--color-bg-secondary); + color: var(--color-text-primary); + display: flex; + flex-direction: column; +} +.strategy-metadata-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; +} +.strategy-canvas-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(128, 128, 128, 0.25); +} +.strategy-zoom-btn { + min-width: 2rem; +} +.strategy-zoom-pct-btn { + min-width: 3.5rem; +} +.strategy-zoom-menu-wrapper { + position: relative; + display: inline-flex; +} +.strategy-zoom-menu { + position: absolute; + top: calc(100% + 0.3rem); + right: 0; + z-index: 10; + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.4rem; + background: var(--color-bg-secondary); + color: var(--color-text-primary); + border: 1px solid var(--color-btn-secondary-border); + border-radius: 0.4rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + white-space: nowrap; +} +.strategy-zoom-menu-label { + min-width: 3rem; + text-align: center; + font-variant-numeric: tabular-nums; +} +.strategy-icon-text-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; +} +.strategy-toolbar-divider { + width: 1px; + height: 1.6rem; + background: rgba(128, 128, 128, 0.35); + margin: 0 0.15rem; +} + .btn-danger { background-color: var(--color-danger, #c0392b); color: #fff; diff --git a/app/common/storage/db.ts b/app/common/storage/db.ts index c2e6d206..92e98fd5 100644 --- a/app/common/storage/db.ts +++ b/app/common/storage/db.ts @@ -9,9 +9,10 @@ import type { RBQuickComment } from "~/types/RBQuickComment.ts"; import type { RBEventLogRecord } from "~/types/RBEventLogRecord.ts"; import type { RBRobotAlert } from "~/types/RBRobotAlert.ts"; import type { CustomTournamentStats } from "~/types/TeamSummaryReport.ts"; +import type { StrategyStroke } from "~/types/StrategyStroke.ts"; const DB_NAME = "RavenEyeDB"; -const DB_VERSION = 12; +const DB_VERSION = 13; const SYNC_STATUS_STORE = "syncStatus"; const TOURNAMENT_LIST_STORE = "tournamentList"; const STRATEGY_AREAS_STORE = "strategyAreas"; @@ -27,6 +28,73 @@ const SYNC_ALERT_STORE = "robotAlertsSynced"; const ROBOT_ALERTS_STORE = "robotAlerts"; const TEAM_TOURNAMENT_IDS_STORE = "teamTournamentIds"; const CUSTOM_STATS_CACHE_STORE = "customStatsCache"; +const STRATEGY_PLAN_STORE = "strategyPlans"; +const STRATEGY_DRAWING_STORE = "strategyDrawings"; + +/** + * Minimal typed reference to a single match in a tournament — the three + * fields that uniquely identify a strategy plan. Mirrors the pattern of + * `ScoutingSessionId` but with only the match-identifying fields (no + * alliance / team / user). Used as both the input to, and the parsed + * output from, the strategy-plan local-key serialisation below. + */ +export interface StrategyPlanMatchRef { + tournamentId: string; + matchLevel: string; + matchNumber: number; +} + +const LOCAL_KEY_SEPARATOR = "|"; + +export function strategyPlanLocalKey(ref: StrategyPlanMatchRef): string { + return ( + ref.tournamentId + + LOCAL_KEY_SEPARATOR + + ref.matchLevel + + LOCAL_KEY_SEPARATOR + + ref.matchNumber + ); +} + +export function parseStrategyPlanLocalKey(key: string): StrategyPlanMatchRef { + const parts = key.split(LOCAL_KEY_SEPARATOR); + return { + tournamentId: parts[0] ?? "", + matchLevel: parts[1] ?? "", + matchNumber: parseInt(parts[2] ?? "0", 10), + }; +} + +export interface StoredStrategyPlan { + localKey: string; + id: number | null; + tournamentId: string; + matchLevel: string; + matchNumber: number; + shortSummary: string; + strategyText: string | null; + updatedByUserId: number; + updatedByDisplayName: string; + updatedAt: string; + dirty: boolean; +} + +export interface StoredStrategyDrawing { + localId: string; // `srv-${serverId}` once synced; `new-${uuid}` while pending + planLocalKey: string; + id: number | null; + planId: number | null; + label: string; + strokes: StrategyStroke[]; + createdByUserId: number | null; + createdByDisplayName: string | null; + updatedByUserId: number | null; + updatedByDisplayName: string | null; + createdAt: string | null; + updatedAt: string | null; + dirty: boolean; + pendingDelete: boolean; +} export class Repository { private db: IDBDatabase | null = null; @@ -90,6 +158,17 @@ export class Repository { if (!db.objectStoreNames.contains(CUSTOM_STATS_CACHE_STORE)) { db.createObjectStore(CUSTOM_STATS_CACHE_STORE, { keyPath: "key" }); } + if (!db.objectStoreNames.contains(STRATEGY_PLAN_STORE)) { + db.createObjectStore(STRATEGY_PLAN_STORE, { keyPath: "localKey" }); + } + if (!db.objectStoreNames.contains(STRATEGY_DRAWING_STORE)) { + const drawingStore = db.createObjectStore(STRATEGY_DRAWING_STORE, { + keyPath: "localId", + }); + drawingStore.createIndex("planLocalKey", "planLocalKey", { + unique: false, + }); + } }; }); } @@ -585,6 +664,156 @@ export class Repository { }); } + // -------- Match Strategy Plans -------- + + async putStrategyPlan(stored: StoredStrategyPlan): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_PLAN_STORE], "readwrite"); + tx.objectStore(STRATEGY_PLAN_STORE).put(stored); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async getStrategyPlan(localKey: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_PLAN_STORE], "readonly"); + const req = tx.objectStore(STRATEGY_PLAN_STORE).get(localKey); + req.onsuccess = () => + resolve((req.result as StoredStrategyPlan | undefined) ?? null); + req.onerror = () => reject(req.error); + }); + } + + async getStrategyPlansForTournament( + tournamentId: string, + ): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_PLAN_STORE], "readonly"); + const req = tx.objectStore(STRATEGY_PLAN_STORE).getAll(); + req.onsuccess = () => { + const all = (req.result as StoredStrategyPlan[]) ?? []; + resolve(all.filter((p) => p.tournamentId === tournamentId)); + }; + req.onerror = () => reject(req.error); + }); + } + + async getDirtyStrategyPlans(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_PLAN_STORE], "readonly"); + const req = tx.objectStore(STRATEGY_PLAN_STORE).getAll(); + req.onsuccess = () => { + const all = (req.result as StoredStrategyPlan[]) ?? []; + resolve(all.filter((p) => p.dirty)); + }; + req.onerror = () => reject(req.error); + }); + } + + // -------- Match Strategy Drawings -------- + + async putStrategyDrawing(stored: StoredStrategyDrawing): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readwrite"); + tx.objectStore(STRATEGY_DRAWING_STORE).put(stored); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async getStrategyDrawing( + localId: string, + ): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readonly"); + const req = tx.objectStore(STRATEGY_DRAWING_STORE).get(localId); + req.onsuccess = () => + resolve((req.result as StoredStrategyDrawing | undefined) ?? null); + req.onerror = () => reject(req.error); + }); + } + + async getStrategyDrawingsForPlan( + planLocalKey: string, + ): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readonly"); + const store = tx.objectStore(STRATEGY_DRAWING_STORE); + const index = store.index("planLocalKey"); + const req = index.getAll(IDBKeyRange.only(planLocalKey)); + req.onsuccess = () => { + const all = (req.result as StoredStrategyDrawing[]) ?? []; + resolve(all.filter((d) => !d.pendingDelete)); + }; + req.onerror = () => reject(req.error); + }); + } + + async getDirtyStrategyDrawings(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readonly"); + const req = tx.objectStore(STRATEGY_DRAWING_STORE).getAll(); + req.onsuccess = () => { + const all = (req.result as StoredStrategyDrawing[]) ?? []; + resolve(all.filter((d) => d.dirty && !d.pendingDelete)); + }; + req.onerror = () => reject(req.error); + }); + } + + async getPendingDeleteStrategyDrawings(): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readonly"); + const req = tx.objectStore(STRATEGY_DRAWING_STORE).getAll(); + req.onsuccess = () => { + const all = (req.result as StoredStrategyDrawing[]) ?? []; + resolve(all.filter((d) => d.pendingDelete)); + }; + req.onerror = () => reject(req.error); + }); + } + + async deleteStrategyDrawingLocal(localId: string): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readwrite"); + tx.objectStore(STRATEGY_DRAWING_STORE).delete(localId); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + /** + * Replace a drawing's localId with a new one (used when a `new-*` localId + * is upgraded to `srv-` after first successful sync). + */ + async renameStrategyDrawing( + oldLocalId: string, + replacement: StoredStrategyDrawing, + ): Promise { + const db = await this.getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction([STRATEGY_DRAWING_STORE], "readwrite"); + const store = tx.objectStore(STRATEGY_DRAWING_STORE); + if (oldLocalId !== replacement.localId) { + store.delete(oldLocalId); + } + store.put(replacement); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + async clearCustomStatsCache(teamId: number): Promise { const db = await this.getDB(); return new Promise((resolve, reject) => { diff --git a/app/common/storage/rb.ts b/app/common/storage/rb.ts index d117218c..e8a5ac0c 100644 --- a/app/common/storage/rb.ts +++ b/app/common/storage/rb.ts @@ -28,6 +28,9 @@ import type { import type { TeamScheduleResponse } from "~/types/TeamSchedule.ts"; import type { NexusQueueStatus } from "~/types/NexusQueueStatus.ts"; import type { PmvaReportResponse } from "~/types/PmvaReport.ts"; +import type { MatchStrategyPlan } from "~/types/MatchStrategyPlan.ts"; +import type { MatchStrategyDrawing } from "~/types/MatchStrategyDrawing.ts"; +import type { StrategyStroke } from "~/types/StrategyStroke.ts"; /** * Sends a ping request to the API to check if the server is reachable. @@ -623,6 +626,8 @@ export interface ConfigSyncResult { events: number; comments: number; alerts: number; + matchStrategyPlans: number; + matchStrategyDrawings: number; tournamentsCleared: boolean; message: string; } @@ -1252,3 +1257,132 @@ export async function forgotPassword(login: string): Promise { throw new Error("Failed to flag forgotten password: " + resp.status); } } + +// ------- Match Strategy Plans ------- + +export interface RBPlanWithDrawings { + plan: RBMatchStrategyPlan; + drawings: RBMatchStrategyDrawing[]; +} + +export interface RBMatchStrategyPlan { + id: number; + tournamentId: string; + matchLevel: string; + matchNumber: number; + shortSummary: string; + strategyText: string | null; + updatedByUserId: number; + updatedByDisplayName: string; + updatedAt: string; +} + +export interface RBMatchStrategyDrawing { + id: number; + planId: number; + label: string; + strokes: string; // JSON string server-side + createdByUserId: number; + createdByDisplayName: string; + updatedByUserId: number; + updatedByDisplayName: string; + createdAt: string; + updatedAt: string; +} + +export function parseDrawingStrokes( + drawing: RBMatchStrategyDrawing, +): StrategyStroke[] { + try { + return JSON.parse(drawing.strokes) as StrategyStroke[]; + } catch { + return []; + } +} + +export async function getStrategyPlan( + tournamentId: string, + matchLevel: string, + matchNumber: number, +): Promise { + const resp = await rbfetch( + "/api/match-strategy/" + + encodeURIComponent(tournamentId) + + "/" + + encodeURIComponent(matchLevel) + + "/" + + matchNumber, + {}, + ); + if (resp.status === 404) return null; + if (!resp.ok) { + throw new Error( + "Failure fetching strategy plan " + + tournamentId + + "/" + + matchLevel + + "/" + + matchNumber, + ); + } + return resp.json() as unknown as RBPlanWithDrawings; +} + +export async function getStrategyPlansForTournament( + tournamentId: string, +): Promise { + const resp = await rbfetch( + "/api/match-strategy/" + encodeURIComponent(tournamentId), + {}, + ); + if (!resp.ok) { + throw new Error( + "Failure fetching strategy plans for tournament " + tournamentId, + ); + } + return resp.json() as unknown as RBPlanWithDrawings[]; +} + +export async function saveStrategyPlan(req: { + tournamentId: string; + matchLevel: string; + matchNumber: number; + shortSummary: string; + strategyText: string | null; +}): Promise { + const resp = await rbfetch("/api/match-strategy", { + method: "POST", + body: JSON.stringify(req), + }); + if (!resp.ok) { + throw new Error("Failed to save strategy plan: " + resp.status); + } + return resp.json() as unknown as MatchStrategyPlan; +} + +export async function saveStrategyDrawing(req: { + id: number | null; + tournamentId: string; + matchLevel: string; + matchNumber: number; + label: string; + strokes: string; // already JSON.stringified +}): Promise { + const resp = await rbfetch("/api/match-strategy/drawing", { + method: "POST", + body: JSON.stringify(req), + }); + if (!resp.ok) { + throw new Error("Failed to save strategy drawing: " + resp.status); + } + return resp.json() as unknown as MatchStrategyDrawing; +} + +export async function deleteStrategyDrawing(id: number): Promise { + const resp = await rbfetch("/api/match-strategy/drawing/" + id, { + method: "DELETE", + }); + if (!resp.ok && resp.status !== 204) { + throw new Error("Failed to delete strategy drawing: " + resp.status); + } +} diff --git a/app/common/strategy/DrawingList.tsx b/app/common/strategy/DrawingList.tsx new file mode 100644 index 00000000..5903c7f7 --- /dev/null +++ b/app/common/strategy/DrawingList.tsx @@ -0,0 +1,64 @@ +import type { StoredStrategyDrawing } from "~/common/storage/db.ts"; + +type Props = { + drawings: StoredStrategyDrawing[]; + activeLocalId: string | null; + onSelect: (localId: string) => void; + onAdd: () => void; + onDelete: (localId: string) => void; + canEdit: boolean; +}; + +export default function DrawingList(props: Props) { + const { drawings, activeLocalId, onSelect, onAdd, onDelete, canEdit } = props; + return ( +
+

Drawings

+
    + {drawings.map((d) => { + const isActive = d.localId === activeLocalId; + return ( +
  • onSelect(d.localId)} + > +
    +
    + {d.label || "(untitled)"} +
    +
    + by {d.createdByDisplayName || "you"} + {d.dirty ? " · unsynced" : ""} +
    +
    + {canEdit && ( + + )} +
  • + ); + })} +
+ {canEdit && ( + + )} +
+ ); +} diff --git a/app/common/strategy/RobotSlotPalette.tsx b/app/common/strategy/RobotSlotPalette.tsx new file mode 100644 index 00000000..5870a0c8 --- /dev/null +++ b/app/common/strategy/RobotSlotPalette.tsx @@ -0,0 +1,64 @@ +import type { RobotSlot } from "~/types/StrategyStroke.ts"; +import { ROBOT_COLORS, ROBOT_SLOTS } from "~/common/strategy/colors.ts"; + +type Props = { + selected: RobotSlot; + onSelect: (slot: RobotSlot) => void; + teamNumbers: Record; + disabled?: boolean; + /** + * When non-null, only this slot's strokes are visible on the canvas. All + * other slot buttons are rendered as hidden/dimmed in the palette. + */ + soloedSlot?: RobotSlot | null; + /** Optional CSS overrides merged into the container. */ + style?: React.CSSProperties; +}; + +export default function RobotSlotPalette(props: Props) { + const { + selected, + onSelect, + teamNumbers, + disabled, + soloedSlot = null, + style, + } = props; + return ( +
+ {ROBOT_SLOTS.map((slot, i) => { + const isSelected = selected === slot; + const color = ROBOT_COLORS[i]!; + const team = teamNumbers[slot]; + const isHidden = soloedSlot != null && slot !== soloedSlot; + const classes = [ + "strategy-palette-slot", + isSelected && "is-selected", + isHidden && "is-hidden", + ] + .filter(Boolean) + .join(" "); + return ( + + ); + })} +
+ ); +} diff --git a/app/common/strategy/StrategyCanvas.tsx b/app/common/strategy/StrategyCanvas.tsx new file mode 100644 index 00000000..534dee20 --- /dev/null +++ b/app/common/strategy/StrategyCanvas.tsx @@ -0,0 +1,1027 @@ +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import type { + RobotSlot, + StrategyPoint, + StrategyStroke, +} from "~/types/StrategyStroke.ts"; +import { ROBOT_COLORS, colorIndexForSlot } from "~/common/strategy/colors.ts"; +import { loadFieldImage } from "~/common/strategy/fieldImage.ts"; + +export type CanvasTool = "draw" | "erase" | "pan"; + +/** + * Display rotation applied to the field image and to stroke rendering. The + * source image is stored with red alliance on the left and blue on the right; + * strokes are persisted in that source frame. A non-zero rotation affects + * rendering only — stored data stays in source space. + * + * 0 — source orientation (red on left) + * 90 — clockwise 90° (red on top, blue on bottom) + * 270 — counter-clockwise 90° (red on bottom, blue on top) + */ +export type CanvasRotation = 0 | 90 | 270; + +type Props = { + backgroundSrc: string; + strokes: StrategyStroke[]; + readOnly: boolean; + selectedSlot: RobotSlot; + /** + * Whether new strokes should be drawn with an arrowhead (true) or as a + * plain line (false). Only meaningful when `tool === "draw"`. Defaults to + * true for backward compatibility. + */ + selectedArrow?: boolean; + /** Active canvas tool. "draw" creates new strokes; "erase" deletes them. */ + tool?: CanvasTool; + /** + * When non-null, only strokes whose `robotSlot === soloedSlot` are rendered, + * hit-testable, and included in playback. Other strokes are hidden. + */ + soloedSlot?: RobotSlot | null; + /** + * Display rotation. 0 is the native source frame; 90 / 270 rotate the + * field image + strokes to keep the owner team's alliance at the bottom. + */ + rotation?: CanvasRotation; + /** View transform. zoom ≥ 1, pan in 0..1 canvas-normalized space. */ + zoom?: number; + panX?: number; + panY?: number; + /** + * When true, the canvas wrapper stretches to fill its parent's height + * (via `flex: 1`) instead of using an aspect-ratio box matching the rotated + * image. Use this inside a flex-column container — e.g. the fullscreen + * overlay — to give the canvas all remaining vertical space. + */ + fillHeight?: boolean; + /** Called when the user pans via the Pan tool or two-finger drag. */ + onPanChange?: (panX: number, panY: number) => void; + /** Called when the user pinches to zoom (also receives centroid for pan). */ + onZoomChange?: (zoom: number, panX: number, panY: number) => void; + onStrokeComplete?: (stroke: StrategyStroke) => void; + onEraseStroke?: (strokeIndex: number) => void; + /** Fires when playback completes naturally (not when stop() is called). */ + onPlaybackEnd?: () => void; +}; + +export const MAX_ZOOM = 5.0; +export const MIN_ZOOM = 1.0; + +const ERASE_HIT_RADIUS_CSS_PX = 14; + +export type StrategyCanvasHandle = { + play: (speed: number) => void; + stop: () => void; + /** + * Compute a zoom/pan that makes the (rotated) image fill the canvas width. + * The image's bottom edge sits at the canvas bottom by default; pass + * `bottomInsetSourcePx` (in *source-image* pixels, along the source X axis) + * to shift the image further down — i.e., slide the "interesting" edge of + * the field up to the canvas bottom while the image's physical bottom + * extends past it. Returns null if the canvas / image dimensions aren't + * yet known. + */ + computeDefaultFit: (options?: { + bottomInsetSourcePx?: number; + }) => { zoom: number; panX: number; panY: number } | null; +}; + +/** + * Rotate a source-normalized point (u, v) ∈ [0,1]² into rotated display- + * normalized space. Keep in sync with `unrotateNorm` (its exact inverse). + */ +function rotateNorm( + u: number, + v: number, + rotation: CanvasRotation, +): [number, number] { + if (rotation === 0) return [u, v]; + if (rotation === 90) return [1 - v, u]; + // 270 CW + return [v, 1 - u]; +} + +/** Inverse of `rotateNorm`: rotated-normalized → source-normalized. */ +function unrotateNorm( + u: number, + v: number, + rotation: CanvasRotation, +): [number, number] { + if (rotation === 0) return [u, v]; + if (rotation === 90) return [v, 1 - u]; + // 270 CW + return [1 - v, u]; +} + +type ImageRect = { + imgLeft: number; + imgTop: number; + imgW: number; + imgH: number; + cssW: number; + cssH: number; +}; + +const STROKE_WIDTH = 2.5; +const ARROW_HEAD_LEN = 9; +const ARROW_HEAD_ANGLE = Math.PI / 6; + +const StrategyCanvas = forwardRef( + function StrategyCanvas(props, ref) { + const { + backgroundSrc, + strokes, + readOnly, + selectedSlot, + selectedArrow = true, + tool = "draw", + soloedSlot = null, + rotation = 0, + zoom = 1, + panX = 0, + panY = 0, + fillHeight = false, + onPanChange, + onZoomChange, + onStrokeComplete, + onEraseStroke, + onPlaybackEnd, + } = props; + + // Clamp helpers for zoom/pan. + const clampZoom = (z: number) => + Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + const clampPan = (p: number, z: number) => + Math.min(1 - 1 / z, Math.max(0, p)); + + // Indices into `strokes` that should be visible under the current solo + // filter. Used by rendering, hit-testing, and playback. + const visibleIndices = useMemo(() => { + if (!soloedSlot) return strokes.map((_, i) => i); + const out: number[] = []; + for (let i = 0; i < strokes.length; i++) { + if (strokes[i]!.robotSlot === soloedSlot) out.push(i); + } + return out; + }, [strokes, soloedSlot]); + const [eraseHoverIndex, setEraseHoverIndex] = useState(null); + const canvasRef = useRef(null); + const wrapperRef = useRef(null); + const [bgImage, setBgImage] = useState(null); + const currentStrokeRef = useRef(null); + const strokeStartMsRef = useRef(0); + const playbackRef = useRef<{ cancelled: boolean } | null>(null); + const [playbackSlice, setPlaybackSlice] = useState<{ + doneStrokes: number; + currentStrokePoints: StrategyPoint[] | null; + currentStrokeIndex: number; + } | null>(null); + + // Load background image via the shared module-level cache. If this URL + // has been decoded before in this session, the promise resolves + // synchronously on the next microtask and we render with no visible + // delay. Otherwise the decode runs once and is cached. + useEffect(() => { + let cancelled = false; + loadFieldImage(backgroundSrc) + .then((img) => { + if (!cancelled) setBgImage(img); + }) + .catch(() => { + // swallow — canvas simply renders without a background + }); + return () => { + cancelled = true; + }; + }, [backgroundSrc]); + + // Make the canvas fill the wrapper entirely (HiDPI-aware). The background + // image is then letterboxed *inside* the canvas with the rotated aspect, + // so the user-visible clipping region matches the wrapper (not just the + // image rect). This is what makes "default zoom = image fills wrapper + // width" work — at higher zoom the image scales past the image-fit-rect + // and into the letterbox area, which is still inside the canvas. + const resizeCanvas = useCallback((): boolean => { + const canvas = canvasRef.current; + const wrapper = wrapperRef.current; + if (!canvas || !wrapper) return false; + const dpr = window.devicePixelRatio || 1; + const wrapperRect = wrapper.getBoundingClientRect(); + const wrapperW = Math.max(1, Math.floor(wrapperRect.width)); + const wrapperH = Math.max(1, Math.floor(wrapperRect.height)); + if (wrapperW < 1 || wrapperH < 1) return false; + + canvas.style.left = "0px"; + canvas.style.top = "0px"; + canvas.style.width = wrapperW + "px"; + canvas.style.height = wrapperH + "px"; + + const targetW = Math.floor(wrapperW * dpr); + const targetH = Math.floor(wrapperH * dpr); + if (canvas.width === targetW && canvas.height === targetH) { + return false; + } + canvas.width = targetW; + canvas.height = targetH; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + return true; + }, []); + + // Keep the latest `redraw` in a ref so the ResizeObserver (installed once + // at mount) always calls the fresh closure with current strokes / bgImage + // / playback state, not the empty initial one. + const redrawRef = useRef<() => void>(() => {}); + + useEffect(() => { + // Coalesce rapid resize fires (e.g. during a fullscreen-toggle layout + // settle or a window-drag resize) into a single `requestAnimationFrame` + // pass. Each `canvas.width = N` reallocates the backing store, so + // batching avoids 10+ MB of churn per transition on HiDPI iPads. + // + // When ResizeObserver fires we ALWAYS redraw — the observed element's + // size changed, so the previous draw (which used old cssW/cssH) is + // stale for the background image aspect-fit + stroke placement even + // when `resizeCanvas()` happens to return `false`. + let rafId: number | null = null; + const scheduleResize = () => { + if (rafId != null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + resizeCanvas(); + redrawRef.current(); + }); + }; + // Initial sizing: resize + redraw immediately (the redraw happens via + // the `useEffect(() => redraw(), [redraw])` below once redraw closes + // over real state, but this gets pixels on the screen ASAP). + resizeCanvas(); + const obs = new ResizeObserver(scheduleResize); + // Observe the wrapper — its size determines the canvas's fit, which + // resizeCanvas() computes from the wrapper rect + image aspect. + if (wrapperRef.current) obs.observe(wrapperRef.current); + return () => { + obs.disconnect(); + if (rafId != null) cancelAnimationFrame(rafId); + }; + }, [resizeCanvas]); + + // Translate a source-normalized stroke point to display canvas pixels + // (pre-zoom/pan): first rotate into rotated-image space, then map onto + // the image's letterbox rect inside the canvas. + const pointToCanvasPx = useCallback( + ( + u: number, + v: number, + rect: ImageRect, + ): [number, number] => { + const [ru, rv] = rotateNorm(u, v, rotation); + return [rect.imgLeft + ru * rect.imgW, rect.imgTop + rv * rect.imgH]; + }, + [rotation], + ); + + const drawStroke = useCallback( + ( + ctx: CanvasRenderingContext2D, + stroke: StrategyStroke, + points: StrategyPoint[], + rect: ImageRect, + drawArrow: boolean, + ) => { + if (points.length === 0) return; + const color = + ROBOT_COLORS[stroke.colorIndex] ?? + ROBOT_COLORS[colorIndexForSlot(stroke.robotSlot)]!; + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = STROKE_WIDTH; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + const [x0, y0] = pointToCanvasPx(points[0]!.x, points[0]!.y, rect); + ctx.moveTo(x0, y0); + if (points.length < 3) { + for (let i = 1; i < points.length; i++) { + const [x, y] = pointToCanvasPx(points[i]!.x, points[i]!.y, rect); + ctx.lineTo(x, y); + } + } else { + // Quadratic smoothing: use midpoints as curve endpoints. + for (let i = 1; i < points.length - 1; i++) { + const [x, y] = pointToCanvasPx(points[i]!.x, points[i]!.y, rect); + const [xn, yn] = pointToCanvasPx( + points[i + 1]!.x, + points[i + 1]!.y, + rect, + ); + const xc = (x + xn) / 2; + const yc = (y + yn) / 2; + ctx.quadraticCurveTo(x, y, xc, yc); + } + const last = points[points.length - 1]!; + const [xL, yL] = pointToCanvasPx(last.x, last.y, rect); + ctx.lineTo(xL, yL); + } + ctx.stroke(); + + const strokeWantsArrow = stroke.arrow !== false; + if (drawArrow && strokeWantsArrow && points.length >= 2) { + const last = points[points.length - 1]!; + // Use a point ~15 samples back to determine direction, if available. + const refIdx = Math.max(0, points.length - 15); + const ref = points[refIdx]!; + const [lastPx, lastPy] = pointToCanvasPx(last.x, last.y, rect); + const [refPx, refPy] = pointToCanvasPx(ref.x, ref.y, rect); + const dx = lastPx - refPx; + const dy = lastPy - refPy; + if (dx * dx + dy * dy > 4) { + const angle = Math.atan2(dy, dx); + // The round line cap extends the stroke past `last` by + // STROKE_WIDTH/.8. Advance the arrow tip forward by that amount so + // it coincides with the visual end of the line. + const advance = STROKE_WIDTH / .8; + const tipX = lastPx + advance * Math.cos(angle); + const tipY = lastPy + advance * Math.sin(angle); + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo( + tipX - ARROW_HEAD_LEN * Math.cos(angle - ARROW_HEAD_ANGLE), + tipY - ARROW_HEAD_LEN * Math.sin(angle - ARROW_HEAD_ANGLE), + ); + ctx.lineTo( + tipX - ARROW_HEAD_LEN * Math.cos(angle + ARROW_HEAD_ANGLE), + tipY - ARROW_HEAD_LEN * Math.sin(angle + ARROW_HEAD_ANGLE), + ); + ctx.closePath(); + ctx.fill(); + } + } + }, + [pointToCanvasPx], + ); + + const getCanvasCssSize = useCallback((): { cssW: number; cssH: number } => { + const canvas = canvasRef.current; + if (!canvas) return { cssW: 0, cssH: 0 }; + const dpr = window.devicePixelRatio || 1; + return { cssW: canvas.width / dpr, cssH: canvas.height / dpr }; + }, []); + + // Compute the rectangle inside the canvas where the (rotated) background + // image will be drawn. The image is fit-inside-canvas preserving its + // rotated aspect ratio, centered. Pre-load falls back to aspect 2.0 so + // the canvas doesn't jump when the image decodes. + const getImageRect = useCallback((): ImageRect | null => { + const { cssW, cssH } = getCanvasCssSize(); + if (cssW < 1 || cssH < 1) return null; + const bgAspect = + bgImage && bgImage.height > 0 ? bgImage.width / bgImage.height : 2; + const rotAspect = rotation === 0 ? bgAspect : 1 / bgAspect; + const canvasAspect = cssW / cssH; + let imgW: number; + let imgH: number; + let imgLeft: number; + let imgTop: number; + if (rotAspect > canvasAspect) { + // Image is wider than the canvas — fit by width, letterbox top/bottom. + imgW = cssW; + imgH = cssW / rotAspect; + imgLeft = 0; + imgTop = (cssH - imgH) / 2; + } else { + // Fit by height, letterbox left/right. + imgH = cssH; + imgW = cssH * rotAspect; + imgTop = 0; + imgLeft = (cssW - imgW) / 2; + } + return { imgLeft, imgTop, imgW, imgH, cssW, cssH }; + }, [bgImage, rotation, getCanvasCssSize]); + + /** + * Distance in CSS pixels from point (px, py) to the segment (ax,ay)-(bx,by). + */ + const distPointToSegment = useCallback( + ( + px: number, + py: number, + ax: number, + ay: number, + bx: number, + by: number, + ): number => { + const dx = bx - ax; + const dy = by - ay; + const lenSq = dx * dx + dy * dy; + let t = lenSq === 0 ? 0 : ((px - ax) * dx + (py - ay) * dy) / lenSq; + t = Math.max(0, Math.min(1, t)); + const cx = ax + t * dx; + const cy = ay + t * dy; + const ex = px - cx; + const ey = py - cy; + return Math.sqrt(ex * ex + ey * ey); + }, + [], + ); + + /** + * Find the topmost (last-drawn) stroke whose closest point to the pointer + * is within ERASE_HIT_RADIUS_CSS_PX. Both pointer and stroke points are + * converted to pre-zoom canvas pixels (via the image rect + rotation) and + * compared there. Returns its index, or null. + */ + const hitTestStrokes = useCallback( + (pointerCanvasPxX: number, pointerCanvasPxY: number): number | null => { + const rect = getImageRect(); + if (!rect) return null; + // The pointer's ~14-px reach on screen is `14 / zoom` in the + // pre-zoom canvas-pixel coord space we're testing in. + const hitRadius = ERASE_HIT_RADIUS_CSS_PX / zoom; + for (let i = strokes.length - 1; i >= 0; i--) { + const s = strokes[i]!; + if (soloedSlot && s.robotSlot !== soloedSlot) continue; + const pts = s.points; + if (pts.length === 0) continue; + if (pts.length === 1) { + const [ax, ay] = pointToCanvasPx(pts[0]!.x, pts[0]!.y, rect); + const d = Math.hypot(ax - pointerCanvasPxX, ay - pointerCanvasPxY); + if (d <= hitRadius) return i; + continue; + } + for (let j = 0; j < pts.length - 1; j++) { + const [ax, ay] = pointToCanvasPx(pts[j]!.x, pts[j]!.y, rect); + const [bx, by] = pointToCanvasPx( + pts[j + 1]!.x, + pts[j + 1]!.y, + rect, + ); + const d = distPointToSegment( + pointerCanvasPxX, + pointerCanvasPxY, + ax, + ay, + bx, + by, + ); + if (d <= hitRadius) return i; + } + } + return null; + }, + [strokes, getImageRect, pointToCanvasPx, distPointToSegment, soloedSlot, zoom], + ); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const cssW = canvas.width / dpr; + const cssH = canvas.height / dpr; + ctx.clearRect(0, 0, cssW, cssH); + + const rect = getImageRect(); + if (!rect) return; + + // Zoom + pan transform on top of the existing dpr transform. Both the + // background image and all strokes render in pre-zoom canvas-px coords + // (i.e. 0..cssW × 0..cssH), so this single scale+translate is all we + // need — rotation lives inside each drawImage call and inside + // `pointToCanvasPx` for strokes. + ctx.save(); + if (zoom !== 1 || panX !== 0 || panY !== 0) { + ctx.scale(zoom, zoom); + ctx.translate(-panX * cssW, -panY * cssH); + } + + if (bgImage) { + const { imgLeft, imgTop, imgW, imgH } = rect; + ctx.save(); + if (rotation === 0) { + ctx.drawImage(bgImage, imgLeft, imgTop, imgW, imgH); + } else if (rotation === 90) { + // Rotate around the image rect: the source's (0,0) corner lands at + // the display rect's top-right. + ctx.translate(imgLeft + imgW, imgTop); + ctx.rotate(Math.PI / 2); + ctx.drawImage(bgImage, 0, 0, imgH, imgW); + } else { + // 270° CW — source's (0,0) lands at the display rect's bottom-left. + ctx.translate(imgLeft, imgTop + imgH); + ctx.rotate(-Math.PI / 2); + ctx.drawImage(bgImage, 0, 0, imgH, imgW); + } + ctx.restore(); + } + + const indicesToDraw = playbackSlice + ? visibleIndices.slice(0, playbackSlice.doneStrokes) + : visibleIndices; + for (const origIdx of indicesToDraw) { + const s = strokes[origIdx]!; + drawStroke(ctx, s, s.points, rect, true); + } + if (playbackSlice && playbackSlice.currentStrokePoints) { + const origIdx = visibleIndices[playbackSlice.currentStrokeIndex]; + if (origIdx != null) { + const stroke = strokes[origIdx]; + if (stroke) { + drawStroke( + ctx, + stroke, + playbackSlice.currentStrokePoints, + rect, + false, + ); + } + } + } + if (currentStrokeRef.current) { + drawStroke( + ctx, + currentStrokeRef.current, + currentStrokeRef.current.points, + rect, + false, + ); + } + // Erase hover highlight — draw a red-dashed outline behind the candidate. + // (hitTestStrokes already skips hidden strokes so the hover index will + // only ever point at a visible one, but we double-check here.) + if ( + tool === "erase" && + eraseHoverIndex != null && + eraseHoverIndex < strokes.length && + (!soloedSlot || strokes[eraseHoverIndex]!.robotSlot === soloedSlot) + ) { + const target = strokes[eraseHoverIndex]!; + if (target.points.length > 0) { + ctx.save(); + ctx.strokeStyle = "#ff3b30"; + ctx.lineWidth = (STROKE_WIDTH + 6) / zoom; + ctx.setLineDash([6 / zoom, 4 / zoom]); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.beginPath(); + const [tx0, ty0] = pointToCanvasPx( + target.points[0]!.x, + target.points[0]!.y, + rect, + ); + ctx.moveTo(tx0, ty0); + for (let i = 1; i < target.points.length; i++) { + const [tx, ty] = pointToCanvasPx( + target.points[i]!.x, + target.points[i]!.y, + rect, + ); + ctx.lineTo(tx, ty); + } + ctx.stroke(); + ctx.restore(); + } + } + ctx.restore(); + }, [ + bgImage, + rotation, + strokes, + drawStroke, + pointToCanvasPx, + getImageRect, + playbackSlice, + tool, + eraseHoverIndex, + visibleIndices, + soloedSlot, + zoom, + panX, + panY, + ]); + + useEffect(() => { + // Keep the ref pointed at the latest redraw closure so the + // ResizeObserver callback (installed once) always runs the fresh + // version — with current strokes, bgImage, playback state, etc. + redrawRef.current = redraw; + redraw(); + }, [redraw]); + + // ----- Pointer events ----- + // Returns the pointer's position in PRE-ZOOM canvas pixels (i.e. + // 0..cssW × 0..cssH). This is the coord space in which strokes are + // drawn (before the ctx.scale zoom), so hit-testing and stroke- + // creation both operate here. + const pointToCanvasPxEvent = useCallback( + (e: { clientX: number; clientY: number }): { x: number; y: number } => { + const canvas = canvasRef.current!; + const clientRect = canvas.getBoundingClientRect(); + const { cssW, cssH } = getCanvasCssSize(); + const x = + ((e.clientX - clientRect.left) / clientRect.width) * cssW / zoom + + panX * cssW; + const y = + ((e.clientY - clientRect.top) / clientRect.height) * cssH / zoom + + panY * cssH; + return { x, y }; + }, + [zoom, panX, panY, getCanvasCssSize], + ); + + // Converts a pointer event to a SOURCE-normalized (0..1) stroke coord, + // undoing zoom/pan + rotation + the image letterbox. + const pointToNormalized = useCallback( + (e: { clientX: number; clientY: number }) => { + const rect = getImageRect(); + if (!rect) return { x: 0, y: 0 }; + const { x, y } = pointToCanvasPxEvent(e); + const rotU = (x - rect.imgLeft) / rect.imgW; + const rotV = (y - rect.imgTop) / rect.imgH; + const [u, v] = unrotateNorm(rotU, rotV, rotation); + return { + x: Math.max(0, Math.min(1, u)), + y: Math.max(0, Math.min(1, v)), + }; + }, + [getImageRect, pointToCanvasPxEvent, rotation], + ); + + // Multi-pointer state for two-finger gestures. + const pointersRef = useRef>( + new Map(), + ); + const gestureRef = useRef<{ + startDist: number; + startZoom: number; + // Pre-zoom canvas-px coords under the centroid at gesture start — we + // anchor the zoom around this point so it stays put while pinching. + anchorCanvasPxX: number; + anchorCanvasPxY: number; + } | null>(null); + // Single-pointer pan drag (Pan tool). + const panDragRef = useRef<{ + startClientX: number; + startClientY: number; + startPanX: number; + startPanY: number; + } | null>(null); + + const firstTwoPointers = (): [ + { x: number; y: number }, + { x: number; y: number }, + ] | null => { + const iter = pointersRef.current.values(); + const p1 = iter.next(); + if (p1.done) return null; + const p2 = iter.next(); + if (p2.done) return null; + return [p1.value, p2.value]; + }; + + const beginGesture = () => { + // Cancel any in-progress single-pointer action without committing. + currentStrokeRef.current = null; + panDragRef.current = null; + const pair = firstTwoPointers(); + if (!pair) return; + const [p1, p2] = pair; + const cx = (p1.x + p2.x) / 2; + const cy = (p1.y + p2.y) / 2; + const anchor = pointToCanvasPxEvent({ clientX: cx, clientY: cy }); + gestureRef.current = { + startDist: Math.hypot(p2.x - p1.x, p2.y - p1.y), + startZoom: zoom, + anchorCanvasPxX: anchor.x, + anchorCanvasPxY: anchor.y, + }; + }; + + const updateGesture = () => { + const g = gestureRef.current; + if (!g) return; + const pair = firstTwoPointers(); + if (!pair) return; + const [p1, p2] = pair; + const canvas = canvasRef.current!; + const clientRect = canvas.getBoundingClientRect(); + const { cssW, cssH } = getCanvasCssSize(); + if (cssW < 1 || cssH < 1) return; + const cx = (p1.x + p2.x) / 2; + const cy = (p1.y + p2.y) / 2; + const currDist = Math.hypot(p2.x - p1.x, p2.y - p1.y); + const scale = g.startDist > 0 ? currDist / g.startDist : 1; + const newZoom = clampZoom(g.startZoom * scale); + // Pan so the anchor (a fixed pre-zoom canvas-px point) stays under the + // current centroid at the new zoom. Solving for pan (fraction of + // cssW/cssH) from: + // anchor = (cx - clientRect.left) / clientRect.width * cssW / newZoom + // + pan * cssDim + let newPanX = + g.anchorCanvasPxX / cssW - + ((cx - clientRect.left) / clientRect.width) / newZoom; + let newPanY = + g.anchorCanvasPxY / cssH - + ((cy - clientRect.top) / clientRect.height) / newZoom; + newPanX = clampPan(newPanX, newZoom); + newPanY = clampPan(newPanY, newZoom); + onZoomChange?.(newZoom, newPanX, newPanY); + }; + + const handlePointerDown = (e: React.PointerEvent) => { + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + + // Second pointer down → enter gesture mode (overrides any tool). + if (pointersRef.current.size >= 2) { + e.preventDefault(); + beginGesture(); + return; + } + + if (readOnly) return; + if (playbackRef.current) return; + + if (tool === "pan") { + e.preventDefault(); + canvasRef.current?.setPointerCapture(e.pointerId); + panDragRef.current = { + startClientX: e.clientX, + startClientY: e.clientY, + startPanX: panX, + startPanY: panY, + }; + return; + } + + if (tool === "erase") { + e.preventDefault(); + const { x, y } = pointToCanvasPxEvent(e); + const hit = hitTestStrokes(x, y); + if (hit != null) { + onEraseStroke?.(hit); + setEraseHoverIndex(null); + } + return; + } + + // tool === "draw" + e.preventDefault(); + canvasRef.current?.setPointerCapture(e.pointerId); + strokeStartMsRef.current = performance.now(); + const { x, y } = pointToNormalized(e); + currentStrokeRef.current = { + robotSlot: selectedSlot, + colorIndex: colorIndexForSlot(selectedSlot), + points: [{ x, y, t: 0 }], + arrow: selectedArrow, + }; + redraw(); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (pointersRef.current.has(e.pointerId)) { + pointersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + } + + if (gestureRef.current && pointersRef.current.size >= 2) { + e.preventDefault(); + updateGesture(); + return; + } + + if (panDragRef.current) { + e.preventDefault(); + const rect = canvasRef.current!.getBoundingClientRect(); + const dx = e.clientX - panDragRef.current.startClientX; + const dy = e.clientY - panDragRef.current.startClientY; + const dxN = dx / (rect.width * zoom); + const dyN = dy / (rect.height * zoom); + const newPanX = clampPan( + panDragRef.current.startPanX - dxN, + zoom, + ); + const newPanY = clampPan( + panDragRef.current.startPanY - dyN, + zoom, + ); + onPanChange?.(newPanX, newPanY); + return; + } + + if (tool === "erase" && !readOnly && !currentStrokeRef.current) { + const { x, y } = pointToCanvasPxEvent(e); + const hit = hitTestStrokes(x, y); + setEraseHoverIndex((prev) => (prev === hit ? prev : hit)); + return; + } + if (!currentStrokeRef.current) return; + e.preventDefault(); + const { x, y } = pointToNormalized(e); + const t = performance.now() - strokeStartMsRef.current; + currentStrokeRef.current.points.push({ x, y, t }); + redraw(); + }; + + const handlePointerUp = (e: React.PointerEvent) => { + pointersRef.current.delete(e.pointerId); + + // Exit gesture mode when we drop below 2 pointers. Don't resume any + // single-pointer action mid-gesture — user needs a fresh pointerdown. + if (gestureRef.current && pointersRef.current.size < 2) { + gestureRef.current = null; + return; + } + + if (panDragRef.current) { + canvasRef.current?.releasePointerCapture(e.pointerId); + panDragRef.current = null; + return; + } + + if (!currentStrokeRef.current) return; + e.preventDefault(); + canvasRef.current?.releasePointerCapture(e.pointerId); + const finished = currentStrokeRef.current; + currentStrokeRef.current = null; + if (finished.points.length >= 2) { + onStrokeComplete?.(finished); + } else { + redraw(); + } + }; + + const handlePointerLeave = (e: React.PointerEvent) => { + pointersRef.current.delete(e.pointerId); + if (tool === "erase") setEraseHoverIndex(null); + }; + + // ----- Imperative playback ----- + useImperativeHandle( + ref, + () => ({ + play(speed) { + if (playbackRef.current) { + playbackRef.current.cancelled = true; + } + const token = { cancelled: false }; + playbackRef.current = token; + (async () => { + for (let v = 0; v < visibleIndices.length; v++) { + if (token.cancelled) break; + const origIdx = visibleIndices[v]!; + const stroke = strokes[origIdx]!; + const start = performance.now(); + for (let p = 1; p <= stroke.points.length; p++) { + if (token.cancelled) break; + const slice = stroke.points.slice(0, p); + setPlaybackSlice({ + doneStrokes: v, + currentStrokePoints: slice, + currentStrokeIndex: v, + }); + const nextT = + p < stroke.points.length ? stroke.points[p]!.t : slice[slice.length - 1]!.t; + const elapsedAnimation = (performance.now() - start) * speed; + // Convert the remaining animation-time gap back to real-time + // by dividing by speed, so setTimeout waits the correct + // wall-clock interval. + const wait = (nextT - elapsedAnimation) / speed; + if (wait > 0) { + await new Promise((r) => setTimeout(r, wait)); + } + } + setPlaybackSlice({ + doneStrokes: v + 1, + currentStrokePoints: null, + currentStrokeIndex: v + 1, + }); + // brief pause between strokes + await new Promise((r) => setTimeout(r, 150 / speed)); + } + if (!token.cancelled) { + setPlaybackSlice(null); + playbackRef.current = null; + onPlaybackEnd?.(); + } + })(); + }, + stop() { + if (playbackRef.current) { + playbackRef.current.cancelled = true; + playbackRef.current = null; + } + setPlaybackSlice(null); + }, + computeDefaultFit(options) { + const rect = getImageRect(); + if (!rect) return null; + const { cssW, cssH, imgLeft, imgTop, imgW, imgH } = rect; + if (imgW < 1 || imgH < 1) return null; + // Target zoom: make image width == canvas width. + const targetZoom = cssW / imgW; + const z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, targetZoom)); + // Convert the source-pixel inset to a normalized fraction of the + // source image's X axis (where left/right margins live). Both 90° + // and 270° rotations map the source X axis onto the rotated image's + // Y axis, so the inset applies to rotV the same way in both cases. + // At rotation=0 the inset is ignored — there's no "rotate blank + // space to the bottom" to compensate for. + const sourceXpx = bgImage && bgImage.width > 0 ? bgImage.width : 1; + const insetPx = options?.bottomInsetSourcePx ?? 0; + const insetFrac = + rotation !== 0 && sourceXpx > 0 + ? Math.max(0, Math.min(1, insetPx / sourceXpx)) + : 0; + // Canvas-pixel y of the "content bottom" edge inside the image rect: + // the image rect's bottom shifted *up* by insetFrac of the image's + // (rotated) height. Setting this coord at the canvas bottom means + // the physical image bottom sits `insetFrac * imgH` px below the + // canvas — which is what the caller wants. + const contentBottomCanvasPx = imgTop + (1 - insetFrac) * imgH; + const panYRaw = contentBottomCanvasPx / cssH - 1 / z; + // Clamp to the valid pan range. Note this may silently lose some + // inset if the inset would require panning past the image top. + const panY = Math.min(1 - 1 / z, Math.max(0, panYRaw)); + const panX = Math.min(1 - 1 / z, Math.max(0, (1 - 1 / z) / 2)); + return { zoom: z, panX, panY }; + }, + }), + [strokes, visibleIndices, onPlaybackEnd, getImageRect, bgImage, rotation], + ); + + // Stop playback if strokes change mid-play. + useEffect(() => { + return () => { + if (playbackRef.current) playbackRef.current.cancelled = true; + }; + }, []); + + const cursorStyle = useMemo(() => { + if (readOnly) return "default"; + if (tool === "erase") return "cell"; + if (tool === "pan") return "grab"; + return "crosshair"; + }, [readOnly, tool]); + + // Clamp the hover index if strokes shrink (e.g. after an erase). + useEffect(() => { + if (eraseHoverIndex != null && eraseHoverIndex >= strokes.length) { + setEraseHoverIndex(null); + } + }, [strokes.length, eraseHoverIndex]); + + // The wrapper gives the canvas its bounding box. In windowed mode, its + // height is derived from width via aspect-ratio matching the (rotated) + // image — so the canvas = image with no letterbox. In fullscreen + // (fillHeight), the wrapper takes all remaining vertical space via + // flex:1 and the canvas fills it — the image is letterboxed *inside* + // the canvas. + const rawImageAspect = + bgImage && bgImage.height > 0 ? bgImage.width / bgImage.height : 2; + const imageAspect = rotation === 0 ? rawImageAspect : 1 / rawImageAspect; + + return ( +
+ +
+ ); + }, +); + +export default StrategyCanvas; diff --git a/app/common/strategy/StrategyReadOnlyCanvas.tsx b/app/common/strategy/StrategyReadOnlyCanvas.tsx new file mode 100644 index 00000000..93a1e467 --- /dev/null +++ b/app/common/strategy/StrategyReadOnlyCanvas.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; +import StrategyCanvas, { + type CanvasRotation, + type StrategyCanvasHandle, +} from "~/common/strategy/StrategyCanvas.tsx"; +import type { StrategyStroke } from "~/types/StrategyStroke.ts"; + +type Props = { + backgroundSrc: string; + strokes: StrategyStroke[]; + rotation?: CanvasRotation; + zoom?: number; + panX?: number; + panY?: number; + fillHeight?: boolean; + onPanChange?: (panX: number, panY: number) => void; + onZoomChange?: (zoom: number, panX: number, panY: number) => void; + onPlaybackEnd?: () => void; +}; + +const StrategyReadOnlyCanvas = forwardRef( + function StrategyReadOnlyCanvas(props, ref) { + return ( + + ); + }, +); + +export default StrategyReadOnlyCanvas; diff --git a/app/common/strategy/colors.ts b/app/common/strategy/colors.ts new file mode 100644 index 00000000..4dd979a2 --- /dev/null +++ b/app/common/strategy/colors.ts @@ -0,0 +1,35 @@ +import type { RobotSlot } from "~/types/StrategyStroke.ts"; + +/** + * Per-robot palette keyed to FRC alliance conventions: R1/R2/R3 are three + * shades of **red** and B1/B2/B3 are three shades of **blue**, so at a glance + * any stroke belongs obviously to its alliance. Within each alliance the + * three shades vary in luminance (light / medium / dark), which keeps them + * distinguishable for viewers with red-green colour vision deficiency since + * brightness is perceived independently of hue. + */ +export const ROBOT_COLORS: readonly string[] = [ + "#FF5252", // R1 — bright red + "#F06292", // R2 — bright pink + "#7F0000", // R3 — dark red / maroon + "#80DEEA", // B1 — bright cyan + "#1976D2", // B2 — standard blue + "#0D47A1", // B3 — dark navy +] as const; + +export const ROBOT_SLOTS: readonly RobotSlot[] = [ + "R1", + "R2", + "R3", + "B1", + "B2", + "B3", +] as const; + +export function colorIndexForSlot(slot: RobotSlot): number { + return ROBOT_SLOTS.indexOf(slot); +} + +export function colorForSlot(slot: RobotSlot): string { + return ROBOT_COLORS[colorIndexForSlot(slot)]!; +} diff --git a/app/common/strategy/fieldImage.ts b/app/common/strategy/fieldImage.ts new file mode 100644 index 00000000..ff224125 --- /dev/null +++ b/app/common/strategy/fieldImage.ts @@ -0,0 +1,89 @@ +// Convention: the field image for each game year lives at +// `app/assets//field.png`. Vite's eager glob import discovers them at +// build time — no manual wiring per year. +const FIELD_IMAGE_MODULES = import.meta.glob<{ default: string }>( + "../../assets/*/field.png", + { eager: true }, +); + +const FIELD_IMAGES: Record = (() => { + const out: Record = {}; + for (const [path, mod] of Object.entries(FIELD_IMAGE_MODULES)) { + const match = path.match(/\/assets\/(\d{4})\/field\.png$/); + if (match) { + out[parseInt(match[1]!, 10)] = mod.default; + } + } + return out; +})(); + +/** + * Return the bundled field-image URL for the given season year, falling back + * to the most recent image we have if the year-specific image isn't bundled. + * + * To add a new year: drop `field.png` into `app/assets/YYYY/`. No code change + * is required — Vite picks it up at build time. + */ +// 1×1 transparent PNG — used only when no field image is bundled at all. +const BLANK_PIXEL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + +export function fieldImageForYear(year: number): string { + if (FIELD_IMAGES[year]) return FIELD_IMAGES[year]!; + const years = Object.keys(FIELD_IMAGES) + .map((y) => parseInt(y, 10)) + .sort((a, b) => b - a); + if (years.length === 0) return BLANK_PIXEL; + return FIELD_IMAGES[years[0]!]!; +} + +/** + * Per-year blank-margin widths (in SOURCE-image pixels) at the left and right + * edges of the field image — the dead space before the interesting content + * begins. Red alliance is on the source image's left; blue on the right. When + * the strategy canvas rotates the field to place an alliance at the bottom of + * the view, the matching margin is used as a bottom inset so the canvas + * bottom lands on the interesting edge rather than the image's physical edge. + * + * Add a new year by dropping an entry in here. Years without an entry + * default to zero margins. + */ +export type FieldMargins = { leftPx: number; rightPx: number }; + +const FIELD_MARGINS: Record = { + 2026: { leftPx: 140, rightPx: 140 }, +}; + +const NO_MARGINS: FieldMargins = { leftPx: 0, rightPx: 0 }; + +export function fieldMarginsForYear(year: number): FieldMargins { + return FIELD_MARGINS[year] ?? NO_MARGINS; +} + +// ---------- Module-level decoded-image cache ---------- +// +// Field images are shared across all plan editor mounts. Caching the decoded +// HTMLImageElement means the PNG only gets fetched + decoded once per URL per +// page session — subsequent canvas mounts reuse the same in-memory bitmap and +// render immediately. + +const imageCache = new Map(); + +/** + * Resolve a bundled field image to a decoded `HTMLImageElement`. Returns the + * cached image synchronously if it's already been loaded; otherwise fetches + * and decodes it, then caches and resolves. + */ +export function loadFieldImage(src: string): Promise { + const cached = imageCache.get(src); + if (cached) return Promise.resolve(cached); + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + imageCache.set(src, img); + resolve(img); + }; + img.onerror = (e) => reject(e); + img.src = src; + }); +} diff --git a/app/common/strategy/icons.tsx b/app/common/strategy/icons.tsx new file mode 100644 index 00000000..65c72d49 --- /dev/null +++ b/app/common/strategy/icons.tsx @@ -0,0 +1,297 @@ +// Small inline SVG icons for the strategy canvas toolbar. Currentcolor-based +// so they inherit text colour from their parent button. + +type IconProps = { size?: number }; + +export const UndoIcon = ({ size = 20 }: IconProps) => ( + +); + +export const EraserIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ArrowIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LineIcon = ({ size = 20 }: IconProps) => ( + +); + +export const StopIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LockedIcon = ({ size = 20 }: IconProps) => ( + +); + +export const UnlockedIcon = ({ size = 20 }: IconProps) => ( + +); + +export const PanIcon = ({ size = 20 }: IconProps) => ( + +); + +export const CloseIcon = ({ size = 20 }: IconProps) => ( + +); + +export const LabelsIcon = ({ size = 20 }: IconProps) => ( + +); + +export const EnterFullscreenIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ExitFullscreenIcon = ({ size = 20 }: IconProps) => ( + +); + +export const ZoomIcon = ({ size = 20 }: IconProps) => ( + +); + +export const TrashIcon = ({ size = 20 }: IconProps) => ( + +); diff --git a/app/common/sync/SyncCountdown.tsx b/app/common/sync/SyncCountdown.tsx new file mode 100644 index 00000000..b15826d7 --- /dev/null +++ b/app/common/sync/SyncCountdown.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from "react"; + +type Props = { + /** How often to fire `onSync`, in milliseconds. */ + intervalMs: number; + /** The sync callback. Invoked immediately on mount and every `intervalMs`. */ + onSync: () => Promise | void; + /** Prefix for the countdown display. Defaults to "next sync in". */ + label?: string; +}; + +/** + * A small self-contained countdown that owns a refresh interval. Pass it a + * sync callback and an interval; it fires the callback on mount and then + * every `intervalMs`, showing "next sync in Ns" so the user can see how + * fresh the data is. + * + * Pattern ported from the pit-kiosk page but generalised — any page that + * wants a periodic server refresh + a visible countdown can drop this in. + */ +export default function SyncCountdown(props: Props) { + const { intervalMs, onSync, label = "next sync in" } = props; + const intervalSec = Math.max(1, Math.floor(intervalMs / 1000)); + const [remaining, setRemaining] = useState(intervalSec); + // Keep the latest callback in a ref so the effect below doesn't have to + // re-subscribe every render just because the parent passed a fresh arrow. + const onSyncRef = useRef(onSync); + useEffect(() => { + onSyncRef.current = onSync; + }, [onSync]); + + useEffect(() => { + let cancelled = false; + const runSync = async () => { + try { + await onSyncRef.current(); + } catch { + // swallow — we'll retry next interval + } + if (!cancelled) setRemaining(intervalSec); + }; + // Fire immediately on mount. + runSync(); + const refreshTimer = setInterval(runSync, intervalMs); + const countdownTimer = setInterval(() => { + setRemaining((r) => (r > 1 ? r - 1 : r)); + }, 1000); + return () => { + cancelled = true; + clearInterval(refreshTimer); + clearInterval(countdownTimer); + }; + }, [intervalMs, intervalSec]); + + return ( + + {label ? `${label} ` : ""} + {remaining}s + + ); +} diff --git a/app/common/sync/SyncStrategyPlans.tsx b/app/common/sync/SyncStrategyPlans.tsx new file mode 100644 index 00000000..0837d20d --- /dev/null +++ b/app/common/sync/SyncStrategyPlans.tsx @@ -0,0 +1,9 @@ +import { useStrategyPlansSyncStatus } from "~/common/sync/sync.ts"; +import SyncStatusLayout from "~/common/sync/SyncStatusLayout.tsx"; + +const SyncStrategyPlans = () => { + const status = useStrategyPlansSyncStatus(); + return ; +}; + +export default SyncStrategyPlans; diff --git a/app/common/sync/sync.ts b/app/common/sync/sync.ts index a961ccf2..7d2b4cca 100644 --- a/app/common/sync/sync.ts +++ b/app/common/sync/sync.ts @@ -1,5 +1,9 @@ import type { SyncStatus } from "~/types/SyncStatus.ts"; -import { repository } from "~/common/storage/db.ts"; +import { + parseStrategyPlanLocalKey, + repository, + type StoredStrategyDrawing, +} from "~/common/storage/db.ts"; import { getEventTypeList, getSequenceTypeList, @@ -12,7 +16,14 @@ import { saveEventLogRecords, saveRobotAlertRecords, getRobotAlertListBulk, + getStrategyPlansForTournament, + getStrategyPlan, + saveStrategyPlan, + saveStrategyDrawing, + deleteStrategyDrawing, + parseDrawingStrokes, } from "~/common/storage/rb.ts"; +import type { RBPlanWithDrawings } from "~/common/storage/rb.ts"; import { useSyncStatus } from "~/common/storage/dbhooks.ts"; const TOURNAMENT_LIST = "Tournament List"; @@ -26,6 +37,7 @@ const TRACKING_DATA = "Tracking Data"; const ROBOT_ALERTS = "Robot Alerts"; const ROBOT_ALERT_LIST = "Robot Alert List"; const DASHBOARD_DATA = "Dashboard Data"; +const STRATEGY_PLANS = "Strategy Plans"; function log(msg: string): void { console.log( @@ -91,6 +103,7 @@ export function initializeSyncSchedule() { updateCommentUnsyncCount(); updateEventUnsyncCount(); updateRobotAlertUnsyncCount(); + updateStrategyPlansUnsyncCount(); // Sync server data on startup if the user has a session const hasSession = @@ -101,6 +114,19 @@ export function initializeSyncSchedule() { } setInterval(autoSyncMatchSchedule, SCHEDULE_SYNC_INTERVAL); + setInterval(autoSyncStrategyPlans, SCHEDULE_SYNC_INTERVAL); +} + +async function autoSyncStrategyPlans(): Promise { + const hasSession = + typeof sessionStorage !== "undefined" && + sessionStorage.getItem("raveneye_access_token") !== null; + if (!hasSession) return; + const active = await hasActiveTournament(); + if (!active) return; + const alive = await ping(); + if (!alive) return; + await syncStrategyPlans(); } async function hasActiveTournament(): Promise { @@ -131,6 +157,7 @@ export async function doManualSync() { syncQuickComments(), syncTrackingData(), syncRobotAlerts(), + syncStrategyPlans(), ]); } else { log("Skipping Manual Sync - not connected"); @@ -435,6 +462,244 @@ export async function syncRobotAlerts() { } } +export async function syncStrategyPlans() { + await runSync(STRATEGY_PLANS, async () => { + await uploadDirtyStrategyPlans(); + await downloadStrategyPlansForActiveTournaments(); + }); +} + +async function uploadDirtyStrategyPlans(): Promise { + // 1. Pending deletes first (drawings the user removed locally). + const pendingDeletes = await repository.getPendingDeleteStrategyDrawings(); + for (const d of pendingDeletes) { + if (d.id != null) { + try { + await deleteStrategyDrawing(d.id); + } catch (e) { + console.warn("[sync] failed to delete strategy drawing", d.id, e); + continue; // keep for retry + } + } + await repository.deleteStrategyDrawingLocal(d.localId); + } + + // 2. Dirty plans. + const dirtyPlans = await repository.getDirtyStrategyPlans(); + for (const p of dirtyPlans) { + const saved = await saveStrategyPlan({ + tournamentId: p.tournamentId, + matchLevel: p.matchLevel, + matchNumber: p.matchNumber, + shortSummary: p.shortSummary, + strategyText: p.strategyText, + }); + await repository.putStrategyPlan({ + ...p, + id: saved.id, + shortSummary: saved.shortSummary, + strategyText: saved.strategyText, + updatedByUserId: saved.updatedByUserId, + updatedByDisplayName: saved.updatedByDisplayName, + updatedAt: saved.updatedAt, + dirty: false, + }); + } + + // 3. Dirty drawings. + const dirtyDrawings = await repository.getDirtyStrategyDrawings(); + for (const d of dirtyDrawings) { + const matchRef = parseStrategyPlanLocalKey(d.planLocalKey); + const saved = await saveStrategyDrawing({ + id: d.id, + tournamentId: matchRef.tournamentId, + matchLevel: matchRef.matchLevel, + matchNumber: matchRef.matchNumber, + label: d.label, + strokes: JSON.stringify(d.strokes), + }); + const replacement: StoredStrategyDrawing = { + ...d, + localId: "srv-" + saved.id, + id: saved.id, + planId: saved.planId, + label: saved.label, + createdByUserId: saved.createdByUserId, + createdByDisplayName: saved.createdByDisplayName, + updatedByUserId: saved.updatedByUserId, + updatedByDisplayName: saved.updatedByDisplayName, + createdAt: saved.createdAt, + updatedAt: saved.updatedAt, + dirty: false, + pendingDelete: false, + }; + await repository.renameStrategyDrawing(d.localId, replacement); + } +} + +async function downloadStrategyPlansForActiveTournaments(): Promise { + const tournaments = await repository.getTournamentList(); + const cutoff = Date.now() - ACTIVE_TOURNAMENT_CUTOFF; + const activeTournamentIds = tournaments + .filter((t) => new Date(t.endTime).getTime() > cutoff) + .map((t) => t.id); + for (const tid of activeTournamentIds) { + const remote = await getStrategyPlansForTournament(tid); + for (const pwd of remote) { + const localKey = + pwd.plan.tournamentId + + "|" + + pwd.plan.matchLevel + + "|" + + pwd.plan.matchNumber; + const existingPlan = await repository.getStrategyPlan(localKey); + // Do not overwrite a locally-dirty plan with a server copy. + if (!existingPlan || !existingPlan.dirty) { + await repository.putStrategyPlan({ + localKey, + id: pwd.plan.id, + tournamentId: pwd.plan.tournamentId, + matchLevel: pwd.plan.matchLevel, + matchNumber: pwd.plan.matchNumber, + shortSummary: pwd.plan.shortSummary, + strategyText: pwd.plan.strategyText, + updatedByUserId: pwd.plan.updatedByUserId, + updatedByDisplayName: pwd.plan.updatedByDisplayName, + updatedAt: pwd.plan.updatedAt, + dirty: false, + }); + } + for (const sd of pwd.drawings) { + const localId = "srv-" + sd.id; + const existingDrawing = await repository.getStrategyDrawing(localId); + if ( + !existingDrawing || + (!existingDrawing.dirty && !existingDrawing.pendingDelete) + ) { + await repository.putStrategyDrawing({ + localId, + planLocalKey: localKey, + id: sd.id, + planId: sd.planId, + label: sd.label, + strokes: parseDrawingStrokes(sd), + createdByUserId: sd.createdByUserId, + createdByDisplayName: sd.createdByDisplayName, + updatedByUserId: sd.updatedByUserId, + updatedByDisplayName: sd.updatedByDisplayName, + createdAt: sd.createdAt, + updatedAt: sd.updatedAt, + dirty: false, + pendingDelete: false, + }); + } + } + } + } +} + +/** + * Fetch a single plan (with its drawings) from the server and merge into + * IndexedDB, regardless of whether the tournament is "active". Used by the + * plan editor on mount so users always see the latest server state for the + * specific match they opened — covers past-tournament testing and the + * "come back to yesterday's match" case. + * + * Respects locally-dirty records: will not overwrite unsynced user edits. + */ +export async function refreshStrategyPlanForMatch( + tournamentId: string, + matchLevel: string, + matchNumber: number, +): Promise { + const hasSession = + typeof sessionStorage !== "undefined" && + sessionStorage.getItem("raveneye_access_token") !== null; + if (!hasSession) return; + const alive = await ping(); + if (!alive) return; + let pwd: RBPlanWithDrawings | null; + try { + pwd = await getStrategyPlan(tournamentId, matchLevel, matchNumber); + } catch (e) { + console.warn("[sync] refreshStrategyPlanForMatch failed", e); + return; + } + if (pwd == null) return; // no plan on server yet + const localKey = + pwd.plan.tournamentId + + "|" + + pwd.plan.matchLevel + + "|" + + pwd.plan.matchNumber; + const existingPlan = await repository.getStrategyPlan(localKey); + if (!existingPlan || !existingPlan.dirty) { + await repository.putStrategyPlan({ + localKey, + id: pwd.plan.id, + tournamentId: pwd.plan.tournamentId, + matchLevel: pwd.plan.matchLevel, + matchNumber: pwd.plan.matchNumber, + shortSummary: pwd.plan.shortSummary, + strategyText: pwd.plan.strategyText, + updatedByUserId: pwd.plan.updatedByUserId, + updatedByDisplayName: pwd.plan.updatedByDisplayName, + updatedAt: pwd.plan.updatedAt, + dirty: false, + }); + } + for (const sd of pwd.drawings) { + const localId = "srv-" + sd.id; + const existing = await repository.getStrategyDrawing(localId); + if (!existing || (!existing.dirty && !existing.pendingDelete)) { + await repository.putStrategyDrawing({ + localId, + planLocalKey: localKey, + id: sd.id, + planId: sd.planId, + label: sd.label, + strokes: parseDrawingStrokes(sd), + createdByUserId: sd.createdByUserId, + createdByDisplayName: sd.createdByDisplayName, + updatedByUserId: sd.updatedByUserId, + updatedByDisplayName: sd.updatedByDisplayName, + createdAt: sd.createdAt, + updatedAt: sd.updatedAt, + dirty: false, + pendingDelete: false, + }); + } + } +} + +export async function updateStrategyPlansUnsyncCount() { + const dirtyPlans = await repository.getDirtyStrategyPlans(); + const dirtyDrawings = await repository.getDirtyStrategyDrawings(); + const pendingDeletes = await repository.getPendingDeleteStrategyDrawings(); + const total = + dirtyPlans.length + dirtyDrawings.length + pendingDeletes.length; + const stat = await repository.getSyncStatus(STRATEGY_PLANS); + if (stat) { + stat.remaining = total; + stat.isComplete = total === 0; + await repository.putSyncStatus(stat); + } else { + await repository.putSyncStatus({ + loading: false, + component: STRATEGY_PLANS, + lastSync: new Date(), + inProgress: false, + isComplete: total === 0, + remaining: total, + error: null, + }); + } +} + +export const useStrategyPlansSyncStatus = (): SyncStatus => { + return useSyncStatus(STRATEGY_PLANS); +}; + export async function syncRobotAlertList() { await runSync(ROBOT_ALERT_LIST, async () => { const tournaments = await repository.getTournamentList(); @@ -587,7 +852,8 @@ export const useManualSyncStatus = (): SyncStatus => { const comments = useQuickCommentsSyncStatus(); const track = useTrackingDataSyncStatus(); const alerts = useRobotAlertsSyncStatus(); - return aggregateStatuses("Manual Sync", [comments, track, alerts]); + const strategy = useStrategyPlansSyncStatus(); + return aggregateStatuses("Manual Sync", [comments, track, alerts, strategy]); }; export const useServerDataSyncStatus = (): SyncStatus => { diff --git a/app/routes.ts b/app/routes.ts index 75a07910..2270f3de 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -127,5 +127,14 @@ export default [ "./routes/report/sequence-tournament-sequences-page.tsx", ), route("track", "./routes/track/track-home-page.tsx"), + route("strategy", "./routes/strategy/strategy-home-page.tsx"), + route( + "strategy/:tournamentId", + "./routes/strategy/strategy-matches-page.tsx", + ), + route( + "strategy/:tournamentId/:level/:matchNumber", + "./routes/strategy/strategy-plan-page.tsx", + ), route("kiosk-pit/:tournamentId?", "./routes/report/pit-kiosk-page.tsx"), ] satisfies RouteConfig; diff --git a/app/routes/admin/config-sync/config-sync-page.tsx b/app/routes/admin/config-sync/config-sync-page.tsx index 36514a69..36229dba 100644 --- a/app/routes/admin/config-sync/config-sync-page.tsx +++ b/app/routes/admin/config-sync/config-sync-page.tsx @@ -235,7 +235,9 @@ const ConfigSyncForm = () => { )} {(result.events > 0 || result.comments > 0 || - result.alerts > 0) && ( + result.alerts > 0 || + result.matchStrategyPlans > 0 || + result.matchStrategyDrawings > 0) && ( <> Events @@ -249,6 +251,14 @@ const ConfigSyncForm = () => { Alerts {result.alerts} + + Match Strategy Plans + {result.matchStrategyPlans} + + + Match Strategy Drawings + {result.matchStrategyDrawings} + )} diff --git a/app/routes/home-page.tsx b/app/routes/home-page.tsx index bbf28d80..45570658 100644 --- a/app/routes/home-page.tsx +++ b/app/routes/home-page.tsx @@ -3,6 +3,7 @@ import type { Route } from "../routes/+types/home-page"; import { getDisplayName, useLoginStatus, + useRole, } from "~/common/storage/rbauth.ts"; import { getActiveTeamTournaments } from "~/common/storage/rb.ts"; import { NavLink } from "react-router"; @@ -39,6 +40,11 @@ export function meta({}: Route.MetaArgs) { const LoggedIn = () => { const fullName = getDisplayName(); const hasActive = useHasActiveTournament(); + const roles = useRole(); + // TEMPORARY: hide Match Strategy from scouts until launch — admins and + // superusers can see it. Restore to + // `roles.isExpertScout || roles.isAdmin || roles.isSuperuser` before launch. + const canStrategize = roles.isAdmin || roles.isSuperuser; const [copyToast, setCopyToast] = useState(false); return (
@@ -53,6 +59,7 @@ const LoggedIn = () => {
Track a Robot View Reports + {canStrategize && Match Strategy} {hasActive && Current Tournament Schedule & Scores} {hasActive && Schedule & Scores}
diff --git a/app/routes/strategy/strategy-home-page.tsx b/app/routes/strategy/strategy-home-page.tsx new file mode 100644 index 00000000..2ba96f45 --- /dev/null +++ b/app/routes/strategy/strategy-home-page.tsx @@ -0,0 +1,170 @@ +import { useEffect, useMemo, useState } from "react"; +import { NavLink } from "react-router"; +import RequireRole from "~/common/auth/RequireRole.tsx"; +import { useActiveTeamTournaments } from "~/common/storage/dbhooks.ts"; +import { repository } from "~/common/storage/db.ts"; +import type { RBTournament } from "~/types/RBTournament.ts"; +import Spinner from "~/common/Spinner.tsx"; + +function useAllTournaments(): { + list: RBTournament[]; + loading: boolean; +} { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + useEffect(() => { + let mounted = true; + repository + .getTournamentList() + .then((l) => { + if (mounted) { + setList(l); + setLoading(false); + } + }) + .catch(() => { + if (mounted) setLoading(false); + }); + return () => { + mounted = false; + }; + }, []); + return { list, loading }; +} + +const TournamentLink = ({ t }: { t: RBTournament }) => ( +
  • + + {t.name} + +
  • +); + +const ActiveTournamentList = () => { + const { list, loading } = useActiveTeamTournaments(); + if (loading) return ; + if (list.length === 0) { + return ( +

    + No active or upcoming team tournaments found. Sync tournaments from the + Sync page. +

    + ); + } + return ( +
      + {list.map((t) => ( + + ))} +
    + ); +}; + +function getCurrentWeek(tournaments: RBTournament[]): number | null { + const now = Date.now(); + for (const t of tournaments) { + const start = new Date(t.startTime).getTime(); + const end = new Date(t.endTime).getTime(); + if (start <= now && end >= now) return t.weekNumber; + } + let closest: RBTournament | null = null; + for (const t of tournaments) { + const start = new Date(t.startTime).getTime(); + if ( + start > now && + (!closest || start < new Date(closest.startTime).getTime()) + ) { + closest = t; + } + } + return closest?.weekNumber ?? null; +} + +const CurrentSeasonTournamentList = () => { + const { list, loading } = useAllTournaments(); + const currentSeasonTournaments = useMemo(() => { + if (list.length === 0) return []; + const maxSeason = Math.max(...list.map((t) => t.season)); + return list + .filter((t) => t.season === maxSeason) + .sort( + (a, b) => + new Date(a.startTime).getTime() - new Date(b.startTime).getTime(), + ); + }, [list]); + + if (loading) return ; + if (currentSeasonTournaments.length === 0) { + return

    No tournaments for the current season.

    ; + } + const season = currentSeasonTournaments[0]!.season; + const currentWeek = getCurrentWeek(currentSeasonTournaments); + + const byWeek = new Map(); + for (const t of currentSeasonTournaments) { + const week = t.weekNumber ?? 0; + if (!byWeek.has(week)) byWeek.set(week, []); + byWeek.get(week)!.push(t); + } + const weeks = [...byWeek.keys()].sort((a, b) => a - b); + + return ( + <> +

    + Season {season} — pick any tournament to plan (useful for testing on + past matches). +

    + {weeks.map((week) => { + const weekTournaments = byWeek.get(week)!; + return ( +
    + + Week {week} + + {weekTournaments.length} tournament + {weekTournaments.length !== 1 ? "s" : ""} + + +
      + {weekTournaments.map((t) => ( + + ))} +
    +
    + ); + })} + + ); +}; + +const StrategyHomePage = () => { + return ( + +
    +
    +

    Match Strategy

    +

    Pick a tournament to plan matches for.

    +
    +
    +

    Active Tournaments

    + +
    +
    +

    Current Season

    + +
    +
    +
    + ); +}; + +export default StrategyHomePage; diff --git a/app/routes/strategy/strategy-matches-page.tsx b/app/routes/strategy/strategy-matches-page.tsx new file mode 100644 index 00000000..866eb4c8 --- /dev/null +++ b/app/routes/strategy/strategy-matches-page.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useState } from "react"; +import { NavLink, useParams } from "react-router"; +import RequireRole from "~/common/auth/RequireRole.tsx"; +import { useMatchSchedule } from "~/common/storage/dbhooks.ts"; +import { + fetchTournamentSchedule, + getScheduleForTournament, + ping, +} from "~/common/storage/rb.ts"; +import { repository } from "~/common/storage/db.ts"; +import Spinner from "~/common/Spinner.tsx"; + +const MATCH_LEVELS = ["Qualification", "Playoff", "Practice"] as const; + +const StrategyMatchesPage = () => { + const params = useParams(); + const tournamentId = decodeURIComponent(params.tournamentId ?? ""); + const { list, loading } = useMatchSchedule(); + const [fetching, setFetching] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [autoFetchTried, setAutoFetchTried] = useState(false); + + const matchesByLevel = useMemo(() => { + const byLevel: Record = {}; + for (const r of list) { + if (r.tournamentId !== tournamentId) continue; + (byLevel[r.level] ||= []).push(r); + } + for (const level of Object.keys(byLevel)) { + byLevel[level]!.sort((a, b) => a.match - b.match); + } + return byLevel; + }, [list, tournamentId]); + + const hasMatchesForTournament = Object.keys(matchesByLevel).length > 0; + + const handleFetchSchedule = async () => { + setFetchError(null); + const online = await ping(); + if (!online) { + setFetchError( + "No local schedule is available and RavenBrain is unreachable. Try again when you're online.", + ); + return; + } + setFetching(true); + try { + await fetchTournamentSchedule(tournamentId); + const records = await getScheduleForTournament(tournamentId); + await repository.mergeMatchSchedule(records); + } catch (e) { + setFetchError( + "Failed to fetch schedule: " + + (e instanceof Error ? e.message : String(e)), + ); + } finally { + setFetching(false); + } + }; + + // When the schedule has loaded but no matches exist for this tournament, + // try fetching from the server automatically — once per page load. + useEffect(() => { + if (!loading && !hasMatchesForTournament && !autoFetchTried && !fetching) { + setAutoFetchTried(true); + handleFetchSchedule(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loading, hasMatchesForTournament, autoFetchTried, fetching]); + + return ( + +
    +
    +

    Match Strategy — {tournamentId}

    +

    Pick a match to plan for.

    + + ← Back to tournaments + +
    + {loading && } + {!loading && + MATCH_LEVELS.map((level) => { + const matches = matchesByLevel[level] ?? []; + if (matches.length === 0) return null; + return ( +
    +

    {level}

    +
    + {matches.map((m) => ( + + {m.match} + + ))} +
    +
    + ); + })} + {!loading && !hasMatchesForTournament && ( +
    + {fetching ? ( +

    Fetching schedule from RavenBrain…

    + ) : ( + <> +

    No matches cached locally for this tournament.

    + {fetchError && ( +

    {fetchError}

    + )} + + + )} +
    + )} +
    +
    + ); +}; + +export default StrategyMatchesPage; diff --git a/app/routes/strategy/strategy-plan-page.tsx b/app/routes/strategy/strategy-plan-page.tsx new file mode 100644 index 00000000..eca6835b --- /dev/null +++ b/app/routes/strategy/strategy-plan-page.tsx @@ -0,0 +1,1315 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { NavLink, useParams } from "react-router"; +import RequireRole from "~/common/auth/RequireRole.tsx"; +import { + repository, + strategyPlanLocalKey, + type StoredStrategyDrawing, + type StoredStrategyPlan, +} from "~/common/storage/db.ts"; +import { useMatchSchedule } from "~/common/storage/dbhooks.ts"; +import { getDisplayName, getUserid, useRole } from "~/common/storage/rbauth.ts"; +import { + refreshStrategyPlanForMatch, + syncStrategyPlans, + useStrategyPlansSyncStatus, +} from "~/common/sync/sync.ts"; +import SyncCountdown from "~/common/sync/SyncCountdown.tsx"; +import Sync from "~/common/icons/Sync.tsx"; +import StrategyCanvas, { + type CanvasRotation, + type StrategyCanvasHandle, +} from "~/common/strategy/StrategyCanvas.tsx"; +import StrategyReadOnlyCanvas from "~/common/strategy/StrategyReadOnlyCanvas.tsx"; +import RobotSlotPalette from "~/common/strategy/RobotSlotPalette.tsx"; +import DrawingList from "~/common/strategy/DrawingList.tsx"; +import { + ArrowIcon, + CloseIcon, + EraserIcon, + LabelsIcon, + LineIcon, + PanIcon, + StopIcon, + TrashIcon, + UndoIcon, + ZoomIcon, +} from "~/common/strategy/icons.tsx"; +import { + fieldImageForYear, + fieldMarginsForYear, +} from "~/common/strategy/fieldImage.ts"; +import { colorIndexForSlot } from "~/common/strategy/colors.ts"; +import type { + RobotSlot, + StrategyStroke, +} from "~/types/StrategyStroke.ts"; +import type { RBTournament } from "~/types/RBTournament.ts"; +import type { RBScheduleRecord } from "~/types/RBScheduleRecord.ts"; +import type { SyncStatus } from "~/types/SyncStatus.ts"; + +const DRAW_TOOL_STORAGE_KEY = "raveneye_strategy_draw_tool"; +const TOOLBAR_LABELS_STORAGE_KEY = "raveneye_strategy_toolbar_labels"; +type PersistedDrawTool = "arrow" | "line" | "erase"; + +/** + * True on devices where the primary input is touch AND multi-touch is + * available — iPads, phones. False on desktops, touch laptops (primary input + * is the trackpad/mouse even though a touchscreen exists). Used to hide the + * zoom/pan toolbar buttons, since on touch-primary devices the user naturally + * pinches to zoom and drags with two fingers to pan. + */ +function isTouchPrimaryDevice(): boolean { + if (typeof window === "undefined") return false; + try { + const coarse = window.matchMedia("(pointer: coarse)").matches; + const multi = (navigator.maxTouchPoints ?? 0) > 1; + return coarse && multi; + } catch { + return false; + } +} + +function loadDrawTool(): PersistedDrawTool { + if (typeof localStorage === "undefined") return "line"; + try { + const v = localStorage.getItem(DRAW_TOOL_STORAGE_KEY); + if (v === "arrow" || v === "line" || v === "erase") return v; + } catch { + // localStorage may throw in private mode or when disabled — fall through. + } + return "line"; +} + +function saveDrawTool(tool: PersistedDrawTool): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(DRAW_TOOL_STORAGE_KEY, tool); + } catch { + // Ignore quota/permission failures — preference is best-effort. + } +} + +const ZOOM_STORAGE_KEY = "raveneye_strategy_zoom"; +const PAN_X_STORAGE_KEY = "raveneye_strategy_pan_x"; +const PAN_Y_STORAGE_KEY = "raveneye_strategy_pan_y"; +const MIN_ZOOM = 1.0; +const MAX_ZOOM = 5.0; +const ZOOM_STEPS: readonly number[] = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]; + +function clampZoomValue(z: number): number { + if (!Number.isFinite(z)) return MIN_ZOOM; + return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); +} + +function clampPanValue(p: number, z: number): number { + if (!Number.isFinite(p)) return 0; + const max = Math.max(0, 1 - 1 / z); + return Math.min(max, Math.max(0, p)); +} + +function loadZoomPan(): { zoom: number; panX: number; panY: number } { + if (typeof localStorage === "undefined") { + return { zoom: 1, panX: 0, panY: 0 }; + } + try { + const z = clampZoomValue( + parseFloat(localStorage.getItem(ZOOM_STORAGE_KEY) ?? "1"), + ); + const x = clampPanValue( + parseFloat(localStorage.getItem(PAN_X_STORAGE_KEY) ?? "0"), + z, + ); + const y = clampPanValue( + parseFloat(localStorage.getItem(PAN_Y_STORAGE_KEY) ?? "0"), + z, + ); + return { zoom: z, panX: x, panY: y }; + } catch { + return { zoom: 1, panX: 0, panY: 0 }; + } +} + +function saveZoomPan(zoom: number, panX: number, panY: number): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(ZOOM_STORAGE_KEY, zoom.toString()); + localStorage.setItem(PAN_X_STORAGE_KEY, panX.toString()); + localStorage.setItem(PAN_Y_STORAGE_KEY, panY.toString()); + } catch { + // best-effort + } +} + +function loadShowLabels(): boolean { + if (typeof localStorage === "undefined") return true; + try { + const v = localStorage.getItem(TOOLBAR_LABELS_STORAGE_KEY); + if (v === "hide") return false; + } catch { + // Fall through. + } + return true; +} + +function saveShowLabels(show: boolean): void { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(TOOLBAR_LABELS_STORAGE_KEY, show ? "show" : "hide"); + } catch { + // Best-effort. + } +} + +function generateLocalId(): string { + const anyCrypto = (globalThis as { crypto?: Crypto }).crypto; + if (anyCrypto && typeof anyCrypto.randomUUID === "function") { + return "new-" + anyCrypto.randomUUID(); + } + return "new-" + Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +function useTournament(tournamentId: string): RBTournament | null { + const [t, setT] = useState(null); + useEffect(() => { + let mounted = true; + repository.getTournamentList().then((list) => { + if (!mounted) return; + setT(list.find((x) => x.id === tournamentId) ?? null); + }); + return () => { + mounted = false; + }; + }, [tournamentId]); + return t; +} + +function teamNumbersForMatch( + schedule: RBScheduleRecord[], + tournamentId: string, + level: string, + matchNumber: number, +): Record { + const m = schedule.find( + (r) => + r.tournamentId === tournamentId && + r.level === level && + r.match === matchNumber, + ); + return { + R1: m?.red1 ?? null, + R2: m?.red2 ?? null, + R3: m?.red3 ?? null, + B1: m?.blue1 ?? null, + B2: m?.blue2 ?? null, + B3: m?.blue3 ?? null, + }; +} + +/** Owner team — Team 1310. Determines which alliance goes at the bottom of + * the rotated field diagram. (If 1310 isn't in the match, rotation falls + * back to source orientation.) */ +const OWNER_TEAM_NUMBER = 1310; + +function ownerAllianceFromSchedule( + schedule: RBScheduleRecord[], + tournamentId: string, + level: string, + matchNumber: number, +): "red" | "blue" | null { + const m = schedule.find( + (r) => + r.tournamentId === tournamentId && + r.level === level && + r.match === matchNumber, + ); + if (!m) return null; + if ( + m.red1 === OWNER_TEAM_NUMBER || + m.red2 === OWNER_TEAM_NUMBER || + m.red3 === OWNER_TEAM_NUMBER || + m.red4 === OWNER_TEAM_NUMBER + ) { + return "red"; + } + if ( + m.blue1 === OWNER_TEAM_NUMBER || + m.blue2 === OWNER_TEAM_NUMBER || + m.blue3 === OWNER_TEAM_NUMBER || + m.blue4 === OWNER_TEAM_NUMBER + ) { + return "blue"; + } + return null; +} + +/** Map owner alliance to the display rotation that puts that alliance at the + * bottom of the canvas. Source images have red on the left, blue on the + * right: + * red → 270° CW (red edge rotates to the bottom) + * blue → 90° CW (blue edge rotates to the bottom) + * null → 0° (fall back to native orientation) */ +function rotationForAlliance( + alliance: "red" | "blue" | null, +): CanvasRotation { + if (alliance === "red") return 270; + if (alliance === "blue") return 90; + return 0; +} + +const StrategyPlanPageInner = (props: { + tournamentId: string; + matchLevel: string; + matchNumber: number; +}) => { + const { tournamentId, matchLevel, matchNumber } = props; + const localKey = strategyPlanLocalKey({ + tournamentId, + matchLevel, + matchNumber, + }); + const tournament = useTournament(tournamentId); + const { list: schedule } = useMatchSchedule(); + const teamNumbers = useMemo( + () => teamNumbersForMatch(schedule, tournamentId, matchLevel, matchNumber), + [schedule, tournamentId, matchLevel, matchNumber], + ); + const rotation = useMemo( + () => + rotationForAlliance( + ownerAllianceFromSchedule( + schedule, + tournamentId, + matchLevel, + matchNumber, + ), + ), + [schedule, tournamentId, matchLevel, matchNumber], + ); + + const [plan, setPlan] = useState(null); + const [drawings, setDrawings] = useState([]); + const [activeLocalId, setActiveLocalId] = useState(null); + // Edit mode is a pure function of the user's role. Expert scouts, admins, + // and superusers always edit; everyone else sees a read-only view. No + // lock / unlock toggle. + const roles = useRole(); + const isEditing = + roles.isExpertScout || roles.isAdmin || roles.isSuperuser; + const [selectedSlot, setSelectedSlot] = useState("R1"); + const [soloedSlot, setSoloedSlot] = useState(null); + const handleSlotClick = (slot: RobotSlot) => { + if (slot === selectedSlot) { + // Double-tap on the selected slot toggles solo mode. + setSoloedSlot((prev) => (prev === slot ? null : slot)); + } else { + // Switching teams clears any solo and selects the new one. + setSelectedSlot(slot); + setSoloedSlot(null); + } + }; + // Clear solo state when switching drawings. + useEffect(() => { + setSoloedSlot(null); + }, [activeLocalId]); + type DrawTool = "arrow" | "line" | "erase" | "pan"; + const [drawTool, setDrawTool] = useState(() => { + const persisted = loadDrawTool(); + // "pan" is allowed in-memory but never persisted (it's transient). + return persisted as DrawTool; + }); + useEffect(() => { + // Don't persist "pan" — fall back to arrow/line/erase on reload. + if (drawTool !== "pan") saveDrawTool(drawTool); + }, [drawTool]); + const [showLabels, setShowLabels] = useState(() => loadShowLabels()); + useEffect(() => { + saveShowLabels(showLabels); + }, [showLabels]); + + // Zoom + pan state, persisted to localStorage (one global value across + // matches). Max zoom is 4×; min is 1× (can't shrink past full size). + const [{ zoom, panX, panY }, setZoomPan] = useState(() => loadZoomPan()); + useEffect(() => { + saveZoomPan(zoom, panX, panY); + }, [zoom, panX, panY]); + const touchPrimary = useMemo(() => isTouchPrimaryDevice(), []); + + // Stack the two cards vertically on narrow viewports (below iPad mini + // portrait width). Keeps the sidebar + canvas side-by-side on anything + // tablet-sized and up. + const [isNarrow, setIsNarrow] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia("(max-width: 767px)").matches; + }); + useEffect(() => { + const mql = window.matchMedia("(max-width: 767px)"); + const onChange = (e: MediaQueryListEvent) => setIsNarrow(e.matches); + // addEventListener isn't on the oldest MediaQueryList shape; fall back + // if missing. + if (mql.addEventListener) mql.addEventListener("change", onChange); + else mql.addListener(onChange); + return () => { + if (mql.removeEventListener) mql.removeEventListener("change", onChange); + else mql.removeListener(onChange); + }; + }, []); + + const handleZoomChange = useCallback( + (next: number, pX: number, pY: number) => { + setZoomPan({ + zoom: clampZoomValue(next), + panX: clampPanValue(pX, next), + panY: clampPanValue(pY, next), + }); + }, + [], + ); + const handlePanChange = useCallback((pX: number, pY: number) => { + setZoomPan((prev) => ({ + ...prev, + panX: clampPanValue(pX, prev.zoom), + panY: clampPanValue(pY, prev.zoom), + })); + }, []); + + /** + * Step zoom up/down by snapping to the next/prev value in ZOOM_STEPS, + * keeping the visible centre of the field stable under the new zoom so the + * content doesn't jump. Used by the zoom popover's − / + buttons. + */ + const stepZoom = useCallback((direction: 1 | -1) => { + setZoomPan((prev) => { + const idx = ZOOM_STEPS.findIndex((z) => z >= prev.zoom - 1e-6); + const targetIdx = Math.max( + 0, + Math.min( + ZOOM_STEPS.length - 1, + (idx === -1 ? 0 : idx) + direction, + ), + ); + const nextZoom = ZOOM_STEPS[targetIdx]!; + if (nextZoom === prev.zoom) return prev; + const centerX = prev.panX + 0.5 / prev.zoom; + const centerY = prev.panY + 0.5 / prev.zoom; + const newPanX = clampPanValue(centerX - 0.5 / nextZoom, nextZoom); + const newPanY = clampPanValue(centerY - 0.5 / nextZoom, nextZoom); + return { zoom: nextZoom, panX: newPanX, panY: newPanY }; + }); + }, []); + + const resetZoom = useCallback(() => { + setZoomPan({ zoom: 1, panX: 0, panY: 0 }); + }, []); + + // Spacebar-hold temporarily activates the Pan tool (Photoshop convention). + // Ignored when the user is typing in an input/textarea or when zoom = 1. + const [spaceHeld, setSpaceHeld] = useState(false); + useEffect(() => { + const isTypingTarget = (el: EventTarget | null) => { + if (!(el instanceof HTMLElement)) return false; + const tag = el.tagName; + return ( + tag === "INPUT" || + tag === "TEXTAREA" || + tag === "SELECT" || + el.isContentEditable + ); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.code !== "Space") return; + if (isTypingTarget(document.activeElement)) return; + if (e.repeat) { + e.preventDefault(); + return; + } + e.preventDefault(); + setSpaceHeld(true); + }; + const onKeyUp = (e: KeyboardEvent) => { + if (e.code !== "Space") return; + setSpaceHeld(false); + }; + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + const onBlur = () => setSpaceHeld(false); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("blur", onBlur); + }; + }, []); + // Only pan-while-zoomed matters; don't flip the tool if nothing to pan. + const spacePanActive = spaceHeld && zoom > 1; + + // If the user was in Pan mode and zoomed back out to 1.0, switch back to + // their previously-persisted draw tool — pan has nothing to do at 1×. + useEffect(() => { + if (drawTool === "pan" && zoom <= 1) { + setDrawTool(loadDrawTool() as DrawTool); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [zoom]); + // Cycling play/stop button. One button with three states: + // stopped → label "▶ 1×", click → play at 1×, move to playing-1x + // playing at 1× → label "▶ 3×", click → play at 3× (change speed) + // playing at 3× → label "■ Stop", click → stop playback + // Playback ending naturally also returns the state to "stopped". + type PlayState = "stopped" | "playing-1x" | "playing-3x"; + const [playState, setPlayState] = useState("stopped"); + const handlePlayStopClick = () => { + if (playState === "stopped") { + canvasRef.current?.play(1); + setPlayState("playing-1x"); + } else if (playState === "playing-1x") { + canvasRef.current?.play(3); + setPlayState("playing-3x"); + } else { + canvasRef.current?.stop(); + setPlayState("stopped"); + } + }; + const handlePlaybackEnd = useCallback(() => setPlayState("stopped"), []); + type UndoEntry = + | { kind: "add"; at: number; stroke: StrategyStroke } + | { kind: "erase"; at: number; stroke: StrategyStroke } + | { kind: "clear"; strokes: StrategyStroke[] }; + const [undoStack, setUndoStack] = useState([]); + const canvasRef = useRef(null); + const strategySyncStatus = useStrategyPlansSyncStatus(); + const [isCanvasFullscreen, setIsCanvasFullscreen] = useState(false); + // Incrementing this key forces SyncCountdown to remount (resetting its + // internal timer) after a manual sync. + const [syncResetKey, setSyncResetKey] = useState(0); + const [showZoomMenu, setShowZoomMenu] = useState(false); + const zoomMenuWrapperRef = useRef(null); + // Close the zoom popover on outside click / Escape. + useEffect(() => { + if (!showZoomMenu) return; + const onDocDown = (e: MouseEvent | TouchEvent) => { + const el = zoomMenuWrapperRef.current; + if (el && e.target instanceof Node && !el.contains(e.target)) { + setShowZoomMenu(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setShowZoomMenu(false); + }; + document.addEventListener("mousedown", onDocDown); + document.addEventListener("touchstart", onDocDown); + window.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDocDown); + document.removeEventListener("touchstart", onDocDown); + window.removeEventListener("keydown", onKey); + }; + }, [showZoomMenu]); + + // Exit fullscreen on Escape for quick keyboard dismiss. + useEffect(() => { + if (!isCanvasFullscreen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsCanvasFullscreen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [isCanvasFullscreen]); + + const year = tournament?.startTime + ? new Date(tournament.startTime).getUTCFullYear() + : new Date().getUTCFullYear(); + const fieldMargins = useMemo(() => fieldMarginsForYear(year), [year]); + // The bottom inset applied when snapping the rotated image bottom-flush is + // whichever source margin rotates to the bottom: red (rotation=270) lifts + // from source-left, blue (rotation=90) lifts from source-right. + const bottomInsetSourcePx = useMemo(() => { + if (rotation === 270) return fieldMargins.leftPx; + if (rotation === 90) return fieldMargins.rightPx; + return 0; + }, [rotation, fieldMargins]); + + // When entering fullscreen on a rotated match (owner team on an alliance), + // fit the image to the canvas width with the bottom edge flush against the + // canvas bottom — the user pans up to see the opposing alliance. Retries + // across animation frames because the canvas + bgImage need a moment to + // lay out before computeDefaultFit() can return real dimensions. + useEffect(() => { + if (!isCanvasFullscreen || rotation === 0) return; + let cancelled = false; + let tries = 0; + const attempt = () => { + if (cancelled) return; + const fit = canvasRef.current?.computeDefaultFit({ + bottomInsetSourcePx, + }); + if (fit) { + setZoomPan({ zoom: fit.zoom, panX: fit.panX, panY: fit.panY }); + return; + } + if (tries++ < 60) requestAnimationFrame(attempt); + }; + requestAnimationFrame(attempt); + return () => { + cancelled = true; + }; + }, [isCanvasFullscreen, rotation, bottomInsetSourcePx]); + + // Initial load + poll for external updates (e.g. after sync). + useEffect(() => { + let mounted = true; + const load = async () => { + const p = await repository.getStrategyPlan(localKey); + const ds = await repository.getStrategyDrawingsForPlan(localKey); + if (!mounted) return; + setPlan(p); + setDrawings(ds); + setActiveLocalId((prev) => { + if (prev && ds.some((d) => d.localId === prev)) return prev; + return ds[0]?.localId ?? null; + }); + }; + load(); + const interval = setInterval(load, 2000); + return () => { + mounted = false; + clearInterval(interval); + }; + }, [localKey]); + + // Reset the undo stack whenever the active drawing changes. + // The stack is in-memory only and scoped to one drawing. + useEffect(() => { + setUndoStack([]); + }, [activeLocalId]); + + // A stable callback for the below to drive. Every 30 s we: + // 1. Run a full syncStrategyPlans() — uploads any locally-dirty plans / + // drawings / pending deletes and pulls down server state for active + // tournaments. This is the ONLY push path reachable while the user is + // in the fullscreen canvas (the manual Sync button in the page-header + // sits behind the fullscreen overlay and can't be tapped). + // 2. Run refreshStrategyPlanForMatch() for the currently-open match — + // bypasses the active-tournament filter (so past-tournament testing + // still receives server updates) and covers the multi-device case. + // Both IDB merges respect locally-dirty records. + const refreshThisPlan = useCallback(async () => { + await syncStrategyPlans(); + await refreshStrategyPlanForMatch(tournamentId, matchLevel, matchNumber); + }, [tournamentId, matchLevel, matchNumber]); + + // Strategy plans are synced on a background interval (every 3 min) and + // on manual "Sync Now", matching the app's pattern for other user-generated + // data (events, comments, alerts). Local edits are persisted to IndexedDB + // immediately below; the server push happens on those existing triggers. + const hasUnsyncedChanges = useMemo(() => { + if (plan?.dirty) return true; + return drawings.some((d) => d.dirty || d.pendingDelete); + }, [plan, drawings]); + + const backgroundSrc = fieldImageForYear(year); + + // Create the plan lazily on first edit. + const ensurePlan = useCallback(async (): Promise => { + const existing = await repository.getStrategyPlan(localKey); + if (existing) return existing; + const stub: StoredStrategyPlan = { + localKey, + id: null, + tournamentId, + matchLevel, + matchNumber, + shortSummary: "", + strategyText: "", + updatedByUserId: getUserid(), + updatedByDisplayName: getDisplayName(), + updatedAt: new Date().toISOString(), + dirty: true, + }; + await repository.putStrategyPlan(stub); + setPlan(stub); + return stub; + }, [localKey, tournamentId, matchLevel, matchNumber]); + + const handleSummaryChange = async (value: string) => { + const capped = value.slice(0, 32); + const p = await ensurePlan(); + const updated: StoredStrategyPlan = { + ...p, + shortSummary: capped, + updatedByUserId: getUserid(), + updatedByDisplayName: getDisplayName(), + updatedAt: new Date().toISOString(), + dirty: true, + }; + await repository.putStrategyPlan(updated); + setPlan(updated); + + }; + + const handleStrategyTextChange = async (value: string) => { + const p = await ensurePlan(); + const updated: StoredStrategyPlan = { + ...p, + strategyText: value, + updatedByUserId: getUserid(), + updatedByDisplayName: getDisplayName(), + updatedAt: new Date().toISOString(), + dirty: true, + }; + await repository.putStrategyPlan(updated); + setPlan(updated); + + }; + + const handleAddDrawing = async () => { + await ensurePlan(); + const label = `Drawing ${drawings.length + 1}`; + const now = new Date().toISOString(); + const stored: StoredStrategyDrawing = { + localId: generateLocalId(), + planLocalKey: localKey, + id: null, + planId: null, + label, + strokes: [], + createdByUserId: getUserid(), + createdByDisplayName: getDisplayName(), + updatedByUserId: getUserid(), + updatedByDisplayName: getDisplayName(), + createdAt: now, + updatedAt: now, + dirty: true, + pendingDelete: false, + }; + await repository.putStrategyDrawing(stored); + setDrawings((prev) => [...prev, stored]); + setActiveLocalId(stored.localId); + + }; + + const handleDeleteDrawing = async (localId: string) => { + const d = drawings.find((x) => x.localId === localId); + if (!d) return; + if (d.id == null) { + // Never synced — delete locally. + await repository.deleteStrategyDrawingLocal(localId); + } else { + await repository.putStrategyDrawing({ ...d, pendingDelete: true }); + } + const remaining = drawings.filter( + (x) => x.localId !== localId && !x.pendingDelete, + ); + setDrawings(remaining); + if (activeLocalId === localId) { + setActiveLocalId(remaining[0]?.localId ?? null); + } + + }; + + const activeDrawing = useMemo( + () => drawings.find((d) => d.localId === activeLocalId) ?? null, + [drawings, activeLocalId], + ); + + const persistStrokes = useCallback( + async (nextStrokes: StrategyStroke[]) => { + if (!activeDrawing) return; + const now = new Date().toISOString(); + const updated: StoredStrategyDrawing = { + ...activeDrawing, + strokes: nextStrokes, + updatedByUserId: getUserid(), + updatedByDisplayName: getDisplayName(), + updatedAt: now, + dirty: true, + }; + await repository.putStrategyDrawing(updated); + setDrawings((prev) => + prev.map((d) => (d.localId === updated.localId ? updated : d)), + ); + + }, + [activeDrawing], + ); + + const handleStrokeComplete = useCallback( + async (stroke: StrategyStroke) => { + if (!activeDrawing) return; + const at = activeDrawing.strokes.length; + await persistStrokes([...activeDrawing.strokes, stroke]); + setUndoStack((prev) => [...prev, { kind: "add", at, stroke }]); + }, + [activeDrawing, persistStrokes], + ); + + const handleEraseStroke = useCallback( + async (index: number) => { + if (!activeDrawing) return; + const erased = activeDrawing.strokes[index]; + if (!erased) return; + const next = activeDrawing.strokes.filter((_, i) => i !== index); + await persistStrokes(next); + setUndoStack((prev) => [...prev, { kind: "erase", at: index, stroke: erased }]); + }, + [activeDrawing, persistStrokes], + ); + + const undoLabel = useMemo(() => { + const top = undoStack[undoStack.length - 1]; + if (!top) return "Undo"; + if (top.kind === "add") + return top.stroke.arrow === false ? "Undo Line" : "Undo Arrow"; + if (top.kind === "erase") return "Undo Erase"; + return "Undo Clear"; + }, [undoStack]); + + const handleUndo = async () => { + if (!activeDrawing) return; + const top = undoStack[undoStack.length - 1]; + if (!top) return; + let next: StrategyStroke[] = activeDrawing.strokes; + if (top.kind === "add") { + // Reverse of add: remove the stroke at its insertion index. + next = activeDrawing.strokes.filter((_, i) => i !== top.at); + } else if (top.kind === "erase") { + // Reverse of erase: splice the stroke back in at its original index. + next = [ + ...activeDrawing.strokes.slice(0, top.at), + top.stroke, + ...activeDrawing.strokes.slice(top.at), + ]; + } else { + // Reverse of clear: restore the whole snapshot. + next = top.strokes; + } + await persistStrokes(next); + setUndoStack((prev) => prev.slice(0, -1)); + }; + + const handleClear = async () => { + if (!activeDrawing || activeDrawing.strokes.length === 0) return; + if (!confirm("Clear all strokes from this drawing?")) return; + const snapshot = activeDrawing.strokes; + await persistStrokes([]); + setUndoStack((prev) => [...prev, { kind: "clear", strokes: snapshot }]); + }; + + const handleLabelChange = async (value: string) => { + if (!activeDrawing) return; + const now = new Date().toISOString(); + const updated: StoredStrategyDrawing = { + ...activeDrawing, + label: value, + updatedByUserId: getUserid(), + updatedByDisplayName: getDisplayName(), + updatedAt: now, + dirty: true, + }; + await repository.putStrategyDrawing(updated); + setDrawings((prev) => + prev.map((d) => (d.localId === updated.localId ? updated : d)), + ); + + }; + + const strokeCountText = activeDrawing + ? `${activeDrawing.strokes.length} stroke${activeDrawing.strokes.length === 1 ? "" : "s"}` + : ""; + + // Derive a SyncStatus for the Sync icon. The global sync-status row's + // `remaining` count only updates after sync cycles fire, but locally we + // know in real time whether this plan has unsynced edits — override + // `remaining`/`isComplete` so the icon colour reflects the authoritative + // local dirty state immediately. + const syncIconStatus: SyncStatus = { + ...strategySyncStatus, + remaining: hasUnsyncedChanges ? 1 : 0, + isComplete: !hasUnsyncedChanges && !strategySyncStatus.error, + }; + const [manualSyncing, setManualSyncing] = useState(false); + const handleManualSync = async () => { + if (manualSyncing || strategySyncStatus.inProgress) return; + setManualSyncing(true); + try { + await syncStrategyPlans(); + // Also pull this specific match's server state — handles the case + // where the tournament is past and wouldn't be covered by the + // active-tournament filter inside syncStrategyPlans(). + await refreshStrategyPlanForMatch( + tournamentId, + matchLevel, + matchNumber, + ); + } finally { + setManualSyncing(false); + setSyncResetKey((k) => k + 1); + } + }; + const syncTitle = strategySyncStatus.error + ? "Sync failed — tap to retry" + : strategySyncStatus.inProgress || manualSyncing + ? "Syncing…" + : hasUnsyncedChanges + ? "Local changes pending — tap to sync now" + : "Synced — tap to sync again"; + + return ( +
    +
    +

    + Match Strategy — {matchLevel} {matchNumber} +

    +

    + + ← Back to matches + + {" "} + + {!isCanvasFullscreen && ( + + + + )} +

    +
    + +
    +
    + +