diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3534aebf..51314bef 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -100,6 +100,23 @@ jobs: node-version: "20" cache: npm + - name: Restore iOS compiler cache + if: ${{ inputs.platform == 'ios' }} + uses: actions/cache@v4 + with: + path: ~/Library/Caches/ccache + key: ios-ccache-${{ inputs.app }}-${{ github.sha }} + restore-keys: | + ios-ccache-${{ inputs.app }}- + + - name: Install ccache + if: ${{ inputs.platform == 'ios' }} + shell: bash + run: | + set -euo pipefail + command -v ccache >/dev/null 2>&1 || brew install ccache + ccache --version + - name: Setup Java if: ${{ inputs.platform == 'android' }} uses: actions/setup-java@v5 @@ -107,6 +124,10 @@ jobs: distribution: temurin java-version: "17" + - name: Setup Gradle + if: ${{ inputs.platform == 'android' }} + uses: gradle/actions/setup-gradle@v6 + - name: Setup Android SDK if: ${{ inputs.platform == 'android' }} uses: android-actions/setup-android@v4 @@ -218,6 +239,7 @@ jobs: working-directory: ${{ steps.resolve.outputs.app_dir }} env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + EXPO_APPLE_TEAM_TYPE: INDIVIDUAL EAS_LOCAL_BUILD_ARTIFACTS_DIR: ${{ steps.resolve.outputs.artifact_dir }} shell: bash run: | diff --git a/.gitignore b/.gitignore index c40e195f..1435567b 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,7 @@ coverage/clover.xml # other .com.facebook.react.devsupport.BundleDownloader.swp +/app.json # local vendor reference docs (do not commit) docs/local-only diff --git a/.opencode/agents/explore.md b/.opencode/agents/explore.md index 06d2cbfb..1616ecc5 100644 --- a/.opencode/agents/explore.md +++ b/.opencode/agents/explore.md @@ -73,7 +73,7 @@ permission: context7_query-docs: allow markitdown_convert_to_markdown: allow model: openai/gpt-5.6-luna -variant: max +variant: high --- You are a read-only exploration and research specialist supporting the primary engineer agent. diff --git a/apps/drill-converter/.gitignore b/apps/drill-converter/.gitignore new file mode 100644 index 00000000..5873d9ab --- /dev/null +++ b/apps/drill-converter/.gitignore @@ -0,0 +1,6 @@ + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/apps/drill-converter/app.config.ts b/apps/drill-converter/app.config.ts new file mode 100644 index 00000000..cfd186dc --- /dev/null +++ b/apps/drill-converter/app.config.ts @@ -0,0 +1,19 @@ +import type { ExpoConfig } from "expo/config"; + +const config: ExpoConfig = { + name: "Eight2Five Drill Converter", + slug: "eight2five-drill-converter", + version: "0.1.0", + platforms: ["web"], + userInterfaceStyle: "automatic", + web: { + bundler: "metro", + output: "static", + }, + plugins: ["expo-router"], + experiments: { + typedRoutes: true, + }, +}; + +export default config; diff --git a/apps/drill-converter/app/_layout.tsx b/apps/drill-converter/app/_layout.tsx new file mode 100644 index 00000000..9ad258a1 --- /dev/null +++ b/apps/drill-converter/app/_layout.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { Stack } from "expo-router"; + +export default function RootLayout() { + return ( + + ); +} diff --git a/apps/drill-converter/app/index.tsx b/apps/drill-converter/app/index.tsx new file mode 100644 index 00000000..13eb1a52 --- /dev/null +++ b/apps/drill-converter/app/index.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { Stack } from "expo-router"; + +import { ConverterScreen } from "../src/converter-screen"; + +export default function IndexRoute() { + return ( + <> + + + + ); +} diff --git a/apps/drill-converter/metro.config.js b/apps/drill-converter/metro.config.js new file mode 100644 index 00000000..d84b494d --- /dev/null +++ b/apps/drill-converter/metro.config.js @@ -0,0 +1,19 @@ +const { getDefaultConfig } = require("expo/metro-config"); +const path = require("path"); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(__dirname, "../.."); +const config = getDefaultConfig(projectRoot); + +config.watchFolders = Array.from( + new Set([...(config.watchFolders ?? []), workspaceRoot]) +); +config.resolver.nodeModulesPaths = Array.from( + new Set([ + ...(config.resolver?.nodeModulesPaths ?? []), + path.resolve(projectRoot, "node_modules"), + path.resolve(workspaceRoot, "node_modules"), + ]) +); + +module.exports = config; diff --git a/apps/drill-converter/package.json b/apps/drill-converter/package.json new file mode 100644 index 00000000..2d1b9ce1 --- /dev/null +++ b/apps/drill-converter/package.json @@ -0,0 +1,52 @@ +{ + "name": "eight2five-drill-converter", + "version": "0.1.0", + "private": true, + "main": "expo-router/entry", + "scripts": { + "start": "expo start --web", + "web": "expo start --web", + "build:web": "expo export --platform web", + "type-check": "tsc --noEmit", + "lint": "expo lint", + "lint:fix": "expo lint --fix", + "test": "jest --watchAll=false --passWithNoTests --runInBand" + }, + "dependencies": { + "@eight2five/drill-importers": "0.1.0", + "@eight2five/drill-schema": "0.1.0", + "@eight2five/ui": "0.1.0", + "@expo/metro-runtime": "~57.0.8", + "expo": "~57.0.10", + "expo-document-picker": "~57.0.1", + "expo-router": "~57.0.10", + "lucide-react-native": "^1.22.0", + "react": "19.2.3", + "react-dom": "^19.2.3", + "react-native": "0.86.2", + "react-native-safe-area-context": "~5.7.0", + "react-native-web": "^0.21.2" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/react": "~19.2.10", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "jest-expo": "~57.0.3", + "prettier": "^3.6.2", + "typescript": "~6.0.3" + }, + "jest": { + "preset": "jest-expo", + "testEnvironment": "node", + "moduleNameMapper": { + "^@eight2five/drill-schema$": "/../../packages/drill-schema/src/index.ts", + "^@eight2five/drill-schema/(.*)$": "/../../packages/drill-schema/src/$1", + "^@eight2five/drill-importers$": "/../../packages/drill-importers/src/index.ts", + "^@eight2five/drill-importers/(.*)$": "/../../packages/drill-importers/src/$1" + } + } +} diff --git a/apps/drill-converter/src/components/details-section.tsx b/apps/drill-converter/src/components/details-section.tsx new file mode 100644 index 00000000..d04504aa --- /dev/null +++ b/apps/drill-converter/src/components/details-section.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { Text, View } from "react-native"; +import { + getFieldPreset, + getGridReference, + type FieldPresetId, +} from "@eight2five/drill-schema"; + +import { + ChoiceChips, + Disclosure, + FormField, + SectionCard, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; +import { + FIELD_PRESET_OPTIONS, + type ConverterSettings, +} from "../converter/settings"; + +const FIELD_OPTIONS = Object.freeze([ + ...FIELD_PRESET_OPTIONS, + { value: "custom", label: "Custom" }, +] as const); + +export function DetailsSection({ + settings, + errors, + onUpdate, +}: { + readonly settings: ConverterSettings; + readonly errors: readonly string[]; + readonly onUpdate: (patch: Partial) => void; +}) { + const titleError = errors.find((error) => error.includes("title")); + const customFieldError = errors.find((error) => + error.startsWith("Custom field"), + ); + return ( + + onUpdate({ title })} + placeholder="Part 4" + error={titleError} + /> + + onUpdate({ fieldMode })} + /> + + {settings.fieldMode === "custom" ? ( + onUpdate({ customFieldJson })} + multiline + autoCapitalize="none" + autoCorrect={false} + spellCheck={false} + error={customFieldError} + helper="Advanced: paste a custom field object with matched physical and marching reference lines. The editor starts from NFHS geometry so you can change only what differs." + /> + ) : ( + + )} + + + onUpdate({ drillWriter })} + placeholder="Optional" + /> + onUpdate({ ensemble })} + placeholder="Optional" + /> + onUpdate({ description })} + multiline + placeholder="Optional notes about this movement" + /> + onUpdate({ lucideIcon })} + autoCapitalize="none" + autoCorrect={false} + placeholder="music-2" + helper="Optional kebab-case Lucide icon name stored as metadata. The converter does not download icon assets." + /> + + + ); +} + +function FieldPresetSummary({ preset }: { readonly preset: FieldPresetId }) { + const field = getFieldPreset(preset); + const frontHash = getGridReference({ type: "preset", preset }, "front-hash"); + const backHash = getGridReference({ type: "preset", preset }, "back-hash"); + const backSideline = getGridReference( + { type: "preset", preset }, + "back-sideline", + ); + return ( + + + {field.name} + + + Origin: center of the 50 on the front sideline · Side 1 is negative X · + Side 2 is positive X · backfield is positive Y. + + + Marching grid: Front sideline 0 · Front hash{" "} + {formatNumber(frontHash?.coordinateSteps)} · Back hash{" "} + {formatNumber(backHash?.coordinateSteps)} · Back sideline{" "} + {formatNumber(backSideline?.coordinateSteps)}. + + + ); +} + +function formatNumber(value: number | undefined): string { + if (value === undefined) return "—"; + return Number.isInteger(value) + ? String(value) + : value.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); +} diff --git a/apps/drill-converter/src/components/entity-settings-section.tsx b/apps/drill-converter/src/components/entity-settings-section.tsx new file mode 100644 index 00000000..d4ce36a6 --- /dev/null +++ b/apps/drill-converter/src/components/entity-settings-section.tsx @@ -0,0 +1,506 @@ +import React from "react"; +import { Pressable, Text, View } from "react-native"; +import { + COLOR_PRESETS, + convertPropSizeValue, + type PropSizeUnit, +} from "@eight2five/drill-schema"; + +import { + ChoiceChips, + Disclosure, + FormField, + SecondaryButton, + ToggleRow, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; +import { + COLOR_PRESET_OPTIONS, + ENTITY_ICON_OPTIONS, + type ConverterSettings, + type EntityRuleDraft, +} from "../converter/settings"; + +const TARGET_OPTIONS = Object.freeze([ + { value: "symbol", label: "Symbol" }, + { value: "label", label: "Label" }, + { value: "id", label: "ID" }, +] as const); + +const ENTITY_TYPE_OPTIONS = Object.freeze([ + { value: "", label: "Default (Performer)" }, + { value: "performer", label: "Performer" }, + { value: "prop", label: "Prop" }, +] as const); + +const LABEL_OPTIONS = Object.freeze([ + { value: "inherit", label: "Default (Show)" }, + { value: "visible", label: "Show" }, + { value: "hidden", label: "Hide" }, +] as const); + +const PROP_SIZE_UNIT_OPTIONS = Object.freeze([ + { value: "8-to-5-steps", label: "8:5 steps" }, + { value: "feet", label: "Feet" }, + { value: "inches", label: "Inches" }, + { value: "meters", label: "Meters" }, +] as const satisfies readonly { value: PropSizeUnit; label: string }[]); + +export function EntitySettingsSection({ + settings, + availableSymbols, + errors, + focusRequestKey = 0, + onUpdate, + onAddRule, + onUpdateRule, + onRemoveRule, +}: { + readonly settings: ConverterSettings; + readonly availableSymbols: readonly string[]; + readonly errors: readonly string[]; + readonly focusRequestKey?: number; + readonly onUpdate: (patch: Partial) => void; + readonly onAddRule: (target?: EntityRuleDraft["target"]) => void; + readonly onUpdateRule: ( + id: string, + patch: Partial>, + ) => void; + readonly onRemoveRule: (id: string) => void; +}) { + const ruleErrors = errors.filter( + (error) => error.startsWith("Rule ") || error.startsWith("Duplicate "), + ); + + return ( + 0} + > + + + + Rules and overrides + + + Precedence is symbol → label → ID → explicit entity values. Leave a + field on its Default value to inherit the broader rule or schema + default. + + + + {settings.rules.map((rule, index) => ( + onUpdateRule(rule.id, patch)} + onRemove={() => onRemoveRule(rule.id)} + /> + ))} + + {ruleErrors.length > 0 ? ( + + {ruleErrors.map((error) => ( + + {error} + + ))} + + ) : null} + + + onAddRule("symbol")} + /> + onAddRule("label")} + /> + onAddRule("id")} + /> + + + + + onUpdate({ explicitStraightPaths }) + } + /> + + onUpdate({ includeSourceReferences }) + } + /> + + ); +} + +function RuleEditor({ + index, + rule, + availableSymbols, + onUpdate, + onRemove, +}: { + readonly index: number; + readonly rule: EntityRuleDraft; + readonly availableSymbols: readonly string[]; + readonly onUpdate: (patch: Partial>) => void; + readonly onRemove: () => void; +}) { + const colorMatchesPreset = COLOR_PRESET_OPTIONS.some( + (option) => option.value.toLowerCase() === rule.color.toLowerCase(), + ); + const isProp = rule.entityType === "prop"; + const defaultIcon = isProp ? "Square" : "Dot"; + + return ( + + + + Rule {index + 1} + + + + + + + onUpdate({ target, key: "" })} + /> + {rule.target === "symbol" && availableSymbols.length > 0 ? ( + ({ + value: symbol, + label: symbol, + })), + ]} + onChange={(key) => onUpdate({ key })} + /> + ) : null} + onUpdate({ key })} + autoCapitalize="none" + autoCorrect={false} + placeholder={ + rule.target === "symbol" + ? "B" + : rule.target === "label" + ? "B1" + : "1595433022185" + } + /> + + + onUpdate( + entityType === "prop" + ? { + entityType, + section: "", + instrument: "", + sizeLength: rule.sizeLength.trim() ? rule.sizeLength : "1", + sizeWidth: rule.sizeWidth.trim() ? rule.sizeWidth : "1", + } + : { entityType }, + ) + } + /> + + onUpdate({ name })} + placeholder="Optional" + /> + + {isProp ? ( + + + + Prop size + + + Default is 1 × 1 8:5 steps. One 8:5 step is 22.5 in (1.875 ft / + 0.5715 m). + + + + + onUpdate({ + sizeUnit, + sizeLength: convertSizeDraftValue( + rule.sizeLength, + rule.sizeUnit, + sizeUnit, + ), + sizeWidth: convertSizeDraftValue( + rule.sizeWidth, + rule.sizeUnit, + sizeUnit, + ), + }) + } + /> + + + + onUpdate({ sizeLength })} + inputMode="decimal" + placeholder="1" + /> + + + onUpdate({ sizeWidth })} + inputMode="decimal" + placeholder="1" + /> + + + + ) : null} + + + + onUpdate({ section })} + placeholder={isProp ? "Unavailable for props" : "Optional"} + editable={!isProp} + helper={isProp ? "Props cannot define a section." : undefined} + /> + + + onUpdate({ instrument })} + placeholder={isProp ? "Unavailable for props" : "Optional"} + editable={!isProp} + helper={isProp ? "Props cannot define an instrument." : undefined} + /> + + + + ({ + value: icon, + label: titleCase(icon), + })), + ]} + onChange={(icon) => onUpdate({ icon })} + /> + + + + Color + + + onUpdate({ color: "" })} + /> + {COLOR_PRESET_OPTIONS.map((option) => ( + onUpdate({ color: option.value })} + /> + ))} + + onUpdate({ color })} + autoCapitalize="none" + autoCorrect={false} + placeholder={COLOR_PRESETS.blue} + helper="Leave blank for Default (Grey) or the selected preset. Any six-digit hex color is valid." + /> + + + onUpdate({ labelVisibility })} + /> + + ); +} + +function ColorChip({ + label, + color, + selected, + onPress, +}: { + readonly label: string; + readonly color?: string; + readonly selected: boolean; + readonly onPress: () => void; +}) { + return ( + ({ + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + minHeight: 38, + borderWidth: 1, + borderColor: selected ? colors.accent : colors.borderStrong, + borderRadius: 999, + paddingHorizontal: 12, + backgroundColor: selected + ? colors.accentSoft + : pressed + ? colors.surfaceMuted + : colors.surface, + })} + > + {color ? ( + + ) : null} + + {label} + + + ); +} + +function convertSizeDraftValue( + value: string, + fromUnit: PropSizeUnit, + toUnit: PropSizeUnit, +): string { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return value; + const converted = convertPropSizeValue(parsed, fromUnit, toUnit); + return String(Number(converted.toFixed(6))); +} + +function titleCase(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} diff --git a/apps/drill-converter/src/components/file-section.tsx b/apps/drill-converter/src/components/file-section.tsx new file mode 100644 index 00000000..5936612b --- /dev/null +++ b/apps/drill-converter/src/components/file-section.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { ActivityIndicator, Text, View } from "react-native"; +import type { DocumentPickerAsset } from "expo-document-picker"; + +import { + PrimaryButton, + SecondaryButton, + SectionCard, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; +import type { ConverterPhase } from "../converter/use-converter-controller"; + +export function FileSection({ + asset, + phase, + extractionError, + pageCount, + pdfJsVersion, + onPick, + onClear, +}: { + readonly asset?: DocumentPickerAsset; + readonly phase: ConverterPhase; + readonly extractionError?: string; + readonly pageCount: number; + readonly pdfJsVersion: string; + readonly onPick: () => void; + readonly onClear: () => void; +}) { + return ( + + {!asset ? ( + + + + ) : ( + + + + {asset.name} + + + {formatBytes(asset.size)} + {pageCount > 0 + ? ` · ${pageCount} PDF page${pageCount === 1 ? "" : "s"}` + : ""} + + + {phase === "extracting" ? ( + + + + Extracting browser text… + + + ) : null} + {extractionError ? ( + + + {extractionError} + + + ) : null} + + + + + + + + + + )} + + Text extraction uses PDF.js {pdfJsVersion}, loaded from a pinned + jsDelivr URL. Only the library is fetched; selected PDF bytes remain + local. + + + ); +} + +function formatBytes(value: number | undefined): string { + if (!value || value < 1) return "PDF file"; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/apps/drill-converter/src/components/preview-section.tsx b/apps/drill-converter/src/components/preview-section.tsx new file mode 100644 index 00000000..6441ff51 --- /dev/null +++ b/apps/drill-converter/src/components/preview-section.tsx @@ -0,0 +1,721 @@ +import React from "react"; +import { Modal, Pressable, ScrollView, Text, View } from "react-native"; +import type { CoordinateSheetImportResult } from "@eight2five/drill-importers"; +import { + drillSetSchema, + formatSetName, + type DrillDocument, + type DrillEntity, + type DrillSet, +} from "@eight2five/drill-schema"; +import { Divider } from "@eight2five/ui/components/divider"; +import { Icon } from "@eight2five/ui/components/icon"; +import { ListPlus, Pencil } from "lucide-react-native"; + +import { + FormField, + PrimaryButton, + SecondaryButton, + SectionCard, +} from "../ui/form-controls"; +import { colors, radius, spacing } from "../ui/theme"; + +export function PreviewSection({ + importResult, + outputDocument, + settingsErrors, + summary, + canDownload, + onAddEntityLabelRule, + onUpdateEntityIdentity, + onUpdateSet, + onDownload, +}: { + readonly importResult?: CoordinateSheetImportResult; + readonly outputDocument?: DrillDocument; + readonly settingsErrors: readonly string[]; + readonly summary?: { + readonly performers: number; + readonly props: number; + readonly primarySets: number; + readonly setEntries: number; + readonly positions: number; + }; + readonly canDownload: boolean; + readonly onAddEntityLabelRule: (label: string) => void; + readonly onUpdateEntityIdentity: ( + entity: Pick, + ) => string | undefined; + readonly onUpdateSet: (set: DrillSet) => string | undefined; + readonly onDownload: () => void; +}) { + const [editingEntity, setEditingEntity] = React.useState(); + const [editingSet, setEditingSet] = React.useState(); + const diagnostics = importResult?.diagnostics ?? []; + const errors = [ + ...settingsErrors, + ...diagnostics + .filter((diagnostic) => diagnostic.severity === "error") + .map((diagnostic) => diagnostic.message), + ]; + const warnings = diagnostics + .filter((diagnostic) => diagnostic.severity === "warning") + .map((diagnostic) => diagnostic.message); + + return ( + + {!importResult ? ( + + Choose a PDF to see parsed performers, sets, coordinates, and + validation results. + + ) : null} + + {errors.length > 0 ? ( + + ) : null} + {warnings.length > 0 ? ( + + ) : null} + + {outputDocument && summary ? ( + + + + + + + + + + + + {outputDocument.entities.map((entity, index) => ( + + {index > 0 ? : null} + onAddEntityLabelRule(entity.label)} + editLabel={`Edit label and symbol for ${entity.label}`} + onEdit={() => setEditingEntity(entity)} + /> + + ))} + + + + {outputDocument.sets.map((set, index) => { + const measures = set.measureRange + ? set.measureRange.start === set.measureRange.end + ? `m. ${set.measureRange.start}` + : `m. ${set.measureRange.start}–${set.measureRange.end}` + : "measures —"; + return ( + + {index > 0 ? : null} + setEditingSet(set)} + /> + + ); + })} + + + + + + Valid Eight2Five {outputDocument.schemaVersion} drill document. + The first set has zero incoming counts, set IDs follow array + order, and all entity/set references validate. + + + + ) : null} + + + + + + {editingEntity ? ( + setEditingEntity(undefined)} + onSave={onUpdateEntityIdentity} + /> + ) : null} + + {editingSet ? ( + setEditingSet(undefined)} + onSave={onUpdateSet} + /> + ) : null} + + ); +} + +function Metric({ + label, + value, +}: { + readonly label: string; + readonly value: number; +}) { + return ( + + + {value} + + {label} + + ); +} + +function ScrollablePreviewBox({ + title, + children, +}: { + readonly title: string; + readonly children: React.ReactNode; +}) { + return ( + + + {title} + + + + {children} + + + + ); +} + +function PreviewDivider() { + return ( + + ); +} + +function PreviewRow({ + text, + addRuleLabel, + onAddRule, + editLabel, + onEdit, +}: { + readonly text: string; + readonly addRuleLabel?: string; + readonly onAddRule?: () => void; + readonly editLabel: string; + readonly onEdit: () => void; +}) { + return ( + + + {text} + + {onAddRule && addRuleLabel ? ( + ({ + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: pressed ? colors.accentSoft : "transparent", + })} + > + + + ) : null} + ({ + width: 30, + height: 30, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + backgroundColor: pressed ? colors.accentSoft : "transparent", + })} + > + + + + ); +} + +function EntityIdentityEditModal({ + entity, + onClose, + onSave, +}: { + readonly entity: DrillEntity; + readonly onClose: () => void; + readonly onSave: ( + entity: Pick, + ) => string | undefined; +}) { + const [label, setLabel] = React.useState(entity.label); + const [symbol, setSymbol] = React.useState(entity.symbol); + const [error, setError] = React.useState(); + + const save = () => { + const trimmedLabel = label.trim(); + const trimmedSymbol = symbol.trim(); + if (!trimmedLabel) { + setError("Entity label cannot be empty."); + return; + } + if (!trimmedSymbol || trimmedSymbol.length > 16) { + setError("Entity symbol must be 1-16 characters."); + return; + } + + const documentError = onSave({ + id: entity.id, + label: trimmedLabel, + symbol: trimmedSymbol, + }); + if (documentError) { + setError(documentError); + return; + } + onClose(); + }; + + return ( + + + + + + Edit entity {entity.label} + + + Change this entity's explicit label or symbol. Its numeric ID + stays {entity.id}. + + + + + + + + + {error ? ( + + {error} + + ) : null} + + + + + + + + + + + ); +} + +function SetEditModal({ + set, + onClose, + onSave, +}: { + readonly set: DrillSet; + readonly onClose: () => void; + readonly onSave: (set: DrillSet) => string | undefined; +}) { + const [number, setNumber] = React.useState(String(set.number)); + const [suffix, setSuffix] = React.useState(set.suffix ?? ""); + const [counts, setCounts] = React.useState(String(set.countsFromPrevious)); + const [measureStart, setMeasureStart] = React.useState( + set.measureRange ? String(set.measureRange.start) : "", + ); + const [measureEnd, setMeasureEnd] = React.useState( + set.measureRange ? String(set.measureRange.end) : "", + ); + const [error, setError] = React.useState(); + + const save = () => { + const parsedNumber = parseNonNegativeInteger(number); + if (parsedNumber === undefined) { + setError("Set number must be a non-negative whole number."); + return; + } + const parsedCounts = parseNonNegativeInteger(counts); + if (parsedCounts === undefined) { + setError("Counts from previous must be a non-negative whole number."); + return; + } + + const trimmedSuffix = suffix.trim(); + const hasMeasureStart = measureStart.trim().length > 0; + const hasMeasureEnd = measureEnd.trim().length > 0; + if (hasMeasureStart !== hasMeasureEnd) { + setError( + "Enter both measure start and measure end, or leave both blank.", + ); + return; + } + + let measureRange: DrillSet["measureRange"]; + if (hasMeasureStart && hasMeasureEnd) { + const start = parseNonNegativeInteger(measureStart); + const end = parseNonNegativeInteger(measureEnd); + if (start === undefined || end === undefined) { + setError("Measure numbers must be non-negative whole numbers."); + return; + } + measureRange = { start, end }; + } + + const candidate = { + id: set.id, + number: parsedNumber, + ...(trimmedSuffix ? { suffix: trimmedSuffix } : {}), + kind: trimmedSuffix ? ("subset" as const) : ("set" as const), + countsFromPrevious: parsedCounts, + ...(measureRange ? { measureRange } : {}), + }; + const parsed = drillSetSchema.safeParse(candidate); + if (!parsed.success) { + setError( + parsed.error.issues[0]?.message ?? "The set values are invalid.", + ); + return; + } + + const documentError = onSave(parsed.data); + if (documentError) { + setError(documentError); + return; + } + onClose(); + }; + + return ( + + + + + + Edit set {formatSetName(set)} + + + Set identity uses a numeric number plus an optional suffix. A + blank suffix is a primary set; a capital letter or decimal suffix + such as A or .5 makes it a subset. + + + + + + + + + + + + + + + + + + + + + + + + + + + + {error ? ( + + {error} + + ) : null} + + + + + + + + + + + ); +} + +function parseNonNegativeInteger(value: string): number | undefined { + const trimmed = value.trim(); + if (!/^(?:0|[1-9][0-9]*)$/.test(trimmed)) return undefined; + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function DiagnosticBox({ + title, + messages, + tone, +}: { + readonly title: string; + readonly messages: readonly string[]; + readonly tone: "error" | "warning"; +}) { + const foreground = tone === "error" ? colors.danger : colors.warning; + const background = tone === "error" ? colors.dangerSoft : colors.warningSoft; + return ( + + + {title} + + {messages.map((message, index) => ( + + • {message} + + ))} + + ); +} diff --git a/apps/drill-converter/src/converter-screen.tsx b/apps/drill-converter/src/converter-screen.tsx new file mode 100644 index 00000000..925c8296 --- /dev/null +++ b/apps/drill-converter/src/converter-screen.tsx @@ -0,0 +1,135 @@ +import React from "react"; +import { ScrollView, Text, View, useWindowDimensions } from "react-native"; + +import { DetailsSection } from "./components/details-section"; +import { EntitySettingsSection } from "./components/entity-settings-section"; +import { FileSection } from "./components/file-section"; +import { PreviewSection } from "./components/preview-section"; +import { useConverterController } from "./converter/use-converter-controller"; +import { colors, spacing } from "./ui/theme"; + +export function ConverterScreen() { + const controller = useConverterController(); + const { addLabelOverride } = controller; + const scrollViewRef = React.useRef(null); + const [rulesSectionY, setRulesSectionY] = React.useState(0); + const [rulesFocusRequestKey, setRulesFocusRequestKey] = React.useState(0); + const { width } = useWindowDimensions(); + + const addEntityLabelRule = React.useCallback( + (label: string) => { + addLabelOverride(label); + setRulesFocusRequestKey((key) => key + 1); + requestAnimationFrame(() => { + scrollViewRef.current?.scrollTo({ + y: Math.max(0, rulesSectionY - spacing.md), + animated: true, + }); + }); + }, + [addLabelOverride, rulesSectionY], + ); + + return ( + + + + + Coordinate Sheet Converter + + + Convert Pyware-style coordinate-sheet PDFs into the portable + Eight2Five drill JSON schema. Sets, counts, measures, performers, + and marching-grid positions are extracted locally in your browser. + + + + void controller.pickPdf()} + onClear={controller.clearPdf} + /> + + + + setRulesSectionY(event.nativeEvent.layout.y)} + > + + + + + + + + Eight2Five drill schema · No account, backend, database, analytics, + or PDF upload. + + + The current parser expects PDFs with extractable text. OCR and + image-only coordinate sheets are intentionally out of scope. + + + + + ); +} diff --git a/apps/drill-converter/src/converter/__tests__/settings.test.ts b/apps/drill-converter/src/converter/__tests__/settings.test.ts new file mode 100644 index 00000000..a7fc9be5 --- /dev/null +++ b/apps/drill-converter/src/converter/__tests__/settings.test.ts @@ -0,0 +1,273 @@ +import { + COLOR_PRESETS, + DRILL_SCHEMA_VERSION, + FIELD_PRESET_IDS, + parseDrillDocument, + resolveDrillEntity, + type DrillDocument, +} from "@eight2five/drill-schema"; + +import { + applyConverterSettings, + createDefaultConverterSettings, + createEmptyRuleDraft, + downloadFileName, + FIELD_PRESET_OPTIONS, + inferTitleFromFileName, + validateConverterSettings, +} from "../settings"; + +const source: DrillDocument = parseDrillDocument({ + schema: "https://eight2five.com/schema/drill", + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Imported", + createdAt: "2026-08-02T18:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entities: [ + { id: 1, type: "performer", symbol: "B", label: "B1" }, + { id: 2, type: "performer", symbol: "X", label: "P2" }, + ], + sets: [ + { id: 0, number: 1, kind: "set", countsFromPrevious: 0 }, + { id: 1, number: 2, kind: "set", countsFromPrevious: 8 }, + ], + positions: [ + { entityId: 1, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 1, setId: 1, xSteps: 8, ySteps: 8 }, + { entityId: 2, setId: 0, xSteps: 0, ySteps: 4 }, + { entityId: 2, setId: 1, xSteps: 4, ySteps: 8 }, + ], + provenance: { + source: { kind: "coordinate-sheet-pdf", fileName: "coords.pdf" }, + references: [ + { + target: { type: "position", entityId: 1, setId: 0 }, + page: 1, + rawText: "Set 1 row", + }, + ], + }, +}); + +describe("drill converter settings", () => { + test("keeps the default UI minimal and validates NFHS settings", () => { + const settings = { ...createDefaultConverterSettings(), title: "Part 4" }; + expect(validateConverterSettings(settings)).toMatchObject({ + field: { type: "preset", preset: "football-nfhs" }, + errors: [], + }); + }); + + test("exposes every schema field preset in schema order", () => { + expect(FIELD_PRESET_OPTIONS.map(({ value }) => value)).toEqual( + FIELD_PRESET_IDS, + ); + }); + + test("applies metadata, props, rules, and optional explicit straight paths", () => { + const symbolRule = { + ...createEmptyRuleDraft("rule-1"), + key: "B", + instrument: "Baritone", + color: COLOR_PRESETS.blue, + }; + const labelRule = { + ...createEmptyRuleDraft("rule-2"), + target: "label" as const, + key: "B1", + name: "Lead Baritone", + color: COLOR_PRESETS.green, + labelVisibility: "hidden" as const, + }; + const propRule = { + ...createEmptyRuleDraft("rule-3"), + key: "X", + entityType: "prop" as const, + sizeLength: "45", + sizeWidth: "22.5", + sizeUnit: "inches" as const, + }; + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + drillWriter: "Writer", + ensemble: "UHS", + description: "Final movement", + lucideIcon: "music-2", + rules: [symbolRule, labelRule, propRule], + setOverrides: [ + { + ...source.sets[1], + number: 3, + countsFromPrevious: 12, + measureRange: { start: 10, end: 12 }, + }, + ], + includeSourceReferences: false, + explicitStraightPaths: true, + }; + const validation = validateConverterSettings(settings); + expect(validation.errors).toEqual([]); + + const result = applyConverterSettings(source, settings, validation); + expect(result.metadata).toMatchObject({ + title: "Part 4", + drillWriter: "Writer", + ensemble: "UHS", + description: "Final movement", + lucideIcon: "music-2", + }); + expect(result.entities[1].type).toBe("prop"); + expect( + resolveDrillEntity(result.entities[1], result.entityRules), + ).toMatchObject({ + size: { length: 45, width: 22.5, unit: "inches" }, + }); + expect(result.sets[1]).toMatchObject({ + number: 3, + countsFromPrevious: 12, + measureRange: { start: 10, end: 12 }, + }); + expect(result.provenance?.references).toBeUndefined(); + expect(result.paths).toHaveLength(2); + expect( + resolveDrillEntity(result.entities[0], result.entityRules), + ).toMatchObject({ + name: "Lead Baritone", + instrument: "Baritone", + appearance: { color: COLOR_PRESETS.green, labelVisible: false }, + }); + }); + + test("applies entity label and symbol edits before resolving rules", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + entityOverrides: [{ id: 1, label: "T1", symbol: "T" }], + rules: [ + { + ...createEmptyRuleDraft("edited-label-rule"), + target: "label" as const, + key: "T1", + name: "Edited Entity", + }, + ], + }; + const validation = validateConverterSettings(settings); + expect(validation.errors).toEqual([]); + + const result = applyConverterSettings(source, settings, validation); + expect(result.entities[0]).toMatchObject({ + id: 1, + label: "T1", + symbol: "T", + }); + expect( + resolveDrillEntity(result.entities[0], result.entityRules), + ).toMatchObject({ name: "Edited Entity" }); + }); + + test("rejects invalid entity identity overrides", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + entityOverrides: [{ id: 1, label: " ", symbol: "ABCDEFGHIJKLMNOPQ" }], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("label cannot be empty"), + expect.stringContaining("symbol must be 1-16 characters"), + ]), + ); + }); + + test("defaults prop rules to 1 by 1 8-to-5 steps", () => { + const rule = { + ...createEmptyRuleDraft("prop-rule"), + key: "X", + entityType: "prop" as const, + }; + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + rules: [rule], + }; + const validation = validateConverterSettings(settings); + expect(validation.errors).toEqual([]); + expect(validation.entityRules?.bySymbol?.X?.size).toEqual({ + length: 1, + width: 1, + unit: "8-to-5-steps", + }); + }); + + test("rejects invalid prop sizes", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + rules: [ + { + ...createEmptyRuleDraft("prop-rule"), + key: "X", + entityType: "prop" as const, + sizeLength: "0", + }, + ], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("prop length must be greater than zero"), + ]), + ); + }); + + test("rejects prop rules that define performer-only fields", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + rules: [ + { + ...createEmptyRuleDraft("prop-rule"), + key: "X", + entityType: "prop" as const, + section: "Guard", + }, + ], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("props cannot define section or instrument"), + ]), + ); + }); + + test("rejects malformed custom fields and duplicate rule targets", () => { + const settings = { + ...createDefaultConverterSettings(), + title: "Part 4", + fieldMode: "custom" as const, + customFieldJson: "{bad json", + rules: [ + { ...createEmptyRuleDraft("1"), key: "B", instrument: "Baritone" }, + { ...createEmptyRuleDraft("2"), key: "B", instrument: "Trombone" }, + ], + }; + expect(validateConverterSettings(settings).errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("Custom field JSON is invalid"), + expect.stringContaining("Duplicate symbol rule"), + ]), + ); + }); + + test("creates stable human-readable output names", () => { + expect(inferTitleFromFileName("2026_UHS-Part-4_COORDINATES.pdf")).toBe( + "2026 UHS Part 4 COORDINATES", + ); + expect(downloadFileName("Part 4 / Finale!")).toBe( + "part-4-finale.eight2five.json", + ); + }); +}); diff --git a/apps/drill-converter/src/converter/download.web.ts b/apps/drill-converter/src/converter/download.web.ts new file mode 100644 index 00000000..25837b7c --- /dev/null +++ b/apps/drill-converter/src/converter/download.web.ts @@ -0,0 +1,23 @@ +export function downloadTextFile( + contents: string, + fileName: string, + mimeType = "application/json;charset=utf-8", +): void { + if (typeof window === "undefined") { + throw new Error("Downloads are available only in the browser."); + } + const blob = new Blob([contents], { type: mimeType }); + const url = URL.createObjectURL(blob); + const anchor = window.document.createElement("a"); + try { + anchor.href = url; + anchor.download = fileName; + anchor.rel = "noopener"; + anchor.style.display = "none"; + window.document.body.appendChild(anchor); + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } +} diff --git a/apps/drill-converter/src/converter/settings.ts b/apps/drill-converter/src/converter/settings.ts new file mode 100644 index 00000000..3be907ee --- /dev/null +++ b/apps/drill-converter/src/converter/settings.ts @@ -0,0 +1,477 @@ +import { + COLOR_PRESETS, + FIELD_PRESET_IDS, + countPrimarySets, + drillSetSchema, + fieldDefinitionSchema, + getFieldPreset, + parseDrillDocument, + resolveEntityRuleValues, + type DrillDocument, + type DrillEntity, + type DrillEntityType, + type DrillPath, + type DrillSet, + type EntityIcon, + type EntityRuleValues, + type EntityRules, + type FieldDefinition, + type FieldPresetId, + type PropSizeUnit, +} from "@eight2five/drill-schema"; + +export const FIELD_PRESET_OPTIONS = Object.freeze( + FIELD_PRESET_IDS.map((value) => ({ + value, + label: getFieldPreset(value).name, + })) satisfies readonly { + value: FieldPresetId; + label: string; + }[], +); + +export const ENTITY_ICON_OPTIONS = Object.freeze([ + "dot", + "square", + "triangle", + "diamond", + "star", + "hexagon", + "cross", +] as const satisfies readonly EntityIcon[]); + +export const COLOR_PRESET_OPTIONS = Object.freeze([ + { label: "Grey", value: COLOR_PRESETS.grey }, + { label: "Red", value: COLOR_PRESETS.red }, + { label: "Orange", value: COLOR_PRESETS.orange }, + { label: "Yellow", value: COLOR_PRESETS.yellow }, + { label: "Green", value: COLOR_PRESETS.green }, + { label: "Light blue", value: COLOR_PRESETS.lightBlue }, + { label: "Blue", value: COLOR_PRESETS.blue }, + { label: "Dark blue", value: COLOR_PRESETS.darkBlue }, + { label: "Purple", value: COLOR_PRESETS.purple }, + { label: "Pink", value: COLOR_PRESETS.pink }, + { label: "Black", value: COLOR_PRESETS.black }, +] as const); + +export type RuleTarget = "symbol" | "label" | "id"; +export type LabelVisibility = "inherit" | "visible" | "hidden"; + +export interface EntityRuleDraft { + readonly id: string; + readonly target: RuleTarget; + readonly key: string; + readonly entityType: "" | DrillEntityType; + readonly name: string; + readonly section: string; + readonly instrument: string; + readonly sizeLength: string; + readonly sizeWidth: string; + readonly sizeUnit: PropSizeUnit; + readonly icon: "" | EntityIcon; + readonly color: string; + readonly labelVisibility: LabelVisibility; +} + +export type EntityIdentityOverride = Pick< + DrillEntity, + "id" | "label" | "symbol" +>; + +export interface ConverterSettings { + readonly title: string; + readonly drillWriter: string; + readonly ensemble: string; + readonly description: string; + readonly lucideIcon: string; + readonly fieldMode: FieldPresetId | "custom"; + readonly customFieldJson: string; + readonly rules: readonly EntityRuleDraft[]; + readonly entityOverrides: readonly EntityIdentityOverride[]; + readonly setOverrides: readonly DrillSet[]; + readonly includeSourceReferences: boolean; + readonly explicitStraightPaths: boolean; +} + +export interface ConverterSettingsValidation { + readonly field?: FieldDefinition; + readonly entityRules?: EntityRules; + readonly errors: readonly string[]; +} + +export function createDefaultConverterSettings(): ConverterSettings { + return { + title: "", + drillWriter: "", + ensemble: "", + description: "", + lucideIcon: "", + fieldMode: "football-nfhs", + customFieldJson: createDefaultCustomFieldJson(), + rules: [], + entityOverrides: [], + setOverrides: [], + includeSourceReferences: true, + explicitStraightPaths: false, + }; +} + +export function createDefaultCustomFieldJson(): string { + const preset = getFieldPreset("football-nfhs"); + return JSON.stringify( + { + type: "custom", + name: "Custom Football Field", + physicalGeometry: preset.physicalGeometry, + marchingGrid: preset.marchingGrid, + markings: preset.markings, + } satisfies FieldDefinition, + null, + 2, + ); +} + +export function createEmptyRuleDraft(id: string): EntityRuleDraft { + return { + id, + target: "symbol", + key: "", + entityType: "", + name: "", + section: "", + instrument: "", + sizeLength: "1", + sizeWidth: "1", + sizeUnit: "8-to-5-steps", + icon: "", + color: "", + labelVisibility: "inherit", + }; +} + +export function validateConverterSettings( + settings: ConverterSettings, +): ConverterSettingsValidation { + const errors: string[] = []; + const title = settings.title.trim(); + if (!title) errors.push("A drill title is required."); + + let field: FieldDefinition | undefined; + if (settings.fieldMode === "custom") { + try { + const parsedJson = JSON.parse(settings.customFieldJson) as unknown; + const parsed = fieldDefinitionSchema.safeParse(parsedJson); + if (!parsed.success || parsed.data.type !== "custom") { + errors.push( + parsed.success + ? 'Custom field JSON must have type "custom".' + : `Custom field is invalid: ${ + parsed.error.issues[0]?.message ?? "unknown validation error" + }`, + ); + } else { + field = parsed.data; + } + } catch (cause) { + errors.push( + cause instanceof Error + ? `Custom field JSON is invalid: ${cause.message}` + : "Custom field JSON is invalid.", + ); + } + } else { + field = { type: "preset", preset: settings.fieldMode }; + } + + const entityRules = buildEntityRules(settings.rules, errors); + const seenEntityOverrideIds = new Set(); + for (const [index, override] of settings.entityOverrides.entries()) { + if (seenEntityOverrideIds.has(override.id)) { + errors.push(`Duplicate entity override for entity id ${override.id}.`); + continue; + } + seenEntityOverrideIds.add(override.id); + if (!Number.isSafeInteger(override.id) || override.id < 0) { + errors.push(`Entity override ${index + 1} has an invalid entity id.`); + } + if (!override.label.trim()) { + errors.push(`Entity override ${index + 1} label cannot be empty.`); + } + const symbol = override.symbol.trim(); + if (!symbol || symbol.length > 16) { + errors.push( + `Entity override ${index + 1} symbol must be 1-16 characters.`, + ); + } + } + + const seenSetOverrideIds = new Set(); + for (const [index, set] of settings.setOverrides.entries()) { + if (seenSetOverrideIds.has(set.id)) { + errors.push(`Duplicate set override for set id ${set.id}.`); + continue; + } + seenSetOverrideIds.add(set.id); + const parsed = drillSetSchema.safeParse(set); + if (!parsed.success) { + errors.push( + `Set override ${index + 1} is invalid: ${ + parsed.error.issues[0]?.message ?? "unknown validation error" + }`, + ); + } + } + + return { + ...(field ? { field } : {}), + ...(entityRules ? { entityRules } : {}), + errors, + }; +} + +export function applyConverterSettings( + source: DrillDocument, + settings: ConverterSettings, + validation = validateConverterSettings(settings), +): DrillDocument { + if (validation.errors.length > 0 || !validation.field) { + throw new Error(validation.errors[0] ?? "Converter settings are invalid."); + } + + const entityOverrides = new Map( + settings.entityOverrides.map( + (override) => [override.id, override] as const, + ), + ); + const entities = source.entities.map((entity) => { + const identityOverride = entityOverrides.get(entity.id); + const entityWithIdentity = identityOverride + ? { + ...entity, + label: identityOverride.label.trim(), + symbol: identityOverride.symbol.trim(), + } + : entity; + const ruleValues = resolveEntityRuleValues( + entityWithIdentity, + validation.entityRules, + ); + const type = ruleValues.type ?? entityWithIdentity.type; + if (type === "prop") { + const { + section: _section, + instrument: _instrument, + ...rest + } = entityWithIdentity; + return { ...rest, type }; + } + const { size: _size, ...rest } = entityWithIdentity; + return { ...rest, type }; + }); + const setOverrides = new Map( + settings.setOverrides.map((set) => [set.id, set] as const), + ); + const sets = source.sets.map((set) => setOverrides.get(set.id) ?? set); + const paths = settings.explicitStraightPaths + ? createStraightPaths({ ...source, sets }) + : source.paths; + const provenance = source.provenance + ? { + ...source.provenance, + ...(settings.includeSourceReferences ? {} : { references: undefined }), + } + : undefined; + + return parseDrillDocument({ + ...source, + metadata: { + title: settings.title.trim(), + createdAt: source.metadata.createdAt, + ...optionalText("drillWriter", settings.drillWriter), + ...optionalText("ensemble", settings.ensemble), + ...(settings.description.trim() + ? { description: settings.description.trim() } + : {}), + ...optionalText("lucideIcon", settings.lucideIcon), + }, + field: validation.field, + ...(validation.entityRules && hasRules(validation.entityRules) + ? { entityRules: validation.entityRules } + : { entityRules: undefined }), + entities, + sets, + ...(paths && paths.length > 0 ? { paths } : { paths: undefined }), + ...(provenance ? { provenance } : { provenance: undefined }), + }); +} + +export function getDocumentSummary(document: DrillDocument) { + return { + performers: document.entities.filter( + (entity) => entity.type === "performer", + ).length, + props: document.entities.filter((entity) => entity.type === "prop").length, + primarySets: countPrimarySets(document.sets), + setEntries: document.sets.length, + positions: document.positions.length, + }; +} + +export function inferTitleFromFileName(fileName: string): string { + return fileName + .replace(/\.pdf$/i, "") + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function downloadFileName(title: string): string { + const slug = title + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + return `${slug || "drill"}.eight2five.json`; +} + +function buildEntityRules( + drafts: readonly EntityRuleDraft[], + errors: string[], +): EntityRules | undefined { + const bySymbol: Record = {}; + const byLabel: Record = {}; + const byId: Record = {}; + const seen = new Set(); + + for (const [index, draft] of drafts.entries()) { + const key = draft.key.trim(); + if (!key) { + errors.push(`Rule ${index + 1} needs a ${draft.target} key.`); + continue; + } + if (draft.target === "id") { + const parsed = Number(key); + if (!/^(?:0|[1-9][0-9]*)$/.test(key) || !Number.isSafeInteger(parsed)) { + errors.push( + `Rule ${index + 1} ID must be a non-negative safe integer.`, + ); + continue; + } + } + if (draft.target === "symbol" && (key.length > 16 || !key.trim())) { + errors.push(`Rule ${index + 1} symbol must be 1-16 characters.`); + continue; + } + const identity = `${draft.target}:${key}`; + if (seen.has(identity)) { + errors.push(`Duplicate ${draft.target} rule for ${key}.`); + continue; + } + seen.add(identity); + + const color = draft.color.trim(); + if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { + errors.push(`Rule ${index + 1} color must be a six-digit hex value.`); + continue; + } + + if ( + draft.entityType === "prop" && + (draft.section.trim() || draft.instrument.trim()) + ) { + errors.push( + `Rule ${index + 1} props cannot define section or instrument.`, + ); + continue; + } + + let size: EntityRuleValues["size"]; + if (draft.entityType === "prop") { + const length = Number(draft.sizeLength); + const width = Number(draft.sizeWidth); + if (!Number.isFinite(length) || length <= 0) { + errors.push(`Rule ${index + 1} prop length must be greater than zero.`); + continue; + } + if (!Number.isFinite(width) || width <= 0) { + errors.push(`Rule ${index + 1} prop width must be greater than zero.`); + continue; + } + size = { length, width, unit: draft.sizeUnit }; + } + + const appearance = { + ...(draft.icon ? { icon: draft.icon } : {}), + ...(color ? { color } : {}), + ...(draft.labelVisibility === "inherit" + ? {} + : { labelVisible: draft.labelVisibility === "visible" }), + }; + const values: EntityRuleValues = { + ...(draft.entityType ? { type: draft.entityType } : {}), + ...optionalText("name", draft.name), + ...optionalText("section", draft.section), + ...optionalText("instrument", draft.instrument), + ...(size ? { size } : {}), + ...(Object.keys(appearance).length > 0 ? { appearance } : {}), + }; + if (Object.keys(values).length === 0) continue; + if (draft.target === "symbol") bySymbol[key] = values; + else if (draft.target === "label") byLabel[key] = values; + else byId[key] = values; + } + + const rules: EntityRules = { + ...(Object.keys(bySymbol).length > 0 ? { bySymbol } : {}), + ...(Object.keys(byLabel).length > 0 ? { byLabel } : {}), + ...(Object.keys(byId).length > 0 ? { byId } : {}), + }; + return hasRules(rules) ? rules : undefined; +} + +function createStraightPaths(document: DrillDocument): DrillPath[] { + const positionKeys = new Set( + document.positions.map( + (position) => `${position.entityId}|${position.setId}`, + ), + ); + const paths: DrillPath[] = []; + for (const entity of document.entities) { + for ( + let fromSetId = 0; + fromSetId < document.sets.length - 1; + fromSetId += 1 + ) { + const toSetId = fromSetId + 1; + if ( + positionKeys.has(`${entity.id}|${fromSetId}`) && + positionKeys.has(`${entity.id}|${toSetId}`) + ) { + paths.push({ + entityId: entity.id, + fromSetId, + toSetId, + kind: "straight", + }); + } + } + } + return paths; +} + +function optionalText( + key: Key, + value: string, +): Record | Record { + const trimmed = value.trim(); + return trimmed ? ({ [key]: trimmed } as Record) : {}; +} + +function hasRules(rules: EntityRules): boolean { + return Boolean( + Object.keys(rules.bySymbol ?? {}).length || + Object.keys(rules.byLabel ?? {}).length || + Object.keys(rules.byId ?? {}).length, + ); +} diff --git a/apps/drill-converter/src/converter/use-converter-controller.ts b/apps/drill-converter/src/converter/use-converter-controller.ts new file mode 100644 index 00000000..25aafff9 --- /dev/null +++ b/apps/drill-converter/src/converter/use-converter-controller.ts @@ -0,0 +1,336 @@ +import React from "react"; +import * as DocumentPicker from "expo-document-picker"; +import { + importCoordinateSheetPages, + type CoordinateSheetImportResult, + type ExtractedPdfPage, +} from "@eight2five/drill-importers"; +import { + serializeDrillDocument, + type DrillDocument, +} from "@eight2five/drill-schema"; + +import { downloadTextFile } from "./download.web"; +import { + applyConverterSettings, + createDefaultConverterSettings, + createEmptyRuleDraft, + downloadFileName, + getDocumentSummary, + inferTitleFromFileName, + validateConverterSettings, + type ConverterSettings, + type EntityIdentityOverride, + type EntityRuleDraft, +} from "./settings"; +import { extractPdfText, getPdfJsVersion } from "../pdf/pdf-text-extractor.web"; + +export type ConverterPhase = "idle" | "extracting" | "ready" | "error"; + +let nextRuleId = 1; + +export function useConverterController() { + const [settings, setSettings] = React.useState( + createDefaultConverterSettings, + ); + const [asset, setAsset] = + React.useState(); + const [extractedPages, setExtractedPages] = React.useState< + readonly ExtractedPdfPage[] + >([]); + const [createdAt, setCreatedAt] = React.useState(() => + new Date().toISOString(), + ); + const [phase, setPhase] = React.useState("idle"); + const [extractionError, setExtractionError] = React.useState(); + + const settingsValidation = React.useMemo( + () => validateConverterSettings(settings), + [settings], + ); + + const importResult = React.useMemo< + CoordinateSheetImportResult | undefined + >(() => { + if (extractedPages.length === 0 || !settingsValidation.field) + return undefined; + return importCoordinateSheetPages(extractedPages, { + title: settings.title.trim() || "Imported Drill", + ...(asset?.name ? { fileName: asset.name } : {}), + createdAt, + field: settingsValidation.field, + }); + }, [ + asset, + createdAt, + extractedPages, + settings.title, + settingsValidation.field, + ]); + + const outputDocument = React.useMemo(() => { + if (!importResult?.document || settingsValidation.errors.length > 0) { + return undefined; + } + try { + return applyConverterSettings( + importResult.document, + settings, + settingsValidation, + ); + } catch { + return undefined; + } + }, [importResult, settings, settingsValidation]); + + const summary = React.useMemo( + () => (outputDocument ? getDocumentSummary(outputDocument) : undefined), + [outputDocument], + ); + + const availableSymbols = React.useMemo( + () => + Array.from( + new Set([ + ...(importResult?.sheets ?? []) + .map((sheet) => sheet.sourceSymbol.trim()) + .filter(Boolean), + ...(outputDocument?.entities ?? []) + .map((entity) => entity.symbol.trim()) + .filter(Boolean), + ]), + ).sort((left, right) => left.localeCompare(right)), + [importResult?.sheets, outputDocument?.entities], + ); + + const pickPdf = React.useCallback(async () => { + const result = await DocumentPicker.getDocumentAsync({ + type: "application/pdf", + multiple: false, + copyToCacheDirectory: false, + }); + if (result.canceled || !result.assets[0]) return; + + const nextAsset = result.assets[0]; + setPhase("extracting"); + setExtractionError(undefined); + setAsset(nextAsset); + setExtractedPages([]); + const nextCreatedAt = new Date().toISOString(); + setCreatedAt(nextCreatedAt); + setSettings((current) => ({ + ...current, + title: inferTitleFromFileName(nextAsset.name), + rules: [], + entityOverrides: [], + setOverrides: [], + })); + + try { + const pages = await extractPdfText(nextAsset); + if ( + pages.length === 0 || + pages.every((page) => page.items.length === 0) + ) { + throw new Error( + "The PDF contains no extractable text. Scanned/image-only coordinate sheets are not currently supported.", + ); + } + setExtractedPages(pages); + setPhase("ready"); + } catch (cause) { + setPhase("error"); + setExtractionError( + cause instanceof Error ? cause.message : "PDF text extraction failed.", + ); + } + }, []); + + const clearPdf = React.useCallback(() => { + setAsset(undefined); + setExtractedPages([]); + setExtractionError(undefined); + setPhase("idle"); + setSettings((current) => ({ + ...current, + title: "", + rules: [], + entityOverrides: [], + setOverrides: [], + })); + }, []); + + const updateSettings = React.useCallback( + (patch: Partial) => + setSettings((current) => ({ ...current, ...patch })), + [], + ); + + const addRule = React.useCallback( + (target: EntityRuleDraft["target"] = "symbol") => { + setSettings((current) => ({ + ...current, + rules: [ + ...current.rules, + { ...createEmptyRuleDraft(`rule-${nextRuleId++}`), target }, + ], + })); + }, + [], + ); + + const addLabelOverride = React.useCallback((label: string) => { + const key = label.trim(); + if (!key) return; + setSettings((current) => { + const existing = current.rules.find( + (rule) => rule.target === "label" && rule.key.trim() === key, + ); + if (existing) { + return { + ...current, + rules: [ + existing, + ...current.rules.filter((rule) => rule.id !== existing.id), + ], + }; + } + return { + ...current, + rules: [ + { + ...createEmptyRuleDraft(`rule-${nextRuleId++}`), + target: "label", + key, + }, + ...current.rules, + ], + }; + }); + }, []); + + const updateRule = React.useCallback( + (id: string, patch: Partial>) => { + setSettings((current) => ({ + ...current, + rules: current.rules.map((rule) => { + if (rule.id !== id) return rule; + const nextRule = { ...rule, ...patch }; + return nextRule.entityType === "prop" + ? { ...nextRule, section: "", instrument: "" } + : nextRule; + }), + })); + }, + [], + ); + + const removeRule = React.useCallback((id: string) => { + setSettings((current) => ({ + ...current, + rules: current.rules.filter((rule) => rule.id !== id), + })); + }, []); + + const updateEntityIdentity = React.useCallback( + (override: EntityIdentityOverride): string | undefined => { + if (!importResult?.document) + return "Parse a PDF before editing entities."; + if ( + !importResult.document.entities.some( + (entity) => entity.id === override.id, + ) + ) { + return `Unknown entity id ${override.id}.`; + } + + const nextSettings: ConverterSettings = { + ...settings, + entityOverrides: [ + ...settings.entityOverrides.filter( + (entity) => entity.id !== override.id, + ), + { + ...override, + label: override.label.trim(), + symbol: override.symbol.trim(), + }, + ], + }; + const validation = validateConverterSettings(nextSettings); + try { + applyConverterSettings(importResult.document, nextSettings, validation); + } catch (cause) { + return cause instanceof Error + ? cause.message + : "The entity edit is invalid."; + } + setSettings(nextSettings); + return undefined; + }, + [importResult, settings], + ); + + const updateSet = React.useCallback( + (setOverride: DrillDocument["sets"][number]): string | undefined => { + if (!importResult?.document) return "Parse a PDF before editing sets."; + if ( + !importResult.document.sets.some((set) => set.id === setOverride.id) + ) { + return `Unknown set id ${setOverride.id}.`; + } + + const nextSettings: ConverterSettings = { + ...settings, + setOverrides: [ + ...settings.setOverrides.filter((set) => set.id !== setOverride.id), + setOverride, + ], + }; + const validation = validateConverterSettings(nextSettings); + try { + applyConverterSettings(importResult.document, nextSettings, validation); + } catch (cause) { + return cause instanceof Error + ? cause.message + : "The set edit is invalid."; + } + setSettings(nextSettings); + return undefined; + }, + [importResult, settings], + ); + + const download = React.useCallback(() => { + if (!outputDocument) return; + downloadTextFile( + serializeDrillDocument(outputDocument), + downloadFileName(outputDocument.metadata.title), + ); + }, [outputDocument]); + + return { + phase, + asset, + extractedPages, + extractionError, + settings, + settingsErrors: settingsValidation.errors, + importResult, + outputDocument, + summary, + availableSymbols, + pdfJsVersion: getPdfJsVersion(), + canDownload: Boolean(outputDocument), + pickPdf, + clearPdf, + updateSettings, + addRule, + addLabelOverride, + updateRule, + removeRule, + updateEntityIdentity, + updateSet, + download, + }; +} diff --git a/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts new file mode 100644 index 00000000..7352bb72 --- /dev/null +++ b/apps/drill-converter/src/pdf/pdf-text-extractor.web.ts @@ -0,0 +1,137 @@ +import type { DocumentPickerAsset } from "expo-document-picker"; +import type { + ExtractedPdfPage, + ExtractedPdfTextItem, +} from "@eight2five/drill-importers"; + +const PDFJS_VERSION = "6.2.108"; +const PDFJS_MODULE_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.min.mjs`; +const PDFJS_WORKER_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.worker.min.mjs`; + +interface PdfJsTextItem { + readonly str: string; + readonly transform: readonly number[]; + readonly width?: number; + readonly height?: number; +} + +interface PdfJsTextContent { + readonly items: readonly (PdfJsTextItem | Record)[]; +} + +interface PdfJsPage { + readonly view: readonly number[]; + getTextContent(): Promise; +} + +interface PdfJsDocument { + readonly numPages: number; + getPage(pageNumber: number): Promise; +} + +interface PdfJsLoadingTask { + readonly promise: Promise; + destroy(): Promise; +} + +interface PdfJsModule { + readonly GlobalWorkerOptions: { workerSrc: string }; + getDocument(source: { data: Uint8Array }): PdfJsLoadingTask; +} + +let pdfJsPromise: Promise | undefined; + +/** + * Load PDF.js only in the browser. The pinned library bundle is fetched from + * jsDelivr, while the selected PDF bytes remain local to the user's browser. + */ +async function loadPdfJs(): Promise { + if (typeof window === "undefined") { + throw new Error("PDF extraction is available only in the browser."); + } + if (!pdfJsPromise) { + pdfJsPromise = (async () => { + // Metro cannot statically bundle an https: ESM import. Using the native + // browser importer keeps the converter package dependency-free while + // still pinning PDF.js to an exact version. + const importModule = new Function("url", "return import(url);") as ( + url: string, + ) => Promise; + const pdfjs = await importModule(PDFJS_MODULE_URL); + pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; + return pdfjs; + })(); + } + return await pdfJsPromise; +} + +export async function extractPdfText( + asset: DocumentPickerAsset, +): Promise { + const bytes = new Uint8Array(await readAssetBytes(asset)); + const pdfjs = await loadPdfJs(); + const loadingTask = pdfjs.getDocument({ data: bytes }); + const document = await loadingTask.promise; + try { + const pages: ExtractedPdfPage[] = []; + for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) { + const page = await document.getPage(pageNumber); + const textContent = await page.getTextContent(); + const items = textContent.items + .filter(isTextItem) + .map(toExtractedTextItem) + .filter((item) => item.text.trim().length > 0); + pages.push({ + pageNumber, + width: Math.abs((page.view[2] ?? 0) - (page.view[0] ?? 0)), + height: Math.abs((page.view[3] ?? 0) - (page.view[1] ?? 0)), + items, + }); + } + return pages; + } finally { + await loadingTask.destroy(); + } +} + +export function getPdfJsVersion(): string { + return PDFJS_VERSION; +} + +async function readAssetBytes( + asset: DocumentPickerAsset, +): Promise { + if (asset.file) return await asset.file.arrayBuffer(); + const response = await fetch(asset.uri); + if (!response.ok) { + throw new Error(`Unable to read ${asset.name}: ${response.statusText}.`); + } + return await response.arrayBuffer(); +} + +function isTextItem( + value: PdfJsTextItem | Record, +): value is PdfJsTextItem { + return ( + typeof (value as { str?: unknown }).str === "string" && + Array.isArray((value as { transform?: unknown }).transform) + ); +} + +function toExtractedTextItem(item: PdfJsTextItem): ExtractedPdfTextItem { + return { + text: item.str, + x: finiteCoordinate(item.transform[4]), + y: finiteCoordinate(item.transform[5]), + ...(typeof item.width === "number" && Number.isFinite(item.width) + ? { width: item.width } + : {}), + ...(typeof item.height === "number" && Number.isFinite(item.height) + ? { height: item.height } + : {}), + }; +} + +function finiteCoordinate(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} diff --git a/apps/drill-converter/src/ui/form-controls.tsx b/apps/drill-converter/src/ui/form-controls.tsx new file mode 100644 index 00000000..c5a85ef1 --- /dev/null +++ b/apps/drill-converter/src/ui/form-controls.tsx @@ -0,0 +1,384 @@ +import React from "react"; +import { + Pressable, + Text, + TextInput, + View, + type TextInputProps, +} from "react-native"; + +import { colors, radius, spacing } from "./theme"; + +export function FormField({ + label, + helper, + value, + onChangeText, + multiline = false, + placeholder, + error, + ...props +}: { + readonly label: string; + readonly helper?: string; + readonly value: string; + readonly onChangeText: (value: string) => void; + readonly multiline?: boolean; + readonly placeholder?: string; + readonly error?: string; +} & Omit< + TextInputProps, + "value" | "onChangeText" | "multiline" | "placeholder" +>) { + return ( + + + {label} + + + {helper ? ( + + {helper} + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +export function ChoiceChips({ + label, + value, + options, + onChange, +}: { + readonly label?: string; + readonly value: Value; + readonly options: readonly { + readonly value: Value; + readonly label: string; + }[]; + readonly onChange: (value: Value) => void; +}) { + return ( + + {label ? ( + + {label} + + ) : null} + + {options.map((option) => { + const selected = option.value === value; + return ( + onChange(option.value)} + accessibilityRole="radio" + accessibilityState={{ checked: selected }} + style={({ pressed }) => ({ + minHeight: 40, + justifyContent: "center", + borderWidth: 1, + borderColor: selected ? colors.accent : colors.borderStrong, + borderRadius: 999, + paddingHorizontal: 14, + backgroundColor: selected + ? colors.accentSoft + : pressed + ? colors.surfaceMuted + : colors.surface, + })} + > + + {option.label} + + + ); + })} + + + ); +} + +export function ToggleRow({ + title, + description, + value, + onChange, +}: { + readonly title: string; + readonly description?: string; + readonly value: boolean; + readonly onChange: (value: boolean) => void; +}) { + return ( + onChange(!value)} + accessibilityRole="checkbox" + accessibilityState={{ checked: value }} + style={({ pressed }) => ({ + flexDirection: "row", + alignItems: "flex-start", + gap: spacing.md, + paddingVertical: spacing.sm, + opacity: pressed ? 0.78 : 1, + })} + > + + {value ? ( + + ✓ + + ) : null} + + + + {title} + + {description ? ( + + {description} + + ) : null} + + + ); +} + +export function SectionCard({ + title, + description, + children, +}: { + readonly title: string; + readonly description?: string; + readonly children: React.ReactNode; +}) { + return ( + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {children} + + ); +} + +export function Disclosure({ + title, + description, + initiallyOpen = false, + children, +}: { + readonly title: string; + readonly description?: string; + readonly initiallyOpen?: boolean; + readonly children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(initiallyOpen); + return ( + + setOpen((value) => !value)} + accessibilityRole="button" + accessibilityState={{ expanded: open }} + style={({ pressed }) => ({ + padding: spacing.lg, + flexDirection: "row", + alignItems: "center", + gap: spacing.md, + backgroundColor: pressed ? colors.surfaceMuted : colors.surface, + })} + > + + + {title} + + {description ? ( + + {description} + + ) : null} + + + {open ? "−" : "+"} + + + {open ? ( + + {children} + + ) : null} + + ); +} + +export function PrimaryButton({ + label, + onPress, + disabled = false, +}: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; +}) { + return ( + ({ + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + paddingHorizontal: 16, + backgroundColor: disabled + ? "#aab8cf" + : pressed + ? "#315ea9" + : colors.accent, + })} + > + + {label} + + + ); +} + +export function SecondaryButton({ + label, + onPress, + disabled = false, + danger = false, +}: { + readonly label: string; + readonly onPress: () => void; + readonly disabled?: boolean; + readonly danger?: boolean; +}) { + return ( + ({ + minHeight: 42, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.sm, + borderWidth: 1, + borderColor: danger ? "#f1aaa4" : colors.borderStrong, + paddingHorizontal: 14, + backgroundColor: pressed ? colors.surfaceMuted : colors.surface, + opacity: disabled ? 0.5 : 1, + })} + > + + {label} + + + ); +} diff --git a/apps/drill-converter/src/ui/theme.ts b/apps/drill-converter/src/ui/theme.ts new file mode 100644 index 00000000..a29236f1 --- /dev/null +++ b/apps/drill-converter/src/ui/theme.ts @@ -0,0 +1,36 @@ +import { COLOR_PRESETS } from "@eight2five/drill-schema"; + +export const colors = Object.freeze({ + page: "#f4f6fa", + surface: "#ffffff", + surfaceMuted: "#f8fafc", + text: "#172033", + textMuted: "#64748b", + border: "#d9e0ea", + borderStrong: "#b8c4d4", + accent: COLOR_PRESETS.blue, + accentSoft: "#edf3ff", + accentText: "#234b92", + success: "#16794a", + successSoft: "#e9f8ef", + warning: "#9a6200", + warningSoft: "#fff6df", + danger: "#b42318", + dangerSoft: "#fff0ef", + code: "#0f172a", +}); + +export const spacing = Object.freeze({ + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +}); + +export const radius = Object.freeze({ + sm: 8, + md: 12, + lg: 18, +}); diff --git a/apps/drill-converter/tsconfig.json b/apps/drill-converter/tsconfig.json new file mode 100644 index 00000000..ede53e2c --- /dev/null +++ b/apps/drill-converter/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["react", "jest"] + }, + "include": [ + "app/**/*.ts", + "app/**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts", + "uniwind-types.d.ts" + ] +} diff --git a/apps/drill-converter/uniwind-types.d.ts b/apps/drill-converter/uniwind-types.d.ts new file mode 100644 index 00000000..2cc862f1 --- /dev/null +++ b/apps/drill-converter/uniwind-types.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 12324659..51bd74ba 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -12,18 +12,45 @@ const androidVersionCode = Number.isSafeInteger(requestedVersionCode) && requestedVersionCode > 0 ? requestedVersionCode : 1; +const requestedIosBuildNumber = Number( + process.env.E2F_IOS_BUILD_NUMBER ?? process.env.GITHUB_RUN_NUMBER ?? 1, +); +const iosBuildNumber = + Number.isSafeInteger(requestedIosBuildNumber) && requestedIosBuildNumber > 0 + ? String(requestedIosBuildNumber) + : "1"; + +const appVariant = process.env.APP_VARIANT; +const isDevelopment = appVariant === "development"; +const isPreview = appVariant === "preview"; + +const appName = isDevelopment + ? "Eight2Five (Development)" + : isPreview + ? "Eight2Five (Preview)" + : "Eight2Five"; + +const appIdentifier = isDevelopment + ? "com.eight2five.app.development" + : isPreview + ? "com.eight2five.app.preview" + : "com.eight2five.app"; const config: ExpoConfig = { owner: "cdguth", - name: "Eight2Five", + name: appName, slug: "eight2five", + scheme: "eight2five", platforms: ["ios", "android"], - version: "0.0.0", - orientation: "portrait", + version: "0.1.0", + // Field is the only route that opts into landscape; Drill and Settings + // apply portrait locks through their nested native stacks. + orientation: "default", icon: "./assets/app-icons/mobile-android-legacy-icon.png", userInterfaceStyle: "automatic", ios: { - bundleIdentifier: "com.eight2five.app", + bundleIdentifier: appIdentifier, + buildNumber: iosBuildNumber, supportsTablet: false, icon: { light: "./assets/app-icons/mobile-ios-icon.png", @@ -32,7 +59,7 @@ const config: ExpoConfig = { }, }, android: { - package: "com.eight2five.app", + package: appIdentifier, versionCode: androidVersionCode, icon: "./assets/app-icons/mobile-android-legacy-icon.png", adaptiveIcon: { @@ -46,6 +73,15 @@ const config: ExpoConfig = { }, plugins: [ "expo-router", + [ + "expo-build-properties", + { + buildReactNativeFromSource: false, + ios: { + ccacheEnabled: true, + }, + }, + ], [ "expo-splash-screen", { @@ -67,6 +103,13 @@ const config: ExpoConfig = { }, }, ], + [ + "expo-sensors", + { + motionPermission: + "Allow $(PRODUCT_NAME) to use device motion for brief live-position prediction.", + }, + ], [ "../../modules/expo-pans-ble-api/app.plugin.js", { diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx new file mode 100644 index 00000000..5ba86925 --- /dev/null +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -0,0 +1,34 @@ +import { NativeTabs } from "expo-router/unstable-native-tabs"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { MOBILE_TABS } from "../../src/navigation/mobile-tabs"; +import { useTabBarVisibility } from "../../src/navigation/tab-bar-visibility-context"; + +export default function MobileTabsLayout() { + const theme = useEight2FiveTheme(); + const { drillFeaturesEnabled, nativeTabBarHidden, nativeTabsRevision } = + useTabBarVisibility(); + + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/drill/_layout.tsx b/apps/mobile/app/(tabs)/drill/_layout.tsx new file mode 100644 index 00000000..47e9fd5f --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/_layout.tsx @@ -0,0 +1,35 @@ +import { Redirect, Stack } from "expo-router"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { useAppSettingsSnapshot } from "../../../src/state/app-settings-store"; +import { getDrillRouteAccess } from "../../../src/features/drill/drill-route-access"; + +export default function DrillLayout() { + const theme = useEight2FiveTheme(); + const { status, settings } = useAppSettingsSnapshot(); + const access = getDrillRouteAccess(status, settings.drillFeaturesEnabled); + + // Keep disabled drill routes inaccessible even when opened from a stale link. + if (access === "loading") return null; + if (access === "redirect") { + return ; + } + + return ( + + + + ); +} diff --git a/apps/mobile/app/(tabs)/drill/index.tsx b/apps/mobile/app/(tabs)/drill/index.tsx new file mode 100644 index 00000000..6fbbb336 --- /dev/null +++ b/apps/mobile/app/(tabs)/drill/index.tsx @@ -0,0 +1,5 @@ +import { DrillListScreen } from "../../../src/features/drill/drill-list-screen"; + +export default function DrillRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/field/_layout.tsx b/apps/mobile/app/(tabs)/field/_layout.tsx new file mode 100644 index 00000000..64feb7c4 --- /dev/null +++ b/apps/mobile/app/(tabs)/field/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from "expo-router"; + +export default function FieldLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/field/index.tsx b/apps/mobile/app/(tabs)/field/index.tsx new file mode 100644 index 00000000..7ffd7ff5 --- /dev/null +++ b/apps/mobile/app/(tabs)/field/index.tsx @@ -0,0 +1,15 @@ +import { FieldScreen } from "../../../src/features/field/field-screen"; +import { useFieldAnchorOverlay } from "../../../src/features/field/use-field-anchor-overlay"; +import { useFieldLivePosition } from "../../../src/pans/mobile-pans-context"; + +export default function FieldRoute() { + const livePosition = useFieldLivePosition(); + const anchorOverlay = useFieldAnchorOverlay(); + return ( + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/_layout.tsx b/apps/mobile/app/(tabs)/settings/_layout.tsx new file mode 100644 index 00000000..4a48cb66 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/_layout.tsx @@ -0,0 +1,37 @@ +import { Stack } from "expo-router"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; + +export default function SettingsLayout() { + const theme = useEight2FiveTheme(); + + return ( + + + + + + + + + + + ); +} diff --git a/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx new file mode 100644 index 00000000..25623d63 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/anchor/[anchorId].tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; + +import { AnchorEditorScreen } from "../../../../src/features/settings/anchor-editor-screen"; + +export default function AnchorRoute() { + const { anchorId } = useLocalSearchParams<{ anchorId: string }>(); + + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/anchors.tsx b/apps/mobile/app/(tabs)/settings/anchors.tsx new file mode 100644 index 00000000..17cf5a26 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/anchors.tsx @@ -0,0 +1,5 @@ +import { AnchorListScreen } from "../../../src/features/settings/anchor-list-screen"; + +export default function AnchorsRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx new file mode 100644 index 00000000..e66d8ead --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/developer-confirmation.tsx @@ -0,0 +1,6 @@ +import { Redirect } from "expo-router"; + +/** Legacy route kept only so stale deep links land on the current settings UI. */ +export default function DeveloperConfirmationRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/developer.tsx b/apps/mobile/app/(tabs)/settings/developer.tsx new file mode 100644 index 00000000..ff80c9dd --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/developer.tsx @@ -0,0 +1,5 @@ +import { DeveloperSettingsScreen } from "../../../src/features/settings/developer-settings-screen"; + +export default function DeveloperSettingsRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/index.tsx b/apps/mobile/app/(tabs)/settings/index.tsx new file mode 100644 index 00000000..9d279423 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/index.tsx @@ -0,0 +1,5 @@ +import { SettingsScreen } from "../../../src/features/settings/settings-screen"; + +export default function SettingsRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/network/[networkId].tsx b/apps/mobile/app/(tabs)/settings/network/[networkId].tsx new file mode 100644 index 00000000..e6dbc9db --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/network/[networkId].tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; + +import { NetworkDetailScreen } from "../../../../src/features/settings/network-detail-screen"; + +export default function NetworkDetailRoute() { + const { networkId } = useLocalSearchParams<{ networkId: string }>(); + + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx b/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx new file mode 100644 index 00000000..3a0a26ab --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/network/[networkId]/anchors.tsx @@ -0,0 +1,9 @@ +import { useLocalSearchParams } from "expo-router"; + +import { NetworkDetailScreen } from "../../../../../src/features/settings/network-detail-screen"; + +export default function NetworkAnchorsRoute() { + const { networkId } = useLocalSearchParams<{ networkId: string }>(); + + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/networks.tsx b/apps/mobile/app/(tabs)/settings/networks.tsx new file mode 100644 index 00000000..2a48874c --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/networks.tsx @@ -0,0 +1,5 @@ +import { NetworksScreen } from "../../../src/features/settings/networks-screen"; + +export default function NetworksRoute() { + return ; +} diff --git a/apps/mobile/app/(tabs)/settings/tag.tsx b/apps/mobile/app/(tabs)/settings/tag.tsx new file mode 100644 index 00000000..f4474767 --- /dev/null +++ b/apps/mobile/app/(tabs)/settings/tag.tsx @@ -0,0 +1,5 @@ +import { TagConnectionScreen } from "../../../src/features/settings/tag-connection-screen"; + +export default function TagConnectionRoute() { + return ; +} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index b08450ba..df25ba90 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,13 +1,25 @@ import React from "react"; -import { Stack } from "expo-router"; +import { DarkTheme, DefaultTheme, Stack, ThemeProvider } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; +import { StatusBar } from "expo-status-bar"; +import { SafeAreaProvider } from "react-native-safe-area-context"; +import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GluestackUIProvider } from "@eight2five/ui/components/gluestack-ui-provider"; import { - eight2FiveFonts, + Eight2FiveThemeProvider, useEight2FiveFonts, useEight2FiveTheme, + useEight2FiveThemeName, } from "@eight2five/ui/theme"; +import { TabBarVisibilityProvider } from "../src/navigation/tab-bar-visibility-context"; +import { useMobileOrientationLock } from "../src/navigation/use-mobile-orientation-lock"; +import { + AppSettingsProvider, + useAppSettingsSnapshot, +} from "../src/state/app-settings-store"; +import { MobilePansProvider } from "../src/pans/mobile-pans-context"; + import "../global.css"; SplashScreen.setOptions({ @@ -17,7 +29,6 @@ void SplashScreen.preventAutoHideAsync(); export default function MobileRootLayout() { const [fontsLoaded, fontError] = useEight2FiveFonts(); - const theme = useEight2FiveTheme(); React.useEffect(() => { if (fontsLoaded || fontError) void SplashScreen.hideAsync(); @@ -26,25 +37,65 @@ export default function MobileRootLayout() { if (!fontsLoaded && !fontError) return null; return ( - - + + + {/* Keep PANS above the UI portal host so modal content retains PANS context. */} + + + + + + + + + ); +} + +function MobilePansWithSettings({ children }: { children: React.ReactNode }) { + const { status, settings } = useAppSettingsSnapshot(); + return ( + + {children} + + ); +} + +function MobileAppearance({ children }: { children: React.ReactNode }) { + const { settings } = useAppSettingsSnapshot(); + return ( + + + {children} + + + ); +} + +function MobileNavigation() { + useMobileOrientationLock(); + const { settings } = useAppSettingsSnapshot(); + const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); + + return ( + + - - - + + + ); } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 8c5b48c5..cc483ae8 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,41 +1,5 @@ -import { StatusBar } from "expo-status-bar"; -import { Text, View } from "react-native"; -import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; +import { Redirect } from "expo-router"; export default function MobileHomeRoute() { - const theme = useEight2FiveTheme(); - - return ( - - - Eight2Five - - - Marching band positioning tools - - - - ); + return ; } diff --git a/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png b/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png index 8d0ccaec..d367765d 100644 Binary files a/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png and b/apps/mobile/assets/app-icons/mobile-android-adaptive-foreground.png differ diff --git a/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png b/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png index c6681263..efcf5a62 100644 Binary files a/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png and b/apps/mobile/assets/app-icons/mobile-android-adaptive-monochrome.png differ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index f8e38578..3f226de6 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -7,18 +7,24 @@ "development": { "developmentClient": true, "distribution": "internal", + "env": { + "APP_VARIANT": "development" + }, "ios": { "resourceClass": "m-medium" }, "android": { "buildType": "apk", "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false" + "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" } } }, "development-simulator": { "developmentClient": true, + "env": { + "APP_VARIANT": "development" + }, "ios": { "simulator": true, "resourceClass": "m-medium" @@ -26,29 +32,38 @@ }, "preview": { "distribution": "internal", + "env": { + "APP_VARIANT": "preview" + }, "ios": { "resourceClass": "m-medium" }, "android": { "buildType": "apk", "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false" + "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" } } }, "preview-simulator": { + "env": { + "APP_VARIANT": "preview" + }, "ios": { "simulator": true, "resourceClass": "m-medium" } }, "production": { + "env": { + "APP_VARIANT": "production" + }, "ios": { "resourceClass": "m-medium" }, "android": { "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false" + "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" } } } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c7a69be1..a9a83be7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -1,6 +1,6 @@ { "name": "eight2five-mobile", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "expo-router/entry", "scripts": { @@ -20,9 +20,10 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "expo": "~57.0.9", + "expo": "~57.0.10", + "expo-blur": "~57.0.2", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" @@ -54,6 +55,10 @@ "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" ], + "moduleNameMapper": { + "^@eight2five/drill-schema$": "/../../packages/drill-schema/src/index.ts", + "^@eight2five/drill-schema/(.*)$": "/../../packages/drill-schema/src/$1" + }, "collectCoverage": true, "collectCoverageFrom": [ "src/**/*.{ts,tsx}", diff --git a/apps/mobile/src/components/spinning-loader-icon.tsx b/apps/mobile/src/components/spinning-loader-icon.tsx new file mode 100644 index 00000000..f9b63124 --- /dev/null +++ b/apps/mobile/src/components/spinning-loader-icon.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Animated, Easing } from "react-native"; +import { LoaderCircle } from "lucide-react-native"; +import { Icon } from "@eight2five/ui/components/icon"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +export function SpinningLoaderIcon({ + color, + size = "md", +}: { + readonly color?: string; + readonly size?: React.ComponentProps["size"]; +}) { + const theme = useEight2FiveTheme(); + const resolvedColor = color ?? theme.raw.white; + const [spin] = React.useState(() => new Animated.Value(0)); + + React.useEffect(() => { + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [spin]); + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts new file mode 100644 index 00000000..c8276fcf --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-icons.test.ts @@ -0,0 +1,17 @@ +import { + DRILL_ICON_REGISTRY, + FALLBACK_DRILL_ICON, + isSupportedDrillIcon, + resolveDrillIcon, +} from "../drill-icons"; + +describe("drill card icon registry", () => { + test("resolves only controlled icon names and falls back for unknown names", () => { + expect(resolveDrillIcon("music-2")).toBe(DRILL_ICON_REGISTRY["music-2"]); + expect(resolveDrillIcon("made-up-icon")).toBe(FALLBACK_DRILL_ICON); + expect(resolveDrillIcon(undefined)).toBe(FALLBACK_DRILL_ICON); + expect(isSupportedDrillIcon("sparkle")).toBe(true); + expect(isSupportedDrillIcon("sparkles")).toBe(true); + expect(isSupportedDrillIcon("made-up-icon")).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/drill/__tests__/drill-import.test.ts b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts new file mode 100644 index 00000000..305beb48 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-import.test.ts @@ -0,0 +1,303 @@ +import type { DrillRepository } from "@eight2five/mobile/drill"; +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + getFieldPreset, + type DrillDocument, +} from "@eight2five/drill-schema"; + +import { + getPerformerSymbolGroups, + importEight2FiveDrillDocument, + importEight2FiveDrillJson, + isEight2FiveDrillFileName, + parseDrillPickerResult, + parseImportableDrillJson, +} from "../drill-import"; + +const VALID_DOCUMENT: DrillDocument = { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Part 4 Finale", + createdAt: "2026-08-03T18:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entities: [ + { + id: 42, + type: "performer", + symbol: "B", + label: "B1", + }, + ], + sets: [ + { + id: 0, + number: 1, + kind: "set", + countsFromPrevious: 0, + }, + { + id: 1, + number: 2, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 12, end: 15 }, + }, + ], + positions: [ + { entityId: 42, setId: 0, xSteps: -8, ySteps: 0 }, + { + entityId: 42, + setId: 1, + xSteps: 4, + ySteps: 32, + facingDegrees: 90, + }, + ], +}; + +const MULTI_ENTITY_DOCUMENT: DrillDocument = { + ...VALID_DOCUMENT, + entities: [ + { id: 42, type: "performer", symbol: "B", label: "B1" }, + { id: 43, type: "performer", symbol: "B", label: "B2" }, + { id: 44, type: "performer", symbol: "T", label: "T1" }, + { id: 99, type: "prop", symbol: "P", label: "P1" }, + ], + positions: [ + ...VALID_DOCUMENT.positions, + { entityId: 43, setId: 0, xSteps: 10, ySteps: 12 }, + { entityId: 43, setId: 1, xSteps: 14, ySteps: 20 }, + { entityId: 44, setId: 0, xSteps: -4, ySteps: 8 }, + { entityId: 44, setId: 1, xSteps: -2, ySteps: 10 }, + { entityId: 99, setId: 0, xSteps: 0, ySteps: 16 }, + { entityId: 99, setId: 1, xSteps: 0, ySteps: 18 }, + ], +}; + +function createRepository() { + const created = { + id: "drill-1", + name: "Part 4 Finale", + fieldPreset: "football-nfhs" as const, + createdAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), + updatedAt: Date.parse(VALID_DOCUMENT.metadata.createdAt), + }; + const repository = { + createImportedDrill: jest.fn(async () => created), + } as unknown as DrillRepository; + return { created, repository }; +} + +describe("Eight2Five drill import", () => { + test("treats a canceled native picker result as a no-op", async () => { + const readText = jest.fn(async () => JSON.stringify(VALID_DOCUMENT)); + + await expect( + parseDrillPickerResult( + { canceled: true, assets: null } as const, + readText, + ), + ).resolves.toBeUndefined(); + expect(readText).not.toHaveBeenCalled(); + }); + + test("preserves picker file rules while reading the selected asset", async () => { + const readText = jest.fn(async (uri: string) => { + expect(uri).toBe("file:///cache/finale.json"); + return JSON.stringify(VALID_DOCUMENT); + }); + + await expect( + parseDrillPickerResult( + { + canceled: false, + assets: [ + { + name: "finale.eight2five.json", + size: 128, + uri: "file:///cache/finale.json", + mimeType: "application/json", + lastModified: 0, + }, + ], + }, + readText, + ), + ).resolves.toEqual({ + document: VALID_DOCUMENT, + fileName: "finale.eight2five.json", + }); + }); + + test("recognizes the converter drill file extension", () => { + expect(isEight2FiveDrillFileName("finale.eight2five.json")).toBe(true); + expect(isEight2FiveDrillFileName("EIGHT2FIVE.JSON")).toBe(true); + expect(isEight2FiveDrillFileName("finale.json")).toBe(false); + }); + + test("validates and imports a single-performer portable drill", async () => { + const { created, repository } = createRepository(); + + await expect( + importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), + ).resolves.toBe(created); + + expect(repository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: VALID_DOCUMENT, + selectedPerformerEntityId: 42, + }); + }); + + test("accepts multi-performer files with props and groups selectable performers by symbol", () => { + const parsed = parseImportableDrillJson( + JSON.stringify(MULTI_ENTITY_DOCUMENT), + ); + + expect(getPerformerSymbolGroups(parsed)).toEqual([ + { + symbol: "B", + performers: [ + expect.objectContaining({ id: 42, label: "B1" }), + expect.objectContaining({ id: 43, label: "B2" }), + ], + }, + { + symbol: "T", + performers: [expect.objectContaining({ id: 44, label: "T1" })], + }, + ]); + }); + + test("passes the selected performer to atomic imported-drill creation", async () => { + const { repository } = createRepository(); + + await importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT, 43); + + expect(repository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: MULTI_ENTITY_DOCUMENT, + selectedPerformerEntityId: 43, + }); + }); + + test("requires an explicit performer selection when a file has multiple performers", async () => { + const { repository } = createRepository(); + + await expect( + importEight2FiveDrillDocument(repository, MULTI_ENTITY_DOCUMENT), + ).rejects.toThrow("Select your performer"); + expect(repository.createImportedDrill).not.toHaveBeenCalled(); + }); + + test("accepts polyline and Bézier paths without losing the import", async () => { + const otherEntityCurved: DrillDocument = { + ...MULTI_ENTITY_DOCUMENT, + paths: [ + { + entityId: 99, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 2, ySteps: 2 }], + }, + ], + }; + const firstRepository = createRepository().repository; + await expect( + importEight2FiveDrillDocument(firstRepository, otherEntityCurved, 43), + ).resolves.toBeDefined(); + + const selectedCurved: DrillDocument = { + ...MULTI_ENTITY_DOCUMENT, + paths: [ + { + entityId: 43, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 12, ySteps: 16 }], + }, + ], + }; + const secondRepository = createRepository().repository; + await expect( + importEight2FiveDrillDocument(secondRepository, selectedCurved, 43), + ).resolves.toBeDefined(); + expect(secondRepository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: selectedCurved, + selectedPerformerEntityId: 43, + }); + }); + + test("rejects custom fields and files without any performers", () => { + const customFieldDocument = { + ...VALID_DOCUMENT, + field: { + type: "custom" as const, + name: "Custom", + physicalGeometry: { + bounds: { + minXMeters: 0, + maxXMeters: 10, + minYMeters: 0, + maxYMeters: 10, + }, + referenceLines: [ + { id: "left", name: "Left", axis: "x", coordinateMeters: 0 }, + { id: "right", name: "Right", axis: "x", coordinateMeters: 10 }, + { id: "front", name: "Front", axis: "y", coordinateMeters: 0 }, + { id: "back", name: "Back", axis: "y", coordinateMeters: 10 }, + ], + }, + marchingGrid: { + bounds: { + minXSteps: 0, + maxXSteps: 10, + minYSteps: 0, + maxYSteps: 10, + }, + referenceLines: [ + { id: "left", name: "Left", axis: "x", coordinateSteps: 0 }, + { id: "right", name: "Right", axis: "x", coordinateSteps: 10 }, + { id: "front", name: "Front", axis: "y", coordinateSteps: 0 }, + { id: "back", name: "Back", axis: "y", coordinateSteps: 10 }, + ], + }, + markings: getFieldPreset("football-nfhs").markings, + }, + } satisfies DrillDocument; + expect(() => + parseImportableDrillJson(JSON.stringify(customFieldDocument)), + ).toThrow("Custom field definitions"); + + const propsOnly: DrillDocument = { + ...VALID_DOCUMENT, + entities: [{ id: 99, type: "prop", symbol: "P", label: "P1" }], + positions: [ + { entityId: 99, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 99, setId: 1, xSteps: 0, ySteps: 8 }, + ], + }; + expect(() => parseImportableDrillJson(JSON.stringify(propsOnly))).toThrow( + "does not contain a performer", + ); + }); + + test("propagates atomic import failures without a cleanup fallback", async () => { + const repository = { + createImportedDrill: jest + .fn() + .mockRejectedValueOnce(new Error("database failed")), + } as unknown as DrillRepository; + + await expect( + importEight2FiveDrillJson(repository, JSON.stringify(VALID_DOCUMENT)), + ).rejects.toThrow("database failed"); + expect(repository.createImportedDrill).toHaveBeenCalledWith({ + sourceDocument: VALID_DOCUMENT, + selectedPerformerEntityId: 42, + }); + }); +}); diff --git a/apps/mobile/src/features/drill/__tests__/drill-management.test.ts b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts new file mode 100644 index 00000000..371cdbbf --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-management.test.ts @@ -0,0 +1,85 @@ +import type { DrillRepository } from "@eight2five/mobile/drill"; + +import { + DRILL_NAME_MAX_LENGTH, + deleteDrillAndRefreshSettings, + formatDrillCount, + getDrillCardActionLabels, + loadDrillList, + validateDrillName, +} from "../drill-management"; +import { getDrillTerms } from "@eight2five/mobile/drill"; + +describe("manual drill management", () => { + test("loads deterministic drill rows with page counts and supports empty state", async () => { + const repository = { + listDrills: jest.fn(async () => [ + { id: "a", name: "First", createdAt: 1, updatedAt: 1 }, + { id: "b", name: "Second", createdAt: 2, updatedAt: 2 }, + ]), + listPages: jest.fn(async (drillId: string) => + drillId === "a" + ? [ + { + id: "page-a", + drillId, + ordinal: 0, + label: "1", + countsFromPrevious: 0, + position: { xMeters: 0, yMeters: 0 }, + }, + ] + : [], + ), + } as unknown as DrillRepository; + + await expect(loadDrillList(repository)).resolves.toEqual([ + { drill: expect.objectContaining({ id: "a" }), pageCount: 1 }, + { drill: expect.objectContaining({ id: "b" }), pageCount: 0 }, + ]); + expect(repository.listPages).toHaveBeenCalledWith("a"); + expect(repository.listPages).toHaveBeenCalledWith("b"); + + repository.listDrills = jest.fn(async () => []); + await expect(loadDrillList(repository)).resolves.toEqual([]); + }); + + test("validates names for properties editing", () => { + expect(validateDrillName(" ")).toBe("Enter a drill name."); + expect(validateDrillName("x".repeat(DRILL_NAME_MAX_LENGTH + 1))).toContain( + String(DRILL_NAME_MAX_LENGTH), + ); + }); + + test("formats card counts using the selected terminology", () => { + expect(formatDrillCount(1, getDrillTerms("sets"))).toBe("1 Set"); + expect(formatDrillCount(3, getDrillTerms("pages"))).toBe("3 Pages"); + }); + + test("provides accessible labels for the three card actions", () => { + expect(getDrillCardActionLabels("Finale")).toEqual({ + info: "Info for Finale", + performer: "Select performer for Finale", + activate: "Activate Finale", + deactivate: "Deactivate Finale", + }); + }); + + test("deletes the drill before refreshing cleared selection pointers", async () => { + const order: string[] = []; + const repository = { + deleteDrill: jest.fn(async () => { + order.push("delete"); + }), + } as unknown as DrillRepository; + const reload = jest.fn(async () => { + order.push("reload"); + }); + + await deleteDrillAndRefreshSettings(repository, "active", reload); + + expect(repository.deleteDrill).toHaveBeenCalledWith("active"); + expect(reload).toHaveBeenCalledTimes(1); + expect(order).toEqual(["delete", "reload"]); + }); +}); diff --git a/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts b/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts new file mode 100644 index 00000000..69d94d81 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/drill-route-access.test.ts @@ -0,0 +1,25 @@ +import { getDrillTerms } from "@eight2five/mobile/drill"; + +import { getDrillRouteAccess } from "../drill-route-access"; + +describe("drill route and terminology behavior", () => { + test("does not expose disabled or failed drill routes", () => { + expect(getDrillRouteAccess("loading", true)).toBe("loading"); + expect(getDrillRouteAccess("ready", true)).toBe("allowed"); + expect(getDrillRouteAccess("ready", false)).toBe("redirect"); + expect(getDrillRouteAccess("error", true)).toBe("redirect"); + }); + + test("keeps Pages and Sets as display-only terminology", () => { + expect(getDrillTerms("pages")).toMatchObject({ + singular: "Page", + plural: "Pages", + lowercaseSingular: "page", + }); + expect(getDrillTerms("sets")).toMatchObject({ + singular: "Set", + plural: "Sets", + lowercaseSingular: "set", + }); + }); +}); diff --git a/apps/mobile/src/features/drill/__tests__/page-form.test.ts b/apps/mobile/src/features/drill/__tests__/page-form.test.ts new file mode 100644 index 00000000..f5c47165 --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/page-form.test.ts @@ -0,0 +1,195 @@ +import type { DrillSet } from "@eight2five/mobile/drill"; +import { + formatMarchingFrontBack, + formatMarchingSide, + marchingCoordinateToDrillGridPoint, +} from "@eight2five/mobile/field"; + +import { + createDefaultPageDraft, + pageToDraft, + validatePageDraft, + type MarchingCoordinateDraft, +} from "../page-form"; + +const VALID_DRAFT: MarchingCoordinateDraft = { + setNumber: "31", + setKind: "subset", + setSuffix: "A", + countsFromPrevious: "16", + measureStart: "126", + measureEnd: "129", + side: "2", + yardLine: "40", + sideRelation: "inside", + sideOffsetSteps: "2.25", + frontBackReference: "front-hash", + frontBackRelation: "in-front-of", + frontBackOffsetSteps: "4.5", +}; + +describe("structured marching coordinate form", () => { + test("defaults the first entry to zero counts and a numeric set identity", () => { + expect( + createDefaultPageDraft({ ordinal: 0, suggestedNumber: 1 }), + ).toMatchObject({ + setNumber: "1", + setKind: "set", + setSuffix: "", + countsFromPrevious: "0", + measureStart: "", + measureEnd: "", + side: "center", + yardLine: "50", + sideRelation: "on", + frontBackReference: "front-sideline", + frontBackRelation: "on", + }); + expect( + createDefaultPageDraft({ ordinal: 2, suggestedNumber: 7 }) + .countsFromPrevious, + ).toBe("8"); + }); + + test("converts structured fractional controls to the canonical drill grid", () => { + const result = validatePageDraft(VALID_DRAFT); + expect(result.errors).toEqual({}); + expect(result.value).toBeDefined(); + expect(formatMarchingSide(result.value!.coordinate.side)).toBe( + "Side 2: 2.25 Steps inside 40 yd ln", + ); + expect(formatMarchingFrontBack(result.value!.coordinate.frontBack)).toBe( + "4.5 Steps in front of HS FH", + ); + expect(result.value).toMatchObject({ + number: 31, + kind: "subset", + suffix: "A", + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + }); + expect(result.value!.position).toEqual( + marchingCoordinateToDrillGridPoint(result.value!.coordinate), + ); + }); + + test.each([ + ["football-nfhs", 28], + ["football-ncaa", 32], + ["football-texas-uil", 32], + ] as const)( + "%s uses its schema-defined front-hash marching reference", + (fieldPreset, expectedFrontHashSteps) => { + const result = validatePageDraft( + { + ...VALID_DRAFT, + frontBackRelation: "on", + frontBackOffsetSteps: "0", + }, + fieldPreset, + ); + expect(result.value?.position.ySteps).toBeCloseTo( + expectedFrontHashSteps, + 8, + ); + }, + ); + + test("NFL uses its schema-defined fractional front-hash marching reference", () => { + const result = validatePageDraft( + { + ...VALID_DRAFT, + frontBackRelation: "on", + frontBackOffsetSteps: "0", + }, + "football-nfl", + ); + expect(result.value?.position.ySteps).toBeCloseTo( + ((70 + 9 / 12) / 160) * 84, + 8, + ); + }); + + test("initializes controls through inverse grid conversion and round trips", () => { + const position = marchingCoordinateToDrillGridPoint({ + side: { side: 1, yardLine: 35, relation: "outside", offsetSteps: 1.25 }, + frontBack: { + reference: "back-hash", + relation: "behind", + offsetSteps: 3.75, + }, + }); + const set: DrillSet = { + id: "set-1", + drillId: "drill", + ordinal: 0, + number: 47, + kind: "set", + countsFromPrevious: 12, + measureRange: { start: 210, end: 214 }, + position, + }; + const draft = pageToDraft(set); + const roundTrip = validatePageDraft(draft); + + expect(draft).toMatchObject({ + setNumber: "47", + setKind: "set", + countsFromPrevious: "12", + measureStart: "210", + measureEnd: "214", + side: "1", + yardLine: "35", + sideRelation: "outside", + frontBackReference: "back-hash", + frontBackRelation: "behind", + }); + expect(roundTrip.value?.position.xSteps).toBeCloseTo(position.xSteps, 10); + expect(roundTrip.value?.position.ySteps).toBeCloseTo(position.ySteps, 10); + }); + + test("normalizes zero offsets and the exact 50 to On with no side", () => { + const result = validatePageDraft({ + ...VALID_DRAFT, + side: "1", + yardLine: "50", + sideRelation: "outside", + sideOffsetSteps: "0", + frontBackRelation: "behind", + frontBackOffsetSteps: "0", + }); + + expect(result.value?.coordinate.side).toEqual({ + side: "center", + yardLine: 50, + relation: "on", + offsetSteps: 0, + }); + expect(result.value?.coordinate.frontBack.relation).toBe("on"); + }); + + test("returns actionable position, count, measure, relation, and bounds errors", () => { + expect( + validatePageDraft({ + ...VALID_DRAFT, + setNumber: "-1", + setSuffix: "aa", + countsFromPrevious: "2.5", + measureStart: "130", + measureEnd: "129", + }).errors, + ).toMatchObject({ + setNumber: expect.stringContaining("position number"), + setSuffix: expect.stringContaining("capital letter"), + countsFromPrevious: expect.stringContaining("whole-number"), + measureEnd: expect.stringContaining("after"), + }); + expect( + validatePageDraft({ + ...VALID_DRAFT, + side: "center", + yardLine: "45", + }).errors.side, + ).toContain("50-yard line"); + }); +}); diff --git a/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts b/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts new file mode 100644 index 00000000..8247b49c --- /dev/null +++ b/apps/mobile/src/features/drill/__tests__/transition-presentation.test.ts @@ -0,0 +1,18 @@ +import { formatTransitionAnalysis } from "../transition-presentation"; + +describe("transition presentation", () => { + test("rounds yard-line crossing xCounts to the nearest whole count", () => { + expect( + formatTransitionAnalysis( + { + distanceSteps: 24, + stepSizeToFive: 5.33, + isHalt: false, + yardLineCrossingCounts: [0.2, 5.333333, 10.666667], + }, + true, + 16, + ).crossingCounts, + ).toBe("1, 5, 11"); + }); +}); diff --git a/apps/mobile/src/features/drill/components/drill-empty-state.tsx b/apps/mobile/src/features/drill/components/drill-empty-state.tsx new file mode 100644 index 00000000..7b9c1353 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-empty-state.tsx @@ -0,0 +1,51 @@ +import { FileUp } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Center } from "@eight2five/ui/components/center"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export function DrillEmptyState({ onUpload }: { onUpload(): void }) { + const theme = useEight2FiveTheme(); + return ( +
+ + + No drills yet + + + Upload an Eight2Five drill file to start working with it. + + + +
+ ); +} diff --git a/apps/mobile/src/features/drill/components/drill-list-item.tsx b/apps/mobile/src/features/drill/components/drill-list-item.tsx new file mode 100644 index 00000000..64080de2 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-list-item.tsx @@ -0,0 +1,256 @@ +import React from "react"; +import { + Animated, + Pressable as NativePressable, + View, + type GestureResponderEvent, +} from "react-native"; +import { + CircleCheck, + CirclePlus, + CircleUserRound, + Info, +} from "lucide-react-native"; +import type { Drill, DrillTerms } from "@eight2five/mobile/drill"; +import { Card } from "@eight2five/ui/components/card"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { resolveDrillIcon } from "../drill-icons"; +import { + formatDrillCount, + getDrillCardActionLabels, +} from "../drill-management"; + +export const DrillListItem = React.memo(function DrillListItem({ + drill, + pageCount, + terms, + active, + busy, + onOpenInfo, + onSelectPerformer, + onToggleActive, +}: { + readonly drill: Drill; + readonly pageCount: number; + readonly terms: DrillTerms; + readonly active: boolean; + readonly busy: boolean; + readonly onOpenInfo: () => void; + readonly onSelectPerformer: () => void; + readonly onToggleActive: () => void; +}) { + const theme = useEight2FiveTheme(); + const countLabel = formatDrillCount(pageCount, terms); + const actionLabels = getDrillCardActionLabels(drill.name); + const DrillIcon = drill.metadata?.lucideIcon + ? resolveDrillIcon(drill.metadata.lucideIcon) + : undefined; + + return ( + + { + if (!active) onToggleActive(); + }} + style={({ pressed }) => ({ + opacity: busy ? 0.55 : pressed ? 0.8 : 1, + })} + > + + {DrillIcon ? ( + + + + ) : null} + + + {drill.name} + + + {countLabel} + + + + + + { + event.stopPropagation(); + onToggleActive(); + }} + disabled={busy} + accessibilityRole="button" + accessibilityLabel={ + active ? actionLabels.deactivate : actionLabels.activate + } + accessibilityState={{ disabled: busy, selected: active }} + hitSlop={4} + style={({ pressed }) => ({ + width: 40, + height: 40, + alignItems: "center", + justifyContent: "center", + opacity: busy ? 0.45 : pressed ? 0.6 : 1, + })} + > + + + + + + + ); +}); + +function DrillActionButton({ + label, + icon, + disabled, + onPress, + iconColor, +}: { + readonly label: string; + readonly icon: React.ElementType; + readonly disabled: boolean; + readonly onPress: () => void; + readonly iconColor: string; +}) { + return ( + { + event.stopPropagation(); + onPress(); + }} + disabled={disabled} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityState={{ disabled }} + hitSlop={6} + style={({ pressed }) => ({ + width: 36, + height: 36, + alignItems: "center", + justifyContent: "center", + opacity: disabled ? 0.45 : pressed ? 0.6 : 1, + })} + > + + + + + ); +} + +function AnimatedSelectionIcon({ + active, + color, +}: { + readonly active: boolean; + readonly color: string; +}) { + const [progress] = React.useState(() => new Animated.Value(active ? 1 : 0)); + React.useEffect(() => { + Animated.timing(progress, { + toValue: active ? 1 : 0, + duration: 180, + useNativeDriver: true, + }).start(); + }, [active, progress]); + + const plusOpacity = progress.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0], + }); + const checkOpacity = progress; + const plusScale = progress.interpolate({ + inputRange: [0, 1], + outputRange: [1, 0.9], + }); + const checkScale = progress.interpolate({ + inputRange: [0, 1], + outputRange: [0.9, 1], + }); + + return ( + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx new file mode 100644 index 00000000..87dbc021 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-properties-dialog.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import { Alert } from "react-native"; +import { Trash2, X } from "lucide-react-native"; +import type { + Drill, + DrillDocument, + DrillTerms, +} from "@eight2five/mobile/drill"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { + Modal, + ModalBackdrop, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SpinningLoaderIcon } from "../../../components/spinning-loader-icon"; +import { SettingsMessage } from "../../settings/settings-components"; +import { resolveDrillIcon } from "../drill-icons"; + +export function DrillPropertiesDialog({ + drill, + document, + terms, + isOpen, + loading, + saving, + error, + onClose, + onDelete, +}: { + readonly drill?: Drill; + readonly document?: DrillDocument; + readonly terms: DrillTerms; + readonly isOpen: boolean; + readonly loading: boolean; + readonly saving: boolean; + readonly error?: Error; + readonly onClose: () => void; + readonly onDelete: () => Promise; +}) { + const theme = useEight2FiveTheme(); + if (!drill) return null; + const metadata = document?.metadata ?? drill.metadata; + const DrillIcon = drill.metadata?.lucideIcon + ? resolveDrillIcon(drill.metadata.lucideIcon) + : undefined; + + const confirmDelete = () => { + Alert.alert( + `Delete “${drill.name}”?`, + `This permanently deletes the drill and all of its ${terms.lowercasePlural}.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => void onDelete().catch(() => undefined), + }, + ], + ); + }; + + return ( + + + + + Drill Info + + + + + + + {loading ? ( + Loading metadata… + ) : null} + {error ? ( + {error.message} + ) : null} + + + {DrillIcon ? ( + + ) : null} + + {drill.name} + + + + + + + + + + + + + + + + + + + ); +} + +function MetadataRow({ label, value }: { label: string; value?: string }) { + const theme = useEight2FiveTheme(); + return ( + + + {label} + + {value || "—"} + + ); +} + +function formatMetadataDate(value: string | undefined): string | undefined { + if (!value) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toLocaleString() : value; +} + +function formatSourceMetadata( + document: DrillDocument | undefined, +): string | undefined { + if (!document?.provenance) return undefined; + const source = document.provenance.source; + const importer = document.provenance.importer; + const values = [ + source?.fileName ?? source?.kind, + importer ? `${importer.name} ${importer.version}` : undefined, + ].filter((value): value is string => Boolean(value)); + return values.length > 0 ? values.join(" · ") : undefined; +} diff --git a/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx new file mode 100644 index 00000000..687334f5 --- /dev/null +++ b/apps/mobile/src/features/drill/components/drill-selection-dialog.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import { X } from "lucide-react-native"; +import { FlatList } from "@eight2five/ui/components/flat-list"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { + Modal, + ModalBackdrop, + ModalCloseButton, + ModalContent, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { DrillListItem } from "./drill-list-item"; +import { DrillPropertiesDialog } from "./drill-properties-dialog"; +import { PerformerSelectionDialog } from "./performer-selection-dialog"; +import { useDrillListController } from "../use-drill-list-controller"; + +export function DrillSelectionDialog({ + isOpen, + onClose, +}: { + readonly isOpen: boolean; + readonly onClose: () => void; +}) { + const { height } = useWindowDimensions(); + const theme = useEight2FiveTheme(); + const controller = useDrillListController(); + + const renderItem = React.useCallback( + ({ item }: { item: (typeof controller.entries)[number] }) => ( + void controller.openProperties(item.drill)} + onSelectPerformer={() => + void controller.openPerformerSelection(item.drill) + } + onToggleActive={() => + void controller.toggleActive(item.drill).catch(() => undefined) + } + /> + ), + [controller], + ); + + return ( + <> + + + + + Select Drill + + + + + entry.drill.id} + renderItem={renderItem} + style={{ maxHeight: Math.max(260, height * 0.66) }} + contentContainerStyle={{ + gap: eight2FiveSpacing.sm, + padding: eight2FiveSpacing.md, + }} + ListEmptyComponent={ + + + No drills uploaded. + + + } + testID="drill-selection-list" + /> + + + + + + { + const drill = controller.propertiesDialog?.drill; + if (!drill) return; + await controller.remove(drill); + }} + /> + + ); +} diff --git a/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx new file mode 100644 index 00000000..5decc34f --- /dev/null +++ b/apps/mobile/src/features/drill/components/marching-coordinate-form.tsx @@ -0,0 +1,484 @@ +import React from "react"; +import { ChevronDown } from "lucide-react-native"; +import { Card } from "@eight2five/ui/components/card"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + getFieldPreset, + getGridReference, + type FieldPresetId, +} from "@eight2five/drill-schema"; +import { getDrillTerms, type DrillTerms } from "@eight2five/mobile/drill"; + +import { + YARD_LINES, + previewCoordinate, + validatePageDraft, + type MarchingCoordinateDraft, +} from "../page-form"; + +const SIDE_CHOICES = [ + { label: "Side 1", value: "1" }, + { label: "Side 2", value: "2" }, + { label: "No side (on 50)", value: "center" }, +] as const; + +const SIDE_RELATION_CHOICES = [ + { label: "On", value: "on" }, + { label: "Inside", value: "inside" }, + { label: "Outside", value: "outside" }, +] as const; + +function frontBackChoices(fieldPreset: FieldPresetId) { + const hashPrefix = fieldHashPrefix(fieldPreset); + return [ + { label: "Front Sideline", value: "front-sideline" }, + { label: `${hashPrefix} FH`, value: "front-hash" }, + { label: `${hashPrefix} BH`, value: "back-hash" }, + { label: "Back Sideline", value: "back-sideline" }, + ] as const; +} + +function fieldHashPrefix(fieldPreset: FieldPresetId): string { + switch (fieldPreset) { + case "football-nfhs": + return "HS"; + case "football-ncaa": + return "NCAA"; + case "football-texas-uil": + return "UIL"; + case "football-nfl": + return "NFL"; + } +} + +function frontBackHelper(fieldPreset: FieldPresetId): string { + const field = getFieldPreset(fieldPreset); + const frontHash = getGridReference(field, "front-hash")?.coordinateSteps; + const backHash = getGridReference(field, "back-hash")?.coordinateSteps; + if (frontHash === undefined || backHash === undefined) { + return "Offsets use the active field's marching-grid references."; + } + return `This field uses front hash ${formatStepReference(frontHash)} and back hash ${formatStepReference(backHash)}.`; +} + +function formatStepReference(value: number): string { + return Number(value.toFixed(3)).toString(); +} + +const FRONT_BACK_RELATION_CHOICES = [ + { label: "On", value: "on" }, + { label: "In front of", value: "in-front-of" }, + { label: "Behind", value: "behind" }, +] as const; + +export function MarchingCoordinateForm({ + draft, + fieldPreset = "football-nfhs", + terms = getDrillTerms("sets"), + showDetails = true, + disabled, + onChange, +}: { + draft: MarchingCoordinateDraft; + fieldPreset?: FieldPresetId; + terms?: DrillTerms; + showDetails?: boolean; + disabled: boolean; + onChange(draft: MarchingCoordinateDraft): void; +}) { + const theme = useEight2FiveTheme(); + const validation = validatePageDraft(draft, fieldPreset); + const preview = previewCoordinate(draft, fieldPreset); + const frontBackReferenceChoices = frontBackChoices(fieldPreset); + const setKindChoices = [ + { label: terms.singular, value: "set" }, + { label: "Subset", value: "subset" }, + ] as const; + const update = ( + key: Key, + value: MarchingCoordinateDraft[Key], + ) => onChange({ ...draft, [key]: value }); + + return ( + + {showDetails ? ( + + update("setNumber", value)} + /> + + onChange({ + ...draft, + setKind: value, + ...(value === "set" ? { setSuffix: "" } : {}), + }) + } + /> + {draft.setKind === "subset" ? ( + update("setSuffix", value)} + /> + ) : null} + update("countsFromPrevious", value)} + /> + update("measureStart", value)} + /> + update("measureEnd", value)} + /> + + ) : null} + + + { + if (value === "center") { + onChange({ + ...draft, + side: "center", + yardLine: "50", + sideRelation: "on", + sideOffsetSteps: "0", + }); + } else { + update("side", value); + } + }} + /> + ({ + label: yardLine === 0 ? "Goal Line" : `${yardLine} yard line`, + value: String(yardLine), + }))} + error={validation.errors.yardLine} + disabled={disabled} + onChange={(value) => { + const leavingCenter = value !== "50" && draft.side === "center"; + const exactlyOnFifty = + value === "50" && Number(draft.sideOffsetSteps) === 0; + onChange({ + ...draft, + yardLine: value, + side: leavingCenter + ? "1" + : exactlyOnFifty + ? "center" + : draft.side, + ...(exactlyOnFifty + ? { sideRelation: "on", sideOffsetSteps: "0" } + : {}), + }); + }} + /> + + onChange({ + ...draft, + sideRelation: value, + ...(value === "on" ? { sideOffsetSteps: "0" } : {}), + }) + } + /> + + onChange({ + ...draft, + sideOffsetSteps: value, + ...(Number(value) === 0 ? { sideRelation: "on" } : {}), + }) + } + /> + + + + update("frontBackReference", value)} + /> + + onChange({ + ...draft, + frontBackRelation: value, + ...(value === "on" ? { frontBackOffsetSteps: "0" } : {}), + }) + } + /> + + onChange({ + ...draft, + frontBackOffsetSteps: value, + ...(Number(value) === 0 ? { frontBackRelation: "on" } : {}), + }) + } + /> + + + + + Coordinate preview + + {preview ? ( + <> + {preview.side} + {preview.frontBack} + + ) : ( + + Complete a valid coordinate to preview it. + + )} + {validation.errors.coordinate ? ( + + {validation.errors.coordinate} + + ) : null} + + + ); +} + +function FormSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {title} + + + {children} + + + ); +} + +function TextField({ + label, + value, + error, + helper, + disabled, + numeric = false, + onChangeText, +}: { + label: string; + value: string; + error?: string; + helper?: string; + disabled: boolean; + numeric?: boolean; + onChangeText(value: string): void; +}) { + return ( + + + {label} + + + + + {helper ? ( + + {helper} + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +function SelectField({ + label, + value, + choices, + error, + disabled, + onChange, +}: { + label: string; + value: Value; + choices: readonly { readonly label: string; readonly value: Value }[]; + error?: string; + disabled: boolean; + onChange(value: Value): void; +}) { + return ( + + + {label} + + + {error ? ( + + {error} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx new file mode 100644 index 00000000..076de3c4 --- /dev/null +++ b/apps/mobile/src/features/drill/components/performer-selection-dialog.tsx @@ -0,0 +1,243 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import type { DrillDocument, DrillEntity } from "@eight2five/drill-schema"; +import { Button, ButtonText } from "@eight2five/ui/components/button"; +import { Heading } from "@eight2five/ui/components/heading"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { + Modal, + ModalBackdrop, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, +} from "@eight2five/ui/components/modal"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SpinningLoaderIcon } from "../../../components/spinning-loader-icon"; +import { SettingsMessage } from "../../settings/settings-components"; +import { getPerformerSymbolGroups } from "../drill-import"; + +interface PerformerSelectionDialogProps { + readonly document?: DrillDocument; + readonly isOpen: boolean; + readonly importing: boolean; + readonly error?: Error; + readonly selectedPerformerEntityId?: number; + readonly title?: string; + readonly confirmLabel?: string; + readonly onClose: () => void; + readonly onConfirm: (performerEntityId: number) => Promise; +} + +export function PerformerSelectionDialog(props: PerformerSelectionDialogProps) { + if (!props.document) return null; + return ( + + ); +} + +function PerformerSelectionDialogContent({ + document, + isOpen, + importing, + error, + selectedPerformerEntityId, + title = "Select your dot", + confirmLabel = "Use This Dot", + onClose, + onConfirm, +}: PerformerSelectionDialogProps & { readonly document: DrillDocument }) { + const theme = useEight2FiveTheme(); + const { height } = useWindowDimensions(); + const groups = React.useMemo( + () => getPerformerSymbolGroups(document), + [document], + ); + const initiallySelectedPerformer = groups + .flatMap((group) => group.performers) + .find((performer) => performer.id === selectedPerformerEntityId); + const [selectedSymbol, setSelectedSymbol] = React.useState( + initiallySelectedPerformer?.symbol ?? groups[0]?.symbol, + ); + const [selectedPerformer, setSelectedPerformer] = React.useState< + DrillEntity | undefined + >(initiallySelectedPerformer); + const visiblePerformers = + groups.find((group) => group.symbol === selectedSymbol)?.performers ?? []; + const listHeight = Math.min(380, Math.max(230, height * 0.45)); + + return ( + { + if (!importing) onClose(); + }} + size="lg" + > + + + + {title} + + + + + Choose your performer symbol on the left, then select your label + on the right. Props may be present in the uploaded file, but they + are not selectable as your dot. + + + {error ? ( + {error.message} + ) : null} + + + + + + {groups.map((group) => { + const selected = group.symbol === selectedSymbol; + return ( + { + if (importing) return; + setSelectedSymbol(group.symbol); + setSelectedPerformer(undefined); + }} + style={{ + minHeight: 44, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: eight2FiveSpacing.sm, + borderRadius: eight2FiveRadii.sm, + backgroundColor: selected + ? theme.accent + : theme.surfaceRaised, + }} + > + + {group.symbol} + + + ); + })} + + + + + + + + {visiblePerformers.map((performer) => { + const selected = performer.id === selectedPerformer?.id; + return ( + { + if (!importing) setSelectedPerformer(performer); + }} + style={{ + minHeight: 48, + justifyContent: "center", + paddingHorizontal: eight2FiveSpacing.md, + paddingVertical: eight2FiveSpacing.sm, + borderRadius: eight2FiveRadii.sm, + backgroundColor: selected + ? theme.accent + : theme.surfaceRaised, + }} + > + + {performer.label} + + {performer.name ? ( + + {performer.name} + + ) : null} + + ); + })} + + + + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/drill/drill-icons.ts b/apps/mobile/src/features/drill/drill-icons.ts new file mode 100644 index 00000000..a0b065e7 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-icons.ts @@ -0,0 +1,50 @@ +import { + Activity, + CircleDot, + Flag, + Music2, + Shapes, + Sparkle, + Sparkles, + Star, + Trophy, + Zap, + type LucideIcon, +} from "lucide-react-native"; + +/** + * Only icons in this explicit registry may be resolved from imported + * metadata. Drill files are data, so their icon name must never be evaluated + * as a component lookup at runtime. + */ +export const DRILL_ICON_REGISTRY = Object.freeze({ + activity: Activity, + "circle-dot": CircleDot, + flag: Flag, + "music-2": Music2, + shapes: Shapes, + sparkle: Sparkle, + sparkles: Sparkles, + star: Star, + trophy: Trophy, + zap: Zap, +} satisfies Readonly>); + +export type DrillIconName = keyof typeof DRILL_ICON_REGISTRY; + +export const DRILL_ICON_NAMES = Object.freeze( + Object.keys(DRILL_ICON_REGISTRY) as DrillIconName[], +); + +export const FALLBACK_DRILL_ICON: LucideIcon = CircleDot; + +export function resolveDrillIcon(iconName: string | undefined): LucideIcon { + if (!iconName) return FALLBACK_DRILL_ICON; + return DRILL_ICON_REGISTRY[iconName as DrillIconName] ?? FALLBACK_DRILL_ICON; +} + +export function isSupportedDrillIcon( + iconName: string | undefined, +): iconName is DrillIconName { + return iconName !== undefined && iconName in DRILL_ICON_REGISTRY; +} diff --git a/apps/mobile/src/features/drill/drill-import.ts b/apps/mobile/src/features/drill/drill-import.ts new file mode 100644 index 00000000..3d986e53 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-import.ts @@ -0,0 +1,185 @@ +import type { Drill, DrillRepository } from "@eight2five/mobile/drill"; +import type { + DocumentPickerAsset, + DocumentPickerResult, +} from "expo-document-picker"; +import { + safeParseDrillDocument, + type DrillDocument, + type DrillEntity, +} from "@eight2five/drill-schema"; + +export const EIGHT2FIVE_DRILL_FILE_SUFFIX = ".eight2five.json"; +export const MAX_DRILL_UPLOAD_BYTES = 10 * 1024 * 1024; + +export interface ParsedDrillPickerResult { + readonly document: DrillDocument; + readonly fileName: string; +} + +export interface PerformerSymbolGroup { + readonly symbol: string; + readonly performers: readonly DrillEntity[]; +} + +export function isEight2FiveDrillFileName(fileName: string): boolean { + const normalized = fileName.trim().toLowerCase(); + return ( + normalized === "eight2five.json" || + normalized.endsWith(EIGHT2FIVE_DRILL_FILE_SUFFIX) + ); +} + +/** + * Applies the existing file-name and size checks to a native picker asset, + * then reads and validates its JSON through the caller-provided Expo file + * reader. Keeping the reader injectable makes the import boundary testable + * without a native document picker or filesystem. + */ +export async function parseDrillPickerResult( + result: DocumentPickerResult, + readText: (uri: string) => Promise, +): Promise { + if (result.canceled) return undefined; + const asset = result.assets[0]; + if (!asset) return undefined; + return { + document: await parseDrillPickerAsset(asset, readText), + fileName: asset.name, + }; +} + +export async function parseDrillPickerAsset( + asset: Pick, + readText: (uri: string) => Promise, +): Promise { + if (!isEight2FiveDrillFileName(asset.name)) { + throw new Error(`Select a file ending in ${EIGHT2FIVE_DRILL_FILE_SUFFIX}.`); + } + if (typeof asset.size === "number" && asset.size > MAX_DRILL_UPLOAD_BYTES) { + throw new Error("The selected drill file is too large to import."); + } + + const json = await readText(asset.uri); + if (json.length > MAX_DRILL_UPLOAD_BYTES) { + throw new Error("The selected drill file is too large to import."); + } + return parseImportableDrillJson(json); +} + +export function parseImportableDrillJson(json: string): DrillDocument { + let value: unknown; + try { + value = JSON.parse(json) as unknown; + } catch { + throw new Error("The selected file is not valid JSON."); + } + + const parsed = safeParseDrillDocument(value); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const detail = issue?.message ? ` ${issue.message}` : ""; + throw new Error(`This is not a valid Eight2Five drill file.${detail}`); + } + + assertMobileDocumentSupport(parsed.data); + return parsed.data; +} + +export function getPerformerSymbolGroups( + document: DrillDocument, +): readonly PerformerSymbolGroup[] { + const groups = new Map(); + for (const entity of document.entities) { + if (entity.type !== "performer") continue; + const performers = groups.get(entity.symbol); + if (performers) performers.push(entity); + else groups.set(entity.symbol, [entity]); + } + return [...groups.entries()].map(([symbol, performers]) => ({ + symbol, + performers, + })); +} + +export async function importEight2FiveDrillJson( + repository: DrillRepository, + json: string, + performerEntityId?: number, +): Promise { + return await importEight2FiveDrillDocument( + repository, + parseImportableDrillJson(json), + performerEntityId, + ); +} + +export async function importEight2FiveDrillDocument( + repository: DrillRepository, + document: DrillDocument, + performerEntityId?: number, +): Promise { + assertMobileDocumentSupport(document); + const performer = resolveSelectedPerformer(document, performerEntityId); + assertSelectedPerformerPositions(document, performer); + + return await repository.createImportedDrill({ + sourceDocument: document, + selectedPerformerEntityId: performer.id, + }); +} + +function assertMobileDocumentSupport( + document: DrillDocument, +): asserts document is DrillDocument & { + readonly field: Extract; +} { + if (document.field.type !== "preset") { + throw new Error( + "Custom field definitions are not supported in the mobile app yet.", + ); + } + + if (!document.entities.some((entity) => entity.type === "performer")) { + throw new Error("This drill file does not contain a performer to select."); + } +} + +function resolveSelectedPerformer( + document: DrillDocument, + performerEntityId?: number, +): DrillEntity { + const performers = document.entities.filter( + (entity) => entity.type === "performer", + ); + if (performerEntityId === undefined) { + if (performers.length === 1) return performers[0]; + throw new Error("Select your performer before importing this drill."); + } + + const performer = performers.find( + (entity) => entity.id === performerEntityId, + ); + if (!performer) { + throw new Error( + "The selected performer is not present in this drill file.", + ); + } + return performer; +} + +function assertSelectedPerformerPositions( + document: DrillDocument, + performer: DrillEntity, +): void { + const positionedSetIds = new Set( + document.positions + .filter((position) => position.entityId === performer.id) + .map((position) => position.setId), + ); + if (document.sets.some((set) => !positionedSetIds.has(set.id))) { + throw new Error( + `Every drill position must include a coordinate for ${performer.label}.`, + ); + } +} diff --git a/apps/mobile/src/features/drill/drill-list-screen.tsx b/apps/mobile/src/features/drill/drill-list-screen.tsx new file mode 100644 index 00000000..68fd5e82 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-list-screen.tsx @@ -0,0 +1,167 @@ +import React from "react"; +import { Stack } from "expo-router"; +import { Plus } from "lucide-react-native"; +import { FlatList } from "@eight2five/ui/components/flat-list"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { SettingsMessage } from "../settings/settings-components"; +import { DrillEmptyState } from "./components/drill-empty-state"; +import { DrillListItem } from "./components/drill-list-item"; +import { DrillPropertiesDialog } from "./components/drill-properties-dialog"; +import { PerformerSelectionDialog } from "./components/performer-selection-dialog"; +import { useDrillListController } from "./use-drill-list-controller"; + +export function DrillListScreen() { + const theme = useEight2FiveTheme(); + const controller = useDrillListController(); + + const renderItem = React.useCallback( + ({ item }: { item: (typeof controller.entries)[number] }) => ( + void controller.openProperties(item.drill)} + onSelectPerformer={() => + void controller.openPerformerSelection(item.drill) + } + onToggleActive={() => + void controller.toggleActive(item.drill).catch(() => undefined) + } + /> + ), + [controller], + ); + + return ( + <> + ( + void controller.pickFile()} + accessibilityRole="button" + accessibilityLabel="Upload Drill" + hitSlop={8} + style={{ + width: 48, + height: 48, + alignItems: "center", + justifyContent: "center", + }} + > + + + ), + }} + /> + + entry.drill.id} + renderItem={renderItem} + contentInsetAdjustmentBehavior="automatic" + contentContainerStyle={{ + flexGrow: 1, + gap: eight2FiveSpacing.sm, + padding: eight2FiveSpacing.md, + paddingBottom: eight2FiveSpacing.xxl, + }} + ListHeaderComponent={ + controller.loading || controller.error ? ( + + {controller.loading ? ( + + Loading drills… + + ) : null} + {controller.error ? ( + + {controller.error.message} + + ) : null} + + ) : null + } + ListEmptyComponent={ + controller.loading ? null : ( + void controller.pickFile()} /> + ) + } + /> + + { + if (controller.pendingImport) controller.cancelPendingImport(); + else controller.closePerformerSelection(); + }} + onConfirm={async (performerEntityId) => { + if (controller.pendingImport) { + await controller.importPendingDocument(performerEntityId); + } else { + await controller.selectPerformer(performerEntityId); + } + }} + /> + + { + const drill = controller.propertiesDialog?.drill; + if (!drill) return; + await controller.remove(drill); + }} + /> + + + ); +} diff --git a/apps/mobile/src/features/drill/drill-management.ts b/apps/mobile/src/features/drill/drill-management.ts new file mode 100644 index 00000000..638ca31a --- /dev/null +++ b/apps/mobile/src/features/drill/drill-management.ts @@ -0,0 +1,71 @@ +import { + type Drill, + type DrillRepository, + type DrillTerms, +} from "@eight2five/mobile/drill"; + +export const DRILL_NAME_MAX_LENGTH = 80; + +export interface DrillListEntry { + readonly drill: Drill; + readonly pageCount: number; +} + +export function normalizeDrillName(value: string): string { + return value.trim(); +} + +export function validateDrillName(value: string): string | undefined { + const name = normalizeDrillName(value); + if (!name) return "Enter a drill name."; + if (name.length > DRILL_NAME_MAX_LENGTH) { + return `Drill names must be ${DRILL_NAME_MAX_LENGTH} characters or fewer.`; + } + return undefined; +} + +export function formatDrillCount(count: number, terms: DrillTerms): string { + return `${count} ${count === 1 ? terms.singular : terms.plural}`; +} + +export function getDrillCardActionLabels(drillName: string): { + readonly info: string; + readonly performer: string; + readonly activate: string; + readonly deactivate: string; +} { + return { + info: `Info for ${drillName}`, + performer: `Select performer for ${drillName}`, + activate: `Activate ${drillName}`, + deactivate: `Deactivate ${drillName}`, + }; +} + +export async function loadDrillList( + repository: DrillRepository, +): Promise { + const drills = await repository.listDrills(); + const pageCounts = await Promise.all( + drills.map(async (drill) => (await repository.listPages(drill.id)).length), + ); + // Preserve Thread 1's repository-defined deterministic ordering. + return drills.map((drill, index) => ({ + drill, + pageCount: pageCounts[index], + })); +} + +export async function deleteDrillAndRefreshSettings( + repository: DrillRepository, + drillId: string, + reloadSettings: () => Promise, +): Promise { + await repository.deleteDrill(drillId); + // SQLite foreign keys clear active/selected pointers; publish that snapshot. + await reloadSettings(); +} + +export function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/drill/drill-route-access.ts b/apps/mobile/src/features/drill/drill-route-access.ts new file mode 100644 index 00000000..bc273288 --- /dev/null +++ b/apps/mobile/src/features/drill/drill-route-access.ts @@ -0,0 +1,11 @@ +import type { AppSettingsStoreStatus } from "../../state/app-settings-store"; + +export type DrillRouteAccess = "loading" | "allowed" | "redirect"; + +export function getDrillRouteAccess( + status: AppSettingsStoreStatus, + drillFeaturesEnabled: boolean, +): DrillRouteAccess { + if (status === "loading") return "loading"; + return status === "ready" && drillFeaturesEnabled ? "allowed" : "redirect"; +} diff --git a/apps/mobile/src/features/drill/page-form.ts b/apps/mobile/src/features/drill/page-form.ts new file mode 100644 index 00000000..90fa46b8 --- /dev/null +++ b/apps/mobile/src/features/drill/page-form.ts @@ -0,0 +1,377 @@ +import { + drillGridPointToMarchingCoordinate, + fieldPointToDrillGridPoint, + formatMarchingFrontBack, + formatMarchingSide, + marchingCoordinateToDrillGridPoint, + type FieldLateralReference, + type MarchingCoordinate, + type MarchingFrontBackRelation, + type MarchingSideReference, + type MarchingSideRelation, +} from "@eight2five/mobile/field"; +import type { + DrillGridPoint, + DrillSet, + MeasureRange, + SetKind, +} from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; + +export const YARD_LINES = Object.freeze( + Array.from({ length: 11 }, (_, index) => index * 5), +); + +export interface MarchingCoordinateDraft { + readonly setNumber: string; + readonly setKind: SetKind; + readonly setSuffix: string; + readonly countsFromPrevious: string; + readonly measureStart: string; + readonly measureEnd: string; + readonly side: "1" | "2" | "center"; + readonly yardLine: string; + readonly sideRelation: MarchingSideRelation; + readonly sideOffsetSteps: string; + readonly frontBackReference: FieldLateralReference; + readonly frontBackRelation: MarchingFrontBackRelation; + readonly frontBackOffsetSteps: string; +} + +export type SetFormField = + | "setNumber" + | "setSuffix" + | "countsFromPrevious" + | "measureStart" + | "measureEnd" + | "side" + | "yardLine" + | "sideOffsetSteps" + | "frontBackOffsetSteps" + | "coordinate"; + +export type SetFormErrors = Partial>; + +export interface ValidatedSetDraft { + readonly number: number; + readonly kind: SetKind; + readonly suffix?: string; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly coordinate: MarchingCoordinate; +} + +export interface SetDraftValidation { + readonly errors: SetFormErrors; + readonly value?: ValidatedSetDraft; +} + +export interface CoordinatePreview { + readonly side: string; + readonly frontBack: string; +} + +export function createDefaultPageDraft({ + ordinal, + suggestedNumber, + suggestedLabel, +}: { + ordinal: number; + suggestedNumber?: number; + /** @deprecated Legacy callers may still pass a numeric-ish label. */ + suggestedLabel?: string; +}): MarchingCoordinateDraft { + const fallbackNumber = Number(suggestedLabel); + const resolvedNumber = + suggestedNumber ?? + (Number.isSafeInteger(fallbackNumber) && fallbackNumber >= 0 + ? fallbackNumber + : ordinal + 1); + return { + setNumber: String(resolvedNumber), + setKind: "set", + setSuffix: "", + countsFromPrevious: ordinal === 0 ? "0" : "8", + measureStart: "", + measureEnd: "", + side: "center", + yardLine: "50", + sideRelation: "on", + sideOffsetSteps: "0", + frontBackReference: "front-sideline", + frontBackRelation: "on", + frontBackOffsetSteps: "0", + }; +} + +export function pageToDraft( + set: DrillSet, + fieldPreset: FieldPresetId = "football-nfhs", +): MarchingCoordinateDraft { + return setDraftFromPosition( + set.position, + { + number: set.number, + kind: set.kind, + suffix: set.suffix, + countsFromPrevious: set.countsFromPrevious, + measureRange: set.measureRange, + }, + fieldPreset, + ); +} + +export function coordinateDraftFromFieldPoint( + position: { + readonly xMeters: number; + readonly yMeters: number; + }, + fieldPreset: FieldPresetId = "football-nfhs", +): MarchingCoordinateDraft { + return setDraftFromPosition( + fieldPointToDrillGridPoint(position, fieldPreset), + { + number: 0, + kind: "set", + countsFromPrevious: 0, + }, + fieldPreset, + ); +} + +function setDraftFromPosition( + position: DrillGridPoint, + details: { + readonly number: number; + readonly kind: SetKind; + readonly suffix?: string; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + }, + fieldPreset: FieldPresetId = "football-nfhs", +): MarchingCoordinateDraft { + const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset); + return { + setNumber: String(details.number), + setKind: details.kind, + setSuffix: details.suffix ?? "", + countsFromPrevious: String(details.countsFromPrevious), + measureStart: details.measureRange + ? String(details.measureRange.start) + : "", + measureEnd: details.measureRange ? String(details.measureRange.end) : "", + side: String(coordinate.side.side) as MarchingCoordinateDraft["side"], + yardLine: String(coordinate.side.yardLine), + sideRelation: coordinate.side.relation, + sideOffsetSteps: String(coordinate.side.offsetSteps), + frontBackReference: coordinate.frontBack.reference, + frontBackRelation: coordinate.frontBack.relation, + frontBackOffsetSteps: String(coordinate.frontBack.offsetSteps), + }; +} + +export function validatePageDraft( + draft: MarchingCoordinateDraft, + fieldPreset: FieldPresetId = "football-nfhs", +): SetDraftValidation { + const errors: SetFormErrors = {}; + const setNumber = parseNonNegativeInteger( + draft.setNumber, + "Enter a non-negative drill position number.", + ); + if (typeof setNumber === "string") errors.setNumber = setNumber; + + const suffix = draft.setSuffix.trim(); + if (draft.setKind === "set" && suffix) { + errors.setSuffix = "Primary drill positions do not have a suffix."; + } else if ( + draft.setKind === "subset" && + !/^(?:[A-Z]|\.[0-9]+)$/.test(suffix) + ) { + errors.setSuffix = "Use one capital letter or a decimal suffix such as .5."; + } + + const counts = parseNonNegativeInteger( + draft.countsFromPrevious, + "Enter non-negative whole-number counts.", + ); + if (typeof counts === "string") errors.countsFromPrevious = counts; + + const measureRange = parseMeasureRange(draft, errors); + const coordinateResult = coordinateFromDraft(draft, fieldPreset); + Object.assign(errors, coordinateResult.errors); + if ( + Object.keys(errors).length > 0 || + typeof setNumber === "string" || + typeof counts === "string" || + !coordinateResult.coordinate || + !coordinateResult.position + ) { + return { errors }; + } + + return { + errors, + value: { + number: setNumber, + kind: draft.setKind, + ...(draft.setKind === "subset" ? { suffix } : {}), + countsFromPrevious: counts, + ...(measureRange ? { measureRange } : {}), + coordinate: coordinateResult.coordinate, + position: coordinateResult.position, + }, + }; +} + +export function previewCoordinate( + draft: MarchingCoordinateDraft, + fieldPreset: FieldPresetId = "football-nfhs", +): CoordinatePreview | undefined { + const result = coordinateFromDraft(draft, fieldPreset); + if (!result.coordinate || Object.keys(result.errors).length > 0) + return undefined; + return { + side: formatMarchingSide(result.coordinate.side), + frontBack: formatMarchingFrontBack( + result.coordinate.frontBack, + fieldPreset, + ), + }; +} + +function parseMeasureRange( + draft: MarchingCoordinateDraft, + errors: SetFormErrors, +): MeasureRange | undefined { + const startText = draft.measureStart.trim(); + const endText = draft.measureEnd.trim(); + if (!startText && !endText) return undefined; + if (!startText || !endText) { + const message = "Enter both measure start and end, or leave both blank."; + if (!startText) errors.measureStart = message; + if (!endText) errors.measureEnd = message; + return undefined; + } + const start = parseNonNegativeInteger( + startText, + "Enter a valid start measure.", + ); + const end = parseNonNegativeInteger(endText, "Enter a valid end measure."); + if (typeof start === "string") errors.measureStart = start; + if (typeof end === "string") errors.measureEnd = end; + if (typeof start === "string" || typeof end === "string") return undefined; + if (end < start) { + errors.measureEnd = "End measure must be at or after the start measure."; + return undefined; + } + return { start, end }; +} + +function coordinateFromDraft( + draft: MarchingCoordinateDraft, + fieldPreset: FieldPresetId, +): { + readonly errors: SetFormErrors; + readonly coordinate?: MarchingCoordinate; + readonly position?: DrillGridPoint; +} { + const errors: SetFormErrors = {}; + const yardLine = parseNonNegativeNumber( + draft.yardLine, + "Choose a five-yard line.", + ); + if (typeof yardLine === "string" || !YARD_LINES.includes(yardLine)) { + errors.yardLine = "Choose a five-yard line from 0 through 50."; + } + const sideOffset = parseNonNegativeNumber( + draft.sideOffsetSteps, + "Enter a finite, non-negative side offset.", + ); + if (typeof sideOffset === "string") errors.sideOffsetSteps = sideOffset; + const frontBackOffset = parseNonNegativeNumber( + draft.frontBackOffsetSteps, + "Enter a finite, non-negative front-to-back offset.", + ); + if (typeof frontBackOffset === "string") + errors.frontBackOffsetSteps = frontBackOffset; + if ( + typeof yardLine === "string" || + typeof sideOffset === "string" || + typeof frontBackOffset === "string" + ) { + return { errors }; + } + + const side = parseSide(draft.side); + if (side === undefined) { + errors.side = "Choose Side 1, Side 2, or no side for the 50."; + return { errors }; + } + if (side === "center" && yardLine !== 50) { + errors.side = "No side is available only when exactly on the 50-yard line."; + } + const normalizedSide = yardLine === 50 && sideOffset === 0 ? "center" : side; + const normalizedSideRelation = sideOffset === 0 ? "on" : draft.sideRelation; + if ( + yardLine === 50 && + normalizedSide !== "center" && + normalizedSideRelation !== "outside" + ) { + errors.coordinate = + "An offset from the 50-yard line must be outside on Side 1 or Side 2."; + } + + const coordinate: MarchingCoordinate = { + side: { + side: normalizedSide, + yardLine, + relation: normalizedSide === "center" ? "on" : normalizedSideRelation, + offsetSteps: normalizedSide === "center" ? 0 : sideOffset, + }, + frontBack: { + reference: draft.frontBackReference, + relation: frontBackOffset === 0 ? "on" : draft.frontBackRelation, + offsetSteps: frontBackOffset, + }, + }; + if (Object.keys(errors).length > 0) return { errors, coordinate }; + + try { + return { + errors, + coordinate, + position: marchingCoordinateToDrillGridPoint(coordinate, fieldPreset), + }; + } catch (cause) { + errors.coordinate = cause instanceof Error ? cause.message : String(cause); + return { errors, coordinate }; + } +} + +function parseNonNegativeInteger( + value: string, + message: string, +): number | string { + if (!value.trim()) return message; + const number = Number(value); + return Number.isSafeInteger(number) && number >= 0 ? number : message; +} + +function parseNonNegativeNumber( + value: string, + message: string, +): number | string { + if (!value.trim()) return message; + const number = Number(value); + return Number.isFinite(number) && number >= 0 ? number : message; +} + +function parseSide( + value: MarchingCoordinateDraft["side"], +): MarchingSideReference | undefined { + if (value === "1") return 1; + if (value === "2") return 2; + return value === "center" ? "center" : undefined; +} diff --git a/apps/mobile/src/features/drill/transition-presentation.ts b/apps/mobile/src/features/drill/transition-presentation.ts new file mode 100644 index 00000000..33358b58 --- /dev/null +++ b/apps/mobile/src/features/drill/transition-presentation.ts @@ -0,0 +1,52 @@ +import { + analyzeDrillTransition, + type DrillPage, + type TransitionAnalysis, +} from "@eight2five/mobile/drill"; + +export interface TransitionPresentation { + readonly stepSize: string; + readonly crossingCounts: string; +} + +export function formatTransitionAnalysis( + analysis: TransitionAnalysis, + hasPreviousPage: boolean, + countsFromPrevious: number, +): TransitionPresentation { + if (!hasPreviousPage || countsFromPrevious === 0) { + return { stepSize: "–", crossingCounts: "–" }; + } + return { + stepSize: analysis.isHalt + ? "Hold" + : analysis.stepSizeToFive === undefined + ? "–" + : `${formatMetricNumber(analysis.stepSizeToFive)} to 5`, + crossingCounts: + analysis.yardLineCrossingCounts.length > 0 + ? analysis.yardLineCrossingCounts + .map((count) => formatCrossingCount(count, countsFromPrevious)) + .join(", ") + : "–", + }; +} + +export function getTransitionPresentation( + previousPage: DrillPage | undefined, + page: DrillPage, +): TransitionPresentation { + return formatTransitionAnalysis( + analyzeDrillTransition(previousPage, page), + Boolean(previousPage), + page.countsFromPrevious, + ); +} + +function formatMetricNumber(value: number): string { + return Number(value.toFixed(6)).toString(); +} + +function formatCrossingCount(value: number, moveCounts: number): string { + return String(Math.min(moveCounts, Math.max(1, Math.round(value)))); +} diff --git a/apps/mobile/src/features/drill/use-drill-list-controller.ts b/apps/mobile/src/features/drill/use-drill-list-controller.ts new file mode 100644 index 00000000..4b0b44af --- /dev/null +++ b/apps/mobile/src/features/drill/use-drill-list-controller.ts @@ -0,0 +1,336 @@ +import React from "react"; +import * as DocumentPicker from "expo-document-picker"; +import { File } from "expo-file-system"; +import { useFocusEffect } from "expo-router"; +import { + getDrillTerms, + type Drill, + type DrillDocument, + type DrillRepository, +} from "@eight2five/mobile/drill"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + importEight2FiveDrillDocument, + parseDrillPickerResult, +} from "./drill-import"; +import { + deleteDrillAndRefreshSettings, + loadDrillList, + toError, + type DrillListEntry, +} from "./drill-management"; + +interface PendingImport { + readonly document: DrillDocument; + readonly fileName: string; +} + +export interface DrillPropertiesDialogState { + readonly drill: Drill; + readonly document?: DrillDocument; +} + +export interface PerformerSelectionDialogState { + readonly drill?: Drill; + readonly document: DrillDocument; +} + +export function useDrillListController() { + const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); + const [entries, setEntries] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(); + const [busyDrillId, setBusyDrillId] = React.useState(); + const [pendingImport, setPendingImport] = React.useState(); + const [selectedFileName, setSelectedFileName] = React.useState(); + const [uploadBusy, setUploadBusy] = React.useState(false); + const [importing, setImporting] = React.useState(false); + const [importError, setImportError] = React.useState(); + const [propertiesDialog, setPropertiesDialog] = + React.useState(); + const [propertiesLoading, setPropertiesLoading] = React.useState(false); + const [propertiesError, setPropertiesError] = React.useState(); + const [performerDialog, setPerformerDialog] = + React.useState(); + const [performerLoading, setPerformerLoading] = React.useState(false); + const [performerError, setPerformerError] = React.useState(); + const mutationInFlight = React.useRef(false); + const pickerInFlight = React.useRef(false); + + const refresh = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + setEntries(await loadDrillList(store.getDrillRepository())); + setError(undefined); + } catch (cause) { + setError(toError(cause)); + } finally { + setLoading(false); + } + }, [snapshot.status, store]); + + useFocusEffect( + React.useCallback(() => { + void refresh(); + }, [refresh]), + ); + + const mutate = React.useCallback( + async ( + drillId: string, + operation: (repository: DrillRepository) => Promise, + ) => { + if (mutationInFlight.current) { + throw new Error("Another drill update is in progress."); + } + mutationInFlight.current = true; + setBusyDrillId(drillId); + setError(undefined); + try { + const result = await operation(store.getDrillRepository()); + await refresh(); + return result; + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + mutationInFlight.current = false; + setBusyDrillId(undefined); + } + }, + [refresh, store], + ); + + const importDocument = React.useCallback( + async (document: DrillDocument, performerEntityId: number) => { + if (snapshot.status !== "ready" || mutationInFlight.current) return; + mutationInFlight.current = true; + setImporting(true); + setImportError(undefined); + setError(undefined); + try { + await importEight2FiveDrillDocument( + store.getDrillRepository(), + document, + performerEntityId, + ); + setPendingImport(undefined); + await refresh(); + } catch (cause) { + const importOperationError = toError(cause); + setImportError(importOperationError); + setError(importOperationError); + } finally { + mutationInFlight.current = false; + setImporting(false); + } + }, + [refresh, snapshot.status, store], + ); + + const pickFile = React.useCallback(async () => { + if ( + snapshot.status !== "ready" || + mutationInFlight.current || + pickerInFlight.current + ) { + return; + } + pickerInFlight.current = true; + setUploadBusy(true); + try { + const result = await DocumentPicker.getDocumentAsync({ + type: ["application/json", "text/json", "text/plain"], + copyToCacheDirectory: true, + multiple: false, + }); + if (result.canceled) return; + + const parsed = await parseDrillPickerResult(result, (uri) => + new File(uri).text(), + ); + if (!parsed) return; + + setSelectedFileName(parsed.fileName); + setError(undefined); + setImportError(undefined); + const performers = parsed.document.entities.filter( + (entity) => entity.type === "performer", + ); + if (performers.length > 1) { + setPendingImport(parsed); + return; + } + const performer = performers[0]; + if (!performer) return; + await importDocument(parsed.document, performer.id); + } catch (cause) { + const pickerError = toError(cause); + setError(pickerError); + setImportError(pickerError); + } finally { + pickerInFlight.current = false; + setUploadBusy(false); + } + }, [importDocument, snapshot.status]); + + const cancelPendingImport = React.useCallback(() => { + if (importing) return; + setPendingImport(undefined); + setImportError(undefined); + }, [importing]); + + const importPendingDocument = React.useCallback( + async (performerEntityId: number) => { + if (!pendingImport) return; + await importDocument(pendingImport.document, performerEntityId); + }, + [importDocument, pendingImport], + ); + + const toggleActive = React.useCallback( + async (drill: Drill) => { + if (mutationInFlight.current || snapshot.status !== "ready") return; + mutationInFlight.current = true; + setBusyDrillId(drill.id); + setError(undefined); + try { + await store.setActiveDrill( + snapshot.settings.activeDrillId === drill.id ? null : drill.id, + ); + } catch (cause) { + const operationError = toError(cause); + setError(operationError); + throw operationError; + } finally { + mutationInFlight.current = false; + setBusyDrillId(undefined); + } + }, + [snapshot.settings.activeDrillId, snapshot.status, store], + ); + + const openProperties = React.useCallback( + async (drill: Drill) => { + if (snapshot.status !== "ready") return; + setPropertiesDialog({ drill }); + setPropertiesLoading(true); + setPropertiesError(undefined); + try { + const document = await store + .getDrillRepository() + .getDrillDocument(drill.id); + setPropertiesDialog((current) => + current?.drill.id === drill.id ? { drill, document } : current, + ); + } catch (cause) { + setPropertiesError(toError(cause)); + } finally { + setPropertiesLoading(false); + } + }, + [snapshot.status, store], + ); + + const closeProperties = React.useCallback(() => { + if (!propertiesLoading) { + setPropertiesDialog(undefined); + setPropertiesError(undefined); + } + }, [propertiesLoading]); + + const openPerformerSelection = React.useCallback( + async (drill: Drill) => { + if (snapshot.status !== "ready") return; + setPerformerLoading(true); + setPerformerError(undefined); + try { + const document = await store + .getDrillRepository() + .getDrillDocument(drill.id); + if (!document) { + throw new Error( + "This drill does not contain an imported performer list.", + ); + } + setPerformerDialog({ drill, document }); + } catch (cause) { + setPerformerError(toError(cause)); + } finally { + setPerformerLoading(false); + } + }, + [snapshot.status, store], + ); + + const closePerformerSelection = React.useCallback(() => { + if (!busyDrillId) { + setPerformerDialog(undefined); + setPerformerError(undefined); + } + }, [busyDrillId]); + + const selectPerformer = React.useCallback( + async (performerEntityId: number) => { + const current = performerDialog; + if (!current?.drill) return; + try { + await mutate(current.drill.id, (repository) => + repository.setSelectedPerformer(current.drill!.id, performerEntityId), + ); + setPerformerDialog(undefined); + } catch (cause) { + setPerformerError(toError(cause)); + } + }, + [mutate, performerDialog], + ); + + const remove = React.useCallback( + async (drill: Drill) => + await mutate(drill.id, async (repository) => { + await deleteDrillAndRefreshSettings(repository, drill.id, () => + store.reload(), + ); + setPropertiesDialog(undefined); + }), + [mutate, store], + ); + + return { + entries, + loading: snapshot.status === "loading" || loading, + error: error ?? snapshot.error, + busyDrillId, + activeDrillId: snapshot.settings.activeDrillId, + terms: getDrillTerms(snapshot.settings.drillTerminology), + refresh, + pickFile, + uploadBusy, + selectedFileName, + pendingImport, + importing, + importError, + cancelPendingImport, + importPendingDocument, + toggleActive, + openProperties, + closeProperties, + propertiesDialog, + propertiesLoading, + propertiesError, + openPerformerSelection, + closePerformerSelection, + performerDialog, + performerLoading, + performerError, + selectPerformer, + remove, + } as const; +} diff --git a/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts b/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts new file mode 100644 index 00000000..07bf2c7f --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/effective-field-preset.test.ts @@ -0,0 +1,31 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; + +import { resolveEffectiveFieldPreset } from "../effective-field-preset"; + +const PRESETS = FIELD_PRESET_IDS; + +describe("effective marching field selection", () => { + test.each(PRESETS)( + "uses %s as the preference when no drill is loaded", + (preset) => { + expect(resolveEffectiveFieldPreset(undefined, preset)).toBe(preset); + }, + ); + + test.each([ + ["football-nfhs", "football-ncaa"], + ["football-ncaa", "football-nfhs"], + ["football-texas-uil", "football-ncaa"], + ["football-nfl", "football-nfhs"], + ] as const)( + "loaded %s drill overrides %s preference", + (drillPreset, defaultPreset) => { + expect( + resolveEffectiveFieldPreset( + { fieldPreset: drillPreset }, + defaultPreset, + ), + ).toBe(drillPreset); + }, + ); +}); diff --git a/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts b/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts new file mode 100644 index 00000000..0497773f --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/field-anchor-overlay-options.test.ts @@ -0,0 +1,32 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { fieldAnchorOverlayOptions } from "../field-anchor-overlay-options"; + +describe("field anchor overlay options", () => { + test("requires Developer Mode and cached geometry before showing range", () => { + expect( + fieldAnchorOverlayOptions({ + ...DEFAULT_APP_SETTINGS, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + }), + ).toEqual({ visible: false, showRange: false, rangeMeters: 20 }); + expect( + fieldAnchorOverlayOptions({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: true, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: true, + }), + ).toEqual({ visible: false, showRange: false, rangeMeters: 20 }); + expect( + fieldAnchorOverlayOptions({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: true, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + comfortableAnchorRangeMeters: 30, + }), + ).toEqual({ visible: true, showRange: true, rangeMeters: 30 }); + }); +}); diff --git a/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts new file mode 100644 index 00000000..fb019b46 --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/field-hud-state.test.ts @@ -0,0 +1,48 @@ +import type { DrillSet } from "@eight2five/mobile/drill"; + +import { + INITIAL_FIELD_HUD_STATE, + formatMeasureRange, + getDrillSetHudPresentation, + reduceFieldHudState, +} from "../field-hud-state"; + +const set = (overrides: Partial = {}): DrillSet => ({ + id: "set-2", + drillId: "drill-1", + ordinal: 1, + number: 2, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 3, end: 4 }, + position: { xSteps: 80, ySteps: 28 }, + ...overrides, +}); + +describe("field HUD state", () => { + test("keeps expansion as explicit session state", () => { + const expanded = reduceFieldHudState(INITIAL_FIELD_HUD_STATE, { + type: "toggle-drill-pill", + }); + expect(expanded.drillPillExpanded).toBe(true); + expect( + reduceFieldHudState(expanded, { type: "collapse-drill-pill" }), + ).toEqual({ drillPillExpanded: false }); + }); + + test("formats counts, measures, metrics, terminology, and coordinates centrally", () => { + const presentation = getDrillSetHudPresentation({ + page: set(), + previousPage: set({ id: "set-1", ordinal: 0, number: 1 }), + terminology: "pages", + metricMode: "crossing-counts", + }); + expect(presentation.term).toBe("Page"); + expect(presentation.counts).toBe("16"); + expect(presentation.measures).toBe("3–4"); + expect(presentation.metricLabel).toBe("xCounts"); + expect(presentation.coordinate).not.toBeNull(); + expect(formatMeasureRange(undefined)).toBe("–"); + expect(formatMeasureRange({ start: 5, end: 5 })).toBe("5"); + }); +}); diff --git a/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts new file mode 100644 index 00000000..edc30071 --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/field-overlay-layout.test.ts @@ -0,0 +1,99 @@ +import { getFieldOverlayMetrics } from "../field-overlay-layout"; + +const insets = { top: 24, right: 10, bottom: 20, left: 10 }; + +describe("Field overlay layout", () => { + test("places a safe-area-aware HUD and right live/dial stack in landscape", () => { + const layout = getFieldOverlayMetrics({ + width: 844, + height: 390, + landscape: true, + insets, + }); + + expect(layout.controlDiameter).toBeGreaterThanOrEqual(140); + expect(layout.controlDiameter).toBeLessThanOrEqual(164); + expect(layout.hudStyle.top).toBe(38); + expect(layout.hudStyle.left).toBe(24); + expect(layout.hudWidth).toBeGreaterThan(0); + expect(layout.liveStyle.right).toBe(layout.outerPadding); + expect(layout.dialStyle.right).toBe(layout.outerPadding); + expect(layout.liveStyle.right).toBe( + Number(layout.hudStyle.top) - insets.top, + ); + const topGap = Number(layout.liveStyle.top) - insets.top; + const betweenGap = + Number(layout.dialStyle.top) - + (Number(layout.liveStyle.top) + layout.controlDiameter); + const bottomGap = + 390 - + insets.bottom - + (Number(layout.dialStyle.top) + layout.controlDiameter); + expect(topGap).toBeCloseTo(layout.controlGap); + expect(betweenGap).toBeCloseTo(layout.controlGap); + expect(bottomGap).toBeCloseTo(layout.controlGap); + }); + + test("ignores the large iPhone side inset but keeps balanced landscape padding", () => { + const layout = getFieldOverlayMetrics({ + width: 844, + height: 390, + landscape: true, + insets: { top: 24, right: 48, bottom: 20, left: 10 }, + }); + + expect(layout.liveStyle.right).toBe(layout.outerPadding); + expect(layout.dialStyle.right).toBe(layout.outerPadding); + }); + + test("centers the live/dial pair above the bottom inset in portrait", () => { + const layout = getFieldOverlayMetrics({ + width: 390, + height: 844, + landscape: false, + insets, + }); + + expect(layout.controlDiameter).toBeGreaterThanOrEqual(140); + expect(layout.controlDiameter).toBeLessThanOrEqual(156); + expect(layout.hudStyle.left).toBe(22); + const leftGap = Number(layout.liveStyle.left) - insets.left; + const betweenGap = + Number(layout.dialStyle.left) - + (Number(layout.liveStyle.left) + layout.controlDiameter); + const rightGap = + 390 - + insets.right - + (Number(layout.dialStyle.left) + layout.controlDiameter); + expect(leftGap).toBeCloseTo(layout.controlGap); + expect(betweenGap).toBeCloseTo(layout.controlGap); + expect(rightGap).toBeCloseTo(layout.controlGap); + expect(layout.dialStyle.bottom).toBe(insets.bottom + layout.controlGap); + }); + + test("shrinks both portrait controls together on a narrow safe width", () => { + const layout = getFieldOverlayMetrics({ + width: 360, + height: 740, + landscape: false, + insets: { top: 20, right: 18, bottom: 20, left: 18 }, + }); + const safeWidth = 360 - 18 - 18; + expect(layout.controlDiameter * 2 + layout.controlGap * 3).toBeCloseTo( + safeWidth, + ); + }); + + test("uses the full safe landscape width for the drill-off live pill", () => { + const layout = getFieldOverlayMetrics({ + width: 844, + height: 390, + landscape: true, + insets, + controlPairVisible: false, + }); + expect(layout.hudWidth).toBe( + 844 - insets.left - insets.right - layout.outerPadding * 2, + ); + }); +}); diff --git a/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts new file mode 100644 index 00000000..680f40d5 --- /dev/null +++ b/apps/mobile/src/features/field/__tests__/live-position-hud-state.test.ts @@ -0,0 +1,55 @@ +import { EMPTY_FIELD_LIVE_POSITION_STATE } from "@eight2five/mobile/field"; + +import { getTargetDistancePresentation } from "../live-position-hud-state"; + +describe("live position HUD state", () => { + test("computes physical 8-to-5 distance and threshold tones", () => { + const live = { + ...EMPTY_FIELD_LIVE_POSITION_STATE, + connectionState: "connected" as const, + position: { xMeters: 0, yMeters: 0 }, + isStale: false, + }; + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 0.28575, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }), + ).toMatchObject({ value: "0.5 steps", tone: "success" }); + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 0.5715, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }), + ).toMatchObject({ value: "one step", tone: "warning" }); + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 1.143, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }).tone, + ).toBe("danger"); + expect( + getTargetDistancePresentation({ + live, + target: { xMeters: 0.5715 * 0.62, yMeters: 0 }, + greenThresholdSteps: 1, + yellowThresholdSteps: 2, + roundingSteps: 0.125, + }).value, + ).toBe("0.625 steps"); + expect( + getTargetDistancePresentation({ + live: { ...live, isStale: true }, + target: { xMeters: 0, yMeters: 0 }, + greenThresholdSteps: 0.5, + yellowThresholdSteps: 1, + }), + ).toEqual({ value: "–", tone: "muted" }); + }); +}); diff --git a/apps/mobile/src/features/field/coordinate-lines-view.tsx b/apps/mobile/src/features/field/coordinate-lines-view.tsx new file mode 100644 index 00000000..91119a96 --- /dev/null +++ b/apps/mobile/src/features/field/coordinate-lines-view.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { ArrowLeftRight, ArrowUpDown } from "lucide-react-native"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveFonts } from "@eight2five/ui/theme"; + +import type { CoordinateLines } from "./field-hud-state"; + +export function CoordinateLinesView({ + coordinate, + color, + mutedColor, + fontSize = 14, + lineHeight = 17, + iconSize = 13, +}: { + readonly coordinate: CoordinateLines | null; + readonly color: string; + readonly mutedColor: string; + readonly fontSize?: number; + readonly lineHeight?: number; + readonly iconSize?: number; +}) { + if (!coordinate) { + return ( + + – + + ); + } + + return ( + + + + + ); +} + +function CoordinateLine({ + icon, + value, + color, + iconColor, + fontSize, + lineHeight, + iconSize, +}: { + readonly icon: React.ElementType; + readonly value: string; + readonly color: string; + readonly iconColor: string; + readonly fontSize: number; + readonly lineHeight: number; + readonly iconSize: number; +}) { + return ( + + + + {value} + + + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts new file mode 100644 index 00000000..e858aae9 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-layout.test.ts @@ -0,0 +1,20 @@ +import { getDrillPillColumnMetrics } from "../drill-pill-layout"; + +describe("drill pill layout", () => { + test.each([ + [360, false], + [390, false], + [600, true], + ])("prioritizes a usable coordinate column at %ipx", (width, landscape) => { + const metrics = getDrillPillColumnMetrics(width, landscape); + const used = + metrics.horizontalPadding * 2 + + metrics.gap * 3 + + metrics.setWidth + + metrics.countWidth + + metrics.metricWidth + + metrics.coordinateWidth; + expect(used).toBeCloseTo(width); + expect(metrics.coordinateWidth).toBeGreaterThanOrEqual(100); + }); +}); diff --git a/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts new file mode 100644 index 00000000..408b5890 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/__tests__/drill-pill-presentation.test.ts @@ -0,0 +1,47 @@ +import { + getCountMetricPresentation, + getTransitionMetricPresentation, +} from "../drill-pill-presentation"; + +describe("drill pill metric modes", () => { + const rows = [ + { + term: "Set" as const, + set: "1", + counts: "0", + measures: "1", + metricLabel: "xCounts" as const, + metric: "–", + coordinate: null, + }, + { + term: "Set" as const, + set: "2", + counts: "16", + measures: "2–3", + metricLabel: "xCounts" as const, + metric: "8", + coordinate: null, + }, + ]; + + test("applies one count mode coherently to every visible row", () => { + expect( + rows.map((row) => getCountMetricPresentation(row, "measures")), + ).toMatchObject([ + { key: "measures", label: "Measures", value: "1" }, + { key: "measures", label: "Measures", value: "2–3" }, + ]); + }); + + test("uses the same animated contract for transition modes", () => { + expect( + rows.map((row) => + getTransitionMetricPresentation(row, "crossing-counts"), + ), + ).toMatchObject([ + { key: "crossing-counts", label: "xCounts" }, + { key: "crossing-counts", label: "xCounts" }, + ]); + }); +}); diff --git a/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx new file mode 100644 index 00000000..67c67ab1 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/animated-value-switch.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import Animated, { + FadeIn, + FadeOut, + ReduceMotion, +} from "react-native-reanimated"; + +export function AnimatedValueSwitch({ + displayKey, + children, + style, + testID, +}: { + readonly displayKey: string; + readonly children: React.ReactNode; + readonly style?: StyleProp; + readonly testID?: string; +}) { + const entering = FadeIn.duration(160).reduceMotion(ReduceMotion.System); + const exiting = FadeOut.duration(160).reduceMotion(ReduceMotion.System); + + return ( + + + {children} + + + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts new file mode 100644 index 00000000..befff644 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-pill-layout.ts @@ -0,0 +1,31 @@ +export interface DrillPillColumnMetrics { + readonly horizontalPadding: number; + readonly gap: number; + readonly setWidth: number; + readonly countWidth: number; + readonly metricWidth: number; + readonly coordinateWidth: number; +} + +export function getDrillPillColumnMetrics( + width: number, + landscape: boolean, +): DrillPillColumnMetrics { + const horizontalPadding = width < 380 || landscape ? 10 : 14; + const gap = width < 380 || landscape ? 6 : 10; + const contentWidth = Math.max(0, width - horizontalPadding * 2 - gap * 3); + const setWidth = Math.min(64, Math.max(48, contentWidth * 0.16)); + const countWidth = Math.min(82, Math.max(62, contentWidth * 0.2)); + const metricWidth = Math.min(96, Math.max(72, contentWidth * 0.23)); + return { + horizontalPadding, + gap, + setWidth, + countWidth, + metricWidth, + coordinateWidth: Math.max( + 0, + contentWidth - setWidth - countWidth - metricWidth, + ), + }; +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts new file mode 100644 index 00000000..d1227933 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-pill-presentation.ts @@ -0,0 +1,40 @@ +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import type { + CountDisplayMode, + DrillSetHudPresentation, +} from "../field-hud-state"; + +export interface AnimatedMetricPresentation { + readonly key: string; + readonly label: string; + readonly value: string; +} + +export function getCountMetricPresentation( + presentation: DrillSetHudPresentation, + mode: CountDisplayMode, +): AnimatedMetricPresentation { + return mode === "counts" + ? { + key: mode, + label: "Counts", + value: presentation.counts, + } + : { + key: mode, + label: "Measures", + value: presentation.measures, + }; +} + +export function getTransitionMetricPresentation( + presentation: DrillSetHudPresentation, + mode: TransitionMetricMode, +): AnimatedMetricPresentation { + return { + key: mode, + label: presentation.metricLabel, + value: presentation.metric, + }; +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-pill.tsx b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx new file mode 100644 index 00000000..d32b9b93 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-pill.tsx @@ -0,0 +1,150 @@ +import React from "react"; +import Animated, { + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; +import type { + CoordinateRoundingSteps, + TransitionMetricMode, +} from "@eight2five/mobile/settings"; +import { Divider } from "@eight2five/ui/components/divider"; +import { Text } from "@eight2five/ui/components/text"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + getDrillSetHudPresentation, + type CountDisplayMode, +} from "../field-hud-state"; +import { getDrillPillColumnMetrics } from "./drill-pill-layout"; +import { DRILL_SET_ROW_HEIGHT, DrillSetList } from "./drill-set-list"; +import { DrillSetMetricGrid } from "./drill-set-metric-grid"; +import { FrostedFieldSurface } from "../field-frosted-surface"; + +export function DrillPill({ + width, + landscape, + listMaxHeight, + pages, + selectedIndex, + terminology, + countDisplayMode, + metricMode, + fieldPreset, + coordinateRoundingSteps, + expanded, + controlsDisabled, + error, + onToggleCounts, + onToggleMetric, + onToggleExpanded, + onSelectIndex, +}: { + readonly width: number; + readonly landscape: boolean; + readonly listMaxHeight: number; + readonly pages: readonly DrillSet[]; + readonly selectedIndex: number; + readonly terminology: DrillTerminology; + readonly countDisplayMode: CountDisplayMode; + readonly metricMode: TransitionMetricMode; + readonly fieldPreset: FieldPresetId; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; + readonly expanded: boolean; + readonly controlsDisabled: boolean; + readonly error?: Error; + readonly onToggleCounts: () => void; + readonly onToggleMetric: () => void; + readonly onToggleExpanded?: () => void; + readonly onSelectIndex: (index: number) => void; +}) { + const theme = useEight2FiveTheme(); + const columns = React.useMemo( + () => getDrillPillColumnMetrics(width, landscape), + [landscape, width], + ); + const current = selectedIndex >= 0 ? pages[selectedIndex] : undefined; + const presentation = getDrillSetHudPresentation({ + page: current, + previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined, + metricMode, + fieldPreset, + terminology, + coordinateRoundingSteps, + }); + const availableListHeight = Math.min( + listMaxHeight, + Math.max(0, pages.length * DRILL_SET_ROW_HEIGHT + 1), + ); + const effectiveExpanded = Boolean(onToggleExpanded && expanded); + const animatedHeight = useSharedValue( + effectiveExpanded ? availableListHeight : 0, + ); + React.useEffect(() => { + animatedHeight.value = withTiming( + effectiveExpanded ? availableListHeight : 0, + { + duration: 220, + reduceMotion: ReduceMotion.System, + }, + ); + }, [animatedHeight, availableListHeight, effectiveExpanded]); + const listStyle = useAnimatedStyle(() => ({ height: animatedHeight.value })); + + return ( + + + {error ? ( + + {error.message} + + ) : null} + + + + + + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx new file mode 100644 index 00000000..46ade22c --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-set-list.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { FlatList, type ListRenderItemInfo } from "react-native"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import type { DrillSet, DrillTerminology } from "@eight2five/mobile/drill"; +import type { + CoordinateRoundingSteps, + TransitionMetricMode, +} from "@eight2five/mobile/settings"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + getDrillSetHudPresentation, + type CountDisplayMode, +} from "../field-hud-state"; +import { DrillSetMetricGrid } from "./drill-set-metric-grid"; +import type { DrillPillColumnMetrics } from "./drill-pill-layout"; + +export const DRILL_SET_ROW_HEIGHT = 104; + +export function DrillSetList({ + pages, + selectedIndex, + columns, + countDisplayMode, + metricMode, + terminology, + fieldPreset, + coordinateRoundingSteps, + expanded, + onSelectIndex, +}: { + readonly pages: readonly DrillSet[]; + readonly selectedIndex: number; + readonly columns: DrillPillColumnMetrics; + readonly countDisplayMode: CountDisplayMode; + readonly metricMode: TransitionMetricMode; + readonly terminology: DrillTerminology; + readonly fieldPreset: FieldPresetId; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; + readonly expanded: boolean; + readonly onSelectIndex: (index: number) => void; +}) { + const theme = useEight2FiveTheme(); + const listRef = React.useRef>(null); + + React.useEffect(() => { + if (!expanded || pages.length === 0) return; + const frame = requestAnimationFrame(() => { + listRef.current?.scrollToOffset({ offset: 0, animated: false }); + }); + return () => cancelAnimationFrame(frame); + }, [expanded, pages.length]); + + const renderItem = React.useCallback( + ({ item, index }: ListRenderItemInfo) => { + const selected = index === selectedIndex; + const presentation = getDrillSetHudPresentation({ + page: item, + previousPage: index > 0 ? pages[index - 1] : undefined, + metricMode, + fieldPreset, + terminology, + coordinateRoundingSteps, + }); + return ( + onSelectIndex(index)} + style={{ + height: DRILL_SET_ROW_HEIGHT, + justifyContent: "center", + backgroundColor: selected ? theme.accentSoft : "transparent", + }} + testID={`drill-set-row-${index}`} + > + + + ); + }, + [ + columns, + countDisplayMode, + coordinateRoundingSteps, + fieldPreset, + metricMode, + onSelectIndex, + pages, + selectedIndex, + terminology, + theme.accentSoft, + ], + ); + + return ( + page.id} + getItemLayout={(_data, index) => ({ + index, + length: DRILL_SET_ROW_HEIGHT, + offset: DRILL_SET_ROW_HEIGHT * index, + })} + extraData={`${countDisplayMode}:${metricMode}:${selectedIndex}`} + style={{ flex: 1 }} + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + nestedScrollEnabled + keyboardShouldPersistTaps="handled" + onScrollToIndexFailed={({ index }) => { + listRef.current?.scrollToOffset({ + offset: Math.max(0, index * DRILL_SET_ROW_HEIGHT), + animated: false, + }); + }} + testID="drill-set-list" + /> + ); +} diff --git a/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx new file mode 100644 index 00000000..23cff9d5 --- /dev/null +++ b/apps/mobile/src/features/field/drill-pill/drill-set-metric-grid.tsx @@ -0,0 +1,334 @@ +import React from "react"; +import { ChevronDown, ChevronUp } from "lucide-react-native"; +import Animated, { + interpolateColor, + ReduceMotion, + useAnimatedStyle, + useSharedValue, + withTiming, +} from "react-native-reanimated"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; +import type { TransitionMetricMode } from "@eight2five/mobile/settings"; + +import type { + CountDisplayMode, + DrillSetHudPresentation, +} from "../field-hud-state"; +import { CoordinateLinesView } from "../coordinate-lines-view"; +import { AnimatedValueSwitch } from "./animated-value-switch"; +import type { DrillPillColumnMetrics } from "./drill-pill-layout"; +import { + getCountMetricPresentation, + getTransitionMetricPresentation, +} from "./drill-pill-presentation"; + +export const DrillSetMetricGrid = React.memo(function DrillSetMetricGrid({ + presentation, + columns, + countDisplayMode, + metricMode, + header = false, + expanded = false, + onToggleCounts, + onToggleMetric, + onToggleExpanded, +}: { + readonly presentation: DrillSetHudPresentation; + readonly columns: DrillPillColumnMetrics; + readonly countDisplayMode: CountDisplayMode; + readonly metricMode: TransitionMetricMode; + readonly header?: boolean; + readonly expanded?: boolean; + readonly onToggleCounts?: () => void; + readonly onToggleMetric?: () => void; + readonly onToggleExpanded?: () => void; +}) { + const theme = useEight2FiveTheme(); + const labelColor = theme.textMuted; + const valueColor = theme.text; + const count = getCountMetricPresentation(presentation, countDisplayMode); + const metric = getTransitionMetricPresentation(presentation, metricMode); + + return ( + + + + + + + + + + + + + Coordinate + + + + {header && onToggleExpanded ? ( + + ) : null} + + + + ); +}); + +function MetricCell({ + width, + label, + value, + labelColor, + valueColor, +}: { + readonly width?: number; + readonly label: string; + readonly value: string; + readonly labelColor: string; + readonly valueColor: string; +}) { + return ( + + + + ); +} + +function SwitchingMetricCell({ + displayKey, + label, + value, + labelColor, + valueColor, + modeIndex, + testID, +}: { + readonly displayKey: string; + readonly label: string; + readonly value: string; + readonly labelColor: string; + readonly valueColor: string; + readonly modeIndex: 0 | 1; + readonly testID?: string; +}) { + return ( + + + + + + + ); +} + +function ModeIndicator({ + modeIndex, + labelColor, + valueColor, +}: { + readonly modeIndex: 0 | 1; + readonly labelColor: string; + readonly valueColor: string; +}) { + const progress = useSharedValue(modeIndex); + React.useEffect(() => { + progress.value = withTiming(modeIndex, { + duration: 180, + reduceMotion: ReduceMotion.System, + }); + }, [modeIndex, progress]); + + const topStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.value, + [0, 1], + [valueColor, labelColor], + ), + opacity: 1 - progress.value * 0.5, + })); + const bottomStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.value, + [0, 1], + [labelColor, valueColor], + ), + opacity: 0.5 + progress.value * 0.5, + })); + const dotStyle = { + width: 5, + height: 5, + borderRadius: 2.5, + } as const; + + return ( + + + + + ); +} + +function MetricText({ + label, + value, + labelColor, + valueColor, +}: { + readonly label: string; + readonly value: string; + readonly labelColor: string; + readonly valueColor: string; +}) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/apps/mobile/src/features/field/effective-field-preset.ts b/apps/mobile/src/features/field/effective-field-preset.ts new file mode 100644 index 00000000..7bd79c08 --- /dev/null +++ b/apps/mobile/src/features/field/effective-field-preset.ts @@ -0,0 +1,10 @@ +import type { Drill } from "@eight2five/mobile/drill"; +import type { FieldPresetId } from "@eight2five/drill-schema"; + +/** A loaded drill owns its field; the preference is only the no-drill default. */ +export function resolveEffectiveFieldPreset( + activeDrill: Pick | undefined, + defaultFieldPreset: FieldPresetId, +): FieldPresetId { + return activeDrill?.fieldPreset ?? defaultFieldPreset; +} diff --git a/apps/mobile/src/features/field/field-anchor-overlay-options.ts b/apps/mobile/src/features/field/field-anchor-overlay-options.ts new file mode 100644 index 00000000..961a469c --- /dev/null +++ b/apps/mobile/src/features/field/field-anchor-overlay-options.ts @@ -0,0 +1,16 @@ +import type { FieldAnchorOverlayOptions } from "@eight2five/mobile/field"; +import { + getEffectiveDeveloperOverlaySettings, + type AppSettings, +} from "@eight2five/mobile/settings"; + +export function fieldAnchorOverlayOptions( + settings: AppSettings, +): FieldAnchorOverlayOptions { + const effective = getEffectiveDeveloperOverlaySettings(settings); + return { + visible: effective.showCachedAnchorGeometry, + showRange: effective.showComfortableAnchorRange, + rangeMeters: settings.comfortableAnchorRangeMeters, + }; +} diff --git a/apps/mobile/src/features/field/field-frosted-surface.tsx b/apps/mobile/src/features/field/field-frosted-surface.tsx new file mode 100644 index 00000000..82ed1766 --- /dev/null +++ b/apps/mobile/src/features/field/field-frosted-surface.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { BlurView } from "expo-blur"; +import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native"; +import { + useEight2FiveTheme, + useEight2FiveThemeName, +} from "@eight2five/ui/theme"; + +export const FieldBlurTargetContext = + React.createContext | null>(null); + +export function FrostedFieldSurface({ + children, + borderRadius, + style, + intensity = 82, + overlayOpacity = 0.72, + shadow = true, + testID, + accessibilityLabel, +}: { + readonly children: React.ReactNode; + readonly borderRadius: number; + readonly style?: StyleProp; + readonly intensity?: number; + readonly overlayOpacity?: number; + readonly shadow?: boolean; + readonly testID?: string; + readonly accessibilityLabel?: string; +}) { + const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); + const blurTarget = React.useContext(FieldBlurTargetContext); + + return ( + + + + {children} + + ); +} + +function colorWithOpacity(color: string, opacity: number): string { + const hex = /^#([0-9a-f]{6})$/i.exec(color); + if (!hex) return color; + const value = hex[1]; + const red = Number.parseInt(value.slice(0, 2), 16); + const green = Number.parseInt(value.slice(2, 4), 16); + const blue = Number.parseInt(value.slice(4, 6), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} diff --git a/apps/mobile/src/features/field/field-hud-state.ts b/apps/mobile/src/features/field/field-hud-state.ts new file mode 100644 index 00000000..1c88de75 --- /dev/null +++ b/apps/mobile/src/features/field/field-hud-state.ts @@ -0,0 +1,134 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; +import { + drillGridPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, +} from "@eight2five/mobile/field"; +import { + formatSetName, + getDrillTerms, + type DrillSet, + type DrillTerminology, +} from "@eight2five/mobile/drill"; +import type { + CoordinateRoundingSteps, + TransitionMetricMode, +} from "@eight2five/mobile/settings"; + +import { getTransitionPresentation } from "../drill/transition-presentation"; + +export type { CountDisplayMode } from "@eight2five/mobile/settings"; + +export interface FieldHudState { + readonly drillPillExpanded: boolean; +} + +export type FieldHudAction = + | { readonly type: "toggle-drill-pill" } + | { readonly type: "collapse-drill-pill" }; + +export const INITIAL_FIELD_HUD_STATE: FieldHudState = Object.freeze({ + drillPillExpanded: false, +}); + +export function reduceFieldHudState( + state: FieldHudState, + action: FieldHudAction, +): FieldHudState { + switch (action.type) { + case "toggle-drill-pill": + return { ...state, drillPillExpanded: !state.drillPillExpanded }; + case "collapse-drill-pill": + return state.drillPillExpanded + ? { ...state, drillPillExpanded: false } + : state; + } +} + +export interface CoordinateLines { + readonly side: string; + readonly frontBack: string; +} + +export interface DrillSetHudPresentation { + readonly term: "Page" | "Set"; + readonly set: string; + readonly counts: string; + readonly measures: string; + readonly metricLabel: "Step Size" | "xCounts"; + readonly metric: string; + readonly coordinate: CoordinateLines | null; + readonly emptyMessage?: string; +} + +export function formatDrillCoordinateLines( + position: DrillSet["position"], + fieldPreset: FieldPresetId = "football-nfhs", + roundingSteps: CoordinateRoundingSteps = 0.25, +): CoordinateLines { + const coordinate = drillGridPointToMarchingCoordinate(position, fieldPreset); + return { + side: formatMarchingSide(coordinate.side, roundingSteps), + frontBack: formatMarchingFrontBack( + coordinate.frontBack, + fieldPreset, + roundingSteps, + ), + }; +} + +export function getDrillSetHudPresentation({ + page, + previousPage, + metricMode, + fieldPreset = "football-nfhs", + terminology, + coordinateRoundingSteps = 0.25, +}: { + readonly page?: DrillSet; + readonly previousPage?: DrillSet; + readonly metricMode: TransitionMetricMode; + readonly fieldPreset?: FieldPresetId; + readonly terminology: DrillTerminology; + readonly coordinateRoundingSteps?: CoordinateRoundingSteps; +}): DrillSetHudPresentation { + const terms = getDrillTerms(terminology); + const metricLabel = metricMode === "step-size" ? "Step Size" : "xCounts"; + if (!page) { + return { + term: terms.singular, + set: "–", + counts: "–", + measures: "–", + metricLabel, + metric: "–", + coordinate: null, + emptyMessage: `No drill ${terms.lowercaseSingular} selected`, + }; + } + + const transition = getTransitionPresentation(previousPage, page); + return { + term: terms.singular, + set: formatSetName(page), + counts: String(page.countsFromPrevious), + measures: formatMeasureRange(page.measureRange), + metricLabel, + metric: + metricMode === "step-size" + ? transition.stepSize + : transition.crossingCounts, + coordinate: formatDrillCoordinateLines( + page.position, + fieldPreset, + coordinateRoundingSteps, + ), + }; +} + +export function formatMeasureRange(range: DrillSet["measureRange"]): string { + if (!range) return "–"; + return range.start === range.end + ? String(range.start) + : `${range.start}–${range.end}`; +} diff --git a/apps/mobile/src/features/field/field-overlay-layout.tsx b/apps/mobile/src/features/field/field-overlay-layout.tsx new file mode 100644 index 00000000..07f483d9 --- /dev/null +++ b/apps/mobile/src/features/field/field-overlay-layout.tsx @@ -0,0 +1,208 @@ +import React from "react"; +import { BlurTargetView } from "expo-blur"; +import { View, type ViewStyle } from "react-native"; + +import { FieldBlurTargetContext } from "./field-frosted-surface"; +import { + useSafeAreaInsets, + type EdgeInsets, +} from "react-native-safe-area-context"; + +export interface FieldOverlayMetrics { + readonly outerPadding: number; + readonly controlGap: number; + readonly controlDiameter: number; + /** @deprecated Use controlDiameter. */ + readonly dialDiameter: number; + readonly hudWidth: number; + readonly hudListMaxHeight: number; + readonly hudStyle: ViewStyle; + readonly liveStyle: ViewStyle; + readonly dialStyle: ViewStyle; +} + +export function getFieldOverlayMetrics({ + width, + height, + landscape, + insets, + controlPairVisible = true, +}: { + readonly width: number; + readonly height: number; + readonly landscape: boolean; + readonly insets: EdgeInsets; + readonly controlPairVisible?: boolean; +}): FieldOverlayMetrics { + const outerPadding = landscape ? 14 : 12; + const safeWidth = Math.max(0, width - insets.left - insets.right); + const safeHeight = Math.max(0, height - insets.top - insets.bottom); + + if (landscape) { + const landscapeEdgePadding = outerPadding; + const maximumFittingDiameter = Math.max( + 0, + (safeHeight - outerPadding * 3) / 2, + ); + const controlDiameter = Math.min(164, maximumFittingDiameter); + const controlGap = Math.max(0, (safeHeight - controlDiameter * 2) / 3); + const stackTop = insets.top + controlGap; + // NativeTabs are hidden on the landscape Field route. Match the right + // visual margin to the same padding used between the status-bar safe area + // and the top HUD instead of inheriting the much larger side safe inset. + const right = landscapeEdgePadding; + const columnLeft = width - right - controlDiameter; + const hudLeft = insets.left + outerPadding; + const hudWidth = controlPairVisible + ? Math.max(0, Math.min(720, columnLeft - controlGap - hudLeft)) + : Math.max(0, safeWidth - outerPadding * 2); + const hudTop = insets.top + outerPadding; + return { + outerPadding, + controlGap, + controlDiameter, + dialDiameter: controlDiameter, + hudWidth, + hudListMaxHeight: Math.min( + 320, + Math.max(0, height - insets.bottom - outerPadding - hudTop - 82), + ), + hudStyle: { + position: "absolute", + top: hudTop, + left: hudLeft, + width: hudWidth, + }, + liveStyle: { + position: "absolute", + right, + top: stackTop, + width: controlDiameter, + height: controlDiameter, + }, + dialStyle: { + position: "absolute", + right, + top: stackTop + controlDiameter + controlGap, + width: controlDiameter, + height: controlDiameter, + }, + }; + } + + const maximumFittingDiameter = Math.max( + 0, + (safeWidth - outerPadding * 3) / 2, + ); + const controlDiameter = Math.min(156, maximumFittingDiameter); + const controlGap = Math.max(0, (safeWidth - controlDiameter * 2) / 3); + const pairLeft = insets.left + controlGap; + const controlsBottom = insets.bottom + controlGap; + const controlsTop = height - controlsBottom - controlDiameter; + const hudLeft = insets.left + outerPadding; + const hudWidth = Math.max(0, safeWidth - outerPadding * 2); + const hudTop = insets.top + outerPadding; + return { + outerPadding, + controlGap, + controlDiameter, + dialDiameter: controlDiameter, + hudWidth, + hudListMaxHeight: Math.min( + 360, + Math.max(0, controlsTop - controlGap - hudTop - 82), + ), + hudStyle: { + position: "absolute", + top: hudTop, + left: hudLeft, + width: hudWidth, + }, + liveStyle: { + position: "absolute", + left: pairLeft, + bottom: controlsBottom, + width: controlDiameter, + height: controlDiameter, + }, + dialStyle: { + position: "absolute", + left: pairLeft + controlDiameter + controlGap, + bottom: controlsBottom, + width: controlDiameter, + height: controlDiameter, + }, + }; +} + +interface FieldOverlayLayoutProps { + readonly width: number; + readonly height: number; + readonly landscape: boolean; + readonly controlPairVisible?: boolean; + readonly field: React.ReactNode; + readonly hud?: (metrics: FieldOverlayMetrics) => React.ReactNode; + readonly live?: (diameter: number) => React.ReactNode; + readonly dial?: (diameter: number) => React.ReactNode; +} + +export function FieldOverlayLayout({ + width, + height, + landscape, + controlPairVisible = true, + field, + hud, + live, + dial, +}: FieldOverlayLayoutProps) { + const insets = useSafeAreaInsets(); + const blurTargetRef = React.useRef(null); + const metrics = getFieldOverlayMetrics({ + width, + height, + landscape, + insets, + controlPairVisible, + }); + + return ( + + + + {field} + + {hud ? ( + + {hud(metrics)} + + ) : null} + {live ? ( + + {live(metrics.controlDiameter)} + + ) : null} + {dial ? ( + + {dial(metrics.controlDiameter)} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/field/field-screen.tsx b/apps/mobile/src/features/field/field-screen.tsx new file mode 100644 index 00000000..87f09fd6 --- /dev/null +++ b/apps/mobile/src/features/field/field-screen.tsx @@ -0,0 +1,331 @@ +import React from "react"; +import { + EMPTY_FIELD_LIVE_POSITION_STATE, + drillGridPointToFieldPoint, + shouldShowFieldGuidanceForScene, + shouldShowFieldTarget, + resolveCurrentTargetPosition, + type FieldAnchorGeometry, + type FieldAnchorOverlayOptions, + type FieldLivePositionInput, + type FieldPoint, +} from "@eight2five/mobile/field"; +import { formatSetName } from "@eight2five/mobile/drill"; +import { + FIELD_FOUR_STEP_GRID_COLOR, + FieldCanvas, +} from "@eight2five/mobile/field/render"; +import { useEight2FiveTheme } from "@eight2five/ui/theme"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; + +import { FieldOverlayLayout } from "./field-overlay-layout"; +import { useFieldScreenController } from "./use-field-screen-controller"; +import { DrillSelectionDialog } from "../drill/components/drill-selection-dialog"; +import { PerformerSelectionDialog } from "../drill/components/performer-selection-dialog"; +import { DrillPill } from "./drill-pill/drill-pill"; +import { + INITIAL_FIELD_HUD_STATE, + reduceFieldHudState, +} from "./field-hud-state"; +import { LiveOnlyPill, LivePositionSquare } from "./live-position-hud"; +import { PageDial } from "./page-dial/page-dial"; +import { TagConnectionDialog } from "./tag-connection-dialog"; + +const EMPTY_ANCHORS: readonly FieldAnchorGeometry[] = Object.freeze([]); + +function setLivePositionValue( + sharedValue: SharedValue, + position: FieldPoint | null, +): void { + sharedValue.value = position; +} + +export function FieldScreen({ + livePosition, + anchors = EMPTY_ANCHORS, + anchorOverlayOptions, +}: { + readonly livePosition?: FieldLivePositionInput; + readonly anchors?: readonly FieldAnchorGeometry[]; + readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; +}) { + const theme = useEight2FiveTheme(); + const controller = useFieldScreenController(); + const [hudState, dispatchHud] = React.useReducer( + reduceFieldHudState, + INITIAL_FIELD_HUD_STATE, + ); + const [drillDialogOpen, setDrillDialogOpen] = React.useState(false); + const [performerDialogOpen, setPerformerDialogOpen] = React.useState(false); + const [tagDialogOpen, setTagDialogOpen] = React.useState(false); + const pansLiveState = livePosition?.state ?? EMPTY_FIELD_LIVE_POSITION_STATE; + const mockLivePositionEnabled = + controller.settings.developerModeEnabled && + controller.settings.mockLivePositionEnabled; + const mockPosition = React.useMemo( + () => + mockLivePositionEnabled + ? drillGridPointToFieldPoint( + { + xSteps: controller.settings.mockLivePositionXSteps, + ySteps: controller.settings.mockLivePositionYSteps, + }, + controller.fieldPreset, + ) + : null, + [ + controller.fieldPreset, + controller.settings.mockLivePositionXSteps, + controller.settings.mockLivePositionYSteps, + mockLivePositionEnabled, + ], + ); + const liveState = mockPosition + ? { + ...pansLiveState, + position: mockPosition, + isStale: false, + interpolationActive: false, + } + : pansLiveState; + const fallbackLivePosition = useSharedValue( + pansLiveState.position ?? null, + ); + const mockLivePosition = useSharedValue(mockPosition); + const livePositionValue = mockLivePositionEnabled + ? mockLivePosition + : (livePosition?.positionValue ?? fallbackLivePosition); + const liveXMeters = pansLiveState.position?.xMeters; + const liveYMeters = pansLiveState.position?.yMeters; + React.useEffect(() => { + if (livePosition?.positionValue) return; + setLivePositionValue( + fallbackLivePosition, + liveXMeters === undefined || liveYMeters === undefined + ? null + : { xMeters: liveXMeters, yMeters: liveYMeters }, + ); + }, [ + fallbackLivePosition, + livePosition?.positionValue, + liveXMeters, + liveYMeters, + ]); + React.useEffect(() => { + setLivePositionValue(mockLivePosition, mockPosition); + }, [mockLivePosition, mockPosition]); + const activeDrillId = controller.settings.activeDrillId; + React.useEffect(() => { + dispatchHud({ type: "collapse-drill-pill" }); + }, [activeDrillId]); + const drillOverlayState = { + drillFeaturesEnabled: controller.settings.drillFeaturesEnabled, + hasActiveDrill: Boolean(controller.activeDrill), + hasSelectedPage: Boolean(controller.selectedPage), + hasLivePosition: Boolean(liveState.position) && !liveState.isStale, + guidanceEnabled: controller.settings.guidanceEnabled, + }; + const fallbackTargetPosition = + shouldShowFieldTarget(drillOverlayState) && controller.selectedPage + ? drillGridPointToFieldPoint( + controller.selectedPage.position, + controller.fieldPreset, + ) + : undefined; + const drillScene = controller.settings.drillFeaturesEnabled + ? controller.drillScene + : undefined; + const targetPolicy = { + fullDrillSceneAvailable: drillScene !== undefined, + sceneHasCurrent: + drillScene?.current !== undefined && drillScene?.current !== null, + legacyFallbackAvailable: fallbackTargetPosition !== undefined, + } as const; + const targetPosition = resolveCurrentTargetPosition({ + fullDrillSceneAvailable: targetPolicy.fullDrillSceneAvailable, + sceneCurrent: drillScene?.current, + legacyFallback: fallbackTargetPosition, + }); + const guidanceVisible = shouldShowFieldGuidanceForScene( + drillOverlayState, + targetPolicy, + ); + const controlsDisabled = + controller.settingsStatus !== "ready" || + controller.loadingDrills || + controller.selectionBusy; + const canExpandDrillPill = Boolean( + controller.activeDrill && controller.pages.length > 0, + ); + const palette = React.useMemo( + () => ({ + canvasBackground: theme.background, + stepGrid: theme.textSubtle, + fieldBackground: theme.surfaceRaised, + fourStepGrid: FIELD_FOUR_STEP_GRID_COLOR, + fieldLines: theme.textMuted, + fieldNumbers: theme.textMuted, + livePosition: theme.accent, + guidance: theme.accent, + anchor: theme.accent, + anchorRange: colorWithAlpha(theme.accent, "24"), + }), + [theme], + ); + + return ( + <> + + } + hud={(metrics) => + controller.settings.drillFeaturesEnabled ? ( + void controller.toggleCountDisplayMode()} + onToggleMetric={() => void controller.toggleMetricMode()} + onToggleExpanded={ + canExpandDrillPill + ? () => dispatchHud({ type: "toggle-drill-pill" }) + : undefined + } + onSelectIndex={(index) => + void controller.selectPageAtIndex(index) + } + /> + ) : ( + setTagDialogOpen(true)} + /> + ) + } + live={ + controller.settings.drillFeaturesEnabled + ? (diameter) => ( + setTagDialogOpen(true)} + /> + ) + : undefined + } + dial={ + controller.settings.drillFeaturesEnabled + ? (diameter) => ( + + void controller.selectPageAtIndex(index) + } + onSelectDrill={() => setDrillDialogOpen(true)} + onSelectPerformer={ + controller.activeDrillDocument + ? () => setPerformerDialogOpen(true) + : undefined + } + /> + ) + : undefined + } + /> + setDrillDialogOpen(false)} + /> + setPerformerDialogOpen(false)} + onConfirm={async (performerEntityId) => { + const saved = await controller.selectPerformer(performerEntityId); + if (saved) setPerformerDialogOpen(false); + }} + /> + setTagDialogOpen(false)} + /> + + ); +} + +function colorWithAlpha(color: string, alpha: string): string { + return /^#[0-9a-f]{6}$/i.test(color) ? `${color}${alpha}` : color; +} diff --git a/apps/mobile/src/features/field/live-position-hud-state.ts b/apps/mobile/src/features/field/live-position-hud-state.ts new file mode 100644 index 00000000..db99aba2 --- /dev/null +++ b/apps/mobile/src/features/field/live-position-hud-state.ts @@ -0,0 +1,73 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings"; +import { + fieldPointToMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, + formatMarchingSteps, + metersToStandardSteps, + type FieldLivePositionState, + type FieldPoint, +} from "@eight2five/mobile/field"; + +import type { CoordinateLines } from "./field-hud-state"; + +export function getLiveCoordinateLines( + live: FieldLivePositionState, + fieldPreset: FieldPresetId = "football-nfhs", + roundingSteps: CoordinateRoundingSteps = 0.25, +): CoordinateLines | null { + if (!live.position || live.isStale) return null; + const coordinate = fieldPointToMarchingCoordinate(live.position, fieldPreset); + return { + side: formatMarchingSide(coordinate.side, roundingSteps), + frontBack: formatMarchingFrontBack( + coordinate.frontBack, + fieldPreset, + roundingSteps, + ), + }; +} + +export type DistanceTone = "success" | "warning" | "danger" | "muted"; + +export interface TargetDistancePresentation { + readonly steps?: number; + readonly value: string; + readonly tone: DistanceTone; +} + +export function getTargetDistancePresentation({ + live, + target, + greenThresholdSteps, + yellowThresholdSteps, + roundingSteps = 0.25, +}: { + readonly live: FieldLivePositionState; + readonly target?: FieldPoint; + readonly greenThresholdSteps: number; + readonly yellowThresholdSteps: number; + readonly roundingSteps?: CoordinateRoundingSteps; +}): TargetDistancePresentation { + if (!live.position || live.isStale || !target) { + return { value: "–", tone: "muted" }; + } + + const distanceMeters = Math.hypot( + live.position.xMeters - target.xMeters, + live.position.yMeters - target.yMeters, + ); + const steps = metersToStandardSteps(distanceMeters); + const roundedSteps = formatMarchingSteps(steps, roundingSteps); + return { + steps, + value: Number(roundedSteps) === 1 ? "one step" : `${roundedSteps} steps`, + tone: + steps <= greenThresholdSteps + ? "success" + : steps <= yellowThresholdSteps + ? "warning" + : "danger", + }; +} diff --git a/apps/mobile/src/features/field/live-position-hud.tsx b/apps/mobile/src/features/field/live-position-hud.tsx new file mode 100644 index 00000000..5428fc38 --- /dev/null +++ b/apps/mobile/src/features/field/live-position-hud.tsx @@ -0,0 +1,343 @@ +import React from "react"; +import { Animated, Easing } from "react-native"; +import { + BluetoothConnected, + BluetoothOff, + LoaderCircle, + RulerDimensionLine, + TriangleAlert, +} from "lucide-react-native"; +import type { + FieldConnectionState, + FieldLivePositionState, + FieldPoint, +} from "@eight2five/mobile/field"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings"; +import { Divider } from "@eight2five/ui/components/divider"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { + getLiveCoordinateLines, + getTargetDistancePresentation, + type DistanceTone, +} from "./live-position-hud-state"; +import { CoordinateLinesView } from "./coordinate-lines-view"; +import { FrostedFieldSurface } from "./field-frosted-surface"; + +export function LivePositionSquare({ + diameter, + live, + target, + fieldPreset, + greenThresholdSteps, + yellowThresholdSteps, + coordinateRoundingSteps, + onOpenTagConnection, +}: { + readonly diameter: number; + readonly live: FieldLivePositionState; + readonly target?: FieldPoint; + readonly fieldPreset: FieldPresetId; + readonly greenThresholdSteps: number; + readonly yellowThresholdSteps: number; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; + readonly onOpenTagConnection: () => void; +}) { + const theme = useEight2FiveTheme(); + const distance = getTargetDistancePresentation({ + live, + target, + greenThresholdSteps, + yellowThresholdSteps, + roundingSteps: coordinateRoundingSteps, + }); + const distanceColor = colorForDistanceTone(distance.tone, theme); + const dividerThickness = 1; + const iconColumnWidth = 32; + const lowerSectionHeight = Math.max(0, (diameter - dividerThickness) / 3); + const sectionPadding = Math.max( + 0, + (lowerSectionHeight - iconColumnWidth) / 2, + ); + const rowGap = eight2FiveSpacing.sm; + + const radius = Math.min(eight2FiveRadii.lg, diameter * 0.16); + return ( + + + + + + + + + + + + {distance.value} + + + + + ); +} + +export function LiveOnlyPill({ + width, + live, + fieldPreset, + coordinateRoundingSteps, + onOpenTagConnection, +}: { + readonly width: number; + readonly live: FieldLivePositionState; + readonly fieldPreset: FieldPresetId; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; + readonly onOpenTagConnection: () => void; +}) { + return ( + + + + ); +} + +function LivePositionHeader({ + live, + fieldPreset, + compact = false, + horizontalPadding, + gap, + iconColumnWidth, + coordinateRoundingSteps, + onOpenTagConnection, +}: { + readonly live: FieldLivePositionState; + readonly fieldPreset: FieldPresetId; + readonly compact?: boolean; + readonly horizontalPadding?: number; + readonly gap?: number; + readonly iconColumnWidth?: number; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; + readonly onOpenTagConnection: () => void; +}) { + const theme = useEight2FiveTheme(); + const coordinate = getLiveCoordinateLines( + live, + fieldPreset, + coordinateRoundingSteps, + ); + return ( + + + + + + + ); +} + +function BluetoothStatusButton({ + state, + size = 44, + onPress, +}: { + readonly state: FieldConnectionState; + readonly size?: number; + readonly onPress: () => void; +}) { + const theme = useEight2FiveTheme(); + const [spin] = React.useState(() => new Animated.Value(0)); + const animated = state === "connecting" || state === "reconnecting"; + React.useEffect(() => { + if (!animated) { + spin.stopAnimation(); + spin.setValue(0); + return; + } + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [animated, spin]); + + const presentation = getConnectionIconPresentation(state); + const color = + presentation.tone === "success" + ? theme.success + : presentation.tone === "accent" + ? theme.accent + : presentation.tone === "danger" + ? theme.danger + : theme.textMuted; + const icon = ; + + return ( + + {animated ? ( + + {icon} + + ) : ( + icon + )} + + ); +} + +function getConnectionIconPresentation(state: FieldConnectionState) { + switch (state) { + case "connected": + return { + label: "Connected", + icon: BluetoothConnected, + tone: "success" as const, + }; + case "connecting": + case "reconnecting": + return { + label: state === "connecting" ? "Connecting" : "Reconnecting", + icon: LoaderCircle, + tone: "accent" as const, + }; + case "error": + return { + label: "Connection error", + icon: TriangleAlert, + tone: "danger" as const, + }; + case "idle": + case "disconnected": + return { + label: "Disconnected", + icon: BluetoothOff, + tone: "muted" as const, + }; + } +} + +function colorForDistanceTone( + tone: DistanceTone, + theme: ReturnType, +): string { + switch (tone) { + case "success": + return theme.success; + case "warning": + return theme.warning; + case "danger": + return theme.danger; + case "muted": + return theme.textMuted; + } +} diff --git a/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts new file mode 100644 index 00000000..b6ce450f --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/__tests__/page-dial-math.test.ts @@ -0,0 +1,176 @@ +import { + getPageDialCardinalPoints, + getPageDialDividerSegments, + getPageDialRingHitRegion, + pageDialAngleIsInValidArc, + pageDialIndexForProgress, + pageDialPointForProgress, + pageDialPointIsInRingHitRegion, + pageDialProgressForAngle, + pageDialProgressForPoint, + pageDialProgressForPointNearReference, + PAGE_DIAL_START_ANGLE_DEGREES, + PAGE_DIAL_USABLE_ARC_DEGREES, + normalizePageIndex, + pageDialAngleForIndex, + pageDialIndexForAngle, +} from "../page-dial-math"; +import { + getPageDialAccessibilityLabel, + getPageDialControlState, + getPageDialProportions, +} from "../page-dial-layout"; + +const radians = (degrees: number) => (degrees * Math.PI) / 180; + +describe("page dial math", () => { + test("maps first and last pages to the shared top endpoint", () => { + expect(normalizePageIndex(0, 38)).toBe(0); + expect(normalizePageIndex(37, 38)).toBe(1); + expect(pageDialAngleForIndex(0, 38)).toBeCloseTo( + radians(PAGE_DIAL_START_ANGLE_DEGREES), + ); + expect(pageDialAngleForIndex(37, 38)).toBeCloseTo( + radians(PAGE_DIAL_START_ANGLE_DEGREES + PAGE_DIAL_USABLE_ARC_DEGREES), + ); + const first = pageDialPointForProgress(0, 200); + const last = pageDialPointForProgress(1, 200); + expect(first.x).toBeCloseTo(last.x); + expect(first.y).toBeCloseTo(last.y); + }); + + test("uses drag history to disambiguate the shared top endpoint", () => { + const diameter = 200; + const center = diameter / 2; + const topY = center - 90; + expect( + pageDialProgressForPointNearReference(center, topY, diameter, 0.02), + ).toBe(0); + expect( + pageDialProgressForPointNearReference(center, topY, diameter, 0.98), + ).toBe(1); + + const clockwisePastTop = pageDialPointForProgress(0.01, diameter, 90); + expect( + pageDialProgressForPointNearReference( + clockwisePastTop.x, + clockwisePastTop.y, + diameter, + 1, + ), + ).toBeCloseTo(0.01); + + const counterClockwisePastTop = pageDialPointForProgress( + 0.99, + diameter, + 90, + ); + expect( + pageDialProgressForPointNearReference( + counterClockwisePastTop.x, + counterClockwisePastTop.y, + diameter, + 0, + ), + ).toBeCloseTo(0.99); + + expect(pageDialIndexForAngle(radians(-89), 38)).toBe(0); + expect(pageDialIndexForAngle(radians(-91), 38)).toBe(37); + expect(pageDialIndexForAngle(radians(90), 5)).toBe(2); + }); + + test("keeps cardinal angles on one continuous full circle", () => { + expect(pageDialProgressForAngle(radians(0))).toBeCloseTo(0.25); + expect(pageDialProgressForAngle(radians(90))).toBeCloseTo(0.5); + expect(pageDialProgressForAngle(radians(180))).toBeCloseTo(0.75); + expect(pageDialAngleIsInValidArc(radians(-90))).toBe(true); + expect(pageDialAngleIsInValidArc(radians(270))).toBe(true); + expect(pageDialIndexForProgress(0.5, 5)).toBe(2); + }); + + test("accepts an enlarged radial ring hit region but not its center", () => { + const diameter = 200; + const center = diameter / 2; + const region = getPageDialRingHitRegion(diameter); + expect( + pageDialPointIsInRingHitRegion( + center + region.innerRadius - 0.1, + center, + diameter, + ), + ).toBe(false); + expect( + pageDialPointIsInRingHitRegion( + center + (region.innerRadius + region.outerRadius) / 2, + center, + diameter, + ), + ).toBe(true); + expect( + pageDialPointIsInRingHitRegion( + center + region.outerRadius + 0.1, + center, + diameter, + ), + ).toBe(false); + expect(pageDialProgressForPoint(center, center, diameter)).toBe(0); + }); + + test("disables unavailable first and last actions", () => { + expect(getPageDialControlState(0, 4)).toEqual({ + previousDisabled: true, + nextDisabled: false, + }); + expect(getPageDialControlState(3, 4)).toEqual({ + previousDisabled: false, + nextDisabled: true, + }); + expect(getPageDialControlState(-1, 4)).toEqual({ + previousDisabled: true, + nextDisabled: false, + }); + }); + + test("uses supplied proportions and terminology-aware accessibility", () => { + const proportions = getPageDialProportions(100); + expect(proportions.ringThickness).toBeCloseTo(7.5); + expect(proportions.innerDiskDiameter).toBeCloseTo(86); + expect(proportions.centerDiskDiameter).toBeCloseTo(30); + expect(proportions.centerBorderWidth).toBe(0); + expect(proportions.knobDiameter).toBeCloseTo(16); + expect(proportions.controlCenterOffset).toBeCloseTo(29); + expect(proportions.controlButtonSize).toBeGreaterThanOrEqual(44); + expect(proportions.canvasOverscan).toBeGreaterThan(proportions.knobRadius); + expect( + getPageDialAccessibilityLabel({ + selectedIndex: 21, + selectedLabel: "22", + pageCount: 38, + terminology: "sets", + }), + ).toBe("Set selector, set 22 of 38"); + }); + + test("places controls at four equal cardinal points and keeps the knob in overscan", () => { + const diameter = 200; + const points = getPageDialCardinalPoints(diameter); + const center = diameter / 2; + const distances = [ + Math.hypot(points.top.x - center, points.top.y - center), + Math.hypot(points.right.x - center, points.right.y - center), + Math.hypot(points.bottom.x - center, points.bottom.y - center), + Math.hypot(points.left.x - center, points.left.y - center), + ]; + distances.forEach((distance) => expect(distance).toBeCloseTo(distances[0])); + + const proportions = getPageDialProportions(diameter); + const knob = pageDialPointForProgress(0, diameter, proportions.ringRadius); + expect(knob.x - proportions.knobRadius).toBeGreaterThanOrEqual( + -proportions.canvasOverscan, + ); + expect(knob.y - proportions.knobRadius).toBeGreaterThanOrEqual( + -proportions.canvasOverscan, + ); + expect(getPageDialDividerSegments(diameter)).toHaveLength(4); + }); +}); diff --git a/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx new file mode 100644 index 00000000..67bd19fd --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-canvas.tsx @@ -0,0 +1,47 @@ +import { FieldPageDialCanvas } from "@eight2five/mobile/field/render"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import { + PAGE_DIAL_START_ANGLE_DEGREES, + PAGE_DIAL_USABLE_ARC_DEGREES, +} from "./page-dial-math"; + +export function PageDialCanvas({ + diameter, + pageCount, + provisionalProgress, + activeColor, + trackColor, + innerColor, + backgroundColor, + knobColor, +}: { + readonly diameter: number; + readonly pageCount: number; + readonly provisionalProgress: SharedValue; + readonly activeColor: string; + readonly trackColor: string; + readonly innerColor?: string; + readonly backgroundColor?: string; + readonly knobColor?: string; +}) { + const progress = useDerivedValue(() => { + if (pageCount <= 0 || !Number.isFinite(provisionalProgress.value)) return 0; + return Math.min(1, Math.max(0, provisionalProgress.value)); + }); + return ( + + ); +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx new file mode 100644 index 00000000..65378ccf --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-controls.tsx @@ -0,0 +1,211 @@ +import type { ViewStyle } from "react-native"; +import { Center } from "@eight2five/ui/components/center"; +import { Divider } from "@eight2five/ui/components/divider"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { CircleUserRound, Folder, Minus, Plus } from "lucide-react-native"; +import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; + +import { + getPageDialAccessibilityLabel, + getPageDialControlState, + getPageDialProportions, +} from "./page-dial-layout"; +import { + getPageDialCardinalPoints, + getPageDialControlSize, + getPageDialDividerSegments, + type PageDialLineSegment, +} from "./page-dial-math"; + +export function PageDialControls({ + diameter, + selectedIndex, + selectedLabel, + pageCount, + terminology, + onPrevious, + onNext, + onSelectDrill, + onSelectPerformer, + foregroundColor = "#FFFFFF", +}: { + readonly diameter: number; + readonly selectedIndex: number; + readonly selectedLabel?: string; + readonly pageCount: number; + readonly terminology: DrillTerminology; + readonly onPrevious: () => void; + readonly onNext: () => void; + readonly onSelectDrill?: () => void; + readonly onSelectPerformer?: () => void; + readonly foregroundColor?: string; +}) { + const terms = getDrillTerms(terminology); + const proportions = getPageDialProportions(diameter); + const state = getPageDialControlState(selectedIndex, pageCount); + const buttonSize = getPageDialControlSize(diameter); + const center = diameter / 2; + const controlCenters = getPageDialCardinalPoints( + diameter, + proportions.controlCenterOffset, + ); + const centerDiameter = proportions.centerDiskDiameter; + const buttonStyle = (x: number, y: number, disabled = false) => ({ + position: "absolute" as const, + left: x - buttonSize / 2, + top: y - buttonSize / 2, + width: buttonSize, + height: buttonSize, + alignItems: "center" as const, + justifyContent: "center" as const, + opacity: disabled ? 0.34 : 1, + }); + + return ( + <> + + + + + + +
+ + {terms.plural} + + + {selectedIndex >= 0 ? (selectedLabel ?? selectedIndex + 1) : "–"} + +
+ + + + + + + + ); +} + +export function PageDialDividers({ diameter }: { readonly diameter: number }) { + const proportions = getPageDialProportions(diameter); + const dividerSegments = getPageDialDividerSegments( + diameter, + proportions.innerDiskDiameter, + proportions.centerDiskDiameter, + ); + return ( + <> + {dividerSegments.map((segment, index) => ( + + ))} + + ); +} + +function getDividerStyle(segment: PageDialLineSegment): ViewStyle { + const dx = segment.end.x - segment.start.x; + const dy = segment.end.y - segment.start.y; + const length = Math.hypot(dx, dy); + const midpointX = (segment.start.x + segment.end.x) / 2; + const midpointY = (segment.start.y + segment.end.y) / 2; + const rotationDegrees = (Math.atan2(dy, dx) * 180) / Math.PI - 90; + return { + position: "absolute", + left: midpointX - 0.5, + top: midpointY - length / 2, + width: 1, + height: length, + transform: [{ rotate: `${rotationDegrees}deg` }], + }; +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts new file mode 100644 index 00000000..474c5815 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-gesture.ts @@ -0,0 +1,143 @@ +import React from "react"; +import * as Haptics from "expo-haptics"; +import { Gesture } from "react-native-gesture-handler"; +import { + cancelAnimation, + useSharedValue, + withSpring, + withTiming, + type SharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; + +import { + normalizePageIndex, + pageDialIndexForProgress, + pageDialPointIsInControlHitTarget, + pageDialProgressForPointNearReference, + pageDialPointIsInRingHitRegion, +} from "./page-dial-math"; + +function setSharedValue(sharedValue: SharedValue, value: T): void { + "worklet"; + sharedValue.value = value; +} + +export function triggerPageDialHaptic(): void { + void Haptics.selectionAsync().catch(() => undefined); +} + +export function usePageDialGesture({ + diameter, + pageCount, + selectedIndex = 0, + provisionalProgress, + onCommitIndex, +}: { + readonly diameter: number; + readonly pageCount: number; + readonly selectedIndex?: number; + readonly provisionalProgress: SharedValue; + readonly onCommitIndex: (index: number) => void; +}) { + const ringActive = useSharedValue(false); + const gestureStartProgress = useSharedValue(0); + const gestureStartIndex = useSharedValue(0); + const previewIndex = useSharedValue(0); + + const updateFromPoint = (x: number, y: number, shouldHaptic = true) => { + "worklet"; + if (!ringActive.value || pageCount <= 0) return; + const nextProgress = pageDialProgressForPointNearReference( + x, + y, + diameter, + provisionalProgress.value, + ); + const nextIndex = pageDialIndexForProgress(nextProgress, pageCount); + setSharedValue(provisionalProgress, nextProgress); + if (nextIndex === previewIndex.value) return; + setSharedValue(previewIndex, nextIndex); + if (shouldHaptic) scheduleOnRN(triggerPageDialHaptic); + }; + + const commitIndex = React.useCallback( + (index: number) => onCommitIndex(index), + [onCommitIndex], + ); + + const settleProgress = (index: number) => { + "worklet"; + const targetProgress = normalizePageIndex(index, pageCount); + const shouldCommit = index !== gestureStartIndex.value; + setSharedValue( + provisionalProgress, + withSpring( + targetProgress, + { + damping: 18, + stiffness: 210, + mass: 0.7, + }, + (finished) => { + "worklet"; + if (finished && shouldCommit) { + scheduleOnRN(commitIndex, index); + } + }, + ), + ); + }; + + return Gesture.Pan() + .withTestId("page-dial-ring-gesture") + .manualActivation(true) + .onTouchesDown((event, manager) => { + const touch = event.allTouches[0]; + if ( + touch && + pageCount > 0 && + pageDialPointIsInRingHitRegion(touch.x, touch.y, diameter) && + !pageDialPointIsInControlHitTarget(touch.x, touch.y, diameter) + ) { + manager.activate(); + } else { + manager.fail(); + } + }) + .minDistance(1) + .onBegin((event) => { + const touchesRing = + pageDialPointIsInRingHitRegion(event.x, event.y, diameter) && + !pageDialPointIsInControlHitTarget(event.x, event.y, diameter); + setSharedValue(ringActive, touchesRing && pageCount > 0); + if (!touchesRing || pageCount <= 0) return; + cancelAnimation(provisionalProgress); + setSharedValue(gestureStartProgress, provisionalProgress.value); + setSharedValue( + gestureStartIndex, + selectedIndex >= 0 ? selectedIndex : -1, + ); + setSharedValue(previewIndex, gestureStartIndex.value); + updateFromPoint(event.x, event.y, false); + }) + .onUpdate((event) => updateFromPoint(event.x, event.y)) + .onEnd((_event, success) => { + if (!success || !ringActive.value || pageCount <= 0) return; + const snappedIndex = pageDialIndexForProgress( + provisionalProgress.value, + pageCount, + ); + settleProgress(snappedIndex); + }) + .onFinalize((_event, success) => { + if (!success && ringActive.value) { + cancelAnimation(provisionalProgress); + setSharedValue( + provisionalProgress, + withTiming(gestureStartProgress.value, { duration: 140 }), + ); + } + setSharedValue(ringActive, false); + }); +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-layout.ts b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts new file mode 100644 index 00000000..16f4123f --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-layout.ts @@ -0,0 +1,80 @@ +import { getDrillTerms, type DrillTerminology } from "@eight2five/mobile/drill"; + +import { + getPageDialCanvasOverscan, + getPageDialControlSize, + getPageDialRingHitRegion, + getPageDialRingRadius, + PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO, + PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO, + PAGE_DIAL_INNER_DISK_DIAMETER_RATIO, + PAGE_DIAL_KNOB_DIAMETER_RATIO, + PAGE_DIAL_RING_THICKNESS_RATIO, +} from "./page-dial-math"; + +export interface PageDialProportions { + readonly ringThickness: number; + readonly ringRadius: number; + readonly innerDiskDiameter: number; + readonly centerDiskDiameter: number; + /** Kept for callers that used the original proportions API. No outline is rendered. */ + readonly centerBorderWidth: number; + readonly knobDiameter: number; + readonly knobRadius: number; + readonly controlCenterOffset: number; + readonly controlButtonSize: number; + readonly ringHitInnerRadius: number; + readonly ringHitOuterRadius: number; + readonly canvasOverscan: number; +} + +export function getPageDialProportions(diameter: number): PageDialProportions { + const ringThickness = diameter * PAGE_DIAL_RING_THICKNESS_RATIO; + const innerDiskDiameter = diameter * PAGE_DIAL_INNER_DISK_DIAMETER_RATIO; + const centerDiskDiameter = diameter * PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO; + const knobDiameter = diameter * PAGE_DIAL_KNOB_DIAMETER_RATIO; + const ringHitRegion = getPageDialRingHitRegion(diameter); + return { + ringThickness, + ringRadius: getPageDialRingRadius(diameter, ringThickness), + innerDiskDiameter, + centerDiskDiameter, + centerBorderWidth: 0, + knobDiameter, + knobRadius: knobDiameter / 2, + controlCenterOffset: diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO, + controlButtonSize: getPageDialControlSize(diameter), + ringHitInnerRadius: ringHitRegion.innerRadius, + ringHitOuterRadius: ringHitRegion.outerRadius, + canvasOverscan: getPageDialCanvasOverscan(diameter), + }; +} + +export function getPageDialControlState( + selectedIndex: number, + pageCount: number, +): { previousDisabled: boolean; nextDisabled: boolean } { + return { + previousDisabled: selectedIndex <= 0, + nextDisabled: + pageCount <= 0 || (selectedIndex >= 0 && selectedIndex >= pageCount - 1), + }; +} + +export function getPageDialAccessibilityLabel({ + selectedIndex, + selectedLabel, + pageCount, + terminology, +}: { + readonly selectedIndex: number; + readonly selectedLabel?: string; + readonly pageCount: number; + readonly terminology: DrillTerminology; +}): string { + const terms = getDrillTerms(terminology); + if (selectedIndex < 0) { + return `${terms.singular} selector, no ${terms.lowercaseSingular} selected, ${pageCount} available`; + } + return `${terms.singular} selector, ${terms.lowercaseSingular} ${selectedLabel ?? selectedIndex + 1} of ${pageCount}`; +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial-math.ts b/apps/mobile/src/features/field/page-dial/page-dial-math.ts new file mode 100644 index 00000000..777c5ae7 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial-math.ts @@ -0,0 +1,376 @@ +/** + * Geometry shared by the React Native controls, the gesture worklet, and the + * Skia renderer. Angles use the React Native coordinate system: zero points + * right and positive values rotate clockwise because y grows downwards. + */ + +export const PAGE_DIAL_DEAD_ZONE_DEGREES = 0; +export const PAGE_DIAL_USABLE_ARC_DEGREES = 360; +export const PAGE_DIAL_START_ANGLE_DEGREES = -90; +export const PAGE_DIAL_END_ANGLE_DEGREES = 270; + +export const PAGE_DIAL_RING_THICKNESS_RATIO = 0.075; +export const PAGE_DIAL_INNER_DISK_DIAMETER_RATIO = 0.86; +export const PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO = 0.3; +export const PAGE_DIAL_KNOB_DIAMETER_RATIO = 0.16; +export const PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO = 0.29; +export const PAGE_DIAL_MIN_CONTROL_SIZE = 44; + +// The visual ring is about 0.46D from the center. Keeping this hit region +// wider makes the dial usable with a thumb without making the center button +// part of the gesture surface. +export const PAGE_DIAL_RING_HIT_INNER_RADIUS_RATIO = 0.36; +export const PAGE_DIAL_RING_HIT_OUTER_RADIUS_RATIO = 0.6; + +// The knob and its shadow extend beyond the visual ring at the two endpoints. +// The canvas is rendered with this much overscan so neither is clipped. +export const PAGE_DIAL_CANVAS_OVERSCAN_RATIO = 0.09; + +const FULL_TURN_RADIANS = Math.PI * 2; +const DEGREES_TO_RADIANS = Math.PI / 180; + +export interface PageDialPoint { + readonly x: number; + readonly y: number; +} + +export interface PageDialLineSegment { + readonly start: PageDialPoint; + readonly end: PageDialPoint; +} + +export interface PageDialRingHitRegion { + readonly innerRadius: number; + readonly outerRadius: number; +} + +export interface PageDialCardinalPoints { + readonly top: PageDialPoint; + readonly right: PageDialPoint; + readonly bottom: PageDialPoint; + readonly left: PageDialPoint; +} + +function clamp(value: number, minimum: number, maximum: number): number { + "worklet"; + return Math.min(maximum, Math.max(minimum, value)); +} + +function normalizeRadians(angleRadians: number): number { + "worklet"; + const normalized = angleRadians % FULL_TURN_RADIANS; + return normalized < 0 ? normalized + FULL_TURN_RADIANS : normalized; +} + +export function normalizePageIndex(index: number, pageCount: number): number { + "worklet"; + if ( + pageCount <= 1 || + !Number.isFinite(pageCount) || + !Number.isFinite(index) + ) { + return 0; + } + return clamp(index / (pageCount - 1), 0, 1); +} + +export const normalizePageProgress = normalizePageIndex; + +export function pageDialAngleForProgress(progress: number): number { + "worklet"; + const safeProgress = Number.isFinite(progress) ? progress : 0; + return ( + (PAGE_DIAL_START_ANGLE_DEGREES + + clamp(safeProgress, 0, 1) * PAGE_DIAL_USABLE_ARC_DEGREES) * + DEGREES_TO_RADIANS + ); +} + +export function pageDialAngleForIndex( + index: number, + pageCount: number, +): number { + "worklet"; + return pageDialAngleForProgress(normalizePageIndex(index, pageCount)); +} + +export function pageDialRelativeAngle(angleRadians: number): number { + "worklet"; + const start = PAGE_DIAL_START_ANGLE_DEGREES * DEGREES_TO_RADIANS; + return normalizeRadians(angleRadians - start); +} + +export function pageDialAngleIsInValidArc(angleRadians: number): boolean { + "worklet"; + return Number.isFinite(angleRadians); +} + +export const isPageDialAngleInValidArc = pageDialAngleIsInValidArc; + +export function pageDialProgressForAngle(angleRadians: number): number { + "worklet"; + if (!Number.isFinite(angleRadians)) return 0; + return pageDialRelativeAngle(angleRadians) / FULL_TURN_RADIANS; +} + +/** + * Resolve the shared top seam while still allowing the dial to wrap forever. + * Exactly at the top, drag history decides whether the knob represents the + * first set (0) or last set (1). Once the pointer crosses the seam, the wrapped + * angular progress is returned immediately so clockwise motion moves last → + * first and counter-clockwise motion moves first → last on every revolution. + */ +export function pageDialProgressForAngleNearReference( + angleRadians: number, + referenceProgress: number, +): number { + "worklet"; + const wrapped = pageDialProgressForAngle(angleRadians); + const reference = clamp( + Number.isFinite(referenceProgress) ? referenceProgress : 0, + 0, + 1, + ); + + // atan2 resolves the exact top point to the start angle, so use the prior + // state to preserve the intentional first/last ambiguity at that one point. + if (wrapped === 0) { + return reference > 0.5 ? 1 : 0; + } + + return wrapped; +} + +export function pageDialProgressForPoint( + x: number, + y: number, + diameter: number, +): number { + "worklet"; + const center = diameter / 2; + if (x === center && y === center) return 0; + return pageDialProgressForAngle(Math.atan2(y - center, x - center)); +} + +export function pageDialProgressForPointNearReference( + x: number, + y: number, + diameter: number, + referenceProgress: number, +): number { + "worklet"; + const center = diameter / 2; + if (x === center && y === center) return clamp(referenceProgress, 0, 1); + return pageDialProgressForAngleNearReference( + Math.atan2(y - center, x - center), + referenceProgress, + ); +} + +export function pageDialIndexForProgress( + progress: number, + pageCount: number, +): number { + "worklet"; + if (pageCount <= 1 || !Number.isFinite(pageCount)) return 0; + const safeProgress = Number.isFinite(progress) ? progress : 0; + return Math.round(clamp(safeProgress, 0, 1) * (pageCount - 1)); +} + +export function pageDialIndexForAngle( + angleRadians: number, + pageCount: number, +): number { + "worklet"; + return pageDialIndexForProgress( + pageDialProgressForAngle(angleRadians), + pageCount, + ); +} + +export function pageDialIndexForPoint( + x: number, + y: number, + diameter: number, + pageCount: number, +): number { + "worklet"; + return pageDialIndexForProgress( + pageDialProgressForPoint(x, y, diameter), + pageCount, + ); +} + +export function pageDialPointForAngle( + angleRadians: number, + diameter: number, + radius = diameter / 2, +): PageDialPoint { + "worklet"; + const center = diameter / 2; + return { + x: center + Math.cos(angleRadians) * radius, + y: center + Math.sin(angleRadians) * radius, + }; +} + +export function pageDialPointForProgress( + progress: number, + diameter: number, + radius?: number, +): PageDialPoint { + "worklet"; + const resolvedRadius = + radius ?? diameter / 2 - (diameter * PAGE_DIAL_RING_THICKNESS_RATIO) / 2; + return pageDialPointForAngle( + pageDialAngleForProgress(progress), + diameter, + resolvedRadius, + ); +} + +export const pageDialKnobPointForProgress = pageDialPointForProgress; + +export function getPageDialRingRadius( + diameter: number, + ringThickness = diameter * PAGE_DIAL_RING_THICKNESS_RATIO, +): number { + "worklet"; + return diameter / 2 - ringThickness / 2; +} + +export function getPageDialRingHitRegion( + diameter: number, +): PageDialRingHitRegion { + "worklet"; + return { + innerRadius: diameter * PAGE_DIAL_RING_HIT_INNER_RADIUS_RATIO, + outerRadius: diameter * PAGE_DIAL_RING_HIT_OUTER_RADIUS_RATIO, + }; +} + +export const pageDialRingHitRegion = getPageDialRingHitRegion; + +export function pageDialRadialDistanceForPoint( + x: number, + y: number, + diameter: number, +): number { + "worklet"; + const center = diameter / 2; + return Math.hypot(x - center, y - center); +} + +export function pageDialPointIsInRingHitRegion( + x: number, + y: number, + diameter: number, +): boolean { + "worklet"; + const distance = pageDialRadialDistanceForPoint(x, y, diameter); + const region = getPageDialRingHitRegion(diameter); + return distance >= region.innerRadius && distance <= region.outerRadius; +} + +export const isPageDialRingHit = pageDialPointIsInRingHitRegion; + +export function getPageDialCardinalPoints( + diameter: number, + offset?: number, +): PageDialCardinalPoints { + "worklet"; + const center = diameter / 2; + const resolvedOffset = + offset ?? diameter * PAGE_DIAL_CONTROL_CENTER_OFFSET_RATIO; + return { + top: { x: center, y: center - resolvedOffset }, + right: { x: center + resolvedOffset, y: center }, + bottom: { x: center, y: center + resolvedOffset }, + left: { x: center - resolvedOffset, y: center }, + }; +} + +export const pageDialCardinalPoints = getPageDialCardinalPoints; + +export function getPageDialControlSize(diameter: number): number { + "worklet"; + return Math.max(PAGE_DIAL_MIN_CONTROL_SIZE, diameter * 0.31); +} + +/** + * Returns whether a touch belongs to one of the four button hit boxes. The + * ring detector uses this only at gesture start so a ring drag can pass over a + * button without being interrupted after it has begun. + */ +export function pageDialPointIsInControlHitTarget( + x: number, + y: number, + diameter: number, +): boolean { + "worklet"; + const size = getPageDialControlSize(diameter); + const halfSize = size / 2; + const points = getPageDialCardinalPoints(diameter); + return ( + (Math.abs(x - points.top.x) <= halfSize && + Math.abs(y - points.top.y) <= halfSize) || + (Math.abs(x - points.right.x) <= halfSize && + Math.abs(y - points.right.y) <= halfSize) || + (Math.abs(x - points.bottom.x) <= halfSize && + Math.abs(y - points.bottom.y) <= halfSize) || + (Math.abs(x - points.left.x) <= halfSize && + Math.abs(y - points.left.y) <= halfSize) + ); +} + +export const pageDialPointHitsControl = pageDialPointIsInControlHitTarget; + +function pointAtRadius( + diameter: number, + angleDegrees: number, + radius: number, +): PageDialPoint { + return pageDialPointForAngle( + angleDegrees * DEGREES_TO_RADIANS, + diameter, + radius, + ); +} + +/** + * X dividers are split at the center disk rather than drawing underneath it. + * This keeps both diagonals visibly flush with the blue disk edge and avoids + * relying on paint order to hide a line through the center label. + */ +export function getPageDialDividerSegments( + diameter: number, + innerDiskDiameter = diameter * PAGE_DIAL_INNER_DISK_DIAMETER_RATIO, + centerDiskDiameter = diameter * PAGE_DIAL_CENTER_DISK_DIAMETER_RATIO, +): readonly PageDialLineSegment[] { + const outerRadius = innerDiskDiameter / 2; + const innerRadius = centerDiskDiameter / 2; + return [ + { + start: pointAtRadius(diameter, -135, outerRadius), + end: pointAtRadius(diameter, -135, innerRadius), + }, + { + start: pointAtRadius(diameter, -45, outerRadius), + end: pointAtRadius(diameter, -45, innerRadius), + }, + { + start: pointAtRadius(diameter, 45, innerRadius), + end: pointAtRadius(diameter, 45, outerRadius), + }, + { + start: pointAtRadius(diameter, 135, innerRadius), + end: pointAtRadius(diameter, 135, outerRadius), + }, + ]; +} + +export const pageDialDividerSegments = getPageDialDividerSegments; + +export function getPageDialCanvasOverscan(diameter: number): number { + return diameter * PAGE_DIAL_CANVAS_OVERSCAN_RATIO; +} diff --git a/apps/mobile/src/features/field/page-dial/page-dial.tsx b/apps/mobile/src/features/field/page-dial/page-dial.tsx new file mode 100644 index 00000000..c3836b85 --- /dev/null +++ b/apps/mobile/src/features/field/page-dial/page-dial.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import { View } from "react-native"; +import { GestureDetector } from "react-native-gesture-handler"; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, + type SharedValue, +} from "react-native-reanimated"; +import type { DrillTerminology } from "@eight2five/mobile/drill"; +import { + useEight2FiveTheme, + useEight2FiveThemeName, +} from "@eight2five/ui/theme"; + +import { FrostedFieldSurface } from "../field-frosted-surface"; + +import { PageDialCanvas } from "./page-dial-canvas"; +import { PageDialControls, PageDialDividers } from "./page-dial-controls"; +import { triggerPageDialHaptic, usePageDialGesture } from "./page-dial-gesture"; +import { + normalizePageIndex, + PAGE_DIAL_KNOB_DIAMETER_RATIO, + pageDialPointForProgress, +} from "./page-dial-math"; + +function animateProgress( + sharedValue: SharedValue, + progress: number, +): void { + sharedValue.value = withTiming(progress, { duration: 180 }); +} + +export function PageDial({ + diameter, + selectedIndex, + selectedLabel, + pageCount, + terminology, + activeColor, + trackColor, + innerColor, + backgroundColor, + foregroundColor, + onSelectIndex, + onSelectDrill, + onSelectPerformer, +}: { + readonly diameter: number; + readonly selectedIndex: number; + readonly selectedLabel?: string; + readonly pageCount: number; + readonly terminology: DrillTerminology; + readonly activeColor: string; + readonly trackColor: string; + readonly innerColor?: string; + readonly backgroundColor?: string; + readonly foregroundColor?: string; + readonly onSelectIndex: (index: number) => void; + readonly onSelectDrill?: () => void; + readonly onSelectPerformer?: () => void; +}) { + const theme = useEight2FiveTheme(); + const themeName = useEight2FiveThemeName(); + const provisionalProgress = useSharedValue( + normalizePageIndex(Math.max(0, selectedIndex), pageCount), + ); + React.useEffect(() => { + animateProgress( + provisionalProgress, + normalizePageIndex(Math.max(0, selectedIndex), pageCount), + ); + }, [pageCount, provisionalProgress, selectedIndex]); + + const gesture = usePageDialGesture({ + diameter, + pageCount, + selectedIndex, + provisionalProgress, + onCommitIndex: onSelectIndex, + }); + const selectFromButton = React.useCallback( + (index: number) => { + if (pageCount <= 0) return; + const bounded = Math.max(0, Math.min(pageCount - 1, index)); + if (bounded === selectedIndex) return; + animateProgress( + provisionalProgress, + normalizePageIndex(bounded, pageCount), + ); + triggerPageDialHaptic(); + onSelectIndex(bounded); + }, + [onSelectIndex, pageCount, provisionalProgress, selectedIndex], + ); + + const resolvedInnerColor = innerColor ?? "transparent"; + const resolvedBackgroundColor = backgroundColor ?? "transparent"; + const resolvedForegroundColor = foregroundColor ?? theme.text; + + return ( + + + + + + + + + + + selectFromButton(selectedIndex - 1)} + onNext={() => + selectFromButton(selectedIndex < 0 ? 0 : selectedIndex + 1) + } + onSelectDrill={onSelectDrill} + onSelectPerformer={onSelectPerformer} + /> + + ); +} + +function PageDialKnob({ + diameter, + progress, + color, +}: { + readonly diameter: number; + readonly progress: SharedValue; + readonly color: string; +}) { + const knobDiameter = diameter * PAGE_DIAL_KNOB_DIAMETER_RATIO; + const animatedStyle = useAnimatedStyle(() => { + const point = pageDialPointForProgress(progress.value, diameter); + return { + left: point.x - knobDiameter / 2, + top: point.y - knobDiameter / 2, + }; + }); + return ( + + ); +} diff --git a/apps/mobile/src/features/field/tag-connection-dialog.tsx b/apps/mobile/src/features/field/tag-connection-dialog.tsx new file mode 100644 index 00000000..fa966be9 --- /dev/null +++ b/apps/mobile/src/features/field/tag-connection-dialog.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { useWindowDimensions } from "react-native"; +import { X } from "lucide-react-native"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Icon } from "@eight2five/ui/components/icon"; +import { + Modal, + ModalBackdrop, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, +} from "@eight2five/ui/components/modal"; + +import { TagConnectionContent } from "../settings/tag-connection-screen"; + +export function TagConnectionDialog({ + isOpen, + onClose, +}: { + readonly isOpen: boolean; + readonly onClose: () => void; +}) { + const { height } = useWindowDimensions(); + if (!isOpen) return null; + return ( + + + + + Tag Connection + + + + + + + + + + ); +} diff --git a/apps/mobile/src/features/field/use-field-anchor-overlay.ts b/apps/mobile/src/features/field/use-field-anchor-overlay.ts new file mode 100644 index 00000000..f2c98820 --- /dev/null +++ b/apps/mobile/src/features/field/use-field-anchor-overlay.ts @@ -0,0 +1,24 @@ +import React from "react"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useKnownPansAnchors, + useRememberedPansTag, +} from "../../pans/mobile-pans-context"; +import { cachedAnchorGeometry } from "../../pans/pans-anchor-cache"; +import { fieldAnchorOverlayOptions } from "./field-anchor-overlay-options"; + +export function useFieldAnchorOverlay() { + const { settings } = useAppSettingsSnapshot(); + const rememberedTag = useRememberedPansTag(); + const knownAnchors = useKnownPansAnchors(); + const anchors = React.useMemo( + () => cachedAnchorGeometry(rememberedTag, knownAnchors), + [knownAnchors, rememberedTag], + ); + const options = React.useMemo( + () => fieldAnchorOverlayOptions(settings), + [settings], + ); + return { anchors, options } as const; +} diff --git a/apps/mobile/src/features/field/use-field-screen-controller.ts b/apps/mobile/src/features/field/use-field-screen-controller.ts new file mode 100644 index 00000000..501bc703 --- /dev/null +++ b/apps/mobile/src/features/field/use-field-screen-controller.ts @@ -0,0 +1,284 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; +import { useWindowDimensions } from "react-native"; +import type { FieldViewport } from "@eight2five/mobile/field"; +import { + buildDrillRenderScene, + resolveSelectedSourceSetId, + shouldBuildDrillRenderScene, + type Drill, + type DrillDocument, + type DrillSet, + type DrillRenderScene, +} from "@eight2five/mobile/drill"; + +import { useFieldOrientation } from "../../navigation/use-field-orientation"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { resolveEffectiveFieldPreset } from "./effective-field-preset"; + +let committedFieldViewport: FieldViewport | undefined; + +/** + * Owns viewport commits outside the renderer. The module-level session value is + * deliberate: native-tab presentation changes may remount the route, but they + * must not reset the performer's field center or zoom. + */ +export function useFieldScreenController() { + const orientation = useFieldOrientation(); + const { width, height } = useWindowDimensions(); + const snapshot = useAppSettingsSnapshot(); + const store = useAppSettingsStore(); + const [initialViewport] = React.useState(() => committedFieldViewport); + const [drills, setDrills] = React.useState([]); + const [drillEntries, setDrillEntries] = React.useState< + readonly { readonly drill: Drill; readonly pageCount: number }[] + >([]); + const [activeDrill, setActiveDrill] = React.useState(); + const [activeDrillDocument, setActiveDrillDocument] = + React.useState(); + const [pages, setPages] = React.useState([]); + const [loadingDrills, setLoadingDrills] = React.useState(true); + const [fieldError, setFieldError] = React.useState(); + const [selectionBusy, setSelectionBusy] = React.useState(false); + const [optimisticSelection, setOptimisticSelection] = React.useState<{ + readonly activeDrillId: string | null; + readonly pageId: string; + }>(); + const refreshGeneration = React.useRef(0); + const pageSelectionGeneration = React.useRef(0); + const commitViewport = React.useCallback((viewport: FieldViewport) => { + committedFieldViewport = viewport; + }, []); + + const refreshDrills = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + const generation = ++refreshGeneration.current; + setLoadingDrills(true); + try { + const repository = store.getDrillRepository(); + const activeDrillId = snapshot.settings.activeDrillId; + const nextDrills = await repository.listDrills(); + const [nextActiveDrill, nextPages, nextDocument, pageCounts] = + await Promise.all([ + activeDrillId ? repository.getDrill(activeDrillId) : undefined, + activeDrillId ? repository.listSets(activeDrillId) : [], + activeDrillId + ? repository.getDrillDocument(activeDrillId) + : undefined, + Promise.all( + nextDrills.map(async (drill) => ({ + drill, + pageCount: (await repository.listSets(drill.id)).length, + })), + ), + ]); + if (generation !== refreshGeneration.current) return; + setDrills(nextDrills); + setDrillEntries(pageCounts); + setActiveDrill(nextActiveDrill); + setActiveDrillDocument(nextDocument); + setPages(nextPages); + setFieldError(undefined); + } catch (cause) { + if (generation !== refreshGeneration.current) return; + setFieldError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + if (generation === refreshGeneration.current) setLoadingDrills(false); + } + }, [snapshot.settings.activeDrillId, snapshot.status, store]); + + useFocusEffect( + React.useCallback(() => { + void refreshDrills(); + }, [refreshDrills]), + ); + + const selectActiveDrill = React.useCallback( + async (drillId: string | null) => { + if (selectionBusy || snapshot.status !== "ready") return; + setSelectionBusy(true); + setFieldError(undefined); + try { + await store.setActiveDrill(drillId); + } catch (cause) { + setFieldError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } finally { + setSelectionBusy(false); + } + }, + [selectionBusy, snapshot.status, store], + ); + + const toggleMetricMode = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + await store.update({ + transitionMetricMode: + snapshot.settings.transitionMetricMode === "step-size" + ? "crossing-counts" + : "step-size", + }); + } catch (cause) { + setFieldError(cause instanceof Error ? cause : new Error(String(cause))); + } + }, [snapshot.settings.transitionMetricMode, snapshot.status, store]); + + const toggleCountDisplayMode = React.useCallback(async () => { + if (snapshot.status !== "ready") return; + try { + await store.update({ + countDisplayMode: + snapshot.settings.countDisplayMode === "counts" + ? "measures" + : "counts", + }); + } catch (cause) { + setFieldError(cause instanceof Error ? cause : new Error(String(cause))); + } + }, [snapshot.settings.countDisplayMode, snapshot.status, store]); + + const selectPageAtIndex = React.useCallback( + async (index: number) => { + const page = pages[index]; + if (!page || snapshot.status !== "ready") return; + const generation = ++pageSelectionGeneration.current; + setOptimisticSelection({ + activeDrillId: snapshot.settings.activeDrillId, + pageId: page.id, + }); + setFieldError(undefined); + try { + await store.setSelectedDrillSet(page.id); + if (generation === pageSelectionGeneration.current) { + setOptimisticSelection(undefined); + } + } catch (cause) { + if (generation === pageSelectionGeneration.current) { + setOptimisticSelection(undefined); + setFieldError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + } + }, + [pages, snapshot.settings.activeDrillId, snapshot.status, store], + ); + + const selectPerformer = React.useCallback( + async (performerEntityId: number) => { + if (!activeDrill || selectionBusy || snapshot.status !== "ready") { + return false; + } + setSelectionBusy(true); + setFieldError(undefined); + try { + await store + .getDrillRepository() + .setSelectedPerformer(activeDrill.id, performerEntityId); + await refreshDrills(); + return true; + } catch (cause) { + setFieldError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + return false; + } finally { + setSelectionBusy(false); + } + }, + [activeDrill, refreshDrills, selectionBusy, snapshot.status, store], + ); + + const effectiveSelectedPageId = + optimisticSelection?.activeDrillId === snapshot.settings.activeDrillId + ? optimisticSelection.pageId + : snapshot.settings.selectedDrillSetId; + const selectedIndex = pages.findIndex( + (page) => page.id === effectiveSelectedPageId, + ); + const selectedPage = selectedIndex >= 0 ? pages[selectedIndex] : undefined; + const fieldPreset = resolveEffectiveFieldPreset( + activeDrill, + snapshot.settings.defaultFieldPreset, + ); + const selectedSourceSetId = resolveSelectedSourceSetId(selectedPage); + const selectedPerformerEntityId = activeDrill?.selectedPerformerEntityId; + const drillScene = React.useMemo(() => { + if ( + !shouldBuildDrillRenderScene(snapshot.settings.drillFeaturesEnabled) || + !activeDrillDocument || + selectedSourceSetId === undefined || + selectedPerformerEntityId === undefined + ) { + return undefined; + } + return buildDrillRenderScene({ + document: activeDrillDocument, + field: fieldPreset, + selectedPerformerEntityId, + selectedSourceSetId, + settings: { + showPerformerLabels: snapshot.settings.showPerformerLabels, + showPerformerNames: snapshot.settings.showPerformerNames, + showPropLabels: snapshot.settings.showPropLabels, + showPropNames: snapshot.settings.showPropNames, + markerEnabled: snapshot.settings.showTransitionMarkers, + showAll: snapshot.settings.showAllTransitionSets, + previousTotalCount: snapshot.settings.previousTransitionSetCount, + nextTotalCount: snapshot.settings.nextTransitionSetCount, + }, + }); + }, [ + activeDrillDocument, + fieldPreset, + selectedPerformerEntityId, + selectedSourceSetId, + snapshot.settings.nextTransitionSetCount, + snapshot.settings.previousTransitionSetCount, + snapshot.settings.drillFeaturesEnabled, + snapshot.settings.showAllTransitionSets, + snapshot.settings.showPerformerLabels, + snapshot.settings.showPerformerNames, + snapshot.settings.showPropLabels, + snapshot.settings.showPropNames, + snapshot.settings.showTransitionMarkers, + ]); + + return { + width, + height, + landscape: orientation.landscape, + defaultViewport: initialViewport, + commitViewport, + settingsStatus: snapshot.status, + settings: snapshot.settings, + drills, + drillEntries, + activeDrill, + activeDrillDocument, + drillScene, + pages, + selectedIndex, + selectedPage, + previousPage: selectedIndex > 0 ? pages[selectedIndex - 1] : undefined, + fieldPreset, + loadingDrills, + selectionBusy, + error: fieldError ?? snapshot.error, + selectActiveDrill, + toggleMetricMode, + toggleCountDisplayMode, + selectPageAtIndex, + selectPerformer, + refreshDrills, + } as const; +} + +export function resetFieldViewportSessionForTests(): void { + committedFieldViewport = undefined; +} diff --git a/apps/mobile/src/features/placeholder-screen.tsx b/apps/mobile/src/features/placeholder-screen.tsx new file mode 100644 index 00000000..8c8011ac --- /dev/null +++ b/apps/mobile/src/features/placeholder-screen.tsx @@ -0,0 +1,47 @@ +import { Center } from "@eight2five/ui/components/center"; +import { Heading } from "@eight2five/ui/components/heading"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export function PlaceholderScreen({ + title, + description, +}: { + title: string; + description: string; +}) { + const theme = useEight2FiveTheme(); + + return ( +
+ + + {title} + + + {description} + + +
+ ); +} diff --git a/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts new file mode 100644 index 00000000..67bfb70c --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/anchor-display.test.ts @@ -0,0 +1,38 @@ +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { getDeveloperAnchorDisplayName } from "../anchor-display"; + +const anchor = (overrides: Partial = {}): ManagedDevice => ({ + id: "anchor-id", + transportDeviceId: "transport-id", + createdAt: 1, + updatedAt: 1, + ...overrides, +}); + +describe("developer anchor display names", () => { + test("prefers the hardware PANS label and falls back to identifiers", () => { + expect( + getDeveloperAnchorDisplayName( + anchor({ + nickname: "Legacy local name", + nodeIdHex: "A001", + label: "HW", + lastKnownConfig: { + role: "anchor", + label: " Front 50 ", + uwbMode: "active", + ledEnabled: true, + firmwareUpdateEnabled: false, + initiatorEnabled: false, + }, + }), + ), + ).toBe("Front 50"); + expect(getDeveloperAnchorDisplayName(anchor({ label: "HW" }))).toBe("HW"); + expect(getDeveloperAnchorDisplayName(anchor({ nodeIdHex: "A001" }))).toBe( + "A001", + ); + expect(getDeveloperAnchorDisplayName(anchor())).toBe("anchor-id"); + }); +}); diff --git a/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts new file mode 100644 index 00000000..a320bc63 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/anchor-editor-form.test.ts @@ -0,0 +1,97 @@ +import { Alert } from "react-native"; + +import { + createAnchorEditorDrafts, + convertMarchingHeightUnit, + formatAnchorCanonicalPreview, + standardDraftFromPosition, + validateMarchingAnchorDraft, + validateStandardAnchorDraft, +} from "../anchor-editor-form"; +import { confirmAnchorPositionWrite } from "../anchor-write-confirmation"; + +describe("anchor editor form", () => { + test("reuses the marching drill-grid coordinate domain", () => { + const draft = createAnchorEditorDrafts(); + const result = validateMarchingAnchorDraft({ + ...draft.marching, + height: "6", + heightUnit: "feet", + }); + + expect(result.errors).toEqual({}); + expect(result.position).toMatchObject({ + xMeters: 0, + yMeters: 0, + zMeters: expect.closeTo(1.8288, 8), + }); + expect(formatAnchorCanonicalPreview(result.position)?.marching).toContain( + "On 50 yd ln", + ); + }); + + test("standard mode validates signed offsets and preserves canonical position", () => { + const result = validateStandardAnchorDraft({ + reference: "center-field", + unit: "feet", + sideToSideOffset: "3", + frontToBackOffset: "-6", + height: "8", + }); + expect(result.errors).toEqual({}); + const converted = standardDraftFromPosition( + result.position!, + "center-field", + "feet", + ); + expect(converted).toEqual({ + reference: "center-field", + unit: "feet", + sideToSideOffset: "3", + frontToBackOffset: "-6", + height: "8", + }); + }); + + test("changing marching height units preserves canonical height", () => { + const draft = { ...createAnchorEditorDrafts().marching, height: "2" }; + const feet = convertMarchingHeightUnit(draft, "feet"); + expect(feet.height).toBe("6.56168"); + expect(validateMarchingAnchorDraft(feet).position?.zMeters).toBeCloseTo(2); + }); + + test("rejects incomplete and unreasonable submissions", () => { + const marching = createAnchorEditorDrafts().marching; + expect( + validateMarchingAnchorDraft({ ...marching, height: "" }).errors, + ).toHaveProperty("height"); + expect( + validateStandardAnchorDraft({ + reference: "side-1-front-corner", + unit: "meters", + sideToSideOffset: "-1", + frontToBackOffset: "0", + height: "1", + }).errors, + ).toHaveProperty("position"); + }); + + test("requires explicit confirmation before invoking one hardware action", () => { + const alert = jest + .spyOn(Alert, "alert") + .mockImplementation(() => undefined); + const write = jest.fn(); + + confirmAnchorPositionWrite({ xMeters: 1, yMeters: 2, zMeters: 3 }, write); + + expect(write).not.toHaveBeenCalled(); + const buttons = alert.mock.calls[0][2]; + expect(buttons?.map((button) => button.text)).toEqual([ + "Cancel", + "Write Position", + ]); + buttons?.[1].onPress?.(); + expect(write).toHaveBeenCalledTimes(1); + alert.mockRestore(); + }); +}); diff --git a/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts b/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts new file mode 100644 index 00000000..26cc9c5b --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/anchor-overlay-settings.test.ts @@ -0,0 +1,13 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { parseComfortableAnchorRange } from "../comfortable-anchor-range"; + +describe("anchor overlay settings", () => { + test("uses the planning default and accepts a finite bounded range", () => { + expect(DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters).toBe(20); + expect(parseComfortableAnchorRange("20.0")).toEqual({ value: 20 }); + expect(parseComfortableAnchorRange("0")).toHaveProperty("error"); + expect(parseComfortableAnchorRange("Infinity")).toHaveProperty("error"); + expect(parseComfortableAnchorRange("200.1")).toHaveProperty("error"); + }); +}); diff --git a/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts new file mode 100644 index 00000000..0eb18bec --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/developer-mode.test.ts @@ -0,0 +1,63 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { buildDeveloperDiagnosticRows } from "../developer-diagnostics"; +import { + canUseDeveloperControls, + disableDeveloperMode, + enableDeveloperMode, +} from "../developer-mode-actions"; + +describe("Developer Mode", () => { + test("enables directly through the developer settings toggle", async () => { + const enabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: true }; + const writer = { update: jest.fn(async () => enabled) }; + + await expect(enableDeveloperMode(writer)).resolves.toEqual(enabled); + + expect(writer.update).toHaveBeenCalledWith({ developerModeEnabled: true }); + }); + + test("disabling hides controls and clears developer-only live mocking", async () => { + const disabled = { ...DEFAULT_APP_SETTINGS, developerModeEnabled: false }; + const writer = { update: jest.fn(async () => disabled) }; + + await disableDeveloperMode(writer); + + expect(writer.update).toHaveBeenCalledWith({ + developerModeEnabled: false, + mockLivePositionEnabled: false, + mockLivePositionXSteps: 0, + mockLivePositionYSteps: 0, + }); + expect(canUseDeveloperControls(disabled)).toBe(false); + }); + + test("diagnostic presentation never includes PANS quality", () => { + const rows = buildDeveloperDiagnosticRows({ + initialization: "ready", + connectionState: "connected", + discoveries: [], + livePosition: { + connectionState: "connected", + isStale: false, + position: { xMeters: 1, yMeters: 2 }, + }, + rawPosition: { xMeters: 1, yMeters: 2, zMeters: 3, quality: 99 } as never, + lastUpdateAt: 1_700_000_000_000, + effectiveUpdateRateHz: 9.5, + diagnosticMessages: [], + knownAnchors: [], + networks: [], + discoveryRssiCutoff: -75, + }); + + expect(rows).toEqual( + expect.arrayContaining([ + { label: "Raw PANS X", value: "1.000 m" }, + { label: "Effective update rate", value: "9.5 Hz" }, + ]), + ); + expect(JSON.stringify(rows).toLowerCase()).not.toContain("quality"); + expect(JSON.stringify(rows)).not.toContain("99"); + }); +}); diff --git a/apps/mobile/src/features/settings/__tests__/network-form.test.ts b/apps/mobile/src/features/settings/__tests__/network-form.test.ts new file mode 100644 index 00000000..437ccc8e --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/network-form.test.ts @@ -0,0 +1,95 @@ +import { + DEFAULT_MANAGED_NETWORK_SETTINGS, + type ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; + +import { networkDraftFromNetwork, validateNetworkDraft } from "../network-form"; + +jest.mock("expo-pans-ble-api", () => ({})); +jest.mock("react-native-worklets", () => ({ + ...jest.requireActual("react-native-worklets/lib/module/mock"), + scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) => + callback(...args), +})); +jest.mock("react-native-reanimated", () => + jest.requireActual("react-native-reanimated/mock"), +); +jest.mock( + "@shopify/react-native-skia", + () => ({ + Canvas: () => null, + Fill: () => null, + Group: () => null, + Path: () => null, + Circle: () => null, + Line: () => null, + Rect: () => null, + useFont: () => ({}), + vec: (x: number, y: number) => ({ x, y }), + }), + { virtual: true }, +); + +describe("mobile network profile validation", () => { + test("accepts trimmed names and hexadecimal PAN IDs", () => { + const result = validateNetworkDraft({ + name: " Stadium ", + panId: "0x00a0", + }); + + expect(result).toEqual({ + errors: {}, + value: { name: "Stadium", panId: 160 }, + }); + }); + + test("rejects an empty name, reserved PAN 0, and duplicate PAN IDs", () => { + const networks = [network("existing", "Existing", 160)]; + + expect(validateNetworkDraft({ name: " ", panId: "0" }, networks)).toEqual({ + errors: { + name: "Enter a network name.", + panId: + "Saved network PAN ID must be an integer from 1 to 65535; PAN 0 is the PANS default used for unassigned devices.", + }, + }); + expect( + validateNetworkDraft({ name: "Other", panId: "160" }, networks).errors, + ).toEqual({ panId: "A network with this PAN ID already exists." }); + }); + + test("rejects duplicate names without blocking the network being edited", () => { + const existing = network("existing", "Field", 160); + const other = network("other", "Auxiliary", 161); + + expect( + validateNetworkDraft( + { name: " field ", panId: "0x00a2" }, + [existing, other], + existing.id, + ), + ).toEqual({ errors: {}, value: { name: "field", panId: 162 } }); + expect( + validateNetworkDraft({ name: "FIELD", panId: "162" }, [existing, other]) + .errors, + ).toEqual({ name: "A network with this name already exists." }); + }); + + test("round-trips the detail form draft", () => { + expect(networkDraftFromNetwork(network("one", "One", 42))).toEqual({ + name: "One", + panId: "42", + }); + }); +}); + +function network(id: string, name: string, panId: number): ManagedNetwork { + return { + id, + name, + panId, + settings: DEFAULT_MANAGED_NETWORK_SETTINGS, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/apps/mobile/src/features/settings/__tests__/network-ui.test.ts b/apps/mobile/src/features/settings/__tests__/network-ui.test.ts new file mode 100644 index 00000000..59ea85b3 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/network-ui.test.ts @@ -0,0 +1,99 @@ +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, +} from "@eight2five/mobile/pans-manager"; + +import { + anchorInitiatorLabel, + commissioningWarningText, + selectAssociatedCachedAnchors, + selectNetworkAnchorDiscoveries, +} from "../network-ui"; + +describe("mobile network commissioning presentation", () => { + test("keeps associated cached anchors and presents initiator state", () => { + const associated = anchor("one", "network-a", true); + const unrelated = anchor("two", "network-b", false); + + expect( + selectAssociatedCachedAnchors("network-a", [associated, unrelated]), + ).toEqual([associated]); + expect(anchorInitiatorLabel(associated)).toBe("Yes"); + expect(anchorInitiatorLabel(anchor("unknown", undefined, false))).toBe( + "No", + ); + expect( + anchorInitiatorLabel({ ...associated, lastKnownConfig: undefined }), + ).toBe("Unknown"); + }); + + test("starts with current compatible discoveries and keeps uncached anchors", () => { + const cached = anchor("cached", "network-a", false); + const rows = selectNetworkAnchorDiscoveries( + [ + discovery("uncached", "anchor", -50), + discovery("cached", "anchor", -60), + discovery("tag", "tag", -55), + { ...discovery("stale", "anchor", -40), stale: true }, + { ...discovery("weak", "anchor", -90) }, + { ...discovery("bad", "anchor", -45), compatibility: "incompatible" }, + ], + [cached], + -75, + ); + + expect(rows.map((row) => row.discovery.transportDeviceId)).toEqual([ + "uncached", + "tag", + "cached", + ]); + expect(rows[0].cachedAnchor).toBeUndefined(); + expect(rows[1].requiresRoleChangeConfirmation).toBe(true); + expect(rows[2].cachedAnchor).toBe(cached); + expect(rows[2].requiresRoleChangeConfirmation).toBe(false); + }); + + test("does not invent a success message when verification is incomplete", () => { + const warning = "Initiator set, but one prior anchor was unreachable."; + expect(commissioningWarningText(warning)).toBe(warning); + expect(commissioningWarningText(" ")).toBeUndefined(); + expect(commissioningWarningText(undefined)).toBeUndefined(); + }); +}); + +function discovery( + transportDeviceId: string, + role: "tag" | "anchor", + rssi: number, +): DiscoveredDeviceSnapshot { + return { + transportDeviceId, + name: transportDeviceId, + rssi, + lastSeenAt: 1, + compatibility: "compatible", + presence: { role } as never, + }; +} + +function anchor( + id: string, + networkId: string | undefined, + initiatorEnabled: boolean, +): ManagedDevice { + return { + id, + networkId, + transportDeviceId: id, + role: "anchor", + lastKnownConfig: { + role: "anchor", + uwbMode: "active", + initiatorEnabled, + ledEnabled: true, + firmwareUpdateEnabled: true, + }, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts new file mode 100644 index 00000000..a1dd17f2 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/settings-actions.test.ts @@ -0,0 +1,53 @@ +import { DEFAULT_APP_SETTINGS } from "@eight2five/mobile/settings"; + +import { + RESET_SETTINGS_MESSAGE, + resetAppSettings, + updateDrillFeatures, +} from "../settings-actions"; + +describe("settings actions", () => { + test("persists Drill disablement before reconfiguring native tabs", async () => { + const events: string[] = []; + const writer = { + update: jest.fn(async () => { + events.push("persist"); + return { ...DEFAULT_APP_SETTINGS, drillFeaturesEnabled: false }; + }), + resetPreferences: jest.fn(), + }; + + await updateDrillFeatures( + writer, + (enabled) => events.push(`reconfigure:${enabled}`), + false, + ); + + expect(writer.update).toHaveBeenCalledWith({ drillFeaturesEnabled: false }); + expect(events).toEqual(["persist", "reconfigure:false"]); + }); + + test("reset preserves selection through the repository and restores Drill", async () => { + const reset = { + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }; + const writer = { + update: jest.fn(), + resetPreferences: jest.fn(async () => reset), + }; + const reconfigure = jest.fn(); + + await expect(resetAppSettings(writer, reconfigure)).resolves.toEqual(reset); + expect(reconfigure).toHaveBeenCalledTimes(1); + expect(reconfigure).toHaveBeenCalledWith(true); + }); + + test("reset confirmation states destructive boundaries", () => { + expect(RESET_SETTINGS_MESSAGE).toBe( + "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" + + "It does not delete drills, forget the remembered tag, delete cached anchor positions, or modify PANS hardware.", + ); + }); +}); diff --git a/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts b/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts new file mode 100644 index 00000000..e5830a05 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/settings-screen-policy.test.ts @@ -0,0 +1,8 @@ +import { shouldShowTransitionCountControls } from "../settings-screen-policy"; + +describe("settings screen transition count policy", () => { + test("hides detailed count controls when Show all is enabled", () => { + expect(shouldShowTransitionCountControls(true)).toBe(false); + expect(shouldShowTransitionCountControls(false)).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts new file mode 100644 index 00000000..2bb33559 --- /dev/null +++ b/apps/mobile/src/features/settings/__tests__/tag-connection-lifecycle.test.ts @@ -0,0 +1,26 @@ +import { ownTagDiscoveryWhileFocused } from "../tag-connection-lifecycle"; + +describe("Tag Connection discovery lifecycle", () => { + test("focus starts a page-owned scan and blur stops it", async () => { + const store = { + startTagDiscovery: jest.fn(async () => undefined), + stopManualDiscovery: jest.fn(), + }; + const cleanup = ownTagDiscoveryWhileFocused(store, true, false, jest.fn()); + await Promise.resolve(); + expect(store.startTagDiscovery).toHaveBeenCalledTimes(1); + cleanup(); + expect(store.stopManualDiscovery).toHaveBeenCalledTimes(1); + }); + + test("does not replace an already-connected global session", () => { + const store = { + startTagDiscovery: jest.fn(async () => undefined), + stopManualDiscovery: jest.fn(), + }; + const cleanup = ownTagDiscoveryWhileFocused(store, true, true, jest.fn()); + expect(store.startTagDiscovery).not.toHaveBeenCalled(); + cleanup(); + expect(store.stopManualDiscovery).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/settings/anchor-display.ts b/apps/mobile/src/features/settings/anchor-display.ts new file mode 100644 index 00000000..83ccfe91 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-display.ts @@ -0,0 +1,10 @@ +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +export function getDeveloperAnchorDisplayName(anchor: ManagedDevice): string { + return ( + anchor.lastKnownConfig?.label?.trim() || + anchor.label?.trim() || + anchor.nodeIdHex?.trim() || + anchor.id + ); +} diff --git a/apps/mobile/src/features/settings/anchor-editor-form.ts b/apps/mobile/src/features/settings/anchor-editor-form.ts new file mode 100644 index 00000000..6c691705 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-editor-form.ts @@ -0,0 +1,188 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { CoordinateRoundingSteps } from "@eight2five/mobile/settings"; +import { + ANCHOR_POSITION_REFERENCE_LABELS, + ANCHOR_POSITION_REFERENCES, + anchorFieldPositionFromMarchingCoordinate, + anchorFieldPositionToStandard, + anchorPositionUnitsToMeters, + convertAnchorPositionUnits, + formatMarchingCoordinate, + getAnchorPositionReferencePoint, + parseAnchorPositionDraft, + type AnchorFieldPosition, + type AnchorPositionReference, + type AnchorPositionUnit, + type StandardAnchorPositionDraft, +} from "@eight2five/mobile/field"; + +import { + coordinateDraftFromFieldPoint, + createDefaultPageDraft, + validatePageDraft, + type MarchingCoordinateDraft, +} from "../drill/page-form"; + +export type AnchorEditorMode = "marching" | "standard"; +export type MarchingHeightUnit = "meters" | "feet"; +const DEFAULT_ANCHOR_HEIGHT_METERS = 2; + +export interface MarchingAnchorDraft { + readonly coordinate: MarchingCoordinateDraft; + readonly height: string; + readonly heightUnit: MarchingHeightUnit; +} + +export interface AnchorDraftValidation { + readonly errors: Readonly>; + readonly position?: AnchorFieldPosition; +} + +export const ANCHOR_REFERENCE_CHOICES = ANCHOR_POSITION_REFERENCES.map( + (value) => ({ label: ANCHOR_POSITION_REFERENCE_LABELS[value], value }), +); + +export const ANCHOR_UNIT_CHOICES: readonly { + readonly label: string; + readonly value: AnchorPositionUnit; +}[] = [ + { label: "Meters", value: "meters" }, + { label: "Yards", value: "yards" }, + { label: "Feet", value: "feet" }, +]; + +export function createAnchorEditorDrafts( + position?: AnchorFieldPosition, + fieldPreset: FieldPresetId = "football-nfhs", +): { + readonly marching: MarchingAnchorDraft; + readonly standard: StandardAnchorPositionDraft; +} { + const center = getAnchorPositionReferencePoint("center-field", fieldPreset); + const initial = position ?? { + ...center, + zMeters: DEFAULT_ANCHOR_HEIGHT_METERS, + }; + const coordinate = position + ? coordinateDraftFromFieldPoint(position, fieldPreset) + : createDefaultPageDraft({ ordinal: 0, suggestedNumber: 0 }); + const standard = anchorFieldPositionToStandard( + initial, + "center-field", + "meters", + fieldPreset, + ); + return { + marching: { + coordinate, + height: String(initial.zMeters), + heightUnit: "meters", + }, + standard: { + reference: standard.reference, + unit: standard.unit, + sideToSideOffset: formatDraftNumber(standard.sideToSideOffset), + frontToBackOffset: formatDraftNumber(standard.frontToBackOffset), + height: formatDraftNumber(standard.height), + }, + }; +} + +export function validateMarchingAnchorDraft( + draft: MarchingAnchorDraft, + fieldPreset: FieldPresetId = "football-nfhs", +): AnchorDraftValidation { + const coordinate = validatePageDraft(draft.coordinate, fieldPreset); + const errors: Record = { ...coordinate.errors }; + const height = Number(draft.height); + if (!draft.height.trim() || !Number.isFinite(height)) { + errors.height = "Enter a finite height."; + } else if (height < 0) { + errors.height = "Height cannot be negative."; + } + if (!coordinate.value || Object.keys(errors).length > 0) return { errors }; + try { + return { + errors, + position: anchorFieldPositionFromMarchingCoordinate( + coordinate.value.coordinate, + anchorPositionUnitsToMeters(height, draft.heightUnit), + fieldPreset, + ), + }; + } catch (cause) { + return { + errors: { + ...errors, + position: cause instanceof Error ? cause.message : String(cause), + }, + }; + } +} + +export function convertMarchingHeightUnit( + draft: MarchingAnchorDraft, + heightUnit: MarchingHeightUnit, +): MarchingAnchorDraft { + if (heightUnit === draft.heightUnit) return draft; + const value = Number(draft.height); + return { + ...draft, + heightUnit, + height: + draft.height.trim() && Number.isFinite(value) + ? formatDraftNumber( + convertAnchorPositionUnits(value, draft.heightUnit, heightUnit), + ) + : draft.height, + }; +} + +export function validateStandardAnchorDraft( + draft: StandardAnchorPositionDraft, + fieldPreset: FieldPresetId = "football-nfhs", +): AnchorDraftValidation { + const result = parseAnchorPositionDraft(draft, fieldPreset); + return { errors: result.errors, position: result.value }; +} + +export function formatAnchorCanonicalPreview( + position: AnchorFieldPosition | undefined, + fieldPreset: FieldPresetId = "football-nfhs", + coordinateRoundingSteps: CoordinateRoundingSteps = 0.25, +): { readonly marching: string; readonly meters: string } | undefined { + if (!position) return undefined; + return { + marching: formatMarchingCoordinate( + position, + fieldPreset, + coordinateRoundingSteps, + ), + meters: `X ${position.xMeters.toFixed(3)} m · Y ${position.yMeters.toFixed(3)} m · Z ${position.zMeters.toFixed(3)} m`, + }; +} + +export function standardDraftFromPosition( + position: AnchorFieldPosition, + reference: AnchorPositionReference, + unit: AnchorPositionUnit, + fieldPreset: FieldPresetId = "football-nfhs", +): StandardAnchorPositionDraft { + const standard = anchorFieldPositionToStandard( + position, + reference, + unit, + fieldPreset, + ); + return { + reference, + unit, + sideToSideOffset: formatDraftNumber(standard.sideToSideOffset), + frontToBackOffset: formatDraftNumber(standard.frontToBackOffset), + height: formatDraftNumber(standard.height), + }; +} + +function formatDraftNumber(value: number): string { + return Number(value.toFixed(6)).toString(); +} diff --git a/apps/mobile/src/features/settings/anchor-editor-screen.tsx b/apps/mobile/src/features/settings/anchor-editor-screen.tsx new file mode 100644 index 00000000..896861c3 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-editor-screen.tsx @@ -0,0 +1,276 @@ +import React from "react"; +import { Radio, Save, TriangleAlert } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Card } from "@eight2five/ui/components/card"; +import { + FormControl, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { MarchingCoordinateForm } from "../drill/components/marching-coordinate-form"; +import { formatAnchorCanonicalPreview } from "./anchor-editor-form"; +import { getDeveloperAnchorDisplayName } from "./anchor-display"; +import { confirmAnchorPositionWrite } from "./anchor-write-confirmation"; +import { + AnchorNumberInput, + StandardAnchorPositionForm, +} from "./standard-anchor-position-form"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import { useAnchorEditorController } from "./use-anchor-editor-controller"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +export function AnchorEditorScreen({ + anchorId, +}: { + readonly anchorId: string; +}) { + const theme = useEight2FiveTheme(); + const controller = useAnchorEditorController(anchorId); + const preview = formatAnchorCanonicalPreview( + controller.validation.position, + controller.fieldPreset, + controller.coordinateRoundingSteps, + ); + + if (!controller.developerModeEnabled) { + return ( + + + Enable Developer Mode before editing anchor positions. + + + ); + } + + return ( + + {controller.error ? ( + + {controller.error.message} + + ) : null} + {controller.saved ? ( + + Anchor position written. PANS positions are write-only, so the cache + records this successful unverified write. + + ) : null} + + + + + + Anchor name + + + + + + + Written to the PANS device name and advertised over Bluetooth. + + + + + + + + + + + + + {controller.mode === "marching" ? ( + + + controller.setMarchingDraft({ + ...controller.marchingDraft, + coordinate, + }) + } + /> + + + + {(["meters", "feet"] as const).map((unit) => ( + + ))} + + + controller.setMarchingDraft({ + ...controller.marchingDraft, + height, + }) + } + /> + + + + ) : ( + + + + + + )} + + + Canonical preview + + {preview?.marching ?? "Complete a valid in-bounds position."} + + {preview ? ( + + {preview.meters} + + ) : null} + + + {!controller.canWritePosition ? ( + + + + Select this anchor's network as active or connect an associated + tag before the confirmed hardware write. + + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/settings/anchor-list-screen.tsx b/apps/mobile/src/features/settings/anchor-list-screen.tsx new file mode 100644 index 00000000..403b5924 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-list-screen.tsx @@ -0,0 +1,141 @@ +import { useRouter } from "expo-router"; +import { Database, Pencil, RefreshCw, Triangle } from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import { getDeveloperAnchorDisplayName } from "./anchor-display"; +import { useAnchorListController } from "./use-anchor-list-controller"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +export function AnchorListScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const controller = useAnchorListController(); + + if (!controller.developerModeEnabled) { + return ( + + + Enable Developer Mode before viewing or editing cached anchors. + + + ); + } + + return ( + + {controller.error ? ( + + {controller.error.message} + + ) : null} + + + + + + + + + {controller.anchors.length === 0 ? ( + + + + No anchors are cached. Discover the deployment with the tag to + cache nearby anchors. + + + ) : ( + controller.anchors.map((anchor) => { + const config = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig + : undefined; + const position = config?.position; + return ( + + router.push({ + pathname: "/(tabs)/settings/anchor/[anchorId]", + params: { anchorId: anchor.id }, + }) + } + > + + + + + {getDeveloperAnchorDisplayName(anchor)} + + + Initiator:{" "} + {config + ? config.initiatorEnabled + ? "Yes" + : "No" + : "Unknown"} + + + {position + ? `${position.xMeters.toFixed(3)}, ${position.yMeters.toFixed(3)}, ${position.zMeters.toFixed(3)} m` + : "Coordinate not cached"} + + + {position + ? "Source: local PANS cache" + : "Status: not configured"} + + + + + + ); + }) + )} + + + ); +} diff --git a/apps/mobile/src/features/settings/anchor-write-confirmation.ts b/apps/mobile/src/features/settings/anchor-write-confirmation.ts new file mode 100644 index 00000000..2e91b421 --- /dev/null +++ b/apps/mobile/src/features/settings/anchor-write-confirmation.ts @@ -0,0 +1,16 @@ +import { Alert } from "react-native"; +import type { AnchorFieldPosition } from "@eight2five/mobile/field"; + +export function confirmAnchorPositionWrite( + position: AnchorFieldPosition, + onConfirm: () => void, +): void { + Alert.alert( + "Write anchor position?", + `This will write X ${position.xMeters.toFixed(3)} m, Y ${position.yMeters.toFixed(3)} m, Z ${position.zMeters.toFixed(3)} m to PANS hardware. Incorrect positions can make reported locations inaccurate.`, + [ + { text: "Cancel", style: "cancel" }, + { text: "Write Position", onPress: onConfirm }, + ], + ); +} diff --git a/apps/mobile/src/features/settings/comfortable-anchor-range.ts b/apps/mobile/src/features/settings/comfortable-anchor-range.ts new file mode 100644 index 00000000..4fe8f4ef --- /dev/null +++ b/apps/mobile/src/features/settings/comfortable-anchor-range.ts @@ -0,0 +1,20 @@ +import { MAX_COMFORTABLE_ANCHOR_RANGE_METERS } from "@eight2five/mobile/settings"; +export interface ComfortableAnchorRangeResult { + readonly value?: number; + readonly error?: string; +} + +export function parseComfortableAnchorRange( + input: string, +): ComfortableAnchorRangeResult { + if (!input.trim()) return { error: "Enter a comfortable range in meters." }; + const value = Number(input); + if (!Number.isFinite(value)) return { error: "Range must be finite." }; + if (value <= 0) return { error: "Range must be greater than 0 meters." }; + if (value > MAX_COMFORTABLE_ANCHOR_RANGE_METERS) { + return { + error: `Range must not exceed ${MAX_COMFORTABLE_ANCHOR_RANGE_METERS} meters.`, + }; + } + return { value }; +} diff --git a/apps/mobile/src/features/settings/connection-status-row.tsx b/apps/mobile/src/features/settings/connection-status-row.tsx new file mode 100644 index 00000000..e808d84d --- /dev/null +++ b/apps/mobile/src/features/settings/connection-status-row.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { Animated, Easing } from "react-native"; +import { + Bluetooth, + BluetoothConnected, + BluetoothOff, + LoaderCircle, + TriangleAlert, +} from "lucide-react-native"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Text } from "@eight2five/ui/components/text"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import type { TagConnectionState } from "../../pans/mobile-pans-model"; +import { connectionStatusViewModel } from "../../pans/mobile-pans-ui"; + +const ICONS = { + connected: BluetoothConnected, + searching: LoaderCircle, + connecting: Bluetooth, + disconnected: BluetoothOff, + error: TriangleAlert, +} as const; + +export function ConnectionStatusRow({ + state, +}: { + readonly state: TagConnectionState; +}) { + const theme = useEight2FiveTheme(); + const presentation = connectionStatusViewModel(state); + const [spin] = React.useState(() => new Animated.Value(0)); + + React.useEffect(() => { + if (!presentation.animated) { + spin.stopAnimation(); + spin.setValue(0); + return; + } + const animation = Animated.loop( + Animated.timing(spin, { + toValue: 1, + duration: 900, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + animation.start(); + return () => animation.stop(); + }, [presentation.animated, spin]); + + const color = + presentation.tone === "success" + ? theme.success + : presentation.tone === "accent" + ? theme.accent + : presentation.tone === "danger" + ? theme.danger + : theme.textMuted; + const icon = ( + + ); + return ( + + {presentation.animated ? ( + + {icon} + + ) : ( + icon + )} + + {presentation.label} + + + ); +} diff --git a/apps/mobile/src/features/settings/developer-confirmation-screen.tsx b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx new file mode 100644 index 00000000..d60b3f11 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-confirmation-screen.tsx @@ -0,0 +1,2 @@ +// Compatibility export for stale imports while the old confirmation flow is retired. +export { DeveloperSettingsScreen as DeveloperConfirmationScreen } from "./developer-settings-screen"; diff --git a/apps/mobile/src/features/settings/developer-diagnostics.ts b/apps/mobile/src/features/settings/developer-diagnostics.ts new file mode 100644 index 00000000..d0ebbcd4 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-diagnostics.ts @@ -0,0 +1,109 @@ +import type { + ManagedDevice, + PansDiagnosticsResult, +} from "@eight2five/mobile/pans-manager"; + +import type { MobilePansSnapshot } from "../../pans/mobile-pans-store"; + +export interface DeveloperDiagnosticRow { + readonly label: string; + readonly value: string; +} + +/** Produces the intentionally quality-free diagnostics shown in production. */ +export function buildDeveloperDiagnosticRows( + snapshot: MobilePansSnapshot, +): readonly DeveloperDiagnosticRow[] { + const diagnostics = snapshot.hardwareDiagnostics; + const position = snapshot.rawPosition; + return [ + { label: "Connection state", value: snapshot.connectionState }, + { + label: "Node ID", + value: + diagnostics?.deviceInfo?.nodeIdHex ?? + snapshot.rememberedTag?.nodeIdHex ?? + "Unavailable", + }, + { + label: "PAN ID", + value: + diagnostics?.panId === undefined + ? (snapshot.rememberedTag?.lastKnownConfig?.panId?.toString() ?? + "Unavailable") + : formatHex(diagnostics.panId, 4), + }, + { label: "Firmware version", value: firmwareVersion(diagnostics) }, + { label: "Raw PANS X", value: formatMeters(position?.xMeters) }, + { label: "Raw PANS Y", value: formatMeters(position?.yMeters) }, + { label: "Raw PANS Z", value: formatMeters(position?.zMeters) }, + { + label: "Last update", + value: snapshot.lastUpdateAt + ? new Date(snapshot.lastUpdateAt).toISOString() + : "Never", + }, + { + label: "Effective update rate", + value: `${snapshot.effectiveUpdateRateHz.toFixed(1)} Hz`, + }, + { + label: "Locally known anchors", + value: snapshot.knownAnchors.length.toString(), + }, + ...(snapshot.counters + ? [ + { + label: "Notification events", + value: snapshot.counters.notificationEvents.toString(), + }, + { + label: "Decoded position frames", + value: snapshot.counters.positionFrames.toString(), + }, + ] + : []), + ...snapshot.diagnosticMessages.map((message, index) => ({ + label: `Stream diagnostic ${index + 1}`, + value: message, + })), + ...(diagnostics?.warnings.map((warning) => ({ + label: `Hardware warning: ${warning.section}`, + value: warning.message, + })) ?? []), + ...snapshot.knownAnchors.map(anchorDiagnosticRow), + ]; +} + +function anchorDiagnosticRow( + anchor: ManagedDevice, + index: number, +): DeveloperDiagnosticRow { + const position = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig.position + : undefined; + return { + label: `Cached anchor ${anchor.nodeIdHex ?? anchor.label ?? index + 1}`, + value: position + ? `${position.xMeters.toFixed(3)}, ${position.yMeters.toFixed(3)}, ${position.zMeters.toFixed(3)} m` + : "Position unavailable", + }; +} + +function firmwareVersion( + diagnostics: PansDiagnosticsResult | undefined, +): string { + if (!diagnostics?.deviceInfo) return "Unavailable"; + return diagnostics.operationMode.selectedFirmware === 2 + ? diagnostics.deviceInfo.firmware2Version.toString() + : diagnostics.deviceInfo.firmware1Version.toString(); +} + +function formatMeters(value: number | undefined): string { + return value === undefined ? "Unavailable" : `${value.toFixed(3)} m`; +} + +function formatHex(value: number, width: number): string { + return `0x${value.toString(16).toUpperCase().padStart(width, "0")}`; +} diff --git a/apps/mobile/src/features/settings/developer-mode-actions.ts b/apps/mobile/src/features/settings/developer-mode-actions.ts new file mode 100644 index 00000000..3e842006 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-mode-actions.ts @@ -0,0 +1,30 @@ +import { + DEFAULT_APP_SETTINGS, + type AppSettings, + type AppSettingsUpdate, +} from "@eight2five/mobile/settings"; + +export interface DeveloperModeWriter { + update(partial: AppSettingsUpdate): Promise; +} + +export async function enableDeveloperMode( + writer: DeveloperModeWriter, +): Promise { + return await writer.update({ developerModeEnabled: true }); +} + +export async function disableDeveloperMode( + writer: DeveloperModeWriter, +): Promise { + return await writer.update({ + developerModeEnabled: false, + mockLivePositionEnabled: false, + mockLivePositionXSteps: DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + mockLivePositionYSteps: DEFAULT_APP_SETTINGS.mockLivePositionYSteps, + }); +} + +export function canUseDeveloperControls(settings: AppSettings): boolean { + return settings.developerModeEnabled; +} diff --git a/apps/mobile/src/features/settings/developer-settings-screen.tsx b/apps/mobile/src/features/settings/developer-settings-screen.tsx new file mode 100644 index 00000000..f614cc36 --- /dev/null +++ b/apps/mobile/src/features/settings/developer-settings-screen.tsx @@ -0,0 +1,511 @@ +import React from "react"; +import { Alert } from "react-native"; +import { useRouter } from "expo-router"; +import { + Activity, + CircleDotDashed, + Code2, + Crosshair, + Database, + Grid3X3, + MapPinned, + Network, + RefreshCw, + Radio, + SlidersHorizontal, + Triangle, +} from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { buildDeveloperDiagnosticRows } from "./developer-diagnostics"; +import { parseComfortableAnchorRange } from "./comfortable-anchor-range"; +import { + disableDeveloperMode, + enableDeveloperMode, +} from "./developer-mode-actions"; +import { AnchorNumberInput } from "./standard-anchor-position-form"; +import { + SettingsMessage, + SettingsNavigationRow, + SettingsScreenContainer, + SettingsSection, + SettingsSwitchRow, + SettingsValueRow, +} from "./settings-components"; + +export function DeveloperSettingsScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const settingsStore = useAppSettingsStore(); + const pansStore = useMobilePansStore(); + const { status, settings, error: settingsError } = useAppSettingsSnapshot(); + const pans = useMobilePansSnapshot(); + const [refreshing, setRefreshing] = React.useState(false); + const [rebuildingDatabase, setRebuildingDatabase] = React.useState(false); + const [operationError, setOperationError] = React.useState(); + const [rangeDraft, setRangeDraft] = React.useState(() => + settings.comfortableAnchorRangeMeters.toString(), + ); + const [rssiDraft, setRssiDraft] = React.useState(() => + pans.discoveryRssiCutoff.toString(), + ); + const [mockXDraft, setMockXDraft] = React.useState(() => + settings.mockLivePositionXSteps.toString(), + ); + const [mockYDraft, setMockYDraft] = React.useState(() => + settings.mockLivePositionYSteps.toString(), + ); + const rows = React.useMemo(() => buildDeveloperDiagnosticRows(pans), [pans]); + + const setDeveloperMode = async (enabled: boolean) => { + setOperationError(undefined); + try { + if (enabled) { + await enableDeveloperMode(settingsStore); + } else { + await disableDeveloperMode(settingsStore); + } + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + }; + + const refresh = async () => { + if (refreshing) return; + setRefreshing(true); + setOperationError(undefined); + try { + await pansStore.refreshDiagnostics(); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } finally { + setRefreshing(false); + } + }; + + const rebuildDatabase = async () => { + if (rebuildingDatabase) return; + setRebuildingDatabase(true); + setOperationError(undefined); + try { + await settingsStore.rebuildDatabase(); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } finally { + setRebuildingDatabase(false); + } + }; + + const confirmDatabaseRebuild = () => { + Alert.alert( + "Rebuild app database?", + "This deletes all locally stored drills and app settings, then recreates the app database from the current schema. PANS device and network data is not deleted.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Rebuild", + style: "destructive", + onPress: () => void rebuildDatabase(), + }, + ], + ); + }; + + const updateOverlay = async (partial: { + showCachedAnchorGeometry?: boolean; + showComfortableAnchorRange?: boolean; + showPerimeterStepGrid?: boolean; + comfortableAnchorRangeMeters?: number; + }) => { + setOperationError(undefined); + try { + await settingsStore.update(partial); + } catch (cause) { + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ); + } + }; + + const rangeValidation = parseComfortableAnchorRange(rangeDraft); + const mockX = parseStaticPositionCoordinate(mockXDraft); + const mockY = parseStaticPositionCoordinate(mockYDraft); + const parsedRssi = Number(rssiDraft); + const validRssi = + Number.isInteger(parsedRssi) && parsedRssi >= -100 && parsedRssi <= -30; + + if (!settings.developerModeEnabled) { + return ( + + {settingsError || operationError ? ( + + {(operationError ?? settingsError)?.message} + + ) : null} + + { + if (enabled) void setDeveloperMode(true); + }} + disabled={status !== "ready"} + testID="developer-mode-setting" + /> + + {status === "error" ? ( + + + + If the app database cannot open after a schema change, rebuild + it from the current schema. This clears local drills and app + settings but leaves PANS device and network data alone. + + + + + ) : null} + + ); + } + + return ( + + {settingsError || operationError ? ( + + {(operationError ?? settingsError)?.message} + + ) : null} + {pans.commissioningWarning ? ( + + {pans.commissioningWarning} + + ) : null} + + void setDeveloperMode(enabled)} + disabled={status !== "ready"} + testID="developer-mode-setting" + /> + + + + + + {rows.slice(1).map((row) => ( + + {row.label} + + {row.value} + + + ))} + + + + + + + + + + + + + + + + + + + void settingsStore + .update({ mockLivePositionEnabled }) + .catch((cause) => + setOperationError( + cause instanceof Error ? cause : new Error(String(cause)), + ), + ) + } + testID="mock-live-position-setting" + /> + {settings.mockLivePositionEnabled ? ( + + + + + + ) : null} + + + + + + Delete the disposable app SQLite database and recreate it from the + current schema. This clears local drills and app settings but does + not delete PANS device or network data. + + + + + + + router.push("/(tabs)/settings/networks" as never)} + testID="networks-link" + /> + router.push("/(tabs)/settings/anchors")} + testID="cached-anchors-link" + /> + + + + + void updateOverlay({ showPerimeterStepGrid }) + } + testID="show-perimeter-step-grid-setting" + /> + + void updateOverlay({ showCachedAnchorGeometry }) + } + testID="show-cached-anchor-geometry-setting" + /> + + void updateOverlay({ showComfortableAnchorRange }) + } + disabled={!settings.showCachedAnchorGeometry} + testID="show-comfortable-anchor-range-setting" + /> + + + + + + + ); +} + +function parseStaticPositionCoordinate(value: string): { + readonly value?: number; + readonly error?: string; +} { + const normalized = value.trim(); + if (!normalized) return { error: "Enter a coordinate." }; + const parsed = Number(normalized); + if (!Number.isFinite(parsed)) return { error: "Enter a finite number." }; + if (Math.abs(parsed) > 1000) { + return { error: "Enter a value between -1000 and 1000 steps." }; + } + return { value: parsed }; +} diff --git a/apps/mobile/src/features/settings/network-detail-screen.tsx b/apps/mobile/src/features/settings/network-detail-screen.tsx new file mode 100644 index 00000000..b1082f59 --- /dev/null +++ b/apps/mobile/src/features/settings/network-detail-screen.tsx @@ -0,0 +1,514 @@ +import React from "react"; +import { Alert } from "react-native"; +import { useFocusEffect, useRouter } from "expo-router"; +import { + Check, + Pencil, + Radio, + ShieldCheck, + Trash2, + Triangle, + TriangleAlert, +} from "lucide-react-native"; +import { + formatPanId, + type ManagedDevice, +} from "@eight2five/mobile/pans-manager"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { + networkDraftFromNetwork, + validateNetworkDraft, + type NetworkDraft, +} from "./network-form"; +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import { getDeveloperAnchorDisplayName } from "./anchor-display"; +import { NetworkProfileForm } from "./network-profile-form"; +import { + anchorInitiatorLabel, + commissioningWarningText, + selectAssociatedCachedAnchors, + selectNetworkAnchorDiscoveries, + type NetworkAnchorDiscoveryRow, +} from "./network-ui"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +export function NetworkDetailScreen({ + networkId, +}: { + readonly networkId: string; +}) { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const { settings } = useAppSettingsSnapshot(); + const network = snapshot.networks.find((item) => item.id === networkId); + const [draftOverride, setDraftOverride] = React.useState(); + const draft = React.useMemo( + () => + draftOverride ?? + (network ? networkDraftFromNetwork(network) : { name: "", panId: "" }), + [draftOverride, network], + ); + const connectionStateRef = React.useRef(snapshot.connectionState); + React.useEffect(() => { + connectionStateRef.current = snapshot.connectionState; + }, [snapshot.connectionState]); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + + useFocusEffect( + React.useCallback(() => { + if ( + settings.developerModeEnabled && + snapshot.initialization === "ready" && + connectionStateRef.current !== "connected" + ) { + void store + .startTagDiscovery() + .catch((cause) => setError(toError(cause))); + } + return () => store.stopManualDiscovery(); + }, [settings.developerModeEnabled, snapshot.initialization, store]), + ); + + const validation = React.useMemo( + () => validateNetworkDraft(draft, snapshot.networks, network?.id), + [draft, network?.id, snapshot.networks], + ); + const associatedAnchors = React.useMemo( + () => selectAssociatedCachedAnchors(networkId, snapshot.knownAnchors), + [networkId, snapshot.knownAnchors], + ); + const discoveryRows = React.useMemo( + () => + selectNetworkAnchorDiscoveries( + snapshot.discoveries, + snapshot.knownAnchors, + snapshot.discoveryRssiCutoff, + ), + [snapshot.discoveries, snapshot.discoveryRssiCutoff, snapshot.knownAnchors], + ); + + if (snapshot.initialization === "loading") { + return ( + + + Preparing network details… + + + ); + } + + if (!settings.developerModeEnabled) { + return ( + + + Enable Developer Mode before managing PANS networks. + + + ); + } + + if (!network) { + return ( + + + The selected network no longer exists. + + + ); + } + + const run = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + setError(undefined); + try { + await action(); + } catch (cause) { + setError(toError(cause)); + } finally { + setBusy(false); + } + }; + + const ensureActiveNetwork = async () => { + if (snapshot.activeNetworkId !== network.id) { + await store.setActiveNetwork(network.id); + } + }; + + const save = () => { + if (!validation.value) return; + void run(async () => { + const saved = await store.updateNetwork(network.id, { + name: validation.value!.name, + panId: validation.value!.panId, + }); + setDraftOverride(networkDraftFromNetwork(saved)); + }); + }; + + const setActive = () => + void run(async () => { + await store.setActiveNetwork(network.id); + }); + + const openAnchor = (anchorId: string) => { + router.push({ + pathname: "/(tabs)/settings/anchor/[anchorId]", + params: { anchorId }, + }); + }; + + const persistDiscovery = async ( + row: NetworkAnchorDiscoveryRow, + confirmRoleChange: boolean, + ) => { + await run(async () => { + await ensureActiveNetwork(); + if (row.cachedAnchor && row.advertisedRole === "anchor") { + await store.assignDeviceToActiveNetwork(row.cachedAnchor.id); + openAnchor(row.cachedAnchor.id); + return; + } + const saved = await store.persistDiscoveredAnchor( + row.discovery.transportDeviceId, + confirmRoleChange, + ); + if (saved.networkId !== network.id) { + await store.assignDeviceToActiveNetwork(saved.id); + } + openAnchor(saved.id); + }); + }; + + const handleDiscovery = (row: NetworkAnchorDiscoveryRow) => { + if ( + row.cachedAnchor?.networkId === network.id && + row.advertisedRole === "anchor" + ) { + openAnchor(row.cachedAnchor.id); + return; + } + if (row.requiresRoleChangeConfirmation) { + Alert.alert( + "Convert device to anchor?", + `${ + row.discovery.name ?? row.discovery.transportDeviceId + } is advertising as ${ + row.advertisedRole === "unknown" ? "an unknown role" : "a tag" + }. Converting it changes the PANS hardware role and may interrupt its current use.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Convert to Anchor", + style: "destructive", + onPress: () => void persistDiscovery(row, true), + }, + ], + ); + return; + } + void persistDiscovery(row, false); + }; + + const confirmDelete = () => { + Alert.alert( + "Delete network profile?", + `Delete ${network.name}? Its cached device associations will be removed, but this does not erase hardware.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete Network", + style: "destructive", + onPress: () => + void run(async () => { + await store.deleteNetwork(network.id); + router.back(); + }), + }, + ], + ); + }; + + return ( + + {error || snapshot.error ? ( + + {(error ?? snapshot.error)?.message} + + ) : null} + {commissioningWarningText(snapshot.commissioningWarning) ? ( + + {commissioningWarningText(snapshot.commissioningWarning)} + + ) : null} + + + + + + + + + + + + + + + + {associatedAnchors.length === 0 ? ( + + + + No cached anchors are associated with this network yet. Start with + the live discovery list below. + + + ) : ( + associatedAnchors.map((anchor) => ( + openAnchor(anchor.id)} + onSetInitiator={() => + void run(async () => { + await ensureActiveNetwork(); + await store.setAnchorInitiator(anchor.id); + }) + } + /> + )) + )} + + + + {snapshot.connectionState === "connected" ? ( + + Disconnect the current performer tag before starting anchor + discovery. + + ) : null} + {discoveryRows.length === 0 ? ( + + {snapshot.connectionState === "scanning" + ? "Looking for compatible PANS devices…" + : "No current compatible devices meet the signal requirement."} + + ) : ( + discoveryRows.map((row) => ( + handleDiscovery(row)} + /> + )) + )} + + + + + {snapshot.commissioningWarning ? ( + + + + Hardware verification is incomplete. Keep the warning above until + the unreachable anchors can be checked. + + + ) : null} + + ); +} + +function CachedAnchorRow({ + anchor, + busy, + onEdit, + onSetInitiator, +}: { + readonly anchor: ManagedDevice; + readonly busy: boolean; + readonly onEdit: () => void; + readonly onSetInitiator: () => void; +}) { + const theme = useEight2FiveTheme(); + const initiator = anchorInitiatorLabel(anchor); + return ( + + + + + + + {getDeveloperAnchorDisplayName(anchor)} + + + Initiator: {initiator} + + + {anchor.transportDeviceId} + + + + + + {initiator === "Yes" ? ( + + ) : ( + + )} + + ); +} + +function DiscoveryAnchorRow({ + row, + busy, + onPress, +}: { + readonly row: NetworkAnchorDiscoveryRow; + readonly busy: boolean; + readonly onPress: () => void; +}) { + const theme = useEight2FiveTheme(); + const cached = row.cachedAnchor; + const title = cached + ? getDeveloperAnchorDisplayName(cached) + : (row.discovery.name ?? row.discovery.transportDeviceId); + const action = cached ? "Assign to this network" : "Add as anchor"; + return ( + + + + + + {title} + + + {row.advertisedRole === "unknown" + ? "Unknown role" + : `Advertised ${row.advertisedRole}`}{" "} + · {row.discovery.rssi} dBm + + + {cached + ? `Cached · Initiator: ${anchorInitiatorLabel(cached)}` + : "Not cached"} + + + + + + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/settings/network-form.ts b/apps/mobile/src/features/settings/network-form.ts new file mode 100644 index 00000000..bf5e7889 --- /dev/null +++ b/apps/mobile/src/features/settings/network-form.ts @@ -0,0 +1,86 @@ +import { + assertNetworkProfilePanId, + assertUniqueName, + parsePanId, + type ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; + +export interface NetworkDraft { + readonly name: string; + readonly panId: string; +} + +export interface NetworkDraftErrors { + readonly name?: string; + readonly panId?: string; +} + +export interface ValidatedNetworkDraft { + readonly name: string; + readonly panId: number; +} + +export interface NetworkDraftValidation { + readonly errors: NetworkDraftErrors; + readonly value?: ValidatedNetworkDraft; +} + +/** + * Validates the small app-local network profile form before it reaches the + * store. The store remains the authority for persistence and validates again; + * this keeps incomplete forms from starting an asynchronous operation. + */ +export function validateNetworkDraft( + draft: NetworkDraft, + networks: readonly ManagedNetwork[] = [], + currentNetworkId?: string, +): NetworkDraftValidation { + const errors: { name?: string; panId?: string } = {}; + const name = draft.name.trim(); + + if (!name) { + errors.name = "Enter a network name."; + } else { + const currentName = networks.find( + (network) => network.id === currentNetworkId, + )?.name; + try { + assertUniqueName( + name, + networks.map((network) => network.name), + currentName, + ); + } catch (cause) { + errors.name = toMessage(cause, "That network name is already in use."); + } + } + + let panId: number | undefined; + try { + panId = parsePanId(draft.panId); + assertNetworkProfilePanId(panId); + } catch (cause) { + errors.panId = toMessage(cause, "Enter a PAN ID from 1 to 65535."); + } + + if ( + panId !== undefined && + networks.some( + (network) => network.id !== currentNetworkId && network.panId === panId, + ) + ) { + errors.panId = "A network with this PAN ID already exists."; + } + + return Object.keys(errors).length > 0 + ? { errors } + : { errors, value: { name, panId: panId! } }; +} + +export function networkDraftFromNetwork(network: ManagedNetwork): NetworkDraft { + return { name: network.name, panId: String(network.panId) }; +} + +function toMessage(cause: unknown, fallback: string): string { + return cause instanceof Error && cause.message ? cause.message : fallback; +} diff --git a/apps/mobile/src/features/settings/network-profile-form.tsx b/apps/mobile/src/features/settings/network-profile-form.tsx new file mode 100644 index 00000000..f530c000 --- /dev/null +++ b/apps/mobile/src/features/settings/network-profile-form.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { Save } from "lucide-react-native"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing } from "@eight2five/ui/theme"; + +import { SpinningLoaderIcon } from "../../components/spinning-loader-icon"; +import type { NetworkDraft, NetworkDraftErrors } from "./network-form"; + +export function NetworkProfileForm({ + draft, + errors, + saving, + submitLabel, + onChange, + onSubmit, +}: { + readonly draft: NetworkDraft; + readonly errors: NetworkDraftErrors; + readonly saving: boolean; + readonly submitLabel: string; + readonly onChange: (draft: NetworkDraft) => void; + readonly onSubmit: () => void; +}) { + return ( + + + + Network name + + + onChange({ ...draft, name })} + /> + + + + A local name for this PANS network profile. + + + {errors.name ? ( + + {errors.name} + + ) : null} + + + + + PAN ID + + + onChange({ ...draft, panId })} + /> + + + + Enter decimal or hexadecimal (for example, 0x1234). PAN 0 is + reserved for unassigned devices. + + + {errors.panId ? ( + + {errors.panId} + + ) : null} + + + + + ); +} diff --git a/apps/mobile/src/features/settings/network-ui.ts b/apps/mobile/src/features/settings/network-ui.ts new file mode 100644 index 00000000..f1841d01 --- /dev/null +++ b/apps/mobile/src/features/settings/network-ui.ts @@ -0,0 +1,98 @@ +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, +} from "@eight2five/mobile/pans-manager"; + +export type AdvertisedAnchorRole = "anchor" | "tag" | "unknown"; + +export interface NetworkAnchorDiscoveryRow { + readonly discovery: DiscoveredDeviceSnapshot; + readonly cachedAnchor?: ManagedDevice; + readonly advertisedRole: AdvertisedAnchorRole; + readonly requiresRoleChangeConfirmation: boolean; +} + +export function isManagedAnchor(device: ManagedDevice): boolean { + return device.role === "anchor" || device.lastKnownConfig?.role === "anchor"; +} + +export function selectAssociatedCachedAnchors( + networkId: string, + knownAnchors: readonly ManagedDevice[], +): readonly ManagedDevice[] { + return knownAnchors.filter( + (anchor) => anchor.networkId === networkId && isManagedAnchor(anchor), + ); +} + +/** + * Discovery is intentionally the first source for this list. A cached device + * is attached to a discovery row when the transport identity matches, but a + * compatible advertisement with no cache entry is retained as well. + */ +export function selectNetworkAnchorDiscoveries( + discoveries: readonly DiscoveredDeviceSnapshot[], + knownAnchors: readonly ManagedDevice[], + cutoff: number, +): readonly NetworkAnchorDiscoveryRow[] { + return discoveries + .filter( + (discovery) => + !discovery.stale && + discovery.compatibility === "compatible" && + discovery.rssi >= cutoff, + ) + .sort((left, right) => { + const rssiDifference = right.rssi - left.rssi; + return rssiDifference !== 0 + ? rssiDifference + : left.transportDeviceId.localeCompare(right.transportDeviceId); + }) + .map((discovery) => { + const cachedAnchor = knownAnchors.find( + (anchor) => + normalizeTransportKey(anchor.transportDeviceId) === + normalizeTransportKey(discovery.transportDeviceId) && + isManagedAnchor(anchor), + ); + const advertisedRole = advertisedRoleForDiscovery(discovery); + return { + discovery, + ...(cachedAnchor ? { cachedAnchor } : {}), + advertisedRole, + requiresRoleChangeConfirmation: advertisedRole !== "anchor", + }; + }); +} + +export function advertisedRoleForDiscovery( + discovery: DiscoveredDeviceSnapshot, +): AdvertisedAnchorRole { + const role = discovery.presence?.role; + return role === "anchor" || role === "tag" ? role : "unknown"; +} + +export function anchorInitiatorLabel( + anchor: ManagedDevice | undefined, +): "Yes" | "No" | "Unknown" { + const config = + anchor?.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig + : undefined; + return config ? (config.initiatorEnabled ? "Yes" : "No") : "Unknown"; +} + +/** Preserve the store's wording; an absent warning must not imply verification. */ +export function commissioningWarningText( + warning: string | undefined, +): string | undefined { + return warning?.trim() || undefined; +} + +function normalizeTransportKey(deviceId: string): string { + const trimmed = deviceId.trim(); + const compact = trimmed.replace(/[:-]/g, ""); + return /^[0-9a-f]+$/i.test(compact) + ? compact.toUpperCase() + : trimmed.toLocaleLowerCase(); +} diff --git a/apps/mobile/src/features/settings/networks-screen.tsx b/apps/mobile/src/features/settings/networks-screen.tsx new file mode 100644 index 00000000..dd065626 --- /dev/null +++ b/apps/mobile/src/features/settings/networks-screen.tsx @@ -0,0 +1,265 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { + Check, + ChevronRight, + Network, + Plus, + TriangleAlert, +} from "lucide-react-native"; +import { + formatPanId, + type ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { NetworkProfileForm } from "./network-profile-form"; +import { validateNetworkDraft, type NetworkDraft } from "./network-form"; +import { commissioningWarningText } from "./network-ui"; +import { + SettingsMessage, + SettingsScreenContainer, + SettingsSection, + SettingsValueRow, +} from "./settings-components"; + +const EMPTY_DRAFT: NetworkDraft = { name: "", panId: "" }; + +export function NetworksScreen() { + const router = useRouter(); + const theme = useEight2FiveTheme(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const { settings } = useAppSettingsSnapshot(); + const [creating, setCreating] = React.useState(false); + const [draft, setDraft] = React.useState(EMPTY_DRAFT); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(); + const validation = React.useMemo( + () => validateNetworkDraft(draft, snapshot.networks), + [draft, snapshot.networks], + ); + const activeNetwork = snapshot.networks.find( + (network) => network.id === snapshot.activeNetworkId, + ); + + if (!snapshot.initialization || snapshot.initialization === "loading") { + return ( + + Preparing PANS networks… + + ); + } + + if (!settings.developerModeEnabled) { + return ( + + + Enable Developer Mode before managing PANS networks. + + + ); + } + + const run = async (action: () => Promise) => { + if (busy) return; + setBusy(true); + setError(undefined); + try { + await action(); + } catch (cause) { + setError(toError(cause)); + } finally { + setBusy(false); + } + }; + + const save = () => { + if (!validation.value) return; + void run(async () => { + const network = await store.createNetwork( + validation.value!.name, + validation.value!.panId, + ); + setDraft(EMPTY_DRAFT); + setCreating(false); + openNetwork(router, network); + }); + }; + + return ( + + {error || snapshot.error ? ( + + {(error ?? snapshot.error)?.message} + + ) : null} + {commissioningWarningText(snapshot.commissioningWarning) ? ( + + {commissioningWarningText(snapshot.commissioningWarning)} + + ) : null} + + + + + + + + + + {creating ? ( + + + + ) : null} + + {snapshot.networks.length === 0 ? ( + + + + No network profiles are saved on this device. + + + ) : ( + snapshot.networks.map((network) => ( + openNetwork(router, network)} + onSetActive={() => + void run(async () => { + await store.setActiveNetwork(network.id); + }) + } + /> + )) + )} + + + + ); +} + +function NetworkRow({ + network, + active, + disabled, + onOpen, + onSetActive, +}: { + readonly network: ManagedNetwork; + readonly active: boolean; + readonly disabled: boolean; + readonly onOpen: () => void; + readonly onSetActive: () => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + + + {network.name} + + + PAN {formatPanId(network.panId)} + + + {active ? ( + + + + Active + + + ) : null} + + + + {!active ? ( + + ) : null} + + ); +} + +function openNetwork( + router: ReturnType, + network: ManagedNetwork, +) { + router.push({ + pathname: "/(tabs)/settings/network/[networkId]" as never, + params: { networkId: network.id }, + }); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/settings/reset-settings-control.tsx b/apps/mobile/src/features/settings/reset-settings-control.tsx new file mode 100644 index 00000000..7254ba4c --- /dev/null +++ b/apps/mobile/src/features/settings/reset-settings-control.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Alert } from "react-native"; +import { RotateCcw } from "lucide-react-native"; +import { Pressable } from "@eight2five/ui/components/pressable"; + +import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context"; +import { useAppSettingsStore } from "../../state/app-settings-store"; +import { RESET_SETTINGS_MESSAGE, resetAppSettings } from "./settings-actions"; +import { SettingsRowContent } from "./settings-components"; + +export function ResetSettingsControl({ + disabled, + onError, +}: { + disabled?: boolean; + onError(error: Error): void; +}) { + const store = useAppSettingsStore(); + const { reconfigureDrillFeatures } = useTabBarVisibility(); + const [resetting, setResetting] = React.useState(false); + + const reset = async () => { + setResetting(true); + try { + await resetAppSettings(store, reconfigureDrillFeatures); + } catch (cause) { + onError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setResetting(false); + } + }; + + const confirmReset = () => { + Alert.alert("Reset App Settings?", RESET_SETTINGS_MESSAGE, [ + { text: "Cancel", style: "cancel" }, + { + text: "Reset", + style: "destructive", + onPress: () => void reset(), + }, + ]); + }; + + return ( + + + + ); +} diff --git a/apps/mobile/src/features/settings/settings-actions.ts b/apps/mobile/src/features/settings/settings-actions.ts new file mode 100644 index 00000000..15e10ab8 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-actions.ts @@ -0,0 +1,34 @@ +import type { + AppSettings, + AppSettingsUpdate, +} from "@eight2five/mobile/settings"; + +export interface SettingsWriter { + update(partial: AppSettingsUpdate): Promise; + resetPreferences(): Promise; +} + +export const RESET_SETTINGS_MESSAGE = + "This restores display, drill-feature, terminology, and developer preferences to their defaults.\n\n" + + "It does not delete drills, forget the remembered tag, delete cached anchor positions, or modify PANS hardware."; + +/** Persistence completes before the native-tab layout is reconfigured. */ +export async function updateDrillFeatures( + writer: SettingsWriter, + reconfigureTabs: (enabled: boolean) => void, + enabled: boolean, +): Promise { + const settings = await writer.update({ drillFeaturesEnabled: enabled }); + reconfigureTabs(settings.drillFeaturesEnabled); + return settings; +} + +/** Reset affects preferences only, then reconciles Drill tab membership once. */ +export async function resetAppSettings( + writer: SettingsWriter, + reconfigureTabs: (enabled: boolean) => void, +): Promise { + const settings = await writer.resetPreferences(); + reconfigureTabs(settings.drillFeaturesEnabled); + return settings; +} diff --git a/apps/mobile/src/features/settings/settings-components.tsx b/apps/mobile/src/features/settings/settings-components.tsx new file mode 100644 index 00000000..663baf89 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-components.tsx @@ -0,0 +1,341 @@ +import React from "react"; +import { + ChevronDown, + ChevronRight, + type LucideIcon, +} from "lucide-react-native"; +import { Card } from "@eight2five/ui/components/card"; +import { Heading } from "@eight2five/ui/components/heading"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; +import { Switch } from "@eight2five/ui/components/switch"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { + eight2FiveFonts, + eight2FiveRadii, + eight2FiveSpacing, + useEight2FiveTheme, +} from "@eight2five/ui/theme"; + +export function SettingsScreenContainer({ + children, +}: { + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + {children} + + ); +} + +export function SettingsSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {title} + + + {children} + + + ); +} + +interface SettingsRowContentProps { + icon: LucideIcon; + title: string; + description?: string; + accessory?: React.ReactNode; + danger?: boolean; +} + +function SettingsRowContent({ + icon, + title, + description, + accessory, + danger = false, +}: SettingsRowContentProps) { + const theme = useEight2FiveTheme(); + const color = danger ? theme.danger : theme.text; + return ( + + + + + {title} + + {description ? ( + + {description} + + ) : null} + + {accessory} + + ); +} + +export function SettingsNavigationRow({ + icon, + title, + description, + onPress, + testID, +}: SettingsRowContentProps & { onPress(): void; testID?: string }) { + const theme = useEight2FiveTheme(); + return ( + + + } + /> + + ); +} + +export function SettingsValueRow({ + icon, + title, + description, + value, +}: SettingsRowContentProps & { value: string }) { + const theme = useEight2FiveTheme(); + return ( + + {value} + + } + /> + ); +} + +export function SettingsSwitchRow({ + icon, + title, + description, + value, + onChange, + disabled, + testID, +}: SettingsRowContentProps & { + value: boolean; + onChange(value: boolean): void; + disabled?: boolean; + testID?: string; +}) { + const theme = useEight2FiveTheme(); + return ( + + } + /> + ); +} + +export interface SettingsSelectChoice { + readonly label: string; + readonly value: T; +} + +export function SettingsSelectRow({ + icon, + title, + description, + value, + choices, + onChange, + disabled, + testID, +}: SettingsRowContentProps & { + value: T; + choices: readonly SettingsSelectChoice[]; + onChange(value: T): void; + disabled?: boolean; + testID?: string; +}) { + const theme = useEight2FiveTheme(); + const selectedLabel = + choices.find((choice) => choice.value === value)?.label ?? value; + return ( + + + + + + + ); +} + +export function SettingsMessage({ + tone, + children, +}: { + tone: "info" | "error" | "warning"; + children: React.ReactNode; +}) { + const theme = useEight2FiveTheme(); + const color = + tone === "error" + ? theme.danger + : tone === "warning" + ? theme.warning + : theme.text; + return ( + + + {children} + + + ); +} + +export { SettingsRowContent }; diff --git a/apps/mobile/src/features/settings/settings-screen-policy.ts b/apps/mobile/src/features/settings/settings-screen-policy.ts new file mode 100644 index 00000000..a4f91914 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-screen-policy.ts @@ -0,0 +1,3 @@ +export function shouldShowTransitionCountControls(showAll: boolean): boolean { + return !showAll; +} diff --git a/apps/mobile/src/features/settings/settings-screen.tsx b/apps/mobile/src/features/settings/settings-screen.tsx new file mode 100644 index 00000000..b5b94128 --- /dev/null +++ b/apps/mobile/src/features/settings/settings-screen.tsx @@ -0,0 +1,412 @@ +import React from "react"; +import { useRouter } from "expo-router"; +import { + Activity, + BookOpenText, + Code2, + Eye, + ListChecks, + Map, + Navigation, + Palette, + Radio, + Route, + Rows3, + RulerDimensionLine, +} from "lucide-react-native"; +import { + COORDINATE_ROUNDING_PRESETS, + type AppearanceMode, + type AppSettingsUpdate, + type CoordinateRoundingSteps, + type FieldPerspective, +} from "@eight2five/mobile/settings"; +import type { DrillTerminology } from "@eight2five/mobile/drill"; +import { + FIELD_PRESET_IDS, + getFieldPreset, + type FieldPresetId, +} from "@eight2five/drill-schema"; + +import { useTabBarVisibility } from "../../navigation/tab-bar-visibility-context"; +import { useMobilePansSnapshot } from "../../pans/mobile-pans-context"; +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { ResetSettingsControl } from "./reset-settings-control"; +import { ConnectionStatusRow } from "./connection-status-row"; +import { updateDrillFeatures } from "./settings-actions"; +import { shouldShowTransitionCountControls } from "./settings-screen-policy"; +import { + SettingsMessage, + SettingsNavigationRow, + SettingsScreenContainer, + SettingsSection, + SettingsSelectRow, + SettingsSwitchRow, +} from "./settings-components"; + +const PERSPECTIVE_CHOICES = [ + { label: "Director", value: "director" }, + { label: "Performer", value: "performer" }, +] as const; + +const APPEARANCE_CHOICES = [ + { label: "System", value: "system" }, + { label: "Light", value: "light" }, + { label: "Dark", value: "dark" }, +] as const; + +const TERMINOLOGY_CHOICES = [ + { label: "Sets", value: "sets" }, + { label: "Pages", value: "pages" }, +] as const; + +const FIELD_PRESET_CHOICES = FIELD_PRESET_IDS.map((value) => ({ + label: getFieldPreset(value).name, + value, +})) satisfies readonly { label: string; value: FieldPresetId }[]; + +const COORDINATE_ROUNDING_CHOICES = COORDINATE_ROUNDING_PRESETS.map( + (value) => ({ + label: + value === 0.125 + ? "⅛ step" + : value === 0.25 + ? "¼ step" + : value === 0.5 + ? "½ step" + : "1 step", + value: String(value), + }), +); + +const TRANSITION_COUNT_CHOICES = Array.from({ length: 6 }, (_, count) => ({ + label: String(count), + value: String(count), +})); + +const DISTANCE_THRESHOLD_VALUES = [0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3]; + +function distanceThresholdChoices(values: readonly number[]) { + return values.map((value) => ({ + label: value === 1 ? "one step" : `${value} steps`, + value: String(value), + })); +} + +export function SettingsScreen() { + const router = useRouter(); + const store = useAppSettingsStore(); + const { status, settings, error: loadError } = useAppSettingsSnapshot(); + const { reconfigureDrillFeatures } = useTabBarVisibility(); + const pans = useMobilePansSnapshot(); + const [operationError, setOperationError] = React.useState(); + const disabled = status !== "ready"; + + const update = async (partial: AppSettingsUpdate) => { + setOperationError(undefined); + try { + await store.update(partial); + } catch (cause) { + setOperationError(toError(cause)); + } + }; + + const setDrillFeatures = async (enabled: boolean) => { + setOperationError(undefined); + try { + await updateDrillFeatures(store, reconfigureDrillFeatures, enabled); + } catch (cause) { + setOperationError(toError(cause)); + } + }; + + return ( + + {status === "loading" ? ( + Loading app settings… + ) : null} + {loadError || operationError ? ( + + {(operationError ?? loadError)?.message} + + ) : null} + + + router.push("/(tabs)/settings/tag")} + testID="tag-connection-link" + /> + + + + + + icon={Palette} + title="App appearance" + description="Follow the system appearance or always use a light or dark theme." + value={settings.appearanceMode} + choices={APPEARANCE_CHOICES} + onChange={(appearanceMode) => void update({ appearanceMode })} + disabled={disabled} + testID="appearance-mode-setting" + /> + + + + void setDrillFeatures(enabled)} + disabled={disabled} + testID="drill-features-setting" + /> + + icon={BookOpenText} + title="Drill terminology" + description="Choose the name used for drill positions." + value={settings.drillTerminology} + choices={TERMINOLOGY_CHOICES} + onChange={(drillTerminology) => void update({ drillTerminology })} + disabled={disabled} + testID="drill-terminology-setting" + /> + + + + + icon={Map} + title="Default marching field" + description="Used when no drill is loaded and for new manual drills. A loaded drill overrides this default." + value={settings.defaultFieldPreset} + choices={FIELD_PRESET_CHOICES} + onChange={(defaultFieldPreset) => void update({ defaultFieldPreset })} + disabled={disabled} + testID="default-field-preset-setting" + /> + + icon={Eye} + title="Field perspective" + description="Choose how the field is oriented." + value={settings.fieldPerspective} + choices={PERSPECTIVE_CHOICES} + onChange={(fieldPerspective) => void update({ fieldPerspective })} + disabled={disabled} + testID="field-perspective-setting" + /> + + icon={RulerDimensionLine} + title="Coordinate rounding" + description="Round displayed marching coordinates to this step increment." + value={String(settings.coordinateRoundingSteps)} + choices={COORDINATE_ROUNDING_CHOICES} + onChange={(value) => + void update({ + coordinateRoundingSteps: Number(value) as CoordinateRoundingSteps, + }) + } + disabled={disabled} + testID="coordinate-rounding-setting" + /> + + void update({ showAuxiliaryFieldMarks }) + } + disabled={disabled} + testID="auxiliary-field-marks-setting" + /> + + void update({ motionInterpolationEnabled }) + } + disabled={disabled} + testID="motion-interpolation-setting" + /> + + + + + icon={RulerDimensionLine} + title="Green distance threshold" + description="Show target distance as green at or below this value." + value={String(settings.distanceGreenThresholdSteps)} + choices={distanceThresholdChoices( + Array.from( + new Set([ + ...DISTANCE_THRESHOLD_VALUES.filter( + (value) => value <= settings.distanceYellowThresholdSteps, + ), + settings.distanceGreenThresholdSteps, + ]), + ).sort((left, right) => left - right), + )} + onChange={(value) => + void update({ distanceGreenThresholdSteps: Number(value) }) + } + disabled={disabled} + testID="distance-green-threshold-setting" + /> + + icon={RulerDimensionLine} + title="Yellow distance threshold" + description="Show target distance as yellow through this value, then red." + value={String(settings.distanceYellowThresholdSteps)} + choices={distanceThresholdChoices( + Array.from( + new Set([ + ...DISTANCE_THRESHOLD_VALUES.filter( + (value) => value >= settings.distanceGreenThresholdSteps, + ), + settings.distanceYellowThresholdSteps, + ]), + ).sort((left, right) => left - right), + )} + onChange={(value) => + void update({ distanceYellowThresholdSteps: Number(value) }) + } + disabled={disabled} + testID="distance-yellow-threshold-setting" + /> + + void update({ showTransitionMarkers }) + } + disabled={disabled} + testID="show-transition-markers-setting" + /> + + void update({ showAllTransitionSets }) + } + disabled={disabled} + testID="show-all-transition-sets-setting" + /> + {shouldShowTransitionCountControls(settings.showAllTransitionSets) ? ( + <> + + icon={Route} + title="Previous transition positions" + description="Total positions, including the immediate previous marker." + value={String(settings.previousTransitionSetCount)} + choices={TRANSITION_COUNT_CHOICES} + onChange={(value) => + void update({ previousTransitionSetCount: Number(value) }) + } + disabled={disabled} + testID="previous-transition-set-count-setting" + /> + + icon={Route} + title="Next transition positions" + description="Total positions, including the immediate next marker." + value={String(settings.nextTransitionSetCount)} + choices={TRANSITION_COUNT_CHOICES} + onChange={(value) => + void update({ nextTransitionSetCount: Number(value) }) + } + disabled={disabled} + testID="next-transition-set-count-setting" + /> + + ) : null} + + + + + void update({ showPerformerLabels }) + } + disabled={disabled} + testID="show-performer-labels-setting" + /> + void update({ showPerformerNames })} + disabled={disabled} + testID="show-performer-names-setting" + /> + void update({ showPropLabels })} + disabled={disabled} + testID="show-prop-labels-setting" + /> + void update({ showPropNames })} + disabled={disabled} + testID="show-prop-names-setting" + /> + + + + void update({ guidanceEnabled })} + disabled={disabled} + testID="guidance-enabled-setting" + /> + + + + router.push("/(tabs)/settings/developer")} + testID="developer-settings-link" + /> + + + + + + + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/settings/standard-anchor-position-form.tsx b/apps/mobile/src/features/settings/standard-anchor-position-form.tsx new file mode 100644 index 00000000..4aff0089 --- /dev/null +++ b/apps/mobile/src/features/settings/standard-anchor-position-form.tsx @@ -0,0 +1,209 @@ +import { ChevronDown } from "lucide-react-native"; +import type { + AnchorPositionReference, + AnchorPositionUnit, + StandardAnchorPositionDraft, +} from "@eight2five/mobile/field"; +import { + FormControl, + FormControlError, + FormControlErrorText, + FormControlHelper, + FormControlHelperText, + FormControlLabel, + FormControlLabelText, +} from "@eight2five/ui/components/form-control"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { + Select, + SelectBackdrop, + SelectContent, + SelectDragIndicator, + SelectDragIndicatorWrapper, + SelectIcon, + SelectInput, + SelectItem, + SelectPortal, + SelectTrigger, +} from "@eight2five/ui/components/select"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { + ANCHOR_REFERENCE_CHOICES, + ANCHOR_UNIT_CHOICES, +} from "./anchor-editor-form"; + +export function StandardAnchorPositionForm({ + draft, + errors, + disabled, + onChange, + onReferenceChange, + onUnitChange, +}: { + readonly draft: StandardAnchorPositionDraft; + readonly errors: Readonly>; + readonly disabled: boolean; + readonly onChange: (draft: StandardAnchorPositionDraft) => void; + readonly onReferenceChange: (reference: AnchorPositionReference) => void; + readonly onUnitChange: (unit: AnchorPositionUnit) => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + + + onChange({ ...draft, sideToSideOffset }) + } + /> + + Negative side-to-side: toward Side 1{"\n"}Positive side-to-side: toward + Side 2 + + + onChange({ ...draft, frontToBackOffset }) + } + /> + + Negative front-to-back: toward front sideline{"\n"}Positive + front-to-back: toward back sideline + + onChange({ ...draft, height })} + /> + {errors.position ? ( + + {errors.position} + + ) : null} + + ); +} + +export function AnchorNumberInput({ + label, + value, + error, + helper, + disabled, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly error?: string; + readonly helper?: string; + readonly disabled: boolean; + readonly onChange: (value: string) => void; +}) { + return ( + + + {label} + + + + + {helper ? ( + + {helper} + + ) : null} + {error ? ( + + {error} + + ) : null} + + ); +} + +function AnchorSelect({ + label, + value, + choices, + disabled, + onChange, +}: { + readonly label: string; + readonly value: Value; + readonly choices: readonly { + readonly label: string; + readonly value: Value; + }[]; + readonly disabled: boolean; + readonly onChange: (value: Value) => void; +}) { + const theme = useEight2FiveTheme(); + return ( + + + {label} + + + + ); +} diff --git a/apps/mobile/src/features/settings/tag-connection-lifecycle.ts b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts new file mode 100644 index 00000000..6bfb1393 --- /dev/null +++ b/apps/mobile/src/features/settings/tag-connection-lifecycle.ts @@ -0,0 +1,19 @@ +export interface TagDiscoveryPageOwner { + startTagDiscovery(): Promise; + stopManualDiscovery(): void; +} + +export function ownTagDiscoveryWhileFocused( + store: TagDiscoveryPageOwner, + servicesReady: boolean, + alreadyConnected: boolean, + onError: (error: Error) => void, +): () => void { + if (!servicesReady || alreadyConnected) return () => undefined; + void store + .startTagDiscovery() + .catch((cause) => + onError(cause instanceof Error ? cause : new Error(String(cause))), + ); + return () => store.stopManualDiscovery(); +} diff --git a/apps/mobile/src/features/settings/tag-connection-screen.tsx b/apps/mobile/src/features/settings/tag-connection-screen.tsx new file mode 100644 index 00000000..9cc46c99 --- /dev/null +++ b/apps/mobile/src/features/settings/tag-connection-screen.tsx @@ -0,0 +1,379 @@ +import React from "react"; +import { useFocusEffect, useRouter } from "expo-router"; +import { + BluetoothConnected, + Edit3, + Network, + Signal, + SignalHigh, + SignalLow, + SignalMedium, + Trash2, + TriangleAlert, +} from "lucide-react-native"; +import { + Button, + ButtonIcon, + ButtonText, +} from "@eight2five/ui/components/button"; +import { HStack } from "@eight2five/ui/components/hstack"; +import { Icon } from "@eight2five/ui/components/icon"; +import { Input, InputField } from "@eight2five/ui/components/input"; +import { Pressable } from "@eight2five/ui/components/pressable"; +import { ScrollView } from "@eight2five/ui/components/scroll-view"; +import { Text } from "@eight2five/ui/components/text"; +import { VStack } from "@eight2five/ui/components/vstack"; +import { eight2FiveSpacing, useEight2FiveTheme } from "@eight2five/ui/theme"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { + selectVisibleDiscoveries, + signalStrengthForRssi, + type SignalStrength, +} from "../../pans/mobile-pans-ui"; +import { ConnectionStatusRow } from "./connection-status-row"; +import { ownTagDiscoveryWhileFocused } from "./tag-connection-lifecycle"; +import { + SettingsMessage, + SettingsNavigationRow, + SettingsScreenContainer, + SettingsSection, + SettingsSelectRow, + SettingsValueRow, +} from "./settings-components"; + +const SIGNAL_ICONS: Record = { + full: Signal, + high: SignalHigh, + medium: SignalMedium, + low: SignalLow, +}; + +export function TagConnectionScreen() { + return ; +} + +/** + * Shared route/modal entry point. Modal content owns discovery for its mounted + * lifetime instead of depending on navigation focus, which keeps it usable + * when rendered through the field HUD modal portal. + */ +export function TagConnectionContent({ + modal = false, +}: { + readonly modal?: boolean; +}) { + return modal ? ( + + ) : ( + + ); +} + +function FocusedTagConnectionContent() { + const router = useRouter(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const [lifecycleError, setLifecycleError] = React.useState(); + + useFocusEffect( + React.useCallback(() => { + setLifecycleError(undefined); + return ownTagDiscoveryWhileFocused( + store, + snapshot.initialization === "ready", + store.getSnapshot().connectionState === "connected", + setLifecycleError, + ); + }, [snapshot.initialization, store]), + ); + + return ( + router.push("/(tabs)/settings/networks" as never)} + /> + ); +} + +function MountedTagConnectionContent() { + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const [lifecycleError, setLifecycleError] = React.useState(); + + React.useEffect(() => { + return ownTagDiscoveryWhileFocused( + store, + snapshot.initialization === "ready", + store.getSnapshot().connectionState === "connected", + setLifecycleError, + ); + }, [snapshot.initialization, store]); + + return ; +} + +function TagConnectionBody({ + modal = false, + lifecycleError, + onOpenNetworks, +}: { + readonly modal?: boolean; + readonly lifecycleError?: Error; + readonly onOpenNetworks?: () => void; +}) { + const theme = useEight2FiveTheme(); + const store = useMobilePansStore(); + const snapshot = useMobilePansSnapshot(); + const { settings } = useAppSettingsSnapshot(); + const developerMode = settings.developerModeEnabled; + const [operation, setOperation] = React.useState(false); + const [error, setError] = React.useState(); + const [labelEdit, setLabelEdit] = React.useState<{ + readonly deviceId?: string; + readonly value: string; + }>({ value: "" }); + const candidates = React.useMemo( + () => + selectVisibleDiscoveries(snapshot.discoveries, { + developerMode, + cutoff: snapshot.discoveryRssiCutoff, + }), + [developerMode, snapshot.discoveries, snapshot.discoveryRssiCutoff], + ); + + const selectedLabel = + snapshot.rememberedTag?.lastKnownConfig?.label ?? + snapshot.rememberedTag?.label ?? + ""; + const labelDraft = + labelEdit.deviceId === snapshot.rememberedTag?.id + ? labelEdit.value + : selectedLabel; + + const run = async (action: () => Promise) => { + if (operation) return; + setOperation(true); + setError(undefined); + try { + await action(); + } catch (cause) { + setError(toError(cause)); + } finally { + setOperation(false); + } + }; + + const content = ( + <> + {snapshot.error || lifecycleError || error ? ( + + {(error ?? lifecycleError ?? snapshot.error)?.message} + + ) : null} + + + + {snapshot.rememberedTag ? ( + + + + {snapshot.rememberedTag.lastKnownConfig?.label ?? + snapshot.rememberedTag.label ?? + "Selected tag"} + + + + ) : null} + + + + {candidates.length === 0 ? ( + + {snapshot.connectionState === "scanning" + ? "Looking for nearby tags…" + : "No nearby tags meet the signal requirement."} + + ) : ( + candidates.map((device) => { + const strength = signalStrengthForRssi( + device.rssi, + snapshot.discoveryRssiCutoff, + ); + const role = device.presence?.role; + return ( + + void run(async () => { + if (role === "tag") { + await store.selectConfigureAndConnectTag( + device.transportDeviceId, + ); + } else if (developerMode) { + await store.persistDiscoveredAnchor( + device.transportDeviceId, + ); + } + }) + } + > + + {developerMode ? ( + + {device.rssi} dBm + + ) : ( + + )} + + + {device.name ?? "Unnamed tag"} + + {developerMode ? ( + + {role ?? "unknown"} · {device.transportDeviceId} + + ) : null} + + + + ); + }) + )} + + + {developerMode ? ( + + + icon={Network} + title="Active network" + description="A selected network is verified on tags during connection." + value={snapshot.activeNetworkId ?? "none"} + choices={[ + { label: "None", value: "none" }, + ...snapshot.networks.map((network) => ({ + label: network.name, + value: network.id, + })), + ]} + onChange={(value) => + void run(() => + store.setActiveNetwork(value === "none" ? undefined : value), + ) + } + disabled={operation} + testID="active-network-setting" + /> + {!modal && onOpenNetworks ? ( + + ) : null} + {snapshot.rememberedTag ? ( + + + + setLabelEdit({ + deviceId: snapshot.rememberedTag?.id, + value, + }) + } + /> + + + + + ) : null} + + ) : null} + + {snapshot.connectionState === "error" ? ( + + + + Move closer, verify Bluetooth is available, and try again. + + + ) : null} + + ); + + if (!modal) + return {content}; + return ( + + {content} + + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} diff --git a/apps/mobile/src/features/settings/use-anchor-editor-controller.ts b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts new file mode 100644 index 00000000..12de1a4e --- /dev/null +++ b/apps/mobile/src/features/settings/use-anchor-editor-controller.ts @@ -0,0 +1,248 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; +import type { + AnchorFieldPosition, + AnchorPositionUnit, + StandardAnchorPositionDraft, +} from "@eight2five/mobile/field"; +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { resolveEffectiveFieldPreset } from "../field/effective-field-preset"; +import { + createAnchorEditorDrafts, + convertMarchingHeightUnit, + standardDraftFromPosition, + validateMarchingAnchorDraft, + validateStandardAnchorDraft, + type AnchorEditorMode, + type MarchingAnchorDraft, +} from "./anchor-editor-form"; + +export function useAnchorEditorController(anchorId: string) { + const settings = useAppSettingsSnapshot(); + const settingsStore = useAppSettingsStore(); + const pans = useMobilePansSnapshot(); + const pansStore = useMobilePansStore(); + const [anchor, setAnchor] = React.useState(); + const [fieldPreset, setFieldPreset] = React.useState( + settings.settings.defaultFieldPreset, + ); + const [mode, setModeState] = React.useState("marching"); + const [marchingDraft, setMarchingDraft] = React.useState( + () => createAnchorEditorDrafts().marching, + ); + const [standardDraft, setStandardDraft] = React.useState( + () => createAnchorEditorDrafts().standard, + ); + const [anchorName, setAnchorName] = React.useState(""); + const [loading, setLoading] = React.useState(true); + const [saving, setSaving] = React.useState(false); + const [savingName, setSavingName] = React.useState(false); + const [saved, setSaved] = React.useState(false); + const [error, setError] = React.useState(); + + const load = React.useCallback(async () => { + if (pans.initialization !== "ready" || settings.status !== "ready") return; + setLoading(true); + setError(undefined); + try { + const [next, activeDrill] = await Promise.all([ + pansStore.getRuntime().repository.getDevice(anchorId), + settings.settings.activeDrillId + ? settingsStore + .getDrillRepository() + .getDrill(settings.settings.activeDrillId) + : Promise.resolve(undefined), + ]); + if ( + !next || + (next.role !== "anchor" && next.lastKnownConfig?.role !== "anchor") + ) { + throw new Error("The cached anchor could not be found."); + } + const position = + next.lastKnownConfig?.role === "anchor" + ? next.lastKnownConfig.position + : undefined; + const nextFieldPreset = resolveEffectiveFieldPreset( + activeDrill, + settings.settings.defaultFieldPreset, + ); + const drafts = createAnchorEditorDrafts(position, nextFieldPreset); + setAnchor(next); + setAnchorName(next.lastKnownConfig?.label ?? next.label ?? ""); + setFieldPreset(nextFieldPreset); + setMarchingDraft(drafts.marching); + setStandardDraft(drafts.standard); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setLoading(false); + } + }, [ + anchorId, + pans.initialization, + pansStore, + settings.settings.activeDrillId, + settings.settings.defaultFieldPreset, + settings.status, + settingsStore, + ]); + + useFocusEffect( + React.useCallback(() => { + void load(); + }, [load]), + ); + + const validation = + mode === "marching" + ? validateMarchingAnchorDraft(marchingDraft, fieldPreset) + : validateStandardAnchorDraft(standardDraft, fieldPreset); + const canWritePosition = Boolean( + anchor && + (pans.connectionState === "connected" || + (pans.activeNetworkId && anchor.networkId === pans.activeNetworkId)), + ); + + const setMode = (nextMode: AnchorEditorMode) => { + if (nextMode === mode) return; + const position = validation.position; + if (position) { + if (nextMode === "standard") { + setStandardDraft( + standardDraftFromPosition( + position, + standardDraft.reference, + standardDraft.unit, + fieldPreset, + ), + ); + } else { + setMarchingDraft( + createAnchorEditorDrafts(position, fieldPreset).marching, + ); + } + } + setSaved(false); + setModeState(nextMode); + }; + + const updateStandardReference = ( + reference: StandardAnchorPositionDraft["reference"], + ) => { + const position = validateStandardAnchorDraft( + standardDraft, + fieldPreset, + ).position; + setStandardDraft( + position + ? standardDraftFromPosition( + position, + reference, + standardDraft.unit, + fieldPreset, + ) + : { ...standardDraft, reference }, + ); + }; + + const updateStandardUnit = (unit: AnchorPositionUnit) => { + const position = validateStandardAnchorDraft( + standardDraft, + fieldPreset, + ).position; + setStandardDraft( + position + ? standardDraftFromPosition( + position, + standardDraft.reference, + unit, + fieldPreset, + ) + : { ...standardDraft, unit }, + ); + }; + + const saveAnchorName = async () => { + if (!anchor || savingName || !settings.settings.developerModeEnabled) { + return; + } + setSavingName(true); + setError(undefined); + try { + const savedAnchor = await pansStore.renameAnchor(anchor.id, anchorName); + setAnchor(savedAnchor); + setAnchorName( + savedAnchor.lastKnownConfig?.label ?? savedAnchor.label ?? "", + ); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setSavingName(false); + } + }; + + const save = async (position: AnchorFieldPosition) => { + if (saving || !settings.settings.developerModeEnabled) return; + setSaving(true); + setSaved(false); + setError(undefined); + try { + await pansStore.writeAnchorPosition(anchorId, position); + setSaved(true); + await load(); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + setSaving(false); + } + }; + + return { + developerModeEnabled: settings.settings.developerModeEnabled, + connectionState: pans.connectionState, + canWritePosition, + anchor, + fieldPreset, + coordinateRoundingSteps: settings.settings.coordinateRoundingSteps, + mode, + marchingDraft, + standardDraft, + validation, + anchorName, + anchorNameDirty: + anchorName.trim() !== + (anchor?.lastKnownConfig?.label ?? anchor?.label ?? "").trim(), + loading, + saving, + savingName, + saved, + error, + setMode, + setAnchorName, + saveAnchorName, + setMarchingDraft: (draft: MarchingAnchorDraft) => { + setSaved(false); + setMarchingDraft(draft); + }, + setStandardDraft: (draft: StandardAnchorPositionDraft) => { + setSaved(false); + setStandardDraft(draft); + }, + updateStandardReference, + updateStandardUnit, + updateMarchingHeightUnit: (heightUnit: MarchingAnchorDraft["heightUnit"]) => + setMarchingDraft((draft) => convertMarchingHeightUnit(draft, heightUnit)), + save, + reload: load, + } as const; +} diff --git a/apps/mobile/src/features/settings/use-anchor-list-controller.ts b/apps/mobile/src/features/settings/use-anchor-list-controller.ts new file mode 100644 index 00000000..aa897949 --- /dev/null +++ b/apps/mobile/src/features/settings/use-anchor-list-controller.ts @@ -0,0 +1,51 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; + +import { useAppSettingsSnapshot } from "../../state/app-settings-store"; +import { + useMobilePansSnapshot, + useMobilePansStore, +} from "../../pans/mobile-pans-context"; +import { selectNetworkAnchors } from "../../pans/pans-anchor-cache"; + +export function useAnchorListController() { + const { settings } = useAppSettingsSnapshot(); + const pans = useMobilePansSnapshot(); + const store = useMobilePansStore(); + const [refreshing, setRefreshing] = React.useState(false); + const refreshingRef = React.useRef(false); + const [error, setError] = React.useState(); + const anchors = React.useMemo( + () => selectNetworkAnchors(pans.rememberedTag, pans.knownAnchors), + [pans.knownAnchors, pans.rememberedTag], + ); + + const refresh = React.useCallback(async () => { + if (pans.initialization !== "ready" || refreshingRef.current) return; + refreshingRef.current = true; + setRefreshing(true); + setError(undefined); + try { + await store.refreshCachedAnchors(); + } catch (cause) { + setError(cause instanceof Error ? cause : new Error(String(cause))); + } finally { + refreshingRef.current = false; + setRefreshing(false); + } + }, [pans.initialization, store]); + + useFocusEffect( + React.useCallback(() => { + void refresh(); + }, [refresh]), + ); + + return { + developerModeEnabled: settings.developerModeEnabled, + anchors, + refreshing, + error: error ?? pans.error, + refresh, + } as const; +} diff --git a/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts b/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts new file mode 100644 index 00000000..8bbb404e --- /dev/null +++ b/apps/mobile/src/navigation/__tests__/mobile-orientation-lock.test.ts @@ -0,0 +1,23 @@ +import * as ScreenOrientation from "expo-screen-orientation"; + +import { getMobileOrientationLock } from "../mobile-orientation"; + +describe("mobile route orientation", () => { + test("allows device rotation only on Field routes", () => { + expect(getMobileOrientationLock("/field")).toBe( + ScreenOrientation.OrientationLock.DEFAULT, + ); + expect(getMobileOrientationLock("/field/details")).toBe( + ScreenOrientation.OrientationLock.DEFAULT, + ); + }); + + test.each(["/", "/drill", "/drill/example", "/settings", "/settings/tag"])( + "locks %s to upright portrait", + (pathname) => { + expect(getMobileOrientationLock(pathname)).toBe( + ScreenOrientation.OrientationLock.PORTRAIT_UP, + ); + }, + ); +}); diff --git a/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts new file mode 100644 index 00000000..2db39ec0 --- /dev/null +++ b/apps/mobile/src/navigation/__tests__/mobile-tabs.test.ts @@ -0,0 +1,55 @@ +import { + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + MOBILE_TABS, + reduceMobileTabNavigationState, + shouldHideNativeTabBar, +} from "../mobile-tabs"; + +describe("mobile native tab navigation", () => { + test("keeps Field first and exposes the expected tabs", () => { + expect(MOBILE_TABS.map(({ name, label }) => ({ name, label }))).toEqual([ + { name: "field", label: "Field" }, + { name: "drill", label: "Drill" }, + { name: "settings", label: "Settings" }, + ]); + }); + + test("hides the entire tab bar only for focused landscape Field", () => { + expect( + shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: true }), + ).toBe(true); + expect( + shouldHideNativeTabBar({ fieldFocused: true, fieldLandscape: false }), + ).toBe(false); + expect( + shouldHideNativeTabBar({ fieldFocused: false, fieldLandscape: true }), + ).toBe(false); + }); + + test("remounts native tabs exactly once per drill-feature change", () => { + const disabled = reduceMobileTabNavigationState( + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + { type: "drill-features-reconfigured", enabled: false }, + ); + expect(disabled).toMatchObject({ + drillFeaturesEnabled: false, + nativeTabsRevision: 1, + }); + + const unchanged = reduceMobileTabNavigationState(disabled, { + type: "drill-features-reconfigured", + enabled: false, + }); + expect(unchanged).toBe(disabled); + + expect( + reduceMobileTabNavigationState(unchanged, { + type: "drill-features-reconfigured", + enabled: true, + }), + ).toMatchObject({ + drillFeaturesEnabled: true, + nativeTabsRevision: 2, + }); + }); +}); diff --git a/apps/mobile/src/navigation/mobile-orientation.ts b/apps/mobile/src/navigation/mobile-orientation.ts new file mode 100644 index 00000000..33fd188b --- /dev/null +++ b/apps/mobile/src/navigation/mobile-orientation.ts @@ -0,0 +1,11 @@ +import * as ScreenOrientation from "expo-screen-orientation"; + +export function getMobileOrientationLock( + pathname: string, +): ScreenOrientation.OrientationLock { + const fieldRoute = pathname === "/field" || pathname.startsWith("/field/"); + + return fieldRoute + ? ScreenOrientation.OrientationLock.DEFAULT + : ScreenOrientation.OrientationLock.PORTRAIT_UP; +} diff --git a/apps/mobile/src/navigation/mobile-tabs.ts b/apps/mobile/src/navigation/mobile-tabs.ts new file mode 100644 index 00000000..a8500b57 --- /dev/null +++ b/apps/mobile/src/navigation/mobile-tabs.ts @@ -0,0 +1,94 @@ +import type { NativeTabsTriggerIconProps } from "expo-router/unstable-native-tabs"; + +export type MobileTabName = "field" | "drill" | "settings"; + +export interface MobileTabConfig { + name: MobileTabName; + label: string; + icon: NativeTabsTriggerIconProps; +} + +export const MOBILE_TABS = [ + { + name: "field", + label: "Field", + icon: { + sf: { default: "map", selected: "map.fill" }, + md: "map", + }, + }, + { + name: "drill", + label: "Drill", + icon: { + sf: { + default: "list.bullet.rectangle", + selected: "list.bullet.rectangle.fill", + }, + md: "format_list_numbered", + }, + }, + { + name: "settings", + label: "Settings", + icon: { + sf: { default: "gearshape", selected: "gearshape.fill" }, + md: "settings", + }, + }, +] as const satisfies readonly MobileTabConfig[]; + +export function shouldHideNativeTabBar({ + fieldFocused, + fieldLandscape, +}: Pick): boolean { + return fieldFocused && fieldLandscape; +} + +export interface MobileTabNavigationState { + fieldFocused: boolean; + fieldLandscape: boolean; + drillFeaturesEnabled: boolean; + nativeTabsRevision: number; +} + +export const INITIAL_MOBILE_TAB_NAVIGATION_STATE: MobileTabNavigationState = { + fieldFocused: false, + fieldLandscape: false, + drillFeaturesEnabled: true, + nativeTabsRevision: 0, +}; + +export type MobileTabNavigationAction = + | { + type: "field-presentation-changed"; + fieldFocused: boolean; + fieldLandscape: boolean; + } + | { type: "drill-features-reconfigured"; enabled: boolean }; + +export function reduceMobileTabNavigationState( + state: MobileTabNavigationState, + action: MobileTabNavigationAction, +): MobileTabNavigationState { + if (action.type === "field-presentation-changed") { + if ( + state.fieldFocused === action.fieldFocused && + state.fieldLandscape === action.fieldLandscape + ) { + return state; + } + return { + ...state, + fieldFocused: action.fieldFocused, + fieldLandscape: action.fieldLandscape, + }; + } + + if (state.drillFeaturesEnabled === action.enabled) return state; + return { + ...state, + drillFeaturesEnabled: action.enabled, + nativeTabsRevision: state.nativeTabsRevision + 1, + }; +} diff --git a/apps/mobile/src/navigation/tab-bar-visibility-context.tsx b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx new file mode 100644 index 00000000..04869884 --- /dev/null +++ b/apps/mobile/src/navigation/tab-bar-visibility-context.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { useRouter } from "expo-router"; + +import { + INITIAL_MOBILE_TAB_NAVIGATION_STATE, + reduceMobileTabNavigationState, + shouldHideNativeTabBar, + type MobileTabNavigationState, +} from "./mobile-tabs"; + +interface FieldPresentation { + focused: boolean; + landscape: boolean; +} + +interface TabBarVisibilityContextValue extends MobileTabNavigationState { + nativeTabBarHidden: boolean; + setFieldPresentation(presentation: FieldPresentation): void; + /** + * Call only after the setting has been persisted. This safely leaves Drill, + * then changes tab membership and remounts the native navigator exactly once. + */ + reconfigureDrillFeatures(enabled: boolean): void; +} + +const TabBarVisibilityContext = React.createContext< + TabBarVisibilityContextValue | undefined +>(undefined); + +export function TabBarVisibilityProvider({ + children, + drillFeaturesEnabled, +}: { + children: React.ReactNode; + drillFeaturesEnabled: boolean; +}) { + const router = useRouter(); + const [state, dispatch] = React.useReducer(reduceMobileTabNavigationState, { + ...INITIAL_MOBILE_TAB_NAVIGATION_STATE, + drillFeaturesEnabled, + }); + const configuredDrillFeatures = React.useRef(drillFeaturesEnabled); + + const setFieldPresentation = React.useCallback( + ({ focused, landscape }: FieldPresentation) => { + dispatch({ + type: "field-presentation-changed", + fieldFocused: focused, + fieldLandscape: landscape, + }); + }, + [], + ); + + const reconfigureDrillFeatures = React.useCallback( + (enabled: boolean) => { + if (configuredDrillFeatures.current === enabled) return; + + configuredDrillFeatures.current = enabled; + router.replace("/(tabs)/settings"); + dispatch({ type: "drill-features-reconfigured", enabled }); + }, + [router], + ); + + React.useEffect(() => { + reconfigureDrillFeatures(drillFeaturesEnabled); + }, [drillFeaturesEnabled, reconfigureDrillFeatures]); + + const value = React.useMemo( + () => ({ + ...state, + nativeTabBarHidden: shouldHideNativeTabBar(state), + setFieldPresentation, + reconfigureDrillFeatures, + }), + [reconfigureDrillFeatures, setFieldPresentation, state], + ); + + return ( + + {children} + + ); +} + +export function useTabBarVisibility(): TabBarVisibilityContextValue { + const value = React.useContext(TabBarVisibilityContext); + if (!value) { + throw new Error( + "useTabBarVisibility must be used inside TabBarVisibilityProvider.", + ); + } + return value; +} diff --git a/apps/mobile/src/navigation/use-field-orientation.ts b/apps/mobile/src/navigation/use-field-orientation.ts new file mode 100644 index 00000000..143a2bd7 --- /dev/null +++ b/apps/mobile/src/navigation/use-field-orientation.ts @@ -0,0 +1,36 @@ +import React from "react"; +import { useFocusEffect } from "expo-router"; +import { useWindowDimensions } from "react-native"; + +import { useTabBarVisibility } from "./tab-bar-visibility-context"; + +export interface FieldOrientationState { + focused: boolean; + landscape: boolean; +} + +/** Bridges Field focus and physical viewport orientation to the native tabs. */ +export function useFieldOrientation(): FieldOrientationState { + const [focused, setFocused] = React.useState(false); + const { width, height } = useWindowDimensions(); + const landscape = width > height; + const { setFieldPresentation } = useTabBarVisibility(); + + useFocusEffect( + React.useCallback(() => { + setFocused(true); + return () => setFocused(false); + }, []), + ); + + React.useEffect(() => { + setFieldPresentation({ focused, landscape }); + }, [focused, landscape, setFieldPresentation]); + + React.useEffect( + () => () => setFieldPresentation({ focused: false, landscape: false }), + [setFieldPresentation], + ); + + return { focused, landscape }; +} diff --git a/apps/mobile/src/navigation/use-mobile-orientation-lock.ts b/apps/mobile/src/navigation/use-mobile-orientation-lock.ts new file mode 100644 index 00000000..67630fc8 --- /dev/null +++ b/apps/mobile/src/navigation/use-mobile-orientation-lock.ts @@ -0,0 +1,14 @@ +import React from "react"; +import { usePathname } from "expo-router"; +import * as ScreenOrientation from "expo-screen-orientation"; + +import { getMobileOrientationLock } from "./mobile-orientation"; + +/** Keeps every route portrait-only except Field, which may rotate freely. */ +export function useMobileOrientationLock(): void { + const pathname = usePathname(); + + React.useEffect(() => { + void ScreenOrientation.lockAsync(getMobileOrientationLock(pathname)); + }, [pathname]); +} diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts new file mode 100644 index 00000000..6adaaa9c --- /dev/null +++ b/apps/mobile/src/pans/__tests__/mobile-pans-position-publisher.test.ts @@ -0,0 +1,168 @@ +import type { FusedPositionOutput, FieldPoint } from "@eight2five/mobile/field"; +import type { + DeviceMotionAdapter, + DeviceMotionSample, +} from "@eight2five/mobile/motion"; +import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager"; +import type { SharedValue } from "react-native-reanimated"; + +import { + INITIAL_MOBILE_PANS_SNAPSHOT, + type MobilePansSnapshot, +} from "../mobile-pans-model"; +import { MobilePansPositionPublisher } from "../mobile-pans-position-publisher"; + +jest.mock("expo-pans-ble-api", () => ({})); +jest.mock("react-native-worklets", () => ({ + ...jest.requireActual("react-native-worklets/lib/module/mock"), + scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) => + callback(...args), +})); +jest.mock("react-native-reanimated", () => + jest.requireActual("react-native-reanimated/mock"), +); +jest.mock( + "@shopify/react-native-skia", + () => ({ + Canvas: () => null, + Fill: () => null, + Group: () => null, + Path: () => null, + Circle: () => null, + Line: () => null, + Rect: () => null, + useFont: () => ({}), + vec: (x: number, y: number) => ({ x, y }), + }), + { virtual: true }, +); + +describe("MobilePansPositionPublisher", () => { + test("publishes UWB-first fused output to both shared values and HUD state", async () => { + let snapshot: MobilePansSnapshot = { + ...INITIAL_MOBILE_PANS_SNAPSHOT, + connectionState: "connected", + }; + let motionListener!: (sample: DeviceMotionSample) => void; + const adapter: DeviceMotionAdapter = { + start: jest.fn(async (listener) => { + motionListener = listener; + return "active"; + }), + stop: jest.fn(), + }; + const positionValue = { value: null } as SharedValue; + const fusionValue = { + value: null, + } as SharedValue; + const publisher = new MobilePansPositionPublisher( + { + staleAfterMs: 2_500, + schedule: setTimeout, + cancel: clearTimeout, + isConnectionCurrent: () => true, + getSnapshot: () => snapshot, + publish: (next) => { + snapshot = next; + }, + }, + { motionAdapter: adapter, motionInterpolationEnabled: true }, + ); + publisher.attachPositionValue(positionValue); + publisher.attachFusionValue(fusionValue); + await publisher.startMotion(1); + + publisher.receiveSample(positionSample(1_000, 0), 1); + publisher.receiveSample(positionSample(2_000, 1), 1); + motionListener({ + receivedAt: 2_200, + acceleration: { x: 2, y: 0, z: 0 }, + rotationRate: { x: 0, y: 0, z: 0 }, + }); + + expect(positionValue.value).toEqual({ xMeters: 0.78, yMeters: 0 }); + expect(fusionValue.value).toMatchObject({ + source: "motion-predicted", + interpolationActive: true, + freshnessMs: 200, + lastUwbAt: 2_000, + }); + expect(snapshot.livePosition).toMatchObject({ + position: { xMeters: 0.78, yMeters: 0 }, + source: "motion-predicted", + interpolationActive: true, + lastUwbPosition: { xMeters: 1, yMeters: 0 }, + lastUwbAt: 2_000, + }); + expect(snapshot.rawPosition).toEqual({ + xMeters: 1, + yMeters: 0, + zMeters: 0, + }); + publisher.setMotionInterpolationEnabled(false); + expect(positionValue.value).toEqual({ xMeters: 1, yMeters: 0 }); + expect(fusionValue.value).toMatchObject({ + source: "uwb", + interpolationActive: false, + position: { xMeters: 1, yMeters: 0 }, + }); + publisher.dispose(); + }); + + test("bypasses the adapter and keeps raw UWB output when the preference is off", async () => { + let snapshot: MobilePansSnapshot = { + ...INITIAL_MOBILE_PANS_SNAPSHOT, + connectionState: "connected", + }; + const adapter: DeviceMotionAdapter = { + start: jest.fn(async () => "active"), + stop: jest.fn(), + }; + const fusionValue = { + value: null, + } as SharedValue; + const publisher = new MobilePansPositionPublisher( + { + staleAfterMs: 2_500, + schedule: setTimeout, + cancel: clearTimeout, + isConnectionCurrent: () => true, + getSnapshot: () => snapshot, + publish: (next) => { + snapshot = next; + }, + }, + { motionAdapter: adapter, motionInterpolationEnabled: false }, + ); + publisher.attachFusionValue(fusionValue); + + await publisher.startMotion(1); + publisher.receiveSample(positionSample(1_000, 5), 1); + publisher.receiveSample(positionSample(2_000, 6), 1); + + expect(adapter.start).not.toHaveBeenCalled(); + expect(fusionValue.value).toMatchObject({ + position: { xMeters: 6, yMeters: 0 }, + source: "uwb", + interpolationActive: false, + }); + expect(snapshot.livePosition.interpolationActive).toBe(false); + publisher.dispose(); + }); +}); + +function positionSample( + receivedAt: number, + xMeters: number, +): PansPositionStreamSample { + return { + deviceId: "tag", + transportDeviceId: "transport-tag", + receivedAt, + source: "notification", + position: { xMeters, yMeters: 0, zMeters: 0, quality: 80 }, + distances: [], + diagnostics: [], + decoderDiagnostics: [], + }; +} diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts new file mode 100644 index 00000000..2d9aa638 --- /dev/null +++ b/apps/mobile/src/pans/__tests__/mobile-pans-store.test.ts @@ -0,0 +1,758 @@ +import { + DEFAULT_MANAGED_NETWORK_SETTINGS, + InMemoryPansManagerRepository, +} from "@eight2five/mobile/pans-manager"; +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, + PansPosition, + PansInspectionResult, + PansPositionStreamSample, + StartPansPositionStreamOptions, +} from "@eight2five/mobile/pans-manager"; +import type { SharedValue } from "react-native-reanimated"; +import type { FieldPoint } from "@eight2five/mobile/field"; + +import type { MobilePansRuntime } from "../mobile-pans-runtime"; +import { + MobilePansStore, + pansPositionToFieldPoint, +} from "../mobile-pans-store"; + +jest.mock("expo-pans-ble-api", () => ({})); +jest.mock("react-native-worklets", () => ({ + ...jest.requireActual("react-native-worklets/lib/module/mock"), + scheduleOnRN: (callback: (...args: unknown[]) => void, ...args: unknown[]) => + callback(...args), +})); +jest.mock("react-native-reanimated", () => + jest.requireActual("react-native-reanimated/mock"), +); +jest.mock( + "@shopify/react-native-skia", + () => ({ + Canvas: () => null, + Fill: () => null, + Group: () => null, + Path: () => null, + Circle: () => null, + Line: () => null, + Rect: () => null, + useFont: () => ({}), + vec: (x: number, y: number) => ({ x, y }), + }), + { virtual: true }, +); + +const DISCOVERY: DiscoveredDeviceSnapshot = { + transportDeviceId: "tag-transport", + name: "Field Tag", + rssi: -48, + lastSeenAt: 1, + stale: false, + compatibility: "compatible", + presence: { role: "tag" } as never, +}; + +const ANCHOR_DISCOVERY: DiscoveredDeviceSnapshot = { + ...DISCOVERY, + transportDeviceId: "anchor-transport", + name: "Field Anchor", + presence: { role: "anchor" } as never, +}; + +describe("MobilePansStore", () => { + afterEach(() => jest.useRealTimers()); + + test("shares one connection attempt and one position stream", async () => { + const start = deferred(); + const harness = await createHarness({ + streamStart: jest.fn( + async (_options: StartPansPositionStreamOptions) => await start.promise, + ), + }); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + + const first = store.connect(); + const second = store.connect(); + await flushPromises(); + expect(harness.streamStart).toHaveBeenCalledTimes(1); + + start.resolve(); + await Promise.all([first, second]); + expect(store.getSnapshot().connectionState).toBe("connected"); + await store.dispose(); + }); + + test("cancels bounded reconnect after explicit disconnect", async () => { + jest.useFakeTimers(); + const remembered = managedTag(); + const streamStart = jest.fn( + async (_options: StartPansPositionStreamOptions) => { + throw new Error("offline"); + }, + ); + const harness = await createHarness({ remembered, streamStart }); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + reconnectDelaysMs: [100, 200], + }); + + await store.initialize(); + await flushPromises(); + expect(streamStart).toHaveBeenCalledTimes(1); + await store.disconnect(); + jest.advanceTimersByTime(1_000); + await flushPromises(); + + expect(streamStart).toHaveBeenCalledTimes(1); + expect(store.getSnapshot().connectionState).toBe("disconnected"); + await store.dispose(); + }); + + test("does not start a pending connection after the app backgrounds", async () => { + const harness = await createHarness({ + remembered: managedTag(), + discoveries: [], + }); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + + await store.initialize(); + await flushPromises(); + expect(harness.streamStart).not.toHaveBeenCalled(); + store.setForeground(false); + harness.emitDiscoveries([DISCOVERY]); + await flushPromises(); + + expect(harness.streamStart).not.toHaveBeenCalled(); + await store.dispose(); + }); + + test("updates the shared marker immediately and marks old data stale", async () => { + jest.useFakeTimers(); + const harness = await createHarness(); + const marker = { value: null } as SharedValue; + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + staleAfterMs: 500, + }); + store.attachPositionValue(marker); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + harness.emitSample(positionSample(1_000, 12.5, 7.25, 1.8)); + harness.emitSample(positionSample(1_100, 12.5, 7.25, 1.8)); + expect(marker.value).toEqual({ xMeters: 12.5, yMeters: 7.25 }); + expect(store.getSnapshot()).toMatchObject({ + connectionState: "connected", + rawPosition: { xMeters: 12.5, yMeters: 7.25, zMeters: 1.8 }, + livePosition: { isStale: false }, + effectiveUpdateRateHz: 10, + }); + + jest.advanceTimersByTime(500); + expect(marker.value).toBeNull(); + expect(store.getSnapshot().livePosition).toMatchObject({ + position: { xMeters: 12.5, yMeters: 7.25 }, + isStale: true, + }); + await store.dispose(); + }); + + test("starts only one reconnect loop for duplicate disconnect events", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + expect(harness.streamStart).toHaveBeenCalledTimes(1); + + harness.emitConnectionState("disconnected"); + harness.emitConnectionState("disconnected"); + await flushPromises(); + + expect(harness.streamStart).toHaveBeenCalledTimes(2); + await store.dispose(); + }); + + test("forgets persisted identity without deleting the device cache", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + const selectedId = store.getSnapshot().rememberedTag?.id; + + await store.forgetTag(); + + expect( + (await harness.repository.getSettings())?.rememberedTagDeviceId, + ).toBeUndefined(); + expect(await harness.repository.getDevice(selectedId!)).toBeDefined(); + expect(store.getSnapshot().connectionState).toBe("idle"); + await store.dispose(); + }); + + test("rejects stale or incompatible tag selections", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + harness.emitDiscoveries([ + { ...DISCOVERY, compatibility: "incompatible", stale: true }, + ]); + + await expect(store.selectTag(DISCOVERY.transportDeviceId)).rejects.toThrow( + "compatible", + ); + expect(store.getSnapshot().rememberedTag).toBeUndefined(); + await store.dispose(); + }); + + test("uses the documented identity-aligned PANS-to-field conversion", () => { + expect( + pansPositionToFieldPoint({ + xMeters: -2, + yMeters: 4, + zMeters: 1, + quality: 20, + }), + ).toEqual({ xMeters: -2, yMeters: 4 }); + }); + + test("verifies PAN data before associating a fresh deployment", async () => { + const harness = await createHarness(); + await harness.repository.saveNetwork({ + id: "network-33", + name: "Field deployment", + panId: 33, + settings: DEFAULT_MANAGED_NETWORK_SETTINGS, + createdAt: 1, + updatedAt: 1, + }); + harness.configurationInspect.mockImplementation( + async (deviceId: string) => { + const device = (await harness.repository.getDevice(deviceId))!; + await harness.repository.saveDevice({ + ...device, + lastKnownConfig: + device.role === "anchor" + ? { ...anchorConfig(), panId: 33 } + : { ...tagConfig(), panId: 33 }, + }); + return {} as never; + }, + ); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + harness.emitDiscoveries([DISCOVERY, ANCHOR_DISCOVERY]); + + await store.selectTag(DISCOVERY.transportDeviceId); + + expect(store.getSnapshot().rememberedTag).toMatchObject({ + networkId: "network-33", + lastKnownConfig: { panId: 33 }, + }); + expect(store.getSnapshot().knownAnchors).toEqual([ + expect.objectContaining({ + networkId: "network-33", + lastKnownConfig: expect.objectContaining({ panId: 33 }), + }), + ]); + await store.dispose(); + }); + + test("writes once with internal quality 100 and caches only successful writes", async () => { + const harness = await createHarness(); + await harness.repository.saveDevice({ + ...managedTag(), + networkId: "network-a", + }); + await harness.repository.saveDevice(managedAnchor("anchor-1", "network-a")); + await harness.repository.saveDevice(managedAnchor("anchor-2", "network-a")); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + const position = { xMeters: 10, yMeters: 20, zMeters: 2 }; + const first = store.writeAnchorPosition("anchor-1", position); + await expect( + store.writeAnchorPosition("anchor-1", position), + ).rejects.toMatchObject({ code: "OPERATION_CANCELLED" }); + await first; + + expect(harness.configurationApply).toHaveBeenCalledTimes(1); + expect(harness.configurationApply).toHaveBeenCalledWith("anchor-1", { + position: { ...position, quality: 100 }, + }); + expect(await harness.repository.getDevice("anchor-1")).toMatchObject({ + lastKnownConfig: { position: { ...position, quality: 100 } }, + }); + await store.writeAnchorPosition("anchor-1", position); + expect(harness.configurationApply).toHaveBeenCalledTimes(1); + + harness.configurationApply.mockRejectedValueOnce(new Error("write failed")); + await expect( + store.writeAnchorPosition("anchor-2", position), + ).rejects.toBeDefined(); + expect( + (await harness.repository.getDevice("anchor-2"))?.lastKnownConfig, + ).not.toHaveProperty("position"); + await store.dispose(); + }); + + test("inspects and sparsely repairs the performer profile before streaming", async () => { + const harness = await createHarness(); + harness.configurationInspect.mockResolvedValueOnce( + correctTagInspection("selection"), + ); + harness.configurationInspect.mockResolvedValueOnce({ + ...correctTagInspection("selected"), + operationMode: { + ...correctTagInspection("selected").operationMode, + ledEnabled: false, + }, + }); + harness.configurationApply.mockResolvedValueOnce({ + deviceId: "selected", + transportDeviceId: DISCOVERY.transportDeviceId, + outcome: "verified", + writes: [ + { + field: "ledEnabled", + status: "verified", + requested: true, + actual: true, + }, + ], + warnings: [], + } as never); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + }); + await store.initialize(); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + expect(harness.configurationApply).toHaveBeenCalledWith( + expect.any(String), + { ledEnabled: true }, + ); + expect(harness.streamStart).toHaveBeenCalledTimes(1); + await store.dispose(); + }); + + test("matches PAN only when Developer Mode has an active network", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + const network = await store.createNetwork("Field", 42); + await store.setActiveNetwork(network.id); + await store.selectTag(DISCOVERY.transportDeviceId); + await store.connect(); + + expect(harness.commissioningAssign).toHaveBeenCalledWith({ + deviceId: expect.any(String), + targetNetworkId: network.id, + }); + await store.dispose(); + }); + + test("resets the developer RSSI override when Developer Mode is disabled", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + await store.setDiscoveryRssiCutoff(-90); + await store.setDeveloperModeEnabled(false); + + expect((await harness.repository.getSettings())?.discoveryRssiCutoff).toBe( + -75, + ); + expect(store.getSnapshot().discoveryRssiCutoff).toBe(-75); + await store.dispose(); + }); + + test("creates, edits, selects, and deletes network profiles locally", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + const created = await store.createNetwork("Field", 100); + await store.setActiveNetwork(created.id); + await store.updateNetwork(created.id, { name: "Stadium", panId: 101 }); + expect(store.getSnapshot()).toMatchObject({ + activeNetworkId: created.id, + networks: [expect.objectContaining({ name: "Stadium", panId: 101 })], + }); + await store.deleteNetwork(created.id); + expect(store.getSnapshot().networks).toEqual([]); + expect(store.getSnapshot().activeNetworkId).toBeUndefined(); + await store.dispose(); + }); + + test("persists a directly discovered uncached anchor before editing", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + harness.emitDiscoveries([ANCHOR_DISCOVERY]); + harness.configurationInspect.mockResolvedValueOnce( + anchorInspection("anchor"), + ); + const saved = await store.persistDiscoveredAnchor( + ANCHOR_DISCOVERY.transportDeviceId, + ); + expect(saved).toMatchObject({ + role: "anchor", + transportDeviceId: ANCHOR_DISCOVERY.transportDeviceId, + }); + expect(store.getSnapshot().knownAnchors).toContainEqual( + expect.objectContaining({ id: saved.id }), + ); + await store.dispose(); + }); + + test("writes an anchor name to the hardware PANS label and refreshes the cache", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + await harness.repository.saveDevice(managedAnchor("named-anchor")); + await store.refreshCachedAnchors(); + + const saved = await store.renameAnchor("named-anchor", " Front 50 "); + + expect(saved.lastKnownConfig?.label).toBe("Front 50"); + expect(harness.configurationApply).toHaveBeenCalledWith("named-anchor", { + label: "Front 50", + }); + expect(store.getSnapshot().knownAnchors).toContainEqual( + expect.objectContaining({ + id: "named-anchor", + lastKnownConfig: expect.objectContaining({ label: "Front 50" }), + }), + ); + await store.dispose(); + }); + + test("sets a reachable initiator and reports unreachable prior initiators", async () => { + const harness = await createHarness(); + const store = new MobilePansStore({ + createRuntime: async () => harness.runtime, + developerModeEnabled: true, + }); + await store.initialize(); + const network = await store.createNetwork("Field", 55); + await store.setActiveNetwork(network.id); + await harness.repository.saveDevice({ + ...managedAnchor("new-initiator", network.id), + lastKnownConfig: { ...anchorConfig(), initiatorEnabled: false }, + }); + await harness.repository.saveDevice({ + ...managedAnchor("old-initiator", network.id), + lastKnownConfig: { ...anchorConfig(), initiatorEnabled: true }, + }); + harness.configurationInspect.mockResolvedValueOnce({ + ...anchorInspection("new-initiator"), + operationMode: { + ...anchorInspection("new-initiator").operationMode, + initiatorEnabled: true, + }, + }); + await store.setAnchorInitiator("new-initiator"); + expect(harness.configurationApply).toHaveBeenCalledWith("new-initiator", { + initiatorEnabled: true, + }); + expect(store.getSnapshot().commissioningWarning).toContain("unreachable"); + await store.dispose(); + }); +}); + +async function createHarness( + options: { + remembered?: ManagedDevice; + discoveries?: DiscoveredDeviceSnapshot[]; + streamStart?: jest.Mock, [StartPansPositionStreamOptions]>; + } = {}, +) { + const repository = new InMemoryPansManagerRepository(); + await repository.initialize(); + if (options.remembered) { + await repository.saveDevice(options.remembered); + const settings = await repository.getSettings(); + await repository.saveSettings({ + ...settings!, + rememberedTagDeviceId: options.remembered.id, + }); + } + let streamOptions: StartPansPositionStreamOptions | undefined; + let connectionListener: + | ((event: { deviceId: string; state: "disconnected" }) => void) + | undefined; + let discoveries = options.discoveries ?? [DISCOVERY]; + const discoveryListeners = new Set< + (items: DiscoveredDeviceSnapshot[]) => void + >(); + const streamStart = options.streamStart ?? jest.fn(async () => undefined); + const configurationApply = jest.fn( + async ( + deviceId: string, + changes: { position?: PansPosition; label?: string }, + ) => { + const device = (await repository.getDevice(deviceId))!; + const baseConfig = + device.lastKnownConfig?.role === "anchor" + ? device.lastKnownConfig + : anchorConfig(); + await repository.saveDevice({ + ...device, + ...(changes.label !== undefined ? { label: changes.label } : {}), + lastKnownConfig: { + ...baseConfig, + ...(changes.position !== undefined + ? { position: changes.position } + : {}), + ...(changes.label !== undefined ? { label: changes.label } : {}), + }, + }); + if (changes.label !== undefined) { + return { + deviceId, + transportDeviceId: device.transportDeviceId, + outcome: "applied" as const, + writes: [ + { + field: "label", + status: "verified" as const, + requested: changes.label, + actual: changes.label, + }, + ], + warnings: [], + }; + } + return { + deviceId, + transportDeviceId: device.transportDeviceId, + outcome: "partial" as const, + writes: [ + { + field: "position", + status: "written-unverified" as const, + requested: changes.position, + }, + ], + warnings: [], + }; + }, + ); + const configurationInspect = jest.fn, [string]>( + async (deviceId: string) => correctTagInspection(deviceId), + ); + const commissioningAssign = jest.fn( + async ({ deviceId, targetNetworkId }) => ({ + deviceId, + targetNetworkId, + stage: "complete" as const, + outcome: "assigned" as const, + }), + ); + const runtime = { + repository, + discovery: { + getPermissionStatus: () => ({ bluetooth: "granted" }), + requestPermissions: async () => ({ bluetooth: "granted" }), + start: jest.fn(async () => undefined), + stop: jest.fn(async () => undefined), + subscribe: (listener: (items: DiscoveredDeviceSnapshot[]) => void) => { + discoveryListeners.add(listener); + listener(discoveries); + return { remove: () => discoveryListeners.delete(listener) }; + }, + subscribeErrors: () => ({ remove: jest.fn() }), + subscribeState: (listener: (state: string) => void) => { + listener("idle"); + return { remove: jest.fn() }; + }, + }, + sessions: { + addConnectionStateListener: (listener: typeof connectionListener) => { + connectionListener = listener; + return { remove: () => (connectionListener = undefined) }; + }, + closeAll: jest.fn(async () => undefined), + }, + stream: { + start: jest.fn(async (next: StartPansPositionStreamOptions) => { + streamOptions = next; + await streamStart(next); + }), + stop: jest.fn(async () => undefined), + }, + configuration: { + applyConfigurationDiff: configurationApply, + inspectAndCache: configurationInspect, + }, + commissioning: { + assignDeviceToNetworkProfile: commissioningAssign, + }, + diagnostics: {}, + close: jest.fn(async () => undefined), + } as unknown as MobilePansRuntime; + return { + repository, + runtime, + streamStart, + configurationApply, + configurationInspect, + commissioningAssign, + emitDiscoveries(next: DiscoveredDeviceSnapshot[]) { + discoveries = next; + for (const listener of discoveryListeners) listener(discoveries); + }, + emitSample(sample: PansPositionStreamSample) { + streamOptions?.onSample(sample); + }, + emitConnectionState(state: "disconnected") { + connectionListener?.({ deviceId: DISCOVERY.transportDeviceId, state }); + }, + }; +} + +function correctTagInspection(deviceId: string): PansInspectionResult { + return { + deviceId, + transportDeviceId: DISCOVERY.transportDeviceId, + inspectedAt: 1, + operationMode: { + role: "tag" as const, + uwbMode: "active" as const, + selectedFirmware: 1 as const, + accelerometerEnabled: false, + ledEnabled: true, + firmwareUpdateEnabled: true, + initiatorEnabled: false, + lowPowerModeEnabled: false, + locationEngineEnabled: true, + raw: [0, 0] as [number, number], + }, + locationDataMode: 2 as const, + warnings: [], + }; +} + +function anchorInspection(deviceId: string): PansInspectionResult { + return { + ...correctTagInspection(deviceId), + operationMode: { + ...correctTagInspection(deviceId).operationMode, + role: "anchor" as const, + initiatorEnabled: false, + lowPowerModeEnabled: false, + locationEngineEnabled: false, + }, + locationDataMode: undefined, + }; +} + +function managedAnchor(id: string, networkId?: string): ManagedDevice { + return { + id, + transportDeviceId: `transport-${id}`, + role: "anchor", + ...(networkId ? { networkId } : {}), + lastKnownConfig: anchorConfig(), + createdAt: 1, + updatedAt: 1, + }; +} + +function anchorConfig() { + return { + role: "anchor" as const, + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: false, + initiatorEnabled: false, + }; +} + +function tagConfig() { + return { + role: "tag" as const, + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: false, + locationEngineEnabled: true, + lowPowerModeEnabled: false, + stationaryDetectionEnabled: true, + }; +} + +function managedTag(): ManagedDevice { + return { + id: "remembered-tag", + transportDeviceId: DISCOVERY.transportDeviceId, + role: "tag", + createdAt: 1, + updatedAt: 1, + }; +} + +function positionSample( + receivedAt: number, + xMeters: number, + yMeters: number, + zMeters: number, +): PansPositionStreamSample { + return { + deviceId: "tag", + transportDeviceId: DISCOVERY.transportDeviceId, + receivedAt, + source: "notification", + position: { xMeters, yMeters, zMeters, quality: 40 }, + distances: [], + diagnostics: [], + decoderDiagnostics: [], + }; +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function flushPromises(): Promise { + for (let index = 0; index < 12; index += 1) await Promise.resolve(); +} diff --git a/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts new file mode 100644 index 00000000..b4eb09fe --- /dev/null +++ b/apps/mobile/src/pans/__tests__/mobile-pans-ui.test.ts @@ -0,0 +1,69 @@ +import type { DiscoveredDeviceSnapshot } from "@eight2five/mobile/pans-manager"; +import { + connectionStatusViewModel, + selectVisibleDiscoveries, + signalStrengthForRssi, +} from "../mobile-pans-ui"; + +const tag = discovery("tag", "tag", -60); +const anchor = discovery("anchor", "anchor", -55); + +describe("mobile PANS UI selectors", () => { + test.each([ + ["connected", "Connected", "connected", false], + ["scanning", "Searching", "searching", true], + ["connecting", "Connecting", "connecting", false], + ["reconnecting", "Reconnecting", "connecting", false], + ["disconnected", "Disconnected", "disconnected", false], + ["error", "Connection error", "error", false], + ] as const)("maps %s status", (state, label, icon, animated) => { + expect(connectionStatusViewModel(state)).toMatchObject({ + label, + icon, + animated, + }); + }); + + test("ordinary mode filters anchors and weak advertisements", () => { + expect( + selectVisibleDiscoveries( + [tag, anchor, { ...tag, transportDeviceId: "weak", rssi: -80 }], + { + developerMode: false, + cutoff: -75, + }, + ).map((item) => item.transportDeviceId), + ).toEqual(["tag"]); + }); + + test("developer mode includes anchors and sorts by raw RSSI", () => { + expect( + selectVisibleDiscoveries([tag, anchor], { + developerMode: true, + cutoff: -75, + }).map((item) => item.transportDeviceId), + ).toEqual(["anchor", "tag"]); + }); + + test("derives signal bands relative to cutoff", () => { + expect(signalStrengthForRssi(-75, -75)).toBe("low"); + expect(signalStrengthForRssi(-65, -75)).toBe("medium"); + expect(signalStrengthForRssi(-50, -75)).toBe("high"); + expect(signalStrengthForRssi(-35, -75)).toBe("full"); + }); +}); + +function discovery( + transportDeviceId: string, + role: "tag" | "anchor", + rssi: number, +): DiscoveredDeviceSnapshot { + return { + transportDeviceId, + name: transportDeviceId, + rssi, + lastSeenAt: 1, + compatibility: "compatible", + presence: { role } as never, + }; +} diff --git a/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts b/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts new file mode 100644 index 00000000..8fdb4ae5 --- /dev/null +++ b/apps/mobile/src/pans/__tests__/pans-anchor-cache.test.ts @@ -0,0 +1,96 @@ +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { + areDevicesNetworkAssociated, + cachedAnchorGeometry, + selectNetworkAnchors, +} from "../pans-anchor-cache"; + +const base = { + transportDeviceId: "transport", + createdAt: 1, + updatedAt: 1, +}; + +describe("network-associated anchor cache", () => { + test("never mixes geometry from another saved network", () => { + const tag = { + ...base, + id: "tag", + role: "tag" as const, + networkId: "network-a", + }; + const anchors = [ + anchor("a", "network-a", 1), + anchor("b", "network-b", 2), + anchor("unassociated", undefined, 3), + ]; + + expect(selectNetworkAnchors(tag, anchors).map((item) => item.id)).toEqual([ + "a", + ]); + expect(cachedAnchorGeometry(tag, anchors)).toEqual([ + { + id: "a", + position: { xMeters: 1, yMeters: 2, zMeters: 3 }, + }, + ]); + expect(areDevicesNetworkAssociated(tag, anchors[0])).toBe(true); + expect(areDevicesNetworkAssociated(tag, anchors[1])).toBe(false); + }); + + test("falls back to equal verified PAN IDs and otherwise hides geometry", () => { + const tag = { + ...base, + id: "tag", + role: "tag" as const, + lastKnownConfig: tagConfig(44), + }; + const anchors = [ + anchor("same", undefined, 1, 44), + anchor("other", undefined, 2, 45), + ]; + expect(selectNetworkAnchors(tag, anchors).map((item) => item.id)).toEqual([ + "same", + ]); + expect( + selectNetworkAnchors({ ...tag, lastKnownConfig: tagConfig() }, anchors), + ).toEqual([]); + }); +}); + +function anchor( + id: string, + networkId: string | undefined, + xMeters: number, + panId = 44, +): ManagedDevice { + return { + ...base, + id, + ...(networkId ? { networkId } : {}), + role: "anchor", + lastKnownConfig: { + role: "anchor", + panId, + uwbMode: "active", + ledEnabled: true, + firmwareUpdateEnabled: false, + initiatorEnabled: false, + position: { xMeters, yMeters: 2, zMeters: 3, quality: 100 }, + }, + }; +} + +function tagConfig(panId?: number) { + return { + role: "tag" as const, + ...(panId === undefined ? {} : { panId }), + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: false, + locationEngineEnabled: true, + lowPowerModeEnabled: false, + stationaryDetectionEnabled: true, + }; +} diff --git a/apps/mobile/src/pans/mobile-pans-connection-controller.ts b/apps/mobile/src/pans/mobile-pans-connection-controller.ts new file mode 100644 index 00000000..884d9642 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-connection-controller.ts @@ -0,0 +1,385 @@ +import { + normalizeManagerError, + normalizeTransportDeviceId, + type DiscoveredDeviceSnapshot, + type ManagedDevice, + type PansConnectionStateEvent, +} from "@eight2five/mobile/pans-manager"; + +import { + findDiscovery, + isSelectableTagDiscovery, + staleLivePosition, + type MobilePansSnapshot, + type TagConnectionState, +} from "./mobile-pans-model"; +import type { MobilePansPositionPublisher } from "./mobile-pans-position-publisher"; +import type { MobilePansRuntime } from "./mobile-pans-runtime"; + +interface ConnectionControllerHost { + readonly reconnectDelaysMs: readonly number[]; + readonly discoveryTimeoutMs: number; + readonly schedule: typeof setTimeout; + readonly cancel: typeof clearTimeout; + readonly positionPublisher: MobilePansPositionPublisher; + getRuntime(): MobilePansRuntime | undefined; + getRememberedTag(): ManagedDevice | undefined; + getDiscoveries(): readonly DiscoveredDeviceSnapshot[]; + getSnapshot(): MobilePansSnapshot; + publish(snapshot: MobilePansSnapshot): void; + publishState( + state: TagConnectionState, + changes?: Partial, + ): void; + prepareTagForStreaming(): Promise; +} + +/** Coordinates one connection attempt and one bounded reconnect loop. */ +export class MobilePansConnectionController { + private connectionGeneration = 0; + private reconnectGeneration = 0; + private foreground = true; + private wantsConnection = false; + private connectPromise?: Promise; + private reconnectPromise?: Promise; + private reconnectTimer?: ReturnType; + private reconnectDelayResolve?: () => void; + private cancelPendingDiscovery?: () => void; + private backgroundShutdown: Promise = Promise.resolve(); + + constructor(private readonly host: ConnectionControllerHost) {} + + get shouldReconnect(): boolean { + return this.wantsConnection && this.foreground; + } + + get isConnecting(): boolean { + return Boolean(this.connectPromise); + } + + setWantsConnection(value: boolean): void { + this.wantsConnection = value; + } + + isConnectionCurrent(generation: number): boolean { + return ( + generation === this.connectionGeneration && + this.wantsConnection && + this.foreground + ); + } + + async connect(reconnecting: boolean): Promise { + if ( + !reconnecting && + this.host.getSnapshot().connectionState === "connected" + ) { + return; + } + this.wantsConnection = true; + this.cancelReconnect(); + await this.connectOnce(reconnecting); + } + + async disconnect(): Promise { + this.wantsConnection = false; + this.invalidateConnection(); + this.host.positionPublisher.stopMotion(); + const runtime = this.requireRuntime(); + await Promise.allSettled([runtime.stream.stop(), runtime.discovery.stop()]); + const rememberedTag = this.host.getRememberedTag(); + this.host.publishState(rememberedTag ? "disconnected" : "idle", { + livePosition: staleLivePosition( + this.host.getSnapshot().livePosition, + rememberedTag ? "disconnected" : "idle", + ), + error: undefined, + }); + } + + setForeground(foreground: boolean): void { + if (this.foreground === foreground) return; + this.foreground = foreground; + if (!foreground) { + this.invalidateConnection(); + const runtime = this.host.getRuntime(); + if (runtime) { + this.backgroundShutdown = Promise.allSettled([ + runtime.stream.stop(), + runtime.discovery.stop(), + ]); + } + this.host.positionPublisher.stopMotion(); + if (this.wantsConnection) { + this.host.publishState("reconnecting", { + livePosition: staleLivePosition( + this.host.getSnapshot().livePosition, + "reconnecting", + ), + }); + } + return; + } + if (this.wantsConnection && this.host.getRememberedTag()) { + void this.resumeAfterBackground(); + } + } + + async pauseForOperation(): Promise { + this.connectionGeneration += 1; + this.host.positionPublisher.resetStreamState(); + this.host.positionPublisher.clearLiveMarker(); + this.host.positionPublisher.stopMotion(); + this.host.publishState("reconnecting", { + livePosition: staleLivePosition( + this.host.getSnapshot().livePosition, + "reconnecting", + ), + error: undefined, + }); + await this.requireRuntime().stream.stop(); + } + + async resumeAfterOperation(): Promise { + if (this.shouldReconnect) await this.connectOnce(true); + } + + startReconnectLoop(): Promise { + if (!this.shouldReconnect || !this.host.getRememberedTag()) { + return Promise.resolve(); + } + if (this.reconnectPromise) return this.reconnectPromise; + const generation = ++this.reconnectGeneration; + const operation = this.runReconnectLoop(generation); + const tracked = operation.finally(() => { + if (this.reconnectPromise === tracked) this.reconnectPromise = undefined; + }); + this.reconnectPromise = tracked; + return tracked; + } + + receiveConnectionEvent(event: PansConnectionStateEvent): void { + const tag = this.host.getRememberedTag(); + if ( + !tag || + normalizeTransportDeviceId(event.deviceId) !== + normalizeTransportDeviceId(tag.transportDeviceId) || + event.state !== "disconnected" || + this.host.getSnapshot().connectionState !== "connected" + ) { + return; + } + this.host.positionPublisher.clearLiveMarker(); + this.host.positionPublisher.stopMotion(); + this.host.publishState( + this.wantsConnection ? "reconnecting" : "disconnected", + { + livePosition: staleLivePosition( + this.host.getSnapshot().livePosition, + this.wantsConnection ? "reconnecting" : "disconnected", + event.reason, + ), + }, + ); + if (this.shouldReconnect) void this.startReconnectLoop(); + } + + dispose(): void { + this.wantsConnection = false; + this.invalidateConnection(); + } + + private async connectOnce(reconnecting: boolean): Promise { + if (this.connectPromise) return await this.connectPromise; + const generation = ++this.connectionGeneration; + const operation = this.performConnect(generation, reconnecting); + const tracked = operation.finally(() => { + if (this.connectPromise === tracked) this.connectPromise = undefined; + }); + this.connectPromise = tracked; + return await tracked; + } + + private async performConnect( + generation: number, + reconnecting: boolean, + ): Promise { + const runtime = this.requireRuntime(); + const tag = this.host.getRememberedTag(); + if (!tag) throw new Error("Select a PANS tag before connecting."); + if (!this.foreground) return; + const state = reconnecting ? "reconnecting" : "connecting"; + this.host.publishState(state, { error: undefined }); + try { + const available = await this.ensureDiscovered(tag, generation); + if (!this.isConnectionCurrent(generation)) return; + this.host.publishState(state); + await runtime.discovery.stop(); + await this.host.prepareTagForStreaming(); + if (!this.isConnectionCurrent(generation)) return; + this.host.positionPublisher.resetStreamState(); + await runtime.stream.start({ + deviceId: tag.id, + transportDeviceId: available.transportDeviceId, + onSample: (sample) => + this.host.positionPublisher.receiveSample(sample, generation), + onDiagnostic: (message) => + this.host.positionPublisher.receiveDiagnostic(message, generation), + onCounters: (counters) => { + if (this.isConnectionCurrent(generation)) { + this.host.publish({ ...this.host.getSnapshot(), counters }); + } + }, + }); + if (!this.isConnectionCurrent(generation)) { + await runtime.stream.stop(); + return; + } + void this.host.positionPublisher.startMotion(generation); + await runtime.discovery.stop().catch(() => undefined); + this.host.publishState("connected", { + livePosition: { + ...this.host.getSnapshot().livePosition, + connectionState: "connected", + }, + error: undefined, + }); + } catch (cause) { + await runtime.discovery.stop().catch(() => undefined); + if (!this.isConnectionCurrent(generation)) return; + const error = normalizeManagerError(cause, { + deviceId: tag.id, + operation: reconnecting ? "reconnect tag" : "connect tag", + }); + this.host.positionPublisher.clearLiveMarker(); + this.host.publishState("error", { + livePosition: staleLivePosition( + this.host.getSnapshot().livePosition, + "error", + error.message, + ), + error, + }); + throw error; + } + } + + private async ensureDiscovered( + tag: ManagedDevice, + generation: number, + ): Promise { + const existing = findDiscovery( + this.host.getDiscoveries(), + tag.transportDeviceId, + ); + if (existing && isSelectableTagDiscovery(existing)) return existing; + const runtime = this.requireRuntime(); + this.host.publishState("scanning"); + const permission = runtime.discovery.getPermissionStatus(); + if (permission.bluetooth !== "granted") { + await runtime.discovery.requestPermissions(); + } + await runtime.discovery.start(); + return await new Promise((resolve, reject) => { + let settled = false; + let subscription: { remove(): void } | undefined; + const finish = (action: () => void) => { + if (settled) return; + settled = true; + this.host.cancel(timer); + subscription?.remove(); + if (this.cancelPendingDiscovery === cancelWait) { + this.cancelPendingDiscovery = undefined; + } + action(); + }; + const cancelWait = () => + finish(() => + reject(new Error("The connection attempt was cancelled.")), + ); + const timer = this.host.schedule(() => { + finish(() => + reject(new Error("The remembered PANS tag was not found nearby.")), + ); + }, this.host.discoveryTimeoutMs); + this.cancelPendingDiscovery = cancelWait; + subscription = runtime.discovery.subscribe((items) => { + if (!this.isConnectionCurrent(generation)) { + cancelWait(); + return; + } + const match = findDiscovery(items, tag.transportDeviceId); + if (!match || !isSelectableTagDiscovery(match)) return; + finish(() => resolve(match)); + }); + if (settled) subscription.remove(); + }); + } + + private async runReconnectLoop(generation: number): Promise { + for ( + let attempt = 0; + attempt <= this.host.reconnectDelaysMs.length; + attempt += 1 + ) { + if (!this.isReconnectCurrent(generation)) return; + if (attempt > 0) { + await new Promise((resolve) => { + this.reconnectDelayResolve = resolve; + this.reconnectTimer = this.host.schedule( + () => { + this.reconnectTimer = undefined; + this.reconnectDelayResolve = undefined; + resolve(); + }, + this.host.reconnectDelaysMs[attempt - 1], + ); + }); + if (!this.isReconnectCurrent(generation)) return; + } + try { + await this.connectOnce(true); + return; + } catch { + // connectOnce publishes the bounded error for each attempt. + } + } + } + + private async resumeAfterBackground(): Promise { + await this.backgroundShutdown.catch(() => undefined); + if (this.shouldReconnect && this.host.getRememberedTag()) { + await this.startReconnectLoop(); + } + } + + private invalidateConnection(): void { + this.connectionGeneration += 1; + this.cancelReconnect(); + this.cancelPendingDiscovery?.(); + this.cancelPendingDiscovery = undefined; + this.host.positionPublisher.resetStreamState(); + this.host.positionPublisher.clearLiveMarker(); + } + + private isReconnectCurrent(generation: number): boolean { + return generation === this.reconnectGeneration && this.shouldReconnect; + } + + private cancelReconnect(): void { + this.reconnectGeneration += 1; + if (this.reconnectTimer !== undefined) { + this.host.cancel(this.reconnectTimer); + } + this.reconnectTimer = undefined; + const resolve = this.reconnectDelayResolve; + this.reconnectDelayResolve = undefined; + resolve?.(); + } + + private requireRuntime(): MobilePansRuntime { + const runtime = this.host.getRuntime(); + if (!runtime) throw new Error("PANS services are not ready."); + return runtime; + } +} diff --git a/apps/mobile/src/pans/mobile-pans-context.tsx b/apps/mobile/src/pans/mobile-pans-context.tsx new file mode 100644 index 00000000..90a98340 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-context.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { AppState, type AppStateStatus } from "react-native"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; +import type { FieldPoint, FusedPositionOutput } from "@eight2five/mobile/field"; +import { + createExpoDeviceMotionAdapter, + type DeviceMotionAdapter, +} from "@eight2five/mobile/motion"; +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +import { MobilePansStore, type MobilePansSnapshot } from "./mobile-pans-store"; + +interface MobilePansContextValue { + readonly store: MobilePansStore; + readonly positionValue: SharedValue; + readonly fusionValue: SharedValue; +} + +const MobilePansContext = React.createContext( + null, +); + +export function MobilePansProvider({ + children, + store: injectedStore, + motionAdapter, + motionInterpolationEnabled, + developerModeEnabled, + appState = AppState, +}: { + readonly children: React.ReactNode; + readonly store?: MobilePansStore; + readonly motionAdapter?: DeviceMotionAdapter; + readonly motionInterpolationEnabled?: boolean; + readonly developerModeEnabled?: boolean; + readonly appState?: { + readonly currentState: AppStateStatus | null; + addEventListener( + type: "change", + listener: (state: AppStateStatus) => void, + ): { remove(): void }; + }; +}) { + const [ownedStore] = React.useState( + () => + new MobilePansStore({ + motionAdapter: motionAdapter ?? createExpoDeviceMotionAdapter(), + motionInterpolationEnabled: motionInterpolationEnabled ?? false, + developerModeEnabled: developerModeEnabled ?? false, + }), + ); + const store = injectedStore ?? ownedStore; + const positionValue = useSharedValue(null); + const fusionValue = useSharedValue(null); + + React.useEffect(() => { + if (motionInterpolationEnabled !== undefined) { + store.setMotionInterpolationEnabled(motionInterpolationEnabled); + } + }, [motionInterpolationEnabled, store]); + + React.useEffect(() => { + if (developerModeEnabled !== undefined) { + void store.setDeveloperModeEnabled(developerModeEnabled); + } + }, [developerModeEnabled, store]); + + React.useEffect(() => { + store.attachPositionValue(positionValue); + store.attachFusionValue(fusionValue); + store.setForeground(appState.currentState === "active"); + void store.initialize(); + const subscription = appState.addEventListener("change", (state) => { + store.setForeground(state === "active"); + }); + return () => { + subscription.remove(); + void store.dispose(); + }; + }, [appState, fusionValue, positionValue, store]); + + const value = React.useMemo( + () => ({ store, positionValue, fusionValue }), + [fusionValue, positionValue, store], + ); + return ( + + {children} + + ); +} + +export function useMobilePansStore(): MobilePansStore { + return useMobilePansContext().store; +} + +export function useMobilePansSnapshot(): MobilePansSnapshot { + const store = useMobilePansStore(); + return React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +export function useFieldLivePosition() { + const context = useMobilePansContext(); + const livePosition = React.useSyncExternalStore( + context.store.subscribe, + () => context.store.getSnapshot().livePosition, + () => context.store.getSnapshot().livePosition, + ); + return React.useMemo( + () => ({ + state: livePosition, + positionValue: context.positionValue, + fusionValue: context.fusionValue, + }), + [context.fusionValue, context.positionValue, livePosition], + ); +} + +export function useRememberedPansTag(): ManagedDevice | undefined { + const store = useMobilePansStore(); + return React.useSyncExternalStore( + store.subscribe, + () => store.getSnapshot().rememberedTag, + () => store.getSnapshot().rememberedTag, + ); +} + +export function useKnownPansAnchors(): readonly ManagedDevice[] { + const store = useMobilePansStore(); + return React.useSyncExternalStore( + store.subscribe, + () => store.getSnapshot().knownAnchors, + () => store.getSnapshot().knownAnchors, + ); +} + +function useMobilePansContext(): MobilePansContextValue { + const context = React.useContext(MobilePansContext); + if (!context) { + throw new Error( + "Mobile PANS hooks must be used inside MobilePansProvider.", + ); + } + return context; +} diff --git a/apps/mobile/src/pans/mobile-pans-device-cache.ts b/apps/mobile/src/pans/mobile-pans-device-cache.ts new file mode 100644 index 00000000..34c707ba --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-device-cache.ts @@ -0,0 +1,122 @@ +import { + deviceFromDiscovery, + normalizeTransportDeviceId, + type DiscoveredDeviceSnapshot, + type ManagedDevice, +} from "@eight2five/mobile/pans-manager"; + +import { createLocalId } from "./mobile-pans-model"; +import type { MobilePansRuntime } from "./mobile-pans-runtime"; + +export async function persistSelectedTagAndNearbyAnchors( + runtime: MobilePansRuntime, + discovery: DiscoveredDeviceSnapshot, + discoveries: readonly DiscoveredDeviceSnapshot[], + now: number, +): Promise { + const devices = await runtime.repository.listDevices(); + const existingTag = findSavedDevice(devices, discovery); + let tag = await runtime.repository.saveDevice({ + ...deviceFromDiscovery(discovery, existingTag, { + id: existingTag?.id ?? createLocalId("tag"), + now, + }), + role: "tag", + }); + const anchors: ManagedDevice[] = []; + for (const nearbyAnchor of discoveries.filter(isCurrentCompatibleAnchor)) { + const existingAnchor = findSavedDevice(devices, nearbyAnchor); + anchors.push( + await runtime.repository.saveDevice({ + ...deviceFromDiscovery(nearbyAnchor, existingAnchor, { + id: existingAnchor?.id ?? createLocalId("anchor"), + now, + }), + role: "anchor", + }), + ); + } + + // Selection is an explicit refresh boundary. Read each discovered device + // once so PAN-based association is verified rather than inferred by proximity. + await runtime.discovery.stop(); + tag = await inspectAndReload(runtime, tag); + for (let index = 0; index < anchors.length; index += 1) { + anchors[index] = await inspectAndReload(runtime, anchors[index]); + } + + const panId = tag.lastKnownConfig?.panId; + if (panId === undefined) return tag; + const matchingNetworks = (await runtime.repository.listNetworks()).filter( + (network) => network.panId === panId, + ); + if (matchingNetworks.length !== 1) return tag; + const network = matchingNetworks[0]; + tag = await runtime.repository.associateDevice({ + networkId: network.id, + deviceId: tag.id, + associatedAt: now, + }); + for (const anchor of anchors) { + if (anchor.lastKnownConfig?.panId !== panId) continue; + await runtime.repository.associateDevice({ + networkId: network.id, + deviceId: anchor.id, + associatedAt: now, + }); + } + return tag; +} + +export function sortedCachedAnchors( + devices: readonly ManagedDevice[], +): readonly ManagedDevice[] { + return devices + .filter( + (device) => + device.role === "anchor" || device.lastKnownConfig?.role === "anchor", + ) + .sort((left, right) => + (left.nodeIdHex ?? left.label ?? left.id).localeCompare( + right.nodeIdHex ?? right.label ?? right.id, + ), + ); +} + +function findSavedDevice( + devices: readonly ManagedDevice[], + discovery: DiscoveredDeviceSnapshot, +): ManagedDevice | undefined { + const transportId = normalizeTransportDeviceId(discovery.transportDeviceId); + return devices.find( + (device) => + normalizeTransportDeviceId(device.transportDeviceId) === transportId || + Boolean( + discovery.macAddress && device.macAddress === discovery.macAddress, + ), + ); +} + +function isCurrentCompatibleAnchor( + discovery: DiscoveredDeviceSnapshot, +): boolean { + return ( + !discovery.stale && + discovery.compatibility === "compatible" && + discovery.presence?.role === "anchor" + ); +} + +async function inspectAndReload( + runtime: MobilePansRuntime, + device: ManagedDevice, +): Promise { + try { + await runtime.configuration.inspectAndCache?.(device.id); + return (await runtime.repository.getDevice(device.id)) ?? device; + } catch { + // Keep the discovery record pending; selectors exclude it until a later + // explicit refresh verifies network data. + return device; + } +} diff --git a/apps/mobile/src/pans/mobile-pans-model.ts b/apps/mobile/src/pans/mobile-pans-model.ts new file mode 100644 index 00000000..6d7b2837 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-model.ts @@ -0,0 +1,139 @@ +import type { + DiscoveredDeviceSnapshot, + ManagedDevice, + ManagerError, + PansDiagnosticsResult, + PansPosition, + PansPositionStreamCounters, + ManagedNetwork, +} from "@eight2five/mobile/pans-manager"; +import { + DEFAULT_DISCOVERY_RSSI_CUTOFF, + normalizeTransportDeviceId, +} from "@eight2five/mobile/pans-manager"; +import type { + FieldLivePositionState, + FieldPoint, +} from "@eight2five/mobile/field"; +import type { DeviceMotionAdapter } from "@eight2five/mobile/motion"; + +import type { CreateMobilePansRuntime } from "./mobile-pans-runtime"; + +export type TagConnectionState = + | "idle" + | "scanning" + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "error"; + +export interface MobilePansSnapshot { + readonly initialization: "loading" | "ready" | "error"; + readonly connectionState: TagConnectionState; + readonly rememberedTag?: ManagedDevice; + readonly discoveries: readonly DiscoveredDeviceSnapshot[]; + readonly livePosition: FieldLivePositionState; + readonly rawPosition?: Readonly< + Pick + >; + readonly lastUpdateAt?: number; + readonly effectiveUpdateRateHz: number; + readonly counters?: Readonly; + readonly hardwareDiagnostics?: PansDiagnosticsResult; + readonly knownAnchors: readonly ManagedDevice[]; + readonly networks: readonly ManagedNetwork[]; + readonly activeNetworkId?: string; + readonly discoveryRssiCutoff: number; + readonly commissioningWarning?: string; + readonly diagnosticMessages: readonly string[]; + readonly error?: ManagerError | Error; +} + +export interface MobilePansStoreOptions { + readonly createRuntime?: CreateMobilePansRuntime; + readonly motionAdapter?: DeviceMotionAdapter; + readonly motionInterpolationEnabled?: boolean; + readonly now?: () => number; + readonly schedule?: typeof setTimeout; + readonly cancel?: typeof clearTimeout; + readonly reconnectDelaysMs?: readonly number[]; + readonly staleAfterMs?: number; + readonly discoveryTimeoutMs?: number; + readonly developerModeEnabled?: boolean; +} + +export const EMPTY_DISCOVERIES: readonly DiscoveredDeviceSnapshot[] = + Object.freeze([]); +export const DEFAULT_RECONNECT_DELAYS = Object.freeze([500, 1_500, 3_000]); + +export const INITIAL_MOBILE_PANS_SNAPSHOT: MobilePansSnapshot = Object.freeze({ + initialization: "loading", + connectionState: "idle", + discoveries: EMPTY_DISCOVERIES, + livePosition: Object.freeze({ + connectionState: "idle", + isStale: false, + interpolationActive: false, + }), + effectiveUpdateRateHz: 0, + diagnosticMessages: Object.freeze([]), + knownAnchors: Object.freeze([]), + networks: Object.freeze([]), + discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF, +}); + +/** + * MVP deployments use an identity-aligned frame: PANS +X is Side 1→Side 2 and + * PANS +Y is front→back. A calibrated arbitrary-frame transform is deferred. + */ +export function pansPositionToFieldPoint(position: PansPosition): FieldPoint { + return { xMeters: position.xMeters, yMeters: position.yMeters }; +} + +export function findDiscovery( + discoveries: readonly DiscoveredDeviceSnapshot[], + transportDeviceId: string, +): DiscoveredDeviceSnapshot | undefined { + const normalized = normalizeTransportDeviceId(transportDeviceId); + return discoveries.find( + (item) => normalizeTransportDeviceId(item.transportDeviceId) === normalized, + ); +} + +export function isSelectableTagDiscovery( + discovery: DiscoveredDeviceSnapshot, +): boolean { + return ( + !discovery.stale && + discovery.compatibility === "compatible" && + discovery.presence?.role === "tag" + ); +} + +export function fieldConnectionState( + state: TagConnectionState, +): FieldLivePositionState["connectionState"] { + return state === "scanning" ? "connecting" : state; +} + +export function staleLivePosition( + live: FieldLivePositionState, + connectionState: FieldLivePositionState["connectionState"], + errorMessage?: string, +): FieldLivePositionState { + return { + ...live, + connectionState, + isStale: Boolean(live.position), + interpolationActive: false, + ...(errorMessage ? { errorMessage } : {}), + }; +} + +export function createLocalId(prefix: string): string { + const uuid = globalThis.crypto?.randomUUID?.(); + return uuid + ? `${prefix}-${uuid}` + : `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/apps/mobile/src/pans/mobile-pans-position-publisher.ts b/apps/mobile/src/pans/mobile-pans-position-publisher.ts new file mode 100644 index 00000000..e4b143ff --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-position-publisher.ts @@ -0,0 +1,384 @@ +import type { FieldPoint, FusedPositionOutput } from "@eight2five/mobile/field"; +import { + ConservativePositionFusion, + type DeviceMotionAdapter, + type DeviceMotionSample, +} from "@eight2five/mobile/motion"; +import type { PansPositionStreamSample } from "@eight2five/mobile/pans-manager"; +import type { SharedValue } from "react-native-reanimated"; + +import { + pansPositionToFieldPoint, + staleLivePosition, + type MobilePansSnapshot, +} from "./mobile-pans-model"; + +const HUD_PUBLICATION_INTERVAL_MS = 100; + +interface PositionPublisherHost { + readonly staleAfterMs: number; + readonly schedule: typeof setTimeout; + readonly cancel: typeof clearTimeout; + isConnectionCurrent(generation: number): boolean; + getSnapshot(): MobilePansSnapshot; + publish(snapshot: MobilePansSnapshot): void; +} + +export interface MobilePansPositionPublisherOptions { + readonly motionAdapter?: DeviceMotionAdapter; + readonly motionInterpolationEnabled?: boolean; +} + +/** Publishes high-rate samples to Skia and coalesced human-readable state to React. */ +export class MobilePansPositionPublisher { + private positionValue?: SharedValue; + private fusionValue?: SharedValue; + private staleTimer?: ReturnType; + private lastHudPublicationAt = Number.NEGATIVE_INFINITY; + private sampleTimes: number[] = []; + private readonly fusion = new ConservativePositionFusion(); + private readonly motionAdapter?: DeviceMotionAdapter; + private motionInterpolationEnabled: boolean; + private motionSensorActive = false; + private activeGeneration?: number; + private motionStartToken = 0; + + constructor( + private readonly host: PositionPublisherHost, + options: MobilePansPositionPublisherOptions = {}, + ) { + this.motionAdapter = options.motionAdapter; + this.motionInterpolationEnabled = + options.motionInterpolationEnabled ?? Boolean(options.motionAdapter); + } + + attachPositionValue(value: SharedValue): void { + if (this.positionValue && this.positionValue !== value) { + this.positionValue.value = null; + } + this.positionValue = value; + const live = this.host.getSnapshot().livePosition; + value.value = live.isStale ? null : (live.position ?? null); + } + + attachFusionValue(value: SharedValue): void { + if (this.fusionValue && this.fusionValue !== value) { + this.fusionValue.value = null; + } + this.fusionValue = value; + const live = this.host.getSnapshot().livePosition; + if (live.isStale || !live.position) { + value.value = null; + return; + } + const fusedAt = live.receivedAt ?? live.lastUwbAt ?? 0; + value.value = { + position: live.position, + source: live.source ?? "uwb", + fusedAt, + freshnessMs: live.freshnessMs ?? 0, + lastUwbAt: live.lastUwbAt ?? fusedAt, + lastUwbPosition: live.lastUwbPosition ?? live.position, + interpolationActive: live.interpolationActive ?? false, + }; + } + + setMotionInterpolationEnabled(enabled: boolean): void { + if (this.motionInterpolationEnabled === enabled) return; + this.motionInterpolationEnabled = enabled; + if (!enabled) { + this.stopMotion(); + this.fusion.reset(); + this.publishUwbOnlyState(); + return; + } + if (this.activeGeneration !== undefined) { + void this.startMotion(this.activeGeneration); + } + } + + async startMotion(generation: number): Promise { + this.activeGeneration = generation; + this.stopMotionSubscription(); + this.fusion.setMotionSensorActive(false); + if (!this.motionInterpolationEnabled || !this.motionAdapter) return; + + const token = ++this.motionStartToken; + try { + const result = await this.motionAdapter.start((sample) => { + if ( + token !== this.motionStartToken || + this.activeGeneration !== generation || + !this.host.isConnectionCurrent(generation) + ) { + return; + } + this.receiveMotionSample(sample, generation); + }); + if ( + token !== this.motionStartToken || + this.activeGeneration !== generation || + !this.host.isConnectionCurrent(generation) || + !this.motionInterpolationEnabled + ) { + try { + this.motionAdapter.stop(); + } catch { + // A best-effort cleanup must not turn optional motion into a + // connection failure. + } + return; + } + this.motionSensorActive = result === "active"; + this.fusion.setMotionSensorActive(this.motionSensorActive); + } catch { + // Sensor permission/availability is optional. UWB continues as the sole + // source when a phone cannot provide DeviceMotion. + this.motionSensorActive = false; + this.fusion.setMotionSensorActive(false); + } + } + + stopMotion(): void { + ++this.motionStartToken; + this.activeGeneration = undefined; + this.stopMotionSubscription(); + this.fusion.setMotionSensorActive(false); + } + + receiveSample(sample: PansPositionStreamSample, generation: number): void { + if (!this.host.isConnectionCurrent(generation) || !sample.position) return; + const receivedAt = sample.receivedAt; + const fieldPoint = pansPositionToFieldPoint(sample.position); + if (!this.motionInterpolationEnabled) { + this.sampleTimes = this.sampleTimes.filter( + (time) => receivedAt - time <= 1_000, + ); + this.sampleTimes.push(receivedAt); + this.publishOutput( + createRawUwbOutput(fieldPoint, receivedAt), + generation, + { + acceptedUwb: true, + rawPosition: { + xMeters: sample.position.xMeters, + yMeters: sample.position.yMeters, + zMeters: sample.position.zMeters, + }, + }, + ); + return; + } + const output = this.fusion.acceptUwb({ + position: fieldPoint, + receivedAt, + }); + if (!output) return; + + const acceptedUwb = output.lastUwbAt === receivedAt; + if (acceptedUwb) { + this.sampleTimes = this.sampleTimes.filter( + (time) => receivedAt - time <= 1_000, + ); + this.sampleTimes.push(receivedAt); + } + this.publishOutput(output, generation, { + acceptedUwb, + rawPosition: { + xMeters: sample.position.xMeters, + yMeters: sample.position.yMeters, + zMeters: sample.position.zMeters, + }, + }); + } + + private receiveMotionSample( + sample: DeviceMotionSample, + generation: number, + ): void { + const output = this.fusion.acceptMotion(sample); + if (!output) return; + this.publishOutput(output, generation, { acceptedUwb: false }); + } + + receiveDiagnostic(message: string, generation: number): void { + if (!this.host.isConnectionCurrent(generation)) return; + const snapshot = this.host.getSnapshot(); + this.host.publish({ + ...snapshot, + diagnosticMessages: [...snapshot.diagnosticMessages, message].slice(-8), + }); + } + + resetStreamState(): void { + this.stopMotion(); + this.fusion.reset(); + this.sampleTimes = []; + this.lastHudPublicationAt = Number.NEGATIVE_INFINITY; + this.cancelStaleTimer(); + const snapshot = this.host.getSnapshot(); + if (snapshot.effectiveUpdateRateHz !== 0 || snapshot.counters) { + this.host.publish({ + ...snapshot, + effectiveUpdateRateHz: 0, + counters: undefined, + }); + } + } + + clearLiveMarker(): void { + if (this.positionValue) this.positionValue.value = null; + if (this.fusionValue) this.fusionValue.value = null; + } + + dispose(): void { + this.stopMotion(); + this.cancelStaleTimer(); + this.clearLiveMarker(); + this.positionValue = undefined; + this.fusionValue = undefined; + } + + private publishOutput( + output: FusedPositionOutput, + generation: number, + options: { + readonly acceptedUwb: boolean; + readonly rawPosition?: MobilePansSnapshot["rawPosition"]; + }, + ): void { + if (!this.host.isConnectionCurrent(generation)) return; + const fieldPoint = output.position; + if (this.positionValue) this.positionValue.value = fieldPoint; + if (this.fusionValue) this.fusionValue.value = output; + this.scheduleStale(generation, output.freshnessMs); + if ( + output.fusedAt - this.lastHudPublicationAt < + HUD_PUBLICATION_INTERVAL_MS + ) { + return; + } + this.lastHudPublicationAt = output.fusedAt; + const snapshot = this.host.getSnapshot(); + this.host.publish({ + ...snapshot, + connectionState: "connected", + livePosition: { + connectionState: "connected", + position: fieldPoint, + receivedAt: output.fusedAt, + isStale: false, + source: output.source, + freshnessMs: output.freshnessMs, + lastUwbAt: output.lastUwbAt, + lastUwbPosition: output.lastUwbPosition, + interpolationActive: output.interpolationActive, + }, + ...(options.acceptedUwb && options.rawPosition + ? { rawPosition: options.rawPosition } + : {}), + lastUpdateAt: output.fusedAt, + effectiveUpdateRateHz: effectiveRate(this.sampleTimes), + error: undefined, + }); + } + + private scheduleStale(generation: number, freshnessMs: number): void { + this.cancelStaleTimer(); + this.staleTimer = this.host.schedule( + () => { + this.staleTimer = undefined; + if (!this.host.isConnectionCurrent(generation)) return; + this.clearLiveMarker(); + const snapshot = this.host.getSnapshot(); + this.host.publish({ + ...snapshot, + livePosition: staleLivePosition( + snapshot.livePosition, + snapshot.connectionState === "connected" + ? "connected" + : "reconnecting", + ), + }); + }, + Math.max(0, this.host.staleAfterMs - Math.max(0, freshnessMs)), + ); + } + + private cancelStaleTimer(): void { + if (this.staleTimer !== undefined) this.host.cancel(this.staleTimer); + this.staleTimer = undefined; + } + + private stopMotionSubscription(): void { + if (!this.motionSensorActive && !this.motionAdapter) return; + try { + this.motionAdapter?.stop(); + } catch { + // Sensor cleanup is best effort; UWB remains independently usable. + } + this.motionSensorActive = false; + } + + private publishUwbOnlyState(): void { + const snapshot = this.host.getSnapshot(); + const live = snapshot.livePosition; + if (!live.position) { + if (this.fusionValue) this.fusionValue.value = null; + return; + } + const position = live.lastUwbPosition ?? live.position; + const lastUwbAt = live.lastUwbAt ?? live.receivedAt ?? 0; + const fusedAt = live.receivedAt ?? lastUwbAt; + const freshnessMs = + live.lastUwbAt !== undefined ? Math.max(0, fusedAt - live.lastUwbAt) : 0; + const output: FusedPositionOutput = { + position, + source: "uwb", + fusedAt, + freshnessMs, + lastUwbAt, + lastUwbPosition: position, + interpolationActive: false, + }; + if (this.positionValue) + this.positionValue.value = live.isStale ? null : position; + if (this.fusionValue) this.fusionValue.value = live.isStale ? null : output; + this.host.publish({ + ...snapshot, + livePosition: { + ...live, + position, + receivedAt: fusedAt, + source: "uwb", + freshnessMs, + lastUwbAt: output.lastUwbAt, + lastUwbPosition: position, + interpolationActive: false, + isStale: live.isStale, + }, + lastUpdateAt: fusedAt, + }); + } +} + +function effectiveRate(sampleTimes: readonly number[]): number { + if (sampleTimes.length < 2) return 0; + const elapsedMs = sampleTimes[sampleTimes.length - 1] - sampleTimes[0]; + return elapsedMs > 0 ? ((sampleTimes.length - 1) * 1_000) / elapsedMs : 0; +} + +function createRawUwbOutput( + position: FieldPoint, + receivedAt: number, +): FusedPositionOutput { + return { + position, + source: "uwb", + fusedAt: receivedAt, + freshnessMs: 0, + lastUwbAt: receivedAt, + lastUwbPosition: position, + interpolationActive: false, + }; +} diff --git a/apps/mobile/src/pans/mobile-pans-runtime.ts b/apps/mobile/src/pans/mobile-pans-runtime.ts new file mode 100644 index 00000000..d5097831 --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-runtime.ts @@ -0,0 +1,67 @@ +import type { + PansConfigurationService, + PansCommissioningService, + PansDeviceSessionManager, + PansDiagnosticsService, + PansDiscoveryService, + PansManagerRepository, + PansPositionStreamService, +} from "@eight2five/mobile/pans-manager"; + +export interface MobilePansRuntime { + readonly repository: PansManagerRepository; + readonly discovery: PansDiscoveryService; + readonly sessions: PansDeviceSessionManager; + readonly stream: PansPositionStreamService; + readonly configuration: PansConfigurationService; + readonly commissioning: PansCommissioningService; + readonly diagnostics: PansDiagnosticsService; + close(): Promise; +} + +export type CreateMobilePansRuntime = () => Promise; + +export const createDefaultMobilePansRuntime: CreateMobilePansRuntime = + async () => { + // The native module is unavailable in Expo Go and registry-style tests. + // Keep it lazy so the app can surface initialization failure safely. + const manager = await import("@eight2five/mobile/pans-manager"); + const storage = await manager.openPansManagerRepository(); + try { + await storage.repository.initialize(); + const settings = manager.normalizePansManagerSettings( + await storage.repository.getSettings(), + ); + const discovery = new manager.PansDiscoveryService(undefined, { + staleAfterMs: settings.discoveryStaleAfterMs, + }); + const sessions = new manager.PansDeviceSessionManager( + undefined, + settings.connectionTimeoutMs, + ); + const configuration = new manager.PansConfigurationService( + sessions, + storage.repository, + ); + return { + repository: storage.repository, + discovery, + sessions, + stream: new manager.PansPositionStreamService(sessions), + configuration, + commissioning: new manager.PansCommissioningService( + storage.repository, + configuration, + ), + diagnostics: new manager.PansDiagnosticsService(sessions), + close: async () => { + await discovery.stop().catch(() => undefined); + await sessions.closeAll().catch(() => undefined); + await storage.close(); + }, + }; + } catch (error) { + await storage.close(); + throw error; + } + }; diff --git a/apps/mobile/src/pans/mobile-pans-store.ts b/apps/mobile/src/pans/mobile-pans-store.ts new file mode 100644 index 00000000..cf05822f --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-store.ts @@ -0,0 +1,1103 @@ +import { + assertNetworkProfilePanId, + assertUniqueName, + assertValidLabel, + DEFAULT_MANAGED_NETWORK_SETTINGS, + DEFAULT_DISCOVERY_RSSI_CUTOFF, + deviceFromDiscovery, + diffPerformerTagProfile, + MAX_DISCOVERY_RSSI_CUTOFF, + MIN_DISCOVERY_RSSI_CUTOFF, + normalizeManagerError, + normalizePansManagerSettings, + normalizeTransportDeviceId, + type DiscoveredDeviceSnapshot, + type ManagedDevice, + type ManagedNetwork, + ManagerError, + type PansManagerSettings, + type PansDiagnosticsResult, +} from "@eight2five/mobile/pans-manager"; +import type { + AnchorFieldPosition, + FieldPoint, + FusedPositionOutput, +} from "@eight2five/mobile/field"; +import type { SharedValue } from "react-native-reanimated"; + +import { + createDefaultMobilePansRuntime, + type CreateMobilePansRuntime, + type MobilePansRuntime, +} from "./mobile-pans-runtime"; +import { + DEFAULT_RECONNECT_DELAYS, + EMPTY_DISCOVERIES, + fieldConnectionState, + INITIAL_MOBILE_PANS_SNAPSHOT, + isSelectableTagDiscovery, + createLocalId, + type MobilePansSnapshot, + type MobilePansStoreOptions, + type TagConnectionState, +} from "./mobile-pans-model"; +import { MobilePansPositionPublisher } from "./mobile-pans-position-publisher"; +import { MobilePansConnectionController } from "./mobile-pans-connection-controller"; +import { areDevicesNetworkAssociated } from "./pans-anchor-cache"; +import { + persistSelectedTagAndNearbyAnchors, + sortedCachedAnchors, +} from "./mobile-pans-device-cache"; + +export { + pansPositionToFieldPoint, + type MobilePansSnapshot, + type MobilePansStoreOptions, + type TagConnectionState, +} from "./mobile-pans-model"; + +/** + * Composes the one production PANS runtime, connection controller, position + * publisher, persistent device cache, and low-rate React snapshot. + */ +export class MobilePansStore { + private snapshot: MobilePansSnapshot = INITIAL_MOBILE_PANS_SNAPSHOT; + private readonly listeners = new Set<() => void>(); + private readonly createRuntime: CreateMobilePansRuntime; + private readonly now: () => number; + private readonly schedule: typeof setTimeout; + private readonly cancel: typeof clearTimeout; + private readonly reconnectDelaysMs: readonly number[]; + private readonly staleAfterMs: number; + private readonly discoveryTimeoutMs: number; + private runtime?: MobilePansRuntime; + private readonly positionPublisher: MobilePansPositionPublisher; + private readonly connectionController: MobilePansConnectionController; + private lifecycleGeneration = 0; + private discoverySubscription?: { remove(): void }; + private discoveryErrorSubscription?: { remove(): void }; + private discoveryStateSubscription?: { remove(): void }; + private connectionSubscription?: { remove(): void }; + private settings?: PansManagerSettings; + private rememberedTag?: ManagedDevice; + private discoveries: readonly DiscoveredDeviceSnapshot[] = EMPTY_DISCOVERIES; + private anchorWritePromise?: Promise; + private hardwareOperationPromise?: Promise; + private manualDiscoveryRequested = false; + private developerModeEnabled: boolean; + + constructor(options: MobilePansStoreOptions = {}) { + this.createRuntime = + options.createRuntime ?? createDefaultMobilePansRuntime; + this.now = options.now ?? Date.now; + this.schedule = options.schedule ?? setTimeout; + this.cancel = options.cancel ?? clearTimeout; + this.reconnectDelaysMs = + options.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS; + this.staleAfterMs = options.staleAfterMs ?? 2_500; + this.discoveryTimeoutMs = options.discoveryTimeoutMs ?? 10_000; + this.developerModeEnabled = options.developerModeEnabled ?? false; + this.positionPublisher = new MobilePansPositionPublisher( + { + staleAfterMs: this.staleAfterMs, + schedule: this.schedule, + cancel: this.cancel, + isConnectionCurrent: (generation) => + this.connectionController.isConnectionCurrent(generation), + getSnapshot: this.getSnapshot, + publish: (snapshot) => this.publish(snapshot), + }, + { + motionAdapter: options.motionAdapter, + motionInterpolationEnabled: options.motionInterpolationEnabled, + }, + ); + this.connectionController = new MobilePansConnectionController({ + reconnectDelaysMs: this.reconnectDelaysMs, + discoveryTimeoutMs: this.discoveryTimeoutMs, + schedule: this.schedule, + cancel: this.cancel, + positionPublisher: this.positionPublisher, + getRuntime: () => this.runtime, + getRememberedTag: () => this.rememberedTag, + getDiscoveries: () => this.discoveries, + getSnapshot: this.getSnapshot, + publish: (snapshot) => this.publish(snapshot), + publishState: (state, changes) => this.publishState(state, changes), + prepareTagForStreaming: () => this.prepareSelectedTagForStreaming(), + }); + } + + readonly getSnapshot = (): MobilePansSnapshot => this.snapshot; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + attachPositionValue(value: SharedValue): void { + this.positionPublisher.attachPositionValue(value); + } + + attachFusionValue(value: SharedValue): void { + this.positionPublisher.attachFusionValue(value); + } + + setMotionInterpolationEnabled(enabled: boolean): void { + this.positionPublisher.setMotionInterpolationEnabled(enabled); + } + + async setDeveloperModeEnabled(enabled: boolean): Promise { + this.developerModeEnabled = enabled; + if ( + !enabled && + this.runtime && + this.snapshot.initialization === "ready" && + this.settings?.discoveryRssiCutoff !== DEFAULT_DISCOVERY_RSSI_CUTOFF + ) { + await this.setDiscoveryRssiCutoff(DEFAULT_DISCOVERY_RSSI_CUTOFF, true); + } + } + + async setDiscoveryRssiCutoff( + cutoff: number, + productionReset = false, + ): Promise { + if (!productionReset && !this.developerModeEnabled) { + throw new Error("Developer Mode is required to change signal filtering."); + } + if ( + !Number.isInteger(cutoff) || + cutoff < MIN_DISCOVERY_RSSI_CUTOFF || + cutoff > MAX_DISCOVERY_RSSI_CUTOFF + ) { + throw new Error( + `Signal cutoff must be an integer from ${MIN_DISCOVERY_RSSI_CUTOFF} to ${MAX_DISCOVERY_RSSI_CUTOFF} dBm.`, + ); + } + await this.saveManagerSettings({ discoveryRssiCutoff: cutoff }); + this.publish({ ...this.snapshot, discoveryRssiCutoff: cutoff }); + } + + async initialize(): Promise { + const generation = ++this.lifecycleGeneration; + this.publish(INITIAL_MOBILE_PANS_SNAPSHOT); + try { + const runtime = await this.createRuntime(); + if (generation !== this.lifecycleGeneration) { + await runtime.close(); + return; + } + this.runtime = runtime; + this.settings = normalizePansManagerSettings( + await runtime.repository.getSettings(), + ); + if ( + !this.developerModeEnabled && + this.settings.discoveryRssiCutoff !== DEFAULT_DISCOVERY_RSSI_CUTOFF + ) { + this.settings = normalizePansManagerSettings({ + ...this.settings, + discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF, + }); + await runtime.repository.saveSettings(this.settings); + } + this.rememberedTag = this.settings.rememberedTagDeviceId + ? await runtime.repository.getDevice( + this.settings.rememberedTagDeviceId, + ) + : undefined; + const [devices, networks] = await Promise.all([ + runtime.repository.listDevices(), + runtime.repository.listNetworks(), + ]); + const knownAnchors = sortedCachedAnchors(devices); + if (!this.rememberedTag && this.settings.rememberedTagDeviceId) { + await this.saveRememberedTag(undefined); + } + if ( + this.settings.activeNetworkId && + !networks.some( + (network) => network.id === this.settings?.activeNetworkId, + ) + ) { + await this.saveManagerSettings({ activeNetworkId: undefined }); + } + this.installRuntimeListeners(runtime, generation); + this.connectionController.setWantsConnection(Boolean(this.rememberedTag)); + this.publishState(this.rememberedTag ? "disconnected" : "idle", { + initialization: "ready", + knownAnchors, + networks, + activeNetworkId: this.settings.activeNetworkId, + discoveryRssiCutoff: this.settings.discoveryRssiCutoff, + }); + void this.connectionController.startReconnectLoop(); + } catch (cause) { + if (generation !== this.lifecycleGeneration) return; + this.publish({ + ...INITIAL_MOBILE_PANS_SNAPSHOT, + initialization: "error", + connectionState: "error", + livePosition: { + connectionState: "error", + isStale: false, + interpolationActive: false, + }, + error: normalizeManagerError(cause, { operation: "initialize" }), + }); + } + } + + async startDiscovery(): Promise { + const runtime = this.requireRuntime(); + if (this.snapshot.connectionState === "connected") { + throw new Error( + "Disconnect the current tag before discovering another tag.", + ); + } + this.manualDiscoveryRequested = true; + this.publishState("scanning", { error: undefined }); + try { + const permission = runtime.discovery.getPermissionStatus(); + if (permission.bluetooth !== "granted") { + await runtime.discovery.requestPermissions(); + } + await runtime.discovery.start(); + } catch (cause) { + this.manualDiscoveryRequested = false; + const error = normalizeManagerError(cause, { operation: "discover tag" }); + this.publishState("error", { error }); + throw error; + } + } + + async startTagDiscovery(): Promise { + if (this.snapshot.connectionState === "connected") return; + await this.startDiscovery(); + } + + async stopDiscovery(): Promise { + this.manualDiscoveryRequested = false; + const runtime = this.runtime; + if (!runtime) return; + await runtime.discovery.stop(); + if (this.snapshot.connectionState === "scanning") { + this.publishState(this.rememberedTag ? "disconnected" : "idle"); + } + } + + stopManualDiscovery(): void { + if (this.manualDiscoveryRequested) void this.stopDiscovery(); + } + + async selectTag(transportDeviceId: string): Promise { + const runtime = this.requireRuntime(); + const discovery = this.discoveries.find( + (item) => item.transportDeviceId === transportDeviceId, + ); + if (!discovery) throw new Error("The selected tag is no longer available."); + if (!isSelectableTagDiscovery(discovery)) { + throw new Error("Select a compatible, current PANS tag advertisement."); + } + const saved = await persistSelectedTagAndNearbyAnchors( + runtime, + discovery, + this.discoveries, + this.now(), + ); + this.rememberedTag = saved; + await this.saveRememberedTag(saved.id); + this.connectionController.setWantsConnection(true); + await this.refreshCachedAnchors(); + this.publishState("disconnected", { + rememberedTag: saved, + error: undefined, + }); + } + + async selectConfigureAndConnectTag(transportDeviceId: string): Promise { + await this.selectTag(transportDeviceId); + await this.stopDiscovery(); + await this.connect(); + } + + async connect(): Promise { + await this.connectionController.connect(false); + } + + async reconnect(): Promise { + await this.connectionController.connect(true); + } + + async disconnect(): Promise { + await this.connectionController.disconnect(); + } + + async forgetTag(): Promise { + await this.disconnect(); + this.rememberedTag = undefined; + await this.saveRememberedTag(undefined); + this.positionPublisher.resetStreamState(); + this.publish({ + ...this.snapshot, + connectionState: "idle", + rememberedTag: undefined, + livePosition: { + connectionState: "idle", + isStale: false, + interpolationActive: false, + }, + rawPosition: undefined, + lastUpdateAt: undefined, + effectiveUpdateRateHz: 0, + error: undefined, + }); + } + + async clearSelectedTag(): Promise { + await this.forgetTag(); + } + + async renameSelectedTag(label: string): Promise { + if (!this.developerModeEnabled) { + throw new Error("Developer Mode is required to rename a tag."); + } + assertValidLabel(label); + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag) throw new Error("Select a tag before changing its name."); + await this.runHardwareOperation(async () => { + const result = await runtime.configuration.applyConfigurationDiff( + tag.id, + { + label, + }, + ); + if ( + result.error || + result.writes.some( + (write) => write.status === "failed" || write.status === "mismatch", + ) + ) { + throw new Error( + result.error?.message ?? "The tag name could not be verified.", + ); + } + this.rememberedTag = (await runtime.repository.getDevice(tag.id)) ?? tag; + this.publish({ ...this.snapshot, rememberedTag: this.rememberedTag }); + }); + } + + async createNetwork(name: string, panId: number): Promise { + const runtime = this.requireDeveloperRuntime(); + const networks = await runtime.repository.listNetworks(); + assertUniqueName( + name, + networks.map((network) => network.name), + ); + assertNetworkProfilePanId(panId); + if (networks.some((network) => network.panId === panId)) { + throw new Error("A network with this PAN ID already exists."); + } + const now = this.now(); + const network = await runtime.repository.saveNetwork({ + id: createLocalId("network"), + name: name.trim(), + panId, + settings: DEFAULT_MANAGED_NETWORK_SETTINGS, + createdAt: now, + updatedAt: now, + }); + await this.refreshNetworksAndDevices(); + return network; + } + + async updateNetwork( + networkId: string, + changes: { readonly name: string; readonly panId: number }, + ): Promise { + const runtime = this.requireDeveloperRuntime(); + const [network, networks] = await Promise.all([ + runtime.repository.getNetwork(networkId), + runtime.repository.listNetworks(), + ]); + if (!network) throw new Error("The selected network no longer exists."); + assertUniqueName( + changes.name, + networks.filter((item) => item.id !== networkId).map((item) => item.name), + ); + assertNetworkProfilePanId(changes.panId); + if ( + networks.some( + (item) => item.id !== networkId && item.panId === changes.panId, + ) + ) { + throw new Error("A network with this PAN ID already exists."); + } + // Profile edits are app-local. Physical nodes change only via explicit assignment. + const saved = await runtime.repository.saveNetwork({ + ...network, + name: changes.name.trim(), + panId: changes.panId, + updatedAt: this.now(), + }); + await this.refreshNetworksAndDevices(); + return saved; + } + + async deleteNetwork(networkId: string): Promise { + const runtime = this.requireDeveloperRuntime(); + const devices = await runtime.repository.listNetworkDevices(networkId); + for (const device of devices) { + await runtime.repository.dissociateDevice( + networkId, + device.id, + this.now(), + ); + } + await runtime.repository.deleteNetwork(networkId); + if (this.settings?.activeNetworkId === networkId) { + await this.saveManagerSettings({ activeNetworkId: undefined }); + } + await this.refreshNetworksAndDevices(); + } + + async setActiveNetwork(networkId: string | undefined): Promise { + const runtime = this.requireDeveloperRuntime(); + if (networkId && !(await runtime.repository.getNetwork(networkId))) { + throw new Error("The selected network no longer exists."); + } + await this.saveManagerSettings({ activeNetworkId: networkId }); + this.publish({ ...this.snapshot, activeNetworkId: networkId }); + } + + async persistDiscoveredAnchor( + transportDeviceId: string, + confirmRoleChange = false, + ): Promise { + const runtime = this.requireDeveloperRuntime(); + const discovery = this.discoveries.find( + (item) => item.transportDeviceId === transportDeviceId, + ); + if ( + !discovery || + discovery.stale || + discovery.compatibility !== "compatible" + ) { + throw new Error("The selected device is no longer available."); + } + const advertisedRole = discovery.presence?.role; + if (advertisedRole !== "anchor" && !confirmRoleChange) { + throw new Error("Confirm changing this device from a tag to an anchor."); + } + const devices = await runtime.repository.listDevices(); + const existing = devices.find( + (device) => device.transportDeviceId === transportDeviceId, + ); + let saved = await runtime.repository.saveDevice({ + ...deviceFromDiscovery(discovery, existing, { + id: existing?.id ?? createLocalId("anchor"), + now: this.now(), + }), + role: advertisedRole ?? existing?.role, + }); + await runtime.discovery.stop(); + await this.runHardwareOperation(async () => { + const inspection = await runtime.configuration.inspectAndCache(saved.id); + if (inspection.operationMode.role !== "anchor") { + if (!confirmRoleChange) { + throw new Error( + "Confirm changing this device from a tag to an anchor.", + ); + } + const result = await runtime.configuration.applyConfigurationDiff( + saved.id, + { + role: "anchor", + uwbMode: "active", + ledEnabled: true, + firmwareUpdateEnabled: true, + initiatorEnabled: false, + }, + ); + if (result.error || result.inspected?.operationMode.role !== "anchor") { + throw new Error( + result.error?.message ?? "The anchor role could not be verified.", + ); + } + const reinspection = await runtime.configuration.inspectAndCache( + saved.id, + ); + if (reinspection.operationMode.role !== "anchor") { + throw new Error( + "The anchor role did not persist after reconnecting.", + ); + } + } + if (this.settings?.activeNetworkId) { + const assignment = + await runtime.commissioning.assignDeviceToNetworkProfile({ + deviceId: saved.id, + targetNetworkId: this.settings.activeNetworkId, + }); + if (assignment.outcome !== "assigned") { + throw new Error( + assignment.error?.message ?? + "The anchor network could not be verified.", + ); + } + } + }); + saved = (await runtime.repository.getDevice(saved.id)) ?? saved; + await this.refreshNetworksAndDevices(); + return saved; + } + + async assignDeviceToActiveNetwork(deviceId: string): Promise { + const runtime = this.requireDeveloperRuntime(); + const targetNetworkId = this.settings?.activeNetworkId; + if (!targetNetworkId) throw new Error("Select an active network first."); + await this.runHardwareOperation(async () => { + const result = await runtime.commissioning.assignDeviceToNetworkProfile({ + deviceId, + targetNetworkId, + }); + if (result.outcome !== "assigned") { + throw new Error(result.error?.message ?? "Network assignment failed."); + } + }); + await this.refreshNetworksAndDevices(); + } + + async setAnchorInitiator(anchorId: string): Promise { + const runtime = this.requireDeveloperRuntime(); + const activeNetworkId = this.settings?.activeNetworkId; + if (!activeNetworkId) throw new Error("Select an active network first."); + const anchors = ( + await runtime.repository.listNetworkDevices(activeNetworkId) + ).filter( + (device) => + device.lastKnownConfig?.role === "anchor" || device.role === "anchor", + ); + const selected = anchors.find((anchor) => anchor.id === anchorId); + if (!selected) + throw new Error("The selected anchor is not in the active network."); + const unreachable: string[] = []; + await this.runHardwareOperation(async () => { + const setResult = await runtime.configuration.applyConfigurationDiff( + anchorId, + { + initiatorEnabled: true, + }, + ); + if ( + setResult.error || + setResult.writes.some( + (write) => write.status === "mismatch" || write.status === "failed", + ) + ) { + throw new Error( + setResult.error?.message ?? "Initiator readback failed.", + ); + } + for (const prior of anchors.filter((anchor) => anchor.id !== anchorId)) { + const reachable = this.discoveries.some( + (item) => + !item.stale && + normalizeTransportDeviceId(item.transportDeviceId) === + normalizeTransportDeviceId(prior.transportDeviceId), + ); + if (!reachable) { + unreachable.push( + prior.lastKnownConfig?.label ?? prior.label ?? prior.id, + ); + continue; + } + const clearResult = await runtime.configuration.applyConfigurationDiff( + prior.id, + { initiatorEnabled: false }, + ); + if ( + clearResult.error || + clearResult.writes.some( + (write) => write.status === "mismatch" || write.status === "failed", + ) + ) { + unreachable.push( + prior.lastKnownConfig?.label ?? prior.label ?? prior.id, + ); + } + } + const verification = + await runtime.configuration.inspectAndCache(anchorId); + if ( + verification.operationMode.role !== "anchor" || + !verification.operationMode.initiatorEnabled + ) { + throw new Error("The selected initiator could not be verified."); + } + }); + await this.refreshNetworksAndDevices(); + this.publish({ + ...this.snapshot, + commissioningWarning: unreachable.length + ? `Initiator set, but ${unreachable.length} prior anchor${unreachable.length === 1 ? " was" : "s were"} unreachable and could not be verified.` + : undefined, + }); + } + + async refreshDiagnostics(): Promise { + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag || this.snapshot.connectionState !== "connected") { + throw new Error( + "Connect the remembered PANS tag before refreshing diagnostics.", + ); + } + try { + const hardwareDiagnostics = await this.runHardwareOperation( + async () => + await runtime.diagnostics.inspect(tag.id, tag.transportDeviceId), + ); + this.publish({ ...this.snapshot, hardwareDiagnostics }); + return hardwareDiagnostics; + } catch (cause) { + const error = normalizeManagerError(cause, { + deviceId: tag.id, + operation: "refresh diagnostics", + }); + this.publish({ ...this.snapshot, error }); + throw error; + } + } + + setForeground(foreground: boolean): void { + this.connectionController.setForeground(foreground); + } + + async dispose(): Promise { + ++this.lifecycleGeneration; + this.connectionController.dispose(); + this.positionPublisher.dispose(); + this.removeRuntimeListeners(); + const runtime = this.runtime; + this.runtime = undefined; + if (runtime) { + await runtime.stream.stop().catch(() => undefined); + await runtime.close(); + } + } + + getRuntime(): MobilePansRuntime { + return this.requireRuntime(); + } + + async refreshCachedAnchors(): Promise { + const runtime = this.requireRuntime(); + const devices = await runtime.repository.listDevices(); + const knownAnchors = sortedCachedAnchors(devices); + this.publish({ ...this.snapshot, knownAnchors }); + return knownAnchors; + } + + async renameAnchor(anchorId: string, label: string): Promise { + if (!this.developerModeEnabled) { + throw new ManagerError( + "INVALID_CONFIGURATION", + "Enable Developer Mode before renaming anchors.", + ); + } + const requestedLabel = label.trim(); + assertValidLabel(requestedLabel); + const runtime = this.requireRuntime(); + const anchor = await runtime.repository.getDevice(anchorId); + if ( + !anchor || + (anchor.role !== "anchor" && anchor.lastKnownConfig?.role !== "anchor") + ) { + throw new Error("The selected cached anchor does not exist."); + } + try { + await this.runHardwareOperation(async () => { + const result = await runtime.configuration.applyConfigurationDiff( + anchor.id, + { label: requestedLabel }, + ); + const write = result.writes.find((item) => item.field === "label"); + if ( + result.error || + write?.status === "failed" || + write?.status === "mismatch" + ) { + throw new ManagerError( + result.error?.code ?? "WRITE_FAILED", + result.error?.message ?? "The anchor name could not be verified.", + { deviceId: anchor.id, operation: "rename anchor" }, + ); + } + }); + await this.refreshCachedAnchors(); + return (await runtime.repository.getDevice(anchor.id)) ?? anchor; + } catch (cause) { + throw normalizeManagerError(cause, { + deviceId: anchor.id, + operation: "rename anchor", + }); + } + } + + async writeAnchorPosition( + anchorId: string, + position: AnchorFieldPosition, + ): Promise { + if (this.anchorWritePromise) { + throw new ManagerError( + "OPERATION_CANCELLED", + "An anchor position write is already in progress.", + ); + } + const operation = this.performAnchorPositionWrite(anchorId, position); + const tracked = operation.finally(() => { + if (this.anchorWritePromise === tracked) + this.anchorWritePromise = undefined; + }); + this.anchorWritePromise = tracked; + return await tracked; + } + + private async performAnchorPositionWrite( + anchorId: string, + position: AnchorFieldPosition, + ): Promise { + const runtime = this.requireRuntime(); + const anchor = await runtime.repository.getDevice(anchorId); + if ( + !anchor || + (anchor.role !== "anchor" && anchor.lastKnownConfig?.role !== "anchor") + ) { + throw new Error("The selected cached anchor does not exist."); + } + const cachedPosition = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig.position + : undefined; + if ( + cachedPosition?.xMeters === position.xMeters && + cachedPosition.yMeters === position.yMeters && + cachedPosition.zMeters === position.zMeters && + cachedPosition.quality === 100 + ) { + return; + } + const activeNetworkMatch = + this.developerModeEnabled && + this.settings?.activeNetworkId !== undefined && + anchor.networkId === this.settings.activeNetworkId; + const connectedTagMatch = Boolean( + this.rememberedTag && + this.snapshot.connectionState === "connected" && + areDevicesNetworkAssociated(this.rememberedTag, anchor), + ); + if (!activeNetworkMatch && !connectedTagMatch) { + throw new ManagerError( + "INVALID_CONFIGURATION", + "The anchor is not verified on the active network.", + { deviceId: anchor.id, operation: "write anchor position" }, + ); + } + try { + await this.runHardwareOperation(async () => { + const result = await runtime.configuration.applyConfigurationDiff( + anchor.id, + { + position: { ...position, quality: 100 }, + }, + ); + const write = result.writes.find((item) => item.field === "position"); + if (result.error || write?.status !== "written-unverified") { + throw new ManagerError( + result.error?.code ?? "WRITE_FAILED", + result.error?.message ?? "The anchor rejected the position write.", + { deviceId: anchor.id, operation: "write anchor position" }, + ); + } + }); + await this.refreshCachedAnchors(); + } catch (cause) { + const error = normalizeManagerError(cause, { + deviceId: anchor.id, + operation: "write anchor position", + }); + throw error; + } + } + + private async prepareSelectedTagForStreaming(): Promise { + const runtime = this.requireRuntime(); + const tag = this.rememberedTag; + if (!tag) throw new Error("Select a tag before connecting."); + await this.runHardwareOperation(async () => { + const inspection = await runtime.configuration.inspectAndCache(tag.id); + const profileChanges = diffPerformerTagProfile(inspection); + let reconnectVerificationRequired = false; + let locationModeWrittenUnverified = false; + if (Object.keys(profileChanges).length > 0) { + const configured = await runtime.configuration.applyConfigurationDiff( + tag.id, + profileChanges, + ); + if ( + configured.error || + configured.writes.some( + (write) => write.status === "failed" || write.status === "mismatch", + ) + ) { + throw new Error( + configured.error?.message ?? + "The performer tag profile could not be verified.", + ); + } + reconnectVerificationRequired = configured.writes.length > 0; + locationModeWrittenUnverified = configured.writes.some( + (write) => + write.field === "locationDataMode" && + write.status === "written-unverified", + ); + if (configured.inspected) { + const remaining = diffPerformerTagProfile(configured.inspected); + if ( + configured.inspected.locationDataMode === undefined && + locationModeWrittenUnverified + ) { + delete remaining.locationDataMode; + } + if (Object.keys(remaining).length > 0) { + throw new Error( + "The performer tag profile readback did not match.", + ); + } + } + } + const activeNetworkId = this.developerModeEnabled + ? this.settings?.activeNetworkId + : undefined; + if (activeNetworkId) { + const assignment = + await runtime.commissioning.assignDeviceToNetworkProfile({ + deviceId: tag.id, + targetNetworkId: activeNetworkId, + }); + if (assignment.outcome !== "assigned") { + throw new Error( + assignment.error?.message ?? + "The tag network could not be verified.", + ); + } + reconnectVerificationRequired = + reconnectVerificationRequired || + Boolean(assignment.configuration?.writes.length); + } + if (reconnectVerificationRequired) { + const reinspection = await runtime.configuration.inspectAndCache( + tag.id, + ); + const remaining = diffPerformerTagProfile(reinspection); + if ( + reinspection.locationDataMode === undefined && + locationModeWrittenUnverified + ) { + delete remaining.locationDataMode; + } + if (Object.keys(remaining).length > 0) { + throw new Error( + "The performer tag profile did not persist after reconnecting.", + ); + } + if (activeNetworkId) { + const activeNetwork = + await runtime.repository.getNetwork(activeNetworkId); + if (activeNetwork && reinspection.panId !== activeNetwork.panId) { + throw new Error( + "The active network did not persist after reconnecting.", + ); + } + } + } + // The native PANS gateway exposes no hardware-reset command. Closing the + // serialized configuration session here and opening the stream session + // below provides the required reconnect/readback boundary. + this.rememberedTag = + (await runtime.repository.getDevice(tag.id)) ?? this.rememberedTag; + }); + this.publish({ ...this.snapshot, rememberedTag: this.rememberedTag }); + } + + private async runHardwareOperation(action: () => Promise): Promise { + if (this.hardwareOperationPromise) { + throw new ManagerError( + "OPERATION_CANCELLED", + "Another PANS hardware operation is already in progress.", + ); + } + const wasConnected = this.snapshot.connectionState === "connected"; + const operation = (async () => { + if (wasConnected) await this.connectionController.pauseForOperation(); + try { + return await action(); + } finally { + if (wasConnected) { + await this.connectionController + .resumeAfterOperation() + .catch(() => undefined); + } + } + })(); + const tracked = operation.finally(() => { + if (this.hardwareOperationPromise === tracked) { + this.hardwareOperationPromise = undefined; + } + }); + this.hardwareOperationPromise = tracked; + return await tracked; + } + + private async refreshNetworksAndDevices(): Promise { + const runtime = this.requireRuntime(); + const [networks, devices] = await Promise.all([ + runtime.repository.listNetworks(), + runtime.repository.listDevices(), + ]); + this.publish({ + ...this.snapshot, + networks, + knownAnchors: sortedCachedAnchors(devices), + activeNetworkId: this.settings?.activeNetworkId, + }); + } + + private installRuntimeListeners( + runtime: MobilePansRuntime, + generation: number, + ): void { + this.discoverySubscription = runtime.discovery.subscribe((discoveries) => { + if (!this.isLifecycleCurrent(generation)) return; + this.discoveries = discoveries; + this.publish({ ...this.snapshot, discoveries }); + }); + this.discoveryErrorSubscription = runtime.discovery.subscribeErrors( + (error) => { + if (this.isLifecycleCurrent(generation)) { + this.publish({ ...this.snapshot, error }); + if (this.snapshot.connectionState === "scanning") { + this.publishState("error", { error }); + } + } + }, + ); + this.discoveryStateSubscription = runtime.discovery.subscribeState( + (state) => { + if (!this.isLifecycleCurrent(generation)) return; + if (state === "error" && this.snapshot.connectionState === "scanning") { + this.publishState("error"); + } else if ( + state === "idle" && + this.snapshot.connectionState === "scanning" && + !this.connectionController.isConnecting + ) { + this.publishState(this.rememberedTag ? "disconnected" : "idle"); + } + }, + ); + this.connectionSubscription = runtime.sessions.addConnectionStateListener( + (event) => { + if (this.isLifecycleCurrent(generation)) { + this.connectionController.receiveConnectionEvent(event); + } + }, + ); + } + + private async saveRememberedTag(deviceId: string | undefined): Promise { + await this.saveManagerSettings({ rememberedTagDeviceId: deviceId }); + } + + private async saveManagerSettings( + changes: Partial, + ): Promise { + const runtime = this.runtime; + if (!runtime) throw new Error("PANS services are not ready."); + const candidate: Partial = { + ...this.settings, + ...changes, + }; + if ( + Object.prototype.hasOwnProperty.call(changes, "rememberedTagDeviceId") && + changes.rememberedTagDeviceId === undefined + ) { + delete candidate.rememberedTagDeviceId; + } + if ( + Object.prototype.hasOwnProperty.call(changes, "activeNetworkId") && + changes.activeNetworkId === undefined + ) { + delete candidate.activeNetworkId; + } + this.settings = normalizePansManagerSettings(candidate); + await runtime.repository.saveSettings(this.settings); + } + + private publishState( + connectionState: TagConnectionState, + changes: Partial = {}, + ): void { + const fieldState = fieldConnectionState(connectionState); + this.publish({ + ...this.snapshot, + ...changes, + connectionState, + rememberedTag: changes.rememberedTag ?? this.rememberedTag, + discoveries: this.discoveries, + livePosition: + changes.livePosition ?? + ({ + ...this.snapshot.livePosition, + connectionState: fieldState, + } as const), + }); + } + + private publish(snapshot: MobilePansSnapshot): void { + this.snapshot = Object.freeze(snapshot); + for (const listener of this.listeners) listener(); + } + + private isLifecycleCurrent(generation: number): boolean { + return generation === this.lifecycleGeneration; + } + + private requireRuntime(): MobilePansRuntime { + if (!this.runtime || this.snapshot.initialization !== "ready") { + throw new Error("PANS services are not ready."); + } + return this.runtime; + } + + private requireDeveloperRuntime(): MobilePansRuntime { + if (!this.developerModeEnabled) { + throw new Error("Developer Mode is required for network commissioning."); + } + return this.requireRuntime(); + } + + private removeRuntimeListeners(): void { + this.discoverySubscription?.remove(); + this.discoveryErrorSubscription?.remove(); + this.discoveryStateSubscription?.remove(); + this.connectionSubscription?.remove(); + this.discoverySubscription = undefined; + this.discoveryErrorSubscription = undefined; + this.discoveryStateSubscription = undefined; + this.connectionSubscription = undefined; + } +} diff --git a/apps/mobile/src/pans/mobile-pans-ui.ts b/apps/mobile/src/pans/mobile-pans-ui.ts new file mode 100644 index 00000000..afb0c43f --- /dev/null +++ b/apps/mobile/src/pans/mobile-pans-ui.ts @@ -0,0 +1,95 @@ +import type { DiscoveredDeviceSnapshot } from "@eight2five/mobile/pans-manager"; + +import type { TagConnectionState } from "./mobile-pans-model"; + +export type ConnectionStatusIcon = + | "connected" + | "searching" + | "connecting" + | "disconnected" + | "error"; + +export interface ConnectionStatusViewModel { + readonly label: string; + readonly icon: ConnectionStatusIcon; + readonly tone: "success" | "accent" | "muted" | "danger"; + readonly animated: boolean; +} + +export function connectionStatusViewModel( + state: TagConnectionState, +): ConnectionStatusViewModel { + switch (state) { + case "connected": + return { + label: "Connected", + icon: "connected", + tone: "success", + animated: false, + }; + case "scanning": + return { + label: "Searching", + icon: "searching", + tone: "accent", + animated: true, + }; + case "connecting": + return { + label: "Connecting", + icon: "connecting", + tone: "accent", + animated: false, + }; + case "reconnecting": + return { + label: "Reconnecting", + icon: "connecting", + tone: "accent", + animated: false, + }; + case "error": + return { + label: "Connection error", + icon: "error", + tone: "danger", + animated: false, + }; + case "idle": + case "disconnected": + return { + label: "Disconnected", + icon: "disconnected", + tone: "muted", + animated: false, + }; + } +} + +export type SignalStrength = "full" | "high" | "medium" | "low"; + +export function signalStrengthForRssi( + rssi: number, + cutoff: number, +): SignalStrength { + const aboveCutoff = rssi - cutoff; + if (aboveCutoff >= 40) return "full"; + if (aboveCutoff >= 25) return "high"; + if (aboveCutoff >= 10) return "medium"; + return "low"; +} + +export function selectVisibleDiscoveries( + discoveries: readonly DiscoveredDeviceSnapshot[], + options: { readonly developerMode: boolean; readonly cutoff: number }, +): readonly DiscoveredDeviceSnapshot[] { + return discoveries + .filter( + (device) => + !device.stale && + device.compatibility === "compatible" && + device.rssi >= options.cutoff && + (options.developerMode || device.presence?.role === "tag"), + ) + .sort((left, right) => right.rssi - left.rssi); +} diff --git a/apps/mobile/src/pans/pans-anchor-cache.ts b/apps/mobile/src/pans/pans-anchor-cache.ts new file mode 100644 index 00000000..6d557897 --- /dev/null +++ b/apps/mobile/src/pans/pans-anchor-cache.ts @@ -0,0 +1,61 @@ +import type { FieldAnchorGeometry } from "@eight2five/mobile/field"; +import type { ManagedDevice } from "@eight2five/mobile/pans-manager"; + +/** + * Selects only anchors explicitly associated with the remembered tag's saved + * network, falling back to a verified cached PAN ID when no profile exists. + */ +export function selectNetworkAnchors( + rememberedTag: ManagedDevice | undefined, + knownAnchors: readonly ManagedDevice[], +): readonly ManagedDevice[] { + if (!rememberedTag) return []; + if (rememberedTag.networkId) { + return knownAnchors.filter( + (anchor) => anchor.networkId === rememberedTag.networkId, + ); + } + const panId = rememberedTag.lastKnownConfig?.panId; + if (panId === undefined) return []; + return knownAnchors.filter( + (anchor) => anchor.lastKnownConfig?.panId === panId, + ); +} + +export function areDevicesNetworkAssociated( + tag: ManagedDevice, + anchor: ManagedDevice, +): boolean { + if (tag.networkId) return anchor.networkId === tag.networkId; + const tagPanId = tag.lastKnownConfig?.panId; + return tagPanId !== undefined && anchor.lastKnownConfig?.panId === tagPanId; +} + +export function cachedAnchorGeometry( + rememberedTag: ManagedDevice | undefined, + knownAnchors: readonly ManagedDevice[], +): readonly FieldAnchorGeometry[] { + return selectNetworkAnchors(rememberedTag, knownAnchors).flatMap((anchor) => { + const position = + anchor.lastKnownConfig?.role === "anchor" + ? anchor.lastKnownConfig.position + : undefined; + if ( + !position || + !Number.isFinite(position.xMeters) || + !Number.isFinite(position.yMeters) + ) { + return []; + } + return [ + { + id: anchor.id, + position: { + xMeters: position.xMeters, + yMeters: position.yMeters, + zMeters: position.zMeters, + }, + }, + ]; + }); +} diff --git a/apps/mobile/src/state/__tests__/app-settings-store.test.ts b/apps/mobile/src/state/__tests__/app-settings-store.test.ts new file mode 100644 index 00000000..09bb6611 --- /dev/null +++ b/apps/mobile/src/state/__tests__/app-settings-store.test.ts @@ -0,0 +1,176 @@ +import { + DEFAULT_APP_SETTINGS, + type AppSettings, +} from "@eight2five/mobile/settings"; +import type { OpenMobileRepositoriesResult } from "@eight2five/mobile/storage"; + +import { AppSettingsStore } from "../app-settings-store"; +import { selectFieldSession } from "../field-session-store"; + +describe("AppSettingsStore", () => { + test("hydrates, serializes updates, resets preferences, and closes storage", async () => { + let settings: AppSettings = { + ...DEFAULT_APP_SETTINGS, + drillTerminology: "sets", + activeDrillId: "drill-1", + selectedDrillPageId: "page-1", + }; + const close = jest.fn(async () => undefined); + const settingsRepository = { + load: jest.fn(async () => settings), + update: jest.fn(async (partial) => { + settings = { ...settings, ...partial }; + return settings; + }), + resetPreferences: jest.fn(async () => { + settings = { + ...DEFAULT_APP_SETTINGS, + activeDrillId: settings.activeDrillId, + selectedDrillPageId: settings.selectedDrillPageId, + }; + return settings; + }), + }; + const drillRepository = { + setActiveDrill: jest.fn(async (id: string | null) => { + settings = { + ...settings, + activeDrillId: id, + selectedDrillPageId: null, + }; + return settings; + }), + setSelectedDrillPage: jest.fn(async (id: string | null) => { + settings = { ...settings, selectedDrillPageId: id }; + return settings; + }), + }; + const storage = { + settingsRepository, + drillRepository, + close, + } as unknown as OpenMobileRepositoriesResult; + const store = new AppSettingsStore(async () => storage); + const listener = jest.fn(); + store.subscribe(listener); + + await store.initialize(); + expect(store.getSnapshot()).toMatchObject({ + status: "ready", + settings: { drillTerminology: "sets", activeDrillId: "drill-1" }, + }); + + await Promise.all([ + store.update({ guidanceEnabled: false }), + store.update({ appearanceMode: "dark" }), + ]); + expect(settingsRepository.update.mock.calls).toEqual([ + [{ guidanceEnabled: false }], + [{ appearanceMode: "dark" }], + ]); + + await store.resetPreferences(); + expect(store.getSnapshot().settings).toEqual({ + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-1", + }); + expect(listener).toHaveBeenCalled(); + + await store.dispose(); + expect(close).toHaveBeenCalledTimes(1); + }); + + test("publishes initialization errors and rejects writes before ready", async () => { + const store = new AppSettingsStore(async () => { + throw new Error("open failed"); + }); + await store.initialize(); + expect(store.getSnapshot()).toMatchObject({ + status: "error", + error: new Error("open failed"), + }); + await expect(store.update({ guidanceEnabled: false })).rejects.toThrow( + "not ready", + ); + }); + + test("can rebuild the app database after an initialization failure", async () => { + const close = jest.fn(async () => undefined); + const storage = { + settingsRepository: { + load: jest.fn(async () => DEFAULT_APP_SETTINGS), + update: jest.fn(), + resetPreferences: jest.fn(), + }, + drillRepository: {}, + close, + } as unknown as OpenMobileRepositoriesResult; + const openStorage = jest + .fn, []>() + .mockRejectedValueOnce(new Error("schema failed")) + .mockResolvedValueOnce(storage); + const deleteStorage = jest.fn(async () => undefined); + const store = new AppSettingsStore(openStorage, deleteStorage); + + await store.initialize(); + expect(store.getSnapshot().status).toBe("error"); + + const settings = await store.rebuildDatabase(); + + expect(deleteStorage).toHaveBeenCalledTimes(1); + expect(openStorage).toHaveBeenCalledTimes(2); + expect(settings).toEqual(DEFAULT_APP_SETTINGS); + expect(store.getSnapshot()).toEqual({ + status: "ready", + settings: DEFAULT_APP_SETTINGS, + }); + + await store.dispose(); + expect(close).toHaveBeenCalledTimes(1); + }); + + test("waits for queued writes before closing storage", async () => { + let releaseUpdate!: () => void; + const close = jest.fn(async () => undefined); + const storage = { + settingsRepository: { + load: jest.fn(async () => DEFAULT_APP_SETTINGS), + update: jest.fn( + async () => + await new Promise((resolve) => { + releaseUpdate = () => resolve(DEFAULT_APP_SETTINGS); + }), + ), + resetPreferences: jest.fn(async () => DEFAULT_APP_SETTINGS), + }, + drillRepository: {}, + close, + } as unknown as OpenMobileRepositoriesResult; + const store = new AppSettingsStore(async () => storage); + await store.initialize(); + + const update = store.update({ guidanceEnabled: false }); + await Promise.resolve(); + const disposal = store.dispose(); + expect(close).not.toHaveBeenCalled(); + + releaseUpdate(); + await update; + await disposal; + expect(close).toHaveBeenCalledTimes(1); + }); + + test("derives the persisted field session contract", () => { + expect( + selectFieldSession({ + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }), + ).toEqual({ + activeDrillId: "drill-1", + selectedDrillPageId: "page-2", + }); + }); +}); diff --git a/apps/mobile/src/state/__tests__/appearance-theme.test.ts b/apps/mobile/src/state/__tests__/appearance-theme.test.ts new file mode 100644 index 00000000..8b3f48a6 --- /dev/null +++ b/apps/mobile/src/state/__tests__/appearance-theme.test.ts @@ -0,0 +1,42 @@ +/// + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { + eight2FiveDrillColors, + eight2FiveThemes, + resolveEight2FiveThemeName, +} from "@eight2five/ui/theme"; +import { COLOR_PRESETS } from "@eight2five/drill-schema"; + +describe("app appearance", () => { + test("uses the OS appearance only in system mode", () => { + expect(resolveEight2FiveThemeName("system", "light")).toBe("light"); + expect(resolveEight2FiveThemeName("system", "dark")).toBe("dark"); + expect(resolveEight2FiveThemeName("system", null)).toBe("light"); + expect(resolveEight2FiveThemeName("system", "unspecified")).toBe("light"); + }); + + test("light and dark modes override the OS appearance", () => { + expect(resolveEight2FiveThemeName("light", "dark")).toBe("light"); + expect(resolveEight2FiveThemeName("dark", "light")).toBe("dark"); + expect(eight2FiveThemes.light.background).not.toBe( + eight2FiveThemes.dark.background, + ); + }); + + test("shares the drill color presets with the UI theme", () => { + expect(eight2FiveDrillColors).toBe(COLOR_PRESETS); + expect(eight2FiveThemes.light.accent).toBe(COLOR_PRESETS.blue); + expect(eight2FiveThemes.dark.accent).toBe(COLOR_PRESETS.blue); + + const css = readFileSync( + path.resolve(__dirname, "../../../../../packages/ui/theme/theme.css"), + "utf8", + ); + const red = Number.parseInt(COLOR_PRESETS.blue.slice(1, 3), 16); + const green = Number.parseInt(COLOR_PRESETS.blue.slice(3, 5), 16); + const blue = Number.parseInt(COLOR_PRESETS.blue.slice(5, 7), 16); + expect(css).toContain(`--eight2five-drill-blue: ${red} ${green} ${blue};`); + }); +}); diff --git a/apps/mobile/src/state/app-settings-store.tsx b/apps/mobile/src/state/app-settings-store.tsx new file mode 100644 index 00000000..977eb3d6 --- /dev/null +++ b/apps/mobile/src/state/app-settings-store.tsx @@ -0,0 +1,258 @@ +import React from "react"; +import { + DEFAULT_APP_SETTINGS, + type AppSettings, + type AppSettingsUpdate, +} from "@eight2five/mobile/settings"; +import { + deleteMobileDatabase, + openMobileRepositories, + type OpenMobileRepositoriesResult, +} from "@eight2five/mobile/storage"; + +export type AppSettingsStoreStatus = "loading" | "ready" | "error"; + +export interface AppSettingsStoreSnapshot { + readonly status: AppSettingsStoreStatus; + readonly settings: AppSettings; + readonly error?: Error; +} + +export type OpenAppSettingsStorage = + () => Promise; +export type DeleteAppSettingsStorage = () => Promise; + +const INITIAL_SNAPSHOT: AppSettingsStoreSnapshot = Object.freeze({ + status: "loading", + settings: DEFAULT_APP_SETTINGS, +}); + +/** Owns the app database lifecycle and publishes one stable settings snapshot. */ +export class AppSettingsStore { + private snapshot: AppSettingsStoreSnapshot = INITIAL_SNAPSHOT; + private readonly listeners = new Set<() => void>(); + private storage?: OpenMobileRepositoriesResult; + private lifecycleGeneration = 0; + private writeQueue: Promise = Promise.resolve(); + + constructor( + private readonly openStorage: OpenAppSettingsStorage = () => + openMobileRepositories(), + private readonly deleteStorage: DeleteAppSettingsStorage = () => + deleteMobileDatabase(), + ) {} + + readonly getSnapshot = (): AppSettingsStoreSnapshot => this.snapshot; + + readonly subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + async initialize(): Promise { + const generation = ++this.lifecycleGeneration; + this.publish(INITIAL_SNAPSHOT); + const previousStorage = this.storage; + this.storage = undefined; + if (previousStorage) { + await this.writeQueue; + await closeStorageQuietly(previousStorage); + if (generation !== this.lifecycleGeneration) return; + } + let storage: OpenMobileRepositoriesResult | undefined; + try { + storage = await this.openStorage(); + if (generation !== this.lifecycleGeneration) { + await closeStorageQuietly(storage); + return; + } + const settings = await storage.settingsRepository.load(); + if (generation !== this.lifecycleGeneration) { + await closeStorageQuietly(storage); + return; + } + this.storage = storage; + this.publish(Object.freeze({ status: "ready", settings })); + } catch (cause) { + if (storage && storage !== this.storage) + await closeStorageQuietly(storage); + if (generation !== this.lifecycleGeneration) return; + this.publish( + Object.freeze({ + status: "error", + settings: this.snapshot.settings, + error: toError(cause), + }), + ); + } + } + + async update(partial: AppSettingsUpdate): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.settingsRepository.update(partial); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async resetPreferences(): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.settingsRepository.resetPreferences(); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async setActiveDrill(id: string | null): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.drillRepository.setActiveDrill(id); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + async setSelectedDrillSet(id: string | null): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.drillRepository.setSelectedDrillSet(id); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + /** @deprecated Use setSelectedDrillSet. */ + async setSelectedDrillPage(id: string | null): Promise { + return await this.setSelectedDrillSet(id); + } + + async reload(): Promise { + return await this.enqueue(async (storage) => { + const settings = await storage.settingsRepository.load(); + this.publish(Object.freeze({ status: "ready", settings })); + return settings; + }); + } + + /** + * Destructively rebuild the disposable app database from the current schema. + * This intentionally does not touch the separate PANS manager database. + */ + async rebuildDatabase(): Promise { + this.publish(INITIAL_SNAPSHOT); + try { + await this.dispose(); + await this.deleteStorage(); + await this.initialize(); + const snapshot = this.snapshot; + if (snapshot.status !== "ready") { + throw snapshot.error ?? new Error("App database rebuild failed."); + } + return snapshot.settings; + } catch (cause) { + const error = toError(cause); + this.publish( + Object.freeze({ + status: "error", + settings: DEFAULT_APP_SETTINGS, + error, + }), + ); + throw error; + } + } + + getDrillRepository() { + return this.requireStorage().drillRepository; + } + + async dispose(): Promise { + this.lifecycleGeneration += 1; + const storage = this.storage; + this.storage = undefined; + await this.writeQueue; + if (storage) await storage.close(); + } + + private async enqueue( + operation: (storage: OpenMobileRepositoriesResult) => Promise, + ): Promise { + const storage = this.requireStorage(); + const result = this.writeQueue.then(() => operation(storage)); + this.writeQueue = result.then( + () => undefined, + () => undefined, + ); + return await result; + } + + private requireStorage(): OpenMobileRepositoriesResult { + if (!this.storage || this.snapshot.status !== "ready") { + throw new Error("App settings storage is not ready."); + } + return this.storage; + } + + private publish(snapshot: AppSettingsStoreSnapshot): void { + if (this.snapshot === snapshot) return; + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +const AppSettingsStoreContext = React.createContext( + null, +); + +export function AppSettingsProvider({ + children, + store: injectedStore, +}: { + children: React.ReactNode; + store?: AppSettingsStore; +}) { + const [ownedStore] = React.useState(() => new AppSettingsStore()); + const store = injectedStore ?? ownedStore; + + React.useEffect(() => { + void store.initialize(); + return () => void store.dispose(); + }, [store]); + + return ( + + {children} + + ); +} + +export function useAppSettingsStore(): AppSettingsStore { + const store = React.useContext(AppSettingsStoreContext); + if (!store) { + throw new Error( + "useAppSettingsStore must be used inside AppSettingsProvider.", + ); + } + return store; +} + +export function useAppSettingsSnapshot(): AppSettingsStoreSnapshot { + const store = useAppSettingsStore(); + return React.useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +async function closeStorageQuietly( + storage: OpenMobileRepositoriesResult, +): Promise { + try { + await storage.close(); + } catch { + // Preserve initialization errors; disposal reports its own close failure. + } +} diff --git a/apps/mobile/src/state/field-session-store.ts b/apps/mobile/src/state/field-session-store.ts new file mode 100644 index 00000000..de89c0bb --- /dev/null +++ b/apps/mobile/src/state/field-session-store.ts @@ -0,0 +1,40 @@ +import React from "react"; +import type { AppSettings } from "@eight2five/mobile/settings"; + +import { + useAppSettingsSnapshot, + useAppSettingsStore, +} from "./app-settings-store"; + +export interface FieldSessionSelection { + readonly activeDrillId: string | null; + readonly selectedDrillPageId: string | null; +} + +export function selectFieldSession( + settings: AppSettings, +): FieldSessionSelection { + return { + activeDrillId: settings.activeDrillId, + selectedDrillPageId: settings.selectedDrillPageId, + }; +} + +/** Persisted field-session selection facade used by Field and Drill screens. */ +export function useFieldSession() { + const store = useAppSettingsStore(); + const { status, settings } = useAppSettingsSnapshot(); + const selection = selectFieldSession(settings); + + return React.useMemo( + () => ({ + status, + activeDrillId: selection.activeDrillId, + selectedDrillPageId: selection.selectedDrillPageId, + setActiveDrill: (id: string | null) => store.setActiveDrill(id), + setSelectedDrillPage: (id: string | null) => + store.setSelectedDrillPage(id), + }), + [selection.activeDrillId, selection.selectedDrillPageId, status, store], + ); +} diff --git a/apps/testbed/app.config.ts b/apps/testbed/app.config.ts index b23b0fbd..c7f9a060 100644 --- a/apps/testbed/app.config.ts +++ b/apps/testbed/app.config.ts @@ -19,7 +19,7 @@ const config: ExpoConfig = { slug: "eight2five-testbed", scheme: "eight2five-testbed", platforms: ["ios", "android"], - version: "0.0.0", + version: "0.1.0", orientation: "portrait", icon: "./assets/app-icons/testbed-android-legacy-icon.png", userInterfaceStyle: "automatic", @@ -47,6 +47,15 @@ const config: ExpoConfig = { }, plugins: [ "expo-router", + [ + "expo-build-properties", + { + buildReactNativeFromSource: false, + ios: { + ccacheEnabled: true, + }, + }, + ], [ "expo-splash-screen", { diff --git a/apps/testbed/app/(tabs)/_layout.tsx b/apps/testbed/app/(tabs)/_layout.tsx index aa79ffc7..ec1657b6 100644 --- a/apps/testbed/app/(tabs)/_layout.tsx +++ b/apps/testbed/app/(tabs)/_layout.tsx @@ -1,5 +1,5 @@ import { NativeTabs } from "expo-router/unstable-native-tabs"; -import { useEight2FiveTheme } from "@eight2five/ui/theme"; +import { eight2FiveFonts, useEight2FiveTheme } from "@eight2five/ui/theme"; import { MANAGER_TABS } from "../../src/pans-manager/manager-tabs"; @@ -10,6 +10,7 @@ export default function TestbedTabsLayout() { {MANAGER_TABS.map((tab) => ( diff --git a/apps/testbed/eas.json b/apps/testbed/eas.json index f8e38578..e95e51b3 100644 --- a/apps/testbed/eas.json +++ b/apps/testbed/eas.json @@ -13,7 +13,7 @@ "android": { "buildType": "apk", "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false" + "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" } } }, @@ -32,7 +32,7 @@ "android": { "buildType": "apk", "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false" + "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" } } }, @@ -41,19 +41,6 @@ "simulator": true, "resourceClass": "m-medium" } - }, - "production": { - "ios": { - "resourceClass": "m-medium" - }, - "android": { - "env": { - "GRADLE_OPTS": "-Dorg.gradle.jvmargs=\"-Xmx4g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8\" -Dorg.gradle.daemon=false -Dorg.gradle.parallel=false" - } - } } - }, - "submit": { - "production": {} } } diff --git a/apps/testbed/jest.setup.ts b/apps/testbed/jest.setup.ts index 3b82518b..8dae30b9 100644 --- a/apps/testbed/jest.setup.ts +++ b/apps/testbed/jest.setup.ts @@ -68,9 +68,17 @@ jest.mock( Group: MockSkiaNode, Line: MockSkiaNode, Path: MockSkiaNode, + Text: MockSkiaNode, Circle: MockSkiaNode, LinearGradient: MockSkiaNode, - useFont: () => ({}), + useFont: () => ({ + measureText: (text: string) => ({ + x: 0, + y: -0.75, + width: text.length * 0.6, + height: 0.75, + }), + }), vec: (x: number, y: number) => ({ x, y }), }; }, diff --git a/apps/testbed/package.json b/apps/testbed/package.json index f9e77719..ba2e8832 100644 --- a/apps/testbed/package.json +++ b/apps/testbed/package.json @@ -1,6 +1,6 @@ { "name": "eight2five-testbed", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "expo-router/entry", "scripts": { @@ -20,9 +20,9 @@ "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "expo": "~57.0.9", + "expo": "~57.0.10", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" diff --git a/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx b/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx index d10dcb42..e23f20c3 100644 --- a/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx +++ b/apps/testbed/src/pans-manager/__tests__/manager-provider.test.tsx @@ -1171,6 +1171,7 @@ describe("PansManagerProvider", () => { connectionTimeoutMs: 10_000, positionLogMemoryCap: 1_000, positionLogFlushSize: 100, + discoveryRssiCutoff: -75, }); }); expect(settingsRenders).toBe(settingsBeforeSave + 1); diff --git a/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx b/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx index 53954542..a18edf44 100644 --- a/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx +++ b/apps/testbed/src/pans-manager/__tests__/manager-settings-screen.test.tsx @@ -153,5 +153,6 @@ function settings( connectionTimeoutMs, positionLogMemoryCap, positionLogFlushSize, + discoveryRssiCutoff: -75, }; } diff --git a/apps/testbed/src/pans-manager/manager-context.tsx b/apps/testbed/src/pans-manager/manager-context.tsx index 400fa438..1d0a8307 100644 --- a/apps/testbed/src/pans-manager/manager-context.tsx +++ b/apps/testbed/src/pans-manager/manager-context.tsx @@ -1669,6 +1669,7 @@ function managerSettingsWithDefaults( connectionTimeoutMs: 10_000, positionLogMemoryCap: 1_000, positionLogFlushSize: 100, + discoveryRssiCutoff: -75, ...compatible, }; } diff --git a/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx b/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx index e4176386..c1a00e2f 100644 --- a/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx +++ b/apps/testbed/src/pans-manager/screens/manager-settings-screen.tsx @@ -65,6 +65,7 @@ export function ManagerSettingsScreen() { } try { await saveManagerSettings({ + ...settings, discoveryStaleAfterMs: values[0], connectionTimeoutMs: values[1], positionLogMemoryCap: values[2], diff --git a/modules/expo-pans-ble-api/android/build.gradle b/modules/expo-pans-ble-api/android/build.gradle index ee734af1..09f2cf9f 100644 --- a/modules/expo-pans-ble-api/android/build.gradle +++ b/modules/expo-pans-ble-api/android/build.gradle @@ -6,7 +6,7 @@ android { defaultConfig { versionCode 1 - versionName "0.0.0" + versionName "0.1.0" consumerProguardFiles 'consumer-rules.pro' } diff --git a/modules/expo-pans-ble-api/package.json b/modules/expo-pans-ble-api/package.json index 57941165..d61eaf4d 100644 --- a/modules/expo-pans-ble-api/package.json +++ b/modules/expo-pans-ble-api/package.json @@ -1,6 +1,6 @@ { "name": "expo-pans-ble-api", - "version": "0.0.0", + "version": "0.1.0", "main": "index.ts", "types": "index.ts", "scripts": { diff --git a/package-lock.json b/package-lock.json index fe65f91f..a91ceb13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "eight2five", - "version": "0.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "eight2five", - "version": "0.0.0", + "version": "0.1.0", "workspaces": [ "apps/*", "packages/*", @@ -21,15 +21,85 @@ "typescript": "~6.0.3" } }, + "apps/drill-converter": { + "name": "eight2five-drill-converter", + "version": "0.1.0", + "dependencies": { + "@eight2five/drill-importers": "0.1.0", + "@eight2five/drill-schema": "0.1.0", + "@eight2five/ui": "0.1.0", + "@expo/metro-runtime": "~57.0.8", + "expo": "~57.0.10", + "expo-document-picker": "~57.0.1", + "expo-router": "~57.0.10", + "lucide-react-native": "^1.22.0", + "react": "19.2.3", + "react-dom": "^19.2.3", + "react-native": "0.86.2", + "react-native-safe-area-context": "~5.7.0", + "react-native-web": "^0.21.2" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/react": "~19.2.10", + "eslint": "^9.0.0", + "eslint-config-expo": "~57.0.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "jest-expo": "~57.0.3", + "prettier": "^3.6.2", + "typescript": "~6.0.3" + } + }, + "apps/drill-converter/node_modules/jest-expo": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/jest-expo/-/jest-expo-57.0.3.tgz", + "integrity": "sha512-Z3tCNmxZwNppxv0fEkiIiLKcR5OlGSnjAY8yhxM6vluojBAnCIM8kwiNzFlI9B5MELEN6tYKDMVbBtoOPfjuBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.2.1", + "@jest/globals": "^29.2.1", + "babel-jest": "^29.2.1", + "jest-environment-jsdom": "^29.2.1", + "jest-snapshot": "^29.2.1", + "jest-watch-select-projects": "^2.0.0", + "jest-watch-typeahead": "2.2.1", + "json5": "^2.2.3", + "lodash": "^4.17.19", + "react-test-renderer": "19.2.3", + "server-only": "^0.0.1", + "stacktrace-js": "^2.0.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "peerDependencies": { + "@react-native/jest-preset": "^0.86.2", + "expo": "*", + "react-native": "*", + "react-server-dom-webpack": "~19.0.4 || ~19.1.5 || ~19.2.4" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, "apps/mobile": { "name": "eight2five-mobile", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "expo": "~57.0.9", + "expo": "~57.0.10", + "expo-blur": "~57.0.2", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" @@ -122,13 +192,13 @@ }, "apps/testbed": { "name": "eight2five-testbed", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { "@eight2five/mobile": "*", "@eight2five/ui": "*", - "expo": "~57.0.9", + "expo": "~57.0.10", "expo-dev-client": "~57.0.10", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "react": "19.2.3", "react-native": "0.86.2", "react-native-safe-area-context": "~5.7.0" @@ -220,7 +290,7 @@ } }, "modules/expo-pans-ble-api": { - "version": "0.0.0", + "version": "0.1.0", "devDependencies": { "@types/jest": "^29.5.14", "jest": "^29.7.0" @@ -2183,6 +2253,14 @@ "node": ">=0.8.0" } }, + "node_modules/@eight2five/drill-importers": { + "resolved": "packages/drill-importers", + "link": true + }, + "node_modules/@eight2five/drill-schema": { + "resolved": "packages/drill-schema", + "link": true + }, "node_modules/@eight2five/mobile": { "resolved": "packages/mobile", "link": true @@ -2336,9 +2414,9 @@ "license": "MIT AND OFL-1.1" }, "node_modules/@expo/cli": { - "version": "57.0.11", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.11.tgz", - "integrity": "sha512-ENXCwRyL8Q9qbabUmm0L6w9kXTmdGotW7eDEEWmUDXLPux+nEzNDqw0MzWQ5K3ZsXS51QmUJZg5yETB+6SnNRg==", + "version": "57.0.12", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.12.tgz", + "integrity": "sha512-mmOcZJmyEDYtEtHr5e4mBr8O+Y2WBSIhqY8PL5uY3EjQts/5vxKNBYuUqIknGUvZHGpdIjAQ1IjaPNQiAf8uPQ==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", @@ -2358,7 +2436,7 @@ "@expo/plist": "^0.8.1", "@expo/prebuild-config": "^57.0.10", "@expo/require-utils": "^57.0.4", - "@expo/router-server": "^57.0.4", + "@expo/router-server": "^57.0.5", "@expo/schema-utils": "^57.0.2", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", @@ -2900,17 +2978,17 @@ } }, "node_modules/@expo/router-server": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.4.tgz", - "integrity": "sha512-ucqCP0hK8nZb9+S8QJYdQxNCkfRClzkKdg2RpWYDKDYgRIHyoRyxEDRgDgvbhH+q78yfY83abU8OTx8MMOrf1g==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.5.tgz", + "integrity": "sha512-vke39l0bo3H2q9JB/KXpAJ7HpscdTG3Mktbxanc8yn3riWzzSsnv0uxwZGZCrZrnDQzFxlLTXgbZrGkU26ng1w==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.7", + "@expo/metro-runtime": "^57.0.8", "expo": "*", - "expo-constants": "^57.0.7", + "expo-constants": "^57.0.9", "expo-font": "^57.0.1", "expo-router": "*", "expo-server": "^57.0.1", @@ -2962,9 +3040,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.8.tgz", - "integrity": "sha512-aR17CRM45k4JyFahX4Xn2b6ti3aiddC4SAzvsm3iUCVacQA6tGmClu012rYNeVXhJ96csX2o7EEymfzYqgJNxw==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.9.tgz", + "integrity": "sha512-VIxvk5ncgylBj2vrIP1iLaMc3XmYucKbf0hIcg3qx9l2anB9JzaYnH7cvVgNU3RfwV8R9m/tA7lX9BP7D8uMQw==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -6553,9 +6631,9 @@ } }, "node_modules/agent-cli-detector": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.4.tgz", - "integrity": "sha512-qPgevFvpaQoBaRJVKzr8R7h1WPvV3DtbgRIQlne4le66KBzXx5hNBwo/+NTw67LgkKBlhCzksrdautpUdlls0Q==", + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.5.tgz", + "integrity": "sha512-6xvLw0EGPuxoYGZeqyMV4AK+WoZ31jsqb7a5pln1BTf6oDFsrgvfCJT7E7y9oAqifJZhJ3fZ0J36H7o6JzrB0Q==", "license": "MIT", "bin": { "agent-cli-detector": "dist/cli.js" @@ -8637,6 +8715,10 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/eight2five-drill-converter": { + "resolved": "apps/drill-converter", + "link": true + }, "node_modules/eight2five-mobile": { "resolved": "apps/mobile", "link": true @@ -9651,13 +9733,13 @@ } }, "node_modules/expo": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.9.tgz", - "integrity": "sha512-NDEvnU+vjRdMbtOyDC25OSok9/E97al97pyx+bMphQgNRGAED0D7oF8ha7oGYsLE+7jFzpPADVM9S60dGbpHIA==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.10.tgz", + "integrity": "sha512-nirZEdsA4ZKkzOZX3sqzpArc2tnVAvdiFmm0gZRmIckl0FnSgC3RGp27JJZ2NzqkEaVZ5e06dFvX/NECdK5cIQ==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.11", + "@expo/cli": "^57.0.12", "@expo/config": "~57.0.6", "@expo/config-plugins": "~57.0.6", "@expo/devtools": "~57.0.1", @@ -9670,12 +9752,12 @@ "@ungap/structured-clone": "^1.3.0", "babel-preset-expo": "~57.0.5", "expo-asset": "~57.0.8", - "expo-constants": "~57.0.8", + "expo-constants": "~57.0.9", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-keep-awake": "~57.0.1", "expo-modules-autolinking": "~57.0.9", - "expo-modules-core": "~57.0.8", + "expo-modules-core": "~57.0.9", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -9823,9 +9905,9 @@ } }, "node_modules/expo-constants": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.8.tgz", - "integrity": "sha512-ts+B7E5076BtkblrKGy7+Sm/R2obHfTwqhmYQVYbWRtilv3+C/xNwHZhyNEnAvzM/JjKqx7UdaITUBYaeFreZw==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.9.tgz", + "integrity": "sha512-Y47sGiF+U8fwicUSPdJPjB27PuU+FgLK4Mpai0ksZF5hv8jNN3HBqKuDNxsiu70uHBKf80TaR4gwMPh0pmETiQ==", "license": "MIT", "dependencies": { "@expo/env": "~2.4.2" @@ -10037,12 +10119,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.4.tgz", - "integrity": "sha512-e1alfHNJdywIfJkCuKMc6M3hBfAGPd2gKMeF/6V7qwFWzHCS2mTBqU+KaO4FLpltA5Nt6CYEx6zmUlGfUF+8lA==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.5.tgz", + "integrity": "sha512-SmJI3wr0EVfeKPGf+Qgr9gUbrXk8mM0ATqYLvAX/EAzawDjohPzMJ5pTt7TYMX0Wknj4XCtyCQrGhnERMXT6cQ==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.7", + "expo-constants": "~57.0.9", "invariant": "^2.2.4" }, "peerDependencies": { @@ -10260,9 +10342,9 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.8", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.8.tgz", - "integrity": "sha512-ZUU943Oso3B8jFggC+2+LOk8oa+im66IQlleYay6WP5iexbdF2fqpgC4BcYsN4xhCkDKh7KDINSt1FlcxyRRjQ==", + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.9.tgz", + "integrity": "sha512-8UL/70VjmN8jOG4Tugdq6JX63Za6Qnri8h5dqFuLsDL2JxmTSB/Fz7fyEhDSpjlxVFyiJo5uF2aYVrz7Vxj2ZQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.6.1", @@ -10332,15 +10414,15 @@ } }, "node_modules/expo-router": { - "version": "57.0.9", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.9.tgz", - "integrity": "sha512-jPBwHmKmBfzCLUuKgFf+I/UhFkxvS3J2WF5MQKcuupC8XzQPBAqdzbxmcPIDY3VIVabHw7WnhwzsROWA74t1LA==", + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.10.tgz", + "integrity": "sha512-E6Cjudl4wQ86KdEHqLGhBmfOFRdtzXTtRzEEDmqOvqtkAc9C637u0us7CGKkkee9m+kwuHqfhn779ww85rn/Pg==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.2", "@expo/metro-runtime": "^57.0.8", "@expo/schema-utils": "^57.0.2", - "@expo/ui": "^57.0.8", + "@expo/ui": "^57.0.9", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -10372,8 +10454,8 @@ "@expo/metro-runtime": "^57.0.8", "@testing-library/react-native": ">= 13.2.0", "expo": "*", - "expo-constants": "^57.0.8", - "expo-linking": "^57.0.4", + "expo-constants": "^57.0.9", + "expo-linking": "^57.0.5", "react": "*", "react-dom": "*", "react-native": "*", @@ -14696,9 +14778,9 @@ "license": "MIT" }, "node_modules/multitars": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", - "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.1.tgz", + "integrity": "sha512-Do9rHaSDfQ2Fk5Dpg2RjQ4M48Ol7rSq1gvKn/dXJYnhKEm8RRjjUvyiGDDEhJqb0hJPGjRaTB1kx6beqKO/i6Q==", "license": "MIT" }, "node_modules/nanoid": { @@ -18975,10 +19057,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/drill-importers": { + "name": "@eight2five/drill-importers", + "version": "0.1.0", + "dependencies": { + "@eight2five/drill-schema": "0.1.0" + } + }, + "packages/drill-schema": { + "name": "@eight2five/drill-schema", + "version": "0.1.0", + "dependencies": { + "zod": "^3.25.76" + } + }, "packages/mobile": { "name": "@eight2five/mobile", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { + "@eight2five/drill-schema": "0.1.0", + "@expo-google-fonts/montserrat": "^0.4.2", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", @@ -19009,7 +19107,7 @@ "expo-image-picker": "~57.0.6", "expo-keep-awake": "~57.0.1", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.4", + "expo-linking": "~57.0.5", "expo-local-authentication": "~57.0.2", "expo-location": "~57.0.6", "expo-media-library": "~57.0.3", @@ -19017,7 +19115,7 @@ "expo-notifications": "~57.0.7", "expo-pans-ble-api": "file:../../modules/expo-pans-ble-api", "expo-print": "~57.0.1", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "expo-screen-capture": "~57.0.1", "expo-screen-orientation": "~57.0.1", "expo-secure-store": "~57.0.1", @@ -19153,8 +19251,9 @@ }, "packages/ui": { "name": "@eight2five/ui", - "version": "0.0.0", + "version": "0.1.0", "dependencies": { + "@eight2five/drill-schema": "0.1.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo-google-fonts/source-sans-3": "^0.4.1", "@expo/html-elements": "^0.12.5", diff --git a/package.json b/package.json index c8eec0cc..a641c6b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eight2five", - "version": "0.0.0", + "version": "0.1.0", "private": true, "workspaces": [ "apps/*", @@ -8,6 +8,8 @@ "modules/expo-pans-ble-api" ], "scripts": { + "start:drill-converter": "npm run start --workspace apps/drill-converter", + "build:drill-converter": "npm run build:web --workspace apps/drill-converter", "start:mobile": "npm run start --workspace apps/mobile", "start:mobile:mcp": "npm run start:mcp --workspace apps/mobile", "start:testbed": "npm run start --workspace apps/testbed", diff --git a/packages/drill-importers/package.json b/packages/drill-importers/package.json new file mode 100644 index 00000000..fd2d9cc2 --- /dev/null +++ b/packages/drill-importers/package.json @@ -0,0 +1,32 @@ +{ + "name": "@eight2five/drill-importers", + "version": "0.1.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit -p tsconfig.json", + "test": "jest src --watchAll=false --passWithNoTests --runInBand" + }, + "dependencies": { + "@eight2five/drill-schema": "0.1.0" + }, + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.[jt]sx?$": [ + "babel-jest", + { + "presets": ["babel-preset-expo"] + } + ] + }, + "moduleNameMapper": { + "^@eight2five/drill-schema$": "/../drill-schema/src/index.ts", + "^@eight2five/drill-schema/(.*)$": "/../drill-schema/src/$1" + } + } +} diff --git a/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts new file mode 100644 index 00000000..7e6987b2 --- /dev/null +++ b/packages/drill-importers/src/__tests__/coordinate-sheet.test.ts @@ -0,0 +1,303 @@ +import { + importCoordinateSheetPages, + parseFrontBack, + parseMeasureRange, + parseSetIdentity, + parseSideToSide, + type ExtractedPdfPage, + type ExtractedPdfTextItem, +} from ".."; + +function item(text: string, x: number, y: number): ExtractedPdfTextItem { + return { text, x, y, width: Math.max(8, text.length * 5), height: 10 }; +} + +function sheetItems({ + offsetX, + offsetY = 0, + performer, + symbol, + label, + id, + sideShift = 0, +}: { + offsetX: number; + offsetY?: number; + performer: string; + symbol: string; + label: string; + id: string; + sideShift?: number; +}): ExtractedPdfTextItem[] { + const x = (value: number) => offsetX + value; + const y = (value: number) => offsetY + value; + return [ + item( + `Performer: ${performer} Symbol: ${symbol} Label: ${label} ID:${id} Part 4`, + x(0), + y(760), + ), + item("Set", x(0), y(720)), + item("Measure", x(55), y(720)), + item("Counts", x(115), y(720)), + item("Side 1-Side 2", x(170), y(720)), + item("Front-Back", x(300), y(720)), + item("31", x(0), y(700)), + item("0", x(55), y(700)), + item("0", x(115), y(700)), + item("Side 1: On 45 yd ln", x(170), y(700)), + item("On Front side line", x(300), y(700)), + item("32", x(0), y(680)), + item("126-129", x(55), y(680)), + item("16", x(115), y(680)), + item( + `Side 2: ${4 + sideShift}.0 steps Inside 45 yd ln`, + x(170), + y(680), + ), + item("4.0 steps Behind Front Hash (HS)", x(300), y(680)), + ]; +} + +const TWO_UP_PAGE: ExtractedPdfPage = { + pageNumber: 1, + width: 800, + height: 800, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Ada Lovelace", + symbol: "B", + label: "1", + id: "1595433022185", + }), + ...sheetItems({ + offsetX: 420, + performer: "Grace Hopper", + symbol: "$", + label: "2", + id: "1595433022186", + }), + ], +}; + +describe("coordinate sheet importer", () => { + test("parses two side-by-side Pyware sheets into one portable drill", () => { + const result = importCoordinateSheetPages([TWO_UP_PAGE], { + title: "Part 4", + fileName: "Part 4 Coordinates.pdf", + createdAt: "2026-08-02T18:00:00.000Z", + }); + + expect(result.diagnostics).toEqual([]); + expect(result.sheets).toHaveLength(2); + expect(result.sheets.map((sheet) => sheet.displayLabel)).toEqual(["B1", "$2"]); + expect(result.document).toBeDefined(); + expect(result.document?.sets).toEqual([ + { + id: 0, + number: 31, + kind: "set", + countsFromPrevious: 0, + measureRange: { start: 0, end: 0 }, + }, + { + id: 1, + number: 32, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + }, + ]); + expect(result.document?.entities).toMatchObject([ + { + id: 1595433022185, + symbol: "B", + label: "B1", + name: "Ada Lovelace", + }, + { + id: 1595433022186, + symbol: "$", + label: "$2", + name: "Grace Hopper", + }, + ]); + expect(result.document?.positions).toEqual([ + { entityId: 1595433022185, setId: 0, xSteps: -8, ySteps: 0 }, + { entityId: 1595433022185, setId: 1, xSteps: 4, ySteps: 32 }, + { entityId: 1595433022186, setId: 0, xSteps: -8, ySteps: 0 }, + { entityId: 1595433022186, setId: 1, xSteps: 4, ySteps: 32 }, + ]); + }); + + test("splits four-up physical pages into logical sheets in row-major order", () => { + const fourUpPage: ExtractedPdfPage = { + pageNumber: 1, + width: 800, + height: 800, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Top Left", + symbol: "B", + label: "1", + id: "101", + }), + ...sheetItems({ + offsetX: 420, + performer: "Top Right", + symbol: "B", + label: "2", + id: "102", + }), + ...sheetItems({ + offsetX: 20, + offsetY: -400, + performer: "Bottom Left", + symbol: "C", + label: "1", + id: "103", + }), + ...sheetItems({ + offsetX: 420, + offsetY: -400, + performer: "Bottom Right", + symbol: "C", + label: "2", + id: "104", + }), + ], + }; + const finalPartialPage: ExtractedPdfPage = { + pageNumber: 2, + width: 800, + height: 800, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Tenth Trumpet", + symbol: "T", + label: "10", + id: "105", + }), + ...sheetItems({ + offsetX: 420, + performer: "(unnamed)", + symbol: "X", + label: "(unlabeled)", + id: "639161623594264901", + }), + ], + }; + + const result = importCoordinateSheetPages([fourUpPage, finalPartialPage], { + title: "Part 4", + createdAt: "2026-08-02T18:00:00.000Z", + }); + + expect(result.sheets).toHaveLength(6); + expect(result.sheets.map((sheet) => sheet.displayLabel)).toEqual([ + "B1", + "B2", + "C1", + "C2", + "T10", + "X", + ]); + expect(result.sheets.every((sheet) => sheet.rows.length === 2)).toBe(true); + expect(result.diagnostics).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "SET_COUNT_MISMATCH" }), + expect.objectContaining({ code: "SOURCE_IDS_REASSIGNED" }), + ]), + ); + expect(result.document?.entities.map((entity) => entity.id)).toEqual([ + 101, + 102, + 103, + 104, + 105, + 0, + ]); + expect(result.document?.extensions?.["eight2five.coordinateSheet"]).toMatchObject({ + sheets: expect.arrayContaining([ + expect.objectContaining({ + entityId: 0, + sourceId: "639161623594264901", + }), + ]), + }); + expect(result.document).toBeDefined(); + }); + + test("rejects global set metadata disagreements instead of silently choosing one", () => { + const mismatched: ExtractedPdfPage = { + ...TWO_UP_PAGE, + items: [ + ...sheetItems({ + offsetX: 20, + performer: "Ada Lovelace", + symbol: "B", + label: "1", + id: "1595433022185", + }), + ...sheetItems({ + offsetX: 420, + performer: "Grace Hopper", + symbol: "$", + label: "2", + id: "1595433022186", + sideShift: 1, + }).map((entry) => + entry.text === "16" && entry.y === 680 + ? { ...entry, text: "12" } + : entry, + ), + ], + }; + + const result = importCoordinateSheetPages([mismatched], { + title: "Part 4", + createdAt: "2026-08-02T18:00:00.000Z", + }); + expect(result.document).toBeUndefined(); + expect(result.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ severity: "error", code: "COUNTS_MISMATCH" }), + ]), + ); + }); + + test("supports Pyware coordinate wording and conventional NFHS hashes", () => { + expect(parseSideToSide("On 50 yd ln")).toBe(0); + expect(parseSideToSide("Side 1: On 45 yd ln")).toBe(-8); + expect(parseSideToSide("Side 1: 2.0 steps Outside 50 yd ln")).toBe(-2); + expect(parseSideToSide("Side 2: 4.0 steps Inside 45 yd ln")).toBe(4); + expect(parseFrontBack("On Front side line")).toBe(0); + expect(parseFrontBack("4.0 steps Behind Front Hash (HS)")).toBe(32); + expect(parseFrontBack("3.5 steps In Front Of Back Hash (HS)")).toBe(52.5); + expect(parseFrontBack("On Back Sideline")).toBe(84); + expect( + parseFrontBack("On Front Hash", { + type: "preset", + preset: "football-ncaa", + }), + ).toBe(32); + }); + + test("keeps set identity and measure ranges structured", () => { + expect(parseSetIdentity("31A")).toEqual({ + number: 31, + suffix: "A", + kind: "subset", + }); + expect(parseSetIdentity("31.5")).toEqual({ + number: 31, + suffix: ".5", + kind: "subset", + }); + expect(parseMeasureRange("126-129")).toEqual({ start: 126, end: 129 }); + expect(parseMeasureRange("0")).toEqual({ start: 0, end: 0 }); + }); +}); diff --git a/packages/drill-importers/src/coordinate-sheet.ts b/packages/drill-importers/src/coordinate-sheet.ts new file mode 100644 index 00000000..e54849bb --- /dev/null +++ b/packages/drill-importers/src/coordinate-sheet.ts @@ -0,0 +1,881 @@ +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + formatSetName, + getGridReference, + parseDrillDocument, + type DrillDocument, + type FieldDefinition, + type DrillEntity, + type DrillPosition, + type DrillSet, + type MeasureRange, +} from "@eight2five/drill-schema"; + +import type { + CoordinateSheetImportOptions, + CoordinateSheetImportResult, + ExtractedPdfPage, + ExtractedPdfTextItem, + ImportDiagnostic, + ParsedCoordinateRow, + ParsedCoordinateSheet, + ParsedSetIdentity, +} from "./types"; + +const IMPORTER_NAME = "@eight2five/drill-importers/coordinate-sheet"; +const IMPORTER_VERSION = "1"; +const DEFAULT_FIELD: FieldDefinition = { + type: "preset", + preset: "football-nfhs", +}; +const LINE_Y_TOLERANCE = 2.5; +const SHEET_ANCHOR_DEDUPLICATION = 24; +const HEADER_FIELD_PATTERN = + /Performer:\s*(.*?)\s+Symbol:\s*(.*?)\s+Label:\s*(.*?)\s+ID:\s*([0-9]+)/i; + +interface TextLine { + readonly y: number; + readonly items: readonly ExtractedPdfTextItem[]; + readonly text: string; +} + +type ColumnKey = "set" | "title" | "measure" | "counts" | "side" | "frontBack"; + +interface ColumnAnchor { + readonly key: ColumnKey; + readonly x: number; +} + +interface SheetSlice { + readonly pageNumber: number; + readonly sheetIndex: number; + readonly items: readonly ExtractedPdfTextItem[]; +} + +interface SheetAnchor { + readonly x: number; + readonly y: number; +} + +interface ParsedSheetResult { + readonly sheet?: ParsedCoordinateSheet; + readonly diagnostics: readonly ImportDiagnostic[]; +} + +/** + * Parse position-aware PDF text extracted from Pyware-style coordinate sheets + * into the portable Eight2Five drill document. The importer is intentionally + * independent of PDF.js; browser and native extraction layers only need to + * supply the generic item geometry in ExtractedPdfPage. + */ +export function importCoordinateSheetPages( + pages: readonly ExtractedPdfPage[], + options: CoordinateSheetImportOptions, +): CoordinateSheetImportResult { + const diagnostics: ImportDiagnostic[] = []; + const sheets: ParsedCoordinateSheet[] = []; + const field = options.field ?? DEFAULT_FIELD; + + for (const page of pages) { + for (const slice of splitPageIntoSheets(page)) { + const parsed = parseCoordinateSheetSlice(slice, field); + diagnostics.push(...parsed.diagnostics); + if (parsed.sheet) sheets.push(parsed.sheet); + } + } + + if (sheets.length === 0) { + diagnostics.push({ + severity: "error", + code: "NO_COORDINATE_SHEETS", + message: "No coordinate-sheet tables were found in the PDF text.", + }); + return { sheets, diagnostics }; + } + + const document = buildDrillDocument(sheets, options, diagnostics); + if (!document || diagnostics.some((diagnostic) => diagnostic.severity === "error")) { + return { sheets, diagnostics }; + } + + try { + return { + sheets, + diagnostics, + document: parseDrillDocument(document), + }; + } catch (cause) { + diagnostics.push({ + severity: "error", + code: "PORTABLE_SCHEMA_VALIDATION_FAILED", + message: + cause instanceof Error + ? `Parsed coordinate sheets did not satisfy the portable drill schema: ${cause.message}` + : "Parsed coordinate sheets did not satisfy the portable drill schema.", + }); + return { sheets, diagnostics }; + } +} + +export function parseCoordinateSheetPage( + page: ExtractedPdfPage, + field: FieldDefinition = DEFAULT_FIELD, +): readonly ParsedSheetResult[] { + return splitPageIntoSheets(page).map((slice) => + parseCoordinateSheetSlice(slice, field), + ); +} + +function splitPageIntoSheets(page: ExtractedPdfPage): readonly SheetSlice[] { + const usefulItems = page.items.filter((item) => item.text.trim().length > 0); + if (usefulItems.length === 0) return []; + + let sheetAnchors = usefulItems + .filter((item) => /\bPerformer\s*:/i.test(item.text)) + .map((item) => ({ x: item.x, y: item.y } satisfies SheetAnchor)); + if (sheetAnchors.length === 0) { + sheetAnchors = usefulItems + .filter((item) => /^\s*Set(?:\s|$)/i.test(item.text)) + .map((item) => ({ x: item.x, y: item.y } satisfies SheetAnchor)); + } + if (sheetAnchors.length === 0) { + sheetAnchors = [ + { + x: Math.min(...usefulItems.map((item) => item.x)), + y: Math.max(...usefulItems.map((item) => item.y)), + }, + ]; + } + + const xAnchors = distinctSortedValues( + sheetAnchors.map((anchor) => anchor.x), + "ascending", + ); + const yAnchors = distinctSortedValues( + sheetAnchors.map((anchor) => anchor.y), + "descending", + ); + + // Pyware can print four logical coordinate sheets on one physical PDF page + // (two columns by two rows). Origins are the top-left edges of each logical + // sheet, so midpoint partitioning is wrong: the coordinate columns can use + // almost the full distance up to the next origin. Partition immediately + // before each next origin instead, in both axes, and only emit grid cells + // that actually contain a detected sheet anchor. PDF.js Y increases upward, + // hence rows are ordered from larger to smaller Y values. + const slices: SheetSlice[] = []; + for (const [rowIndex, yAnchor] of yAnchors.entries()) { + const minY = + rowIndex === yAnchors.length - 1 + ? Number.NEGATIVE_INFINITY + : yAnchors[rowIndex + 1] + 0.5; + const maxY = + rowIndex === 0 ? Number.POSITIVE_INFINITY : yAnchor + 0.5; + + for (const [columnIndex, xAnchor] of xAnchors.entries()) { + if (!hasSheetAnchor(sheetAnchors, xAnchor, yAnchor)) continue; + const minX = + columnIndex === 0 ? Number.NEGATIVE_INFINITY : xAnchor - 0.5; + const maxX = + columnIndex === xAnchors.length - 1 + ? Number.POSITIVE_INFINITY + : xAnchors[columnIndex + 1] - 0.5; + const items = usefulItems.filter( + (item) => + item.x >= minX && item.x < maxX && item.y >= minY && item.y < maxY, + ); + if (items.length === 0) continue; + slices.push({ + pageNumber: page.pageNumber, + sheetIndex: slices.length, + items, + }); + } + } + return slices; +} + +function distinctSortedValues( + values: readonly number[], + direction: "ascending" | "descending", +): number[] { + const sorted = [...values].sort((left, right) => + direction === "ascending" ? left - right : right - left, + ); + const distinct: number[] = []; + for (const value of sorted) { + const previous = distinct.at(-1); + if ( + previous === undefined || + Math.abs(value - previous) > SHEET_ANCHOR_DEDUPLICATION + ) { + distinct.push(value); + } + } + return distinct; +} + +function hasSheetAnchor( + anchors: readonly SheetAnchor[], + x: number, + y: number, +): boolean { + return anchors.some( + (anchor) => + Math.abs(anchor.x - x) <= SHEET_ANCHOR_DEDUPLICATION && + Math.abs(anchor.y - y) <= SHEET_ANCHOR_DEDUPLICATION, + ); +} + +function parseCoordinateSheetSlice( + slice: SheetSlice, + field: FieldDefinition, +): ParsedSheetResult { + const diagnostics: ImportDiagnostic[] = []; + const lines = groupTextLines(slice.items); + const headerIndex = lines.findIndex(isTableHeaderLine); + if (headerIndex < 0) { + return { + diagnostics: [ + { + severity: "error", + code: "TABLE_HEADER_NOT_FOUND", + message: "Could not find a Set/Measure/Counts coordinate table header.", + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + }, + ], + }; + } + + const headerText = lines + .slice(0, headerIndex) + .map((line) => line.text) + .join(" "); + const metadata = parseHeaderMetadata(headerText); + if (!metadata) { + diagnostics.push({ + severity: "error", + code: "PERFORMER_HEADER_NOT_FOUND", + message: "Could not parse the Performer / Symbol / Label / ID header.", + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + }); + return { diagnostics }; + } + + const anchors = deriveColumnAnchors(lines[headerIndex]); + const rows: ParsedCoordinateRow[] = []; + for (const line of lines.slice(headerIndex + 1)) { + if (/\bPrinted\s*:/i.test(line.text) || /^Page\s+\d+/i.test(line.text)) continue; + const parsed = + anchors.length >= 5 + ? parsePositionedRow(line, anchors, field) + : parseFlatRow(line.text, field); + if (parsed === undefined) continue; + if (typeof parsed === "string") { + diagnostics.push({ + severity: "error", + code: "ROW_PARSE_FAILED", + message: parsed, + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + rowText: line.text, + }); + continue; + } + rows.push(parsed); + } + + if (rows.length === 0) { + diagnostics.push({ + severity: "error", + code: "NO_COORDINATE_ROWS", + message: "The coordinate sheet header was found, but no set rows could be parsed.", + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + }); + return { diagnostics }; + } + + return { + diagnostics, + sheet: { + pageNumber: slice.pageNumber, + sheetIndex: slice.sheetIndex, + ...metadata, + rows, + }, + }; +} + +function groupTextLines(items: readonly ExtractedPdfTextItem[]): readonly TextLine[] { + const sorted = [...items].sort((left, right) => { + const yDifference = right.y - left.y; + return Math.abs(yDifference) > LINE_Y_TOLERANCE ? yDifference : left.x - right.x; + }); + const groups: { y: number; items: ExtractedPdfTextItem[] }[] = []; + for (const item of sorted) { + let group = groups.find((candidate) => Math.abs(candidate.y - item.y) <= LINE_Y_TOLERANCE); + if (!group) { + group = { y: item.y, items: [] }; + groups.push(group); + } + group.items.push(item); + group.y = + group.items.reduce((total, current) => total + current.y, 0) / group.items.length; + } + return groups + .sort((left, right) => right.y - left.y) + .map((group) => { + const lineItems = [...group.items].sort((left, right) => left.x - right.x); + return { + y: group.y, + items: lineItems, + text: joinTextItems(lineItems), + }; + }); +} + +function joinTextItems(items: readonly ExtractedPdfTextItem[]): string { + return items + .map((item) => item.text.trim()) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + +function isTableHeaderLine(line: TextLine): boolean { + const text = line.text.toLowerCase(); + return ( + /\bset\b/.test(text) && + /\bcounts?\b/.test(text) && + /\bside\b/.test(text) && + /\bfront\b/.test(text) && + /\bback\b/.test(text) + ); +} + +function parseHeaderMetadata(text: string): Omit | undefined { + const match = text.match(HEADER_FIELD_PATTERN); + if (!match) return undefined; + const performerName = normalizeHeaderValue(match[1], "unnamed"); + const sourceSymbol = cleanOptionalText(match[2]) ?? "?"; + const sourceLabel = normalizeHeaderValue(match[3], "unlabeled"); + const sourceId = cleanOptionalText(match[4]); + const displayLabel = composeDisplayLabel(sourceSymbol, sourceLabel, performerName); + const tailStart = (match.index ?? 0) + match[0].length; + const tail = cleanOptionalText( + text + .slice(tailStart) + .replace(/\bPrinted\s*:.*$/i, "") + .trim(), + ); + return { + ...(performerName && !/^\(unnamed\)$/i.test(performerName) + ? { performerName } + : {}), + sourceSymbol, + ...(sourceLabel ? { sourceLabel } : {}), + ...(sourceId ? { sourceId } : {}), + displayLabel, + ...(tail ? { showTitle: tail } : {}), + }; +} + +function cleanOptionalText(value: string | undefined): string | undefined { + const cleaned = value?.replace(/\s+/g, " ").trim(); + return cleaned ? cleaned : undefined; +} + +function normalizeHeaderValue( + value: string | undefined, + placeholder: string, +): string | undefined { + const cleaned = cleanOptionalText(value); + if (!cleaned) return undefined; + return new RegExp(`^\\(${placeholder}\\)$`, "i").test(cleaned) + ? undefined + : cleaned; +} + +function composeDisplayLabel( + symbol: string, + label: string | undefined, + performerName: string | undefined, +): string { + if (label) { + if (symbol === "?") return label; + return label.startsWith(symbol) ? label : `${symbol}${label}`; + } + if (performerName && /^[^\s]{1,8}\d{1,4}$/.test(performerName)) return performerName; + return symbol; +} + +function deriveColumnAnchors(header: TextLine): readonly ColumnAnchor[] { + const candidates: ColumnAnchor[] = []; + for (const item of header.items) { + const text = item.text.toLowerCase().replace(/\s+/g, " ").trim(); + if (/^set\b/.test(text)) candidates.push({ key: "set", x: item.x }); + else if (/^title\b/.test(text)) candidates.push({ key: "title", x: item.x }); + else if (/^measure\b/.test(text)) candidates.push({ key: "measure", x: item.x }); + else if (/^counts?\b/.test(text)) candidates.push({ key: "counts", x: item.x }); + else if (/^side\b/.test(text)) candidates.push({ key: "side", x: item.x }); + else if (/^front\b/.test(text)) candidates.push({ key: "frontBack", x: item.x }); + } + const byKey = new Map(); + for (const candidate of candidates.sort((left, right) => left.x - right.x)) { + if (!byKey.has(candidate.key)) byKey.set(candidate.key, candidate); + } + return [...byKey.values()].sort((left, right) => left.x - right.x); +} + +function parsePositionedRow( + line: TextLine, + anchors: readonly ColumnAnchor[], + field: FieldDefinition, +): ParsedCoordinateRow | string | undefined { + const columns = bucketLineByColumns(line, anchors); + const setText = columns.get("set")?.trim() ?? ""; + if (!looksLikeSetToken(setText)) return undefined; + const set = parseSetIdentity(setText); + if (typeof set === "string") return set; + + const countsText = columns.get("counts")?.trim() ?? ""; + const counts = parseCounts(countsText); + if (typeof counts === "string") return counts; + + const measureText = columns.get("measure")?.trim() ?? ""; + const measureRange = parseMeasureRange(measureText); + if (typeof measureRange === "string") return measureRange; + + const sideText = columns.get("side")?.trim() ?? ""; + const frontBackText = columns.get("frontBack")?.trim() ?? ""; + const xSteps = parseSideToSide(sideText); + if (typeof xSteps === "string") return xSteps; + const ySteps = parseFrontBack(frontBackText, field); + if (typeof ySteps === "string") return ySteps; + + return { + set, + countsFromPrevious: counts, + ...(measureRange ? { measureRange } : {}), + position: { xSteps, ySteps }, + rawText: line.text, + }; +} + +function bucketLineByColumns( + line: TextLine, + anchors: readonly ColumnAnchor[], +): ReadonlyMap { + const sorted = [...anchors].sort((left, right) => left.x - right.x); + const buckets = new Map(); + for (const item of line.items) { + let index = 0; + for (let anchorIndex = 0; anchorIndex < sorted.length - 1; anchorIndex += 1) { + const boundary = (sorted[anchorIndex].x + sorted[anchorIndex + 1].x) / 2; + if (item.x >= boundary) index = anchorIndex + 1; + else break; + } + const key = sorted[index].key; + const bucket = buckets.get(key) ?? []; + bucket.push(item); + buckets.set(key, bucket); + } + return new Map( + [...buckets.entries()].map(([key, items]) => [key, joinTextItems(items)]), + ); +} + +/** Fallback for extractors that provide a whole row as one text fragment. */ +function parseFlatRow( + text: string, + field: FieldDefinition, +): ParsedCoordinateRow | string | undefined { + const normalized = text.replace(/\s+/g, " ").trim(); + const setMatch = normalized.match(/^(\d+(?:[A-Z]|\.[0-9]+)?)\s+(.*)$/); + if (!setMatch) return undefined; + const set = parseSetIdentity(setMatch[1]); + if (typeof set === "string") return set; + + const sideStart = setMatch[2].search(/(?:Side\s*[12]\s*:|On\s+(?:\d+\s*(?:yd\s*ln|yard\s*line)|50\b))/i); + if (sideStart < 0) return `Could not find the side-to-side coordinate in row: ${normalized}`; + const prefix = setMatch[2].slice(0, sideStart).trim(); + const coordinateTail = setMatch[2].slice(sideStart).trim(); + const frontStart = coordinateTail.search( + /(?:\bOn\s+(?:Front|Back|Home|Visitor)|\b[0-9]+(?:\.[0-9]+)?\s*(?:steps?)?\s*(?:Behind|In\s+Front\s+Of)\s+(?:Front|Back|Home|Visitor))/i, + ); + if (frontStart < 0) return `Could not find the front-to-back coordinate in row: ${normalized}`; + const sideText = coordinateTail.slice(0, frontStart).trim(); + const frontBackText = coordinateTail.slice(frontStart).trim(); + + const prefixTokens = prefix.split(" ").filter(Boolean); + let countsIndex = -1; + for (let index = prefixTokens.length - 1; index >= 0; index -= 1) { + if (/^\d+$/.test(prefixTokens[index])) { + countsIndex = index; + break; + } + } + if (countsIndex < 0) return `Could not find whole-number counts in row: ${normalized}`; + const counts = parseCounts(prefixTokens[countsIndex]); + if (typeof counts === "string") return counts; + const beforeCounts = prefixTokens.slice(0, countsIndex); + const measureToken = [...beforeCounts].reverse().find((token) => /^\d+(?:[-–]\d+)?$/.test(token)); + const measureRange = parseMeasureRange(measureToken ?? ""); + if (typeof measureRange === "string") return measureRange; + const xSteps = parseSideToSide(sideText); + if (typeof xSteps === "string") return xSteps; + const ySteps = parseFrontBack(frontBackText, field); + if (typeof ySteps === "string") return ySteps; + return { + set, + countsFromPrevious: counts, + ...(measureRange ? { measureRange } : {}), + position: { xSteps, ySteps }, + rawText: normalized, + }; +} + +function looksLikeSetToken(value: string): boolean { + return /^\d+(?:[A-Z]|\.[0-9]+)?$/.test(value.trim()); +} + +export function parseSetIdentity(value: string): ParsedSetIdentity | string { + const match = value.trim().match(/^(\d+)([A-Z]|\.[0-9]+)?$/); + if (!match) return `Invalid set identifier "${value}".`; + const number = Number(match[1]); + if (!Number.isSafeInteger(number)) return `Set number "${match[1]}" is too large.`; + const suffix = match[2]; + return suffix + ? { number, suffix, kind: "subset" } + : { number, kind: "set" }; +} + +function parseCounts(value: string): number | string { + if (!/^\d+$/.test(value)) return `Counts must be a non-negative whole number; received "${value}".`; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : `Counts value "${value}" is too large.`; +} + +export function parseMeasureRange(value: string): MeasureRange | undefined | string { + const normalized = value.trim(); + if (!normalized || normalized === "-" || normalized === "—") return undefined; + const match = normalized.match(/^(\d+)(?:\s*[-–]\s*(\d+))?$/); + if (!match) return `Invalid measure value "${value}".`; + const start = Number(match[1]); + const end = Number(match[2] ?? match[1]); + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end)) { + return `Measure value "${value}" is too large.`; + } + if (end < start) return `Measure range "${value}" ends before it starts.`; + return { start, end }; +} + +export function parseSideToSide(value: string): number | string { + const normalized = normalizeCoordinateText(value); + const goalLine = /\bgoal\s*line\b/i.test(normalized); + const yardMatch = normalized.match( + /\b(?:on|inside|outside)\s+(50|45|40|35|30|25|20|15|10|5|0)(?:\s*(?:yd\s*ln|yard\s*line))?\b/i, + ); + const yardLine = goalLine ? 0 : yardMatch ? Number(yardMatch[1]) : undefined; + if (yardLine === undefined) return `Could not parse yard-line reference "${value}".`; + + const sideMatch = normalized.match(/\bside\s*([12])\s*:/i); + const side = sideMatch ? Number(sideMatch[1]) : undefined; + const baseMagnitude = ((50 - yardLine) / 5) * 8; + if (/\bon\b/i.test(normalized) && !/\b(?:inside|outside)\b/i.test(normalized)) { + if (yardLine === 50) return 0; + if (side !== 1 && side !== 2) { + return `Yard line ${yardLine} requires Side 1 or Side 2 in "${value}".`; + } + return side === 1 ? -baseMagnitude : baseMagnitude; + } + + const offsetMatch = normalized.match( + /([0-9]+(?:\.[0-9]+)?)\s*(?:steps?)?\s*(inside|outside)\b/i, + ); + if (!offsetMatch || (side !== 1 && side !== 2)) { + return `Could not parse side-to-side coordinate "${value}".`; + } + const offset = Number(offsetMatch[1]); + const relation = offsetMatch[2].toLowerCase(); + const base = side === 1 ? -baseMagnitude : baseMagnitude; + const towardCenter = relation === "inside"; + return side === 1 + ? base + (towardCenter ? offset : -offset) + : base + (towardCenter ? -offset : offset); +} + +export function parseFrontBack( + value: string, + field: FieldDefinition = DEFAULT_FIELD, +): number | string { + const normalized = normalizeCoordinateText(value); + const reference = parseFrontBackReference(normalized, field); + if (!reference) return `Could not parse front/back reference "${value}".`; + const base = reference.ySteps; + if (/^\s*on\b/i.test(normalized)) return base; + const movement = normalized.match( + /^\s*([0-9]+(?:\.[0-9]+)?)\s*(?:steps?)?\s*(behind|in\s+front\s+of)\b/i, + ); + if (!movement) return `Could not parse front-to-back coordinate "${value}".`; + const offset = Number(movement[1]); + return /^behind$/i.test(movement[2]) ? base + offset : base - offset; +} + +function normalizeCoordinateText(value: string): string { + return value + .replace(/\(\s*HS\s*\)/gi, "") + .replace(/\s+/g, " ") + .trim(); +} + +function parseFrontBackReference( + value: string, + field: FieldDefinition, +): { readonly name: string; readonly ySteps: number } | undefined { + const references = [ + { + pattern: /\b(?:front|home)\s+(?:side\s*line|sideline)\b/i, + id: "front-sideline", + }, + { pattern: /\bfront\s+hash\b/i, id: "front-hash" }, + { pattern: /\bback\s+hash\b/i, id: "back-hash" }, + { + pattern: /\b(?:back|visitor)\s+(?:side\s*line|sideline)\b/i, + id: "back-sideline", + }, + ] as const; + const matched = references.find((reference) => reference.pattern.test(value)); + if (!matched) return undefined; + const gridReference = getGridReference(field, matched.id); + return gridReference + ? { name: matched.id, ySteps: gridReference.coordinateSteps } + : undefined; +} + +function buildDrillDocument( + sheets: readonly ParsedCoordinateSheet[], + options: CoordinateSheetImportOptions, + diagnostics: ImportDiagnostic[], +): DrillDocument | undefined { + const canonical = sheets[0]; + const canonicalSets: DrillSet[] = canonical.rows.map((row, index) => ({ + id: index, + number: row.set.number, + ...(row.set.suffix ? { suffix: row.set.suffix } : {}), + kind: row.set.kind, + countsFromPrevious: row.countsFromPrevious, + ...(row.measureRange ? { measureRange: row.measureRange } : {}), + })); + + for (const [sheetIndex, sheet] of sheets.entries()) { + if (sheet.rows.length !== canonical.rows.length) { + diagnostics.push({ + severity: "error", + code: "SET_COUNT_MISMATCH", + message: `${sheet.displayLabel} has ${sheet.rows.length} rows; expected ${canonical.rows.length}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + }); + continue; + } + for (let index = 0; index < canonical.rows.length; index += 1) { + const expected = canonical.rows[index]; + const actual = sheet.rows[index]; + if (!sameSetIdentity(expected.set, actual.set)) { + diagnostics.push({ + severity: "error", + code: "SET_IDENTITY_MISMATCH", + message: `${sheet.displayLabel} row ${index + 1} is Set ${formatSetName(actual.set)}, expected Set ${formatSetName(expected.set)}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: actual.rawText, + }); + } + if (actual.countsFromPrevious !== expected.countsFromPrevious) { + diagnostics.push({ + severity: "error", + code: "COUNTS_MISMATCH", + message: `Set ${formatSetName(expected.set)} has inconsistent counts (${expected.countsFromPrevious} vs ${actual.countsFromPrevious}) on ${sheet.displayLabel}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: actual.rawText, + }); + } + if (!sameMeasureRange(actual.measureRange, expected.measureRange)) { + diagnostics.push({ + severity: "error", + code: "MEASURE_MISMATCH", + message: `Set ${formatSetName(expected.set)} has inconsistent measure metadata on ${sheet.displayLabel}.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: actual.rawText, + }); + } + } + if (sheetIndex === 0 && sheet.rows[0]?.countsFromPrevious !== 0) { + diagnostics.push({ + severity: "error", + code: "FIRST_SET_COUNTS_NONZERO", + message: "The first imported set must have 0 counts from previous.", + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + rowText: sheet.rows[0]?.rawText, + }); + } + } + + const entityIds = assignEntityIds(sheets, diagnostics); + if (!entityIds) return undefined; + const labels = new Set(); + const entities: DrillEntity[] = []; + const positions: DrillPosition[] = []; + const references: NonNullable["references"]>[number][] = []; + + for (const [sheetIndex, sheet] of sheets.entries()) { + if (labels.has(sheet.displayLabel)) { + diagnostics.push({ + severity: "error", + code: "DUPLICATE_PERFORMER_LABEL", + message: `Performer label ${sheet.displayLabel} appears more than once.`, + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + }); + continue; + } + labels.add(sheet.displayLabel); + const entityId = entityIds[sheetIndex]; + entities.push({ + id: entityId, + type: "performer", + symbol: sheet.sourceSymbol, + label: sheet.displayLabel, + ...(sheet.performerName ? { name: sheet.performerName } : {}), + }); + references.push({ + target: { type: "entity", entityId }, + page: sheet.pageNumber, + }); + for (const [setId, row] of sheet.rows.entries()) { + positions.push({ + entityId, + setId, + xSteps: row.position.xSteps, + ySteps: row.position.ySteps, + }); + references.push({ + target: { type: "position", entityId, setId }, + page: sheet.pageNumber, + rawText: row.rawText, + }); + } + } + + return { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: options.title.trim() || "Imported Drill", + createdAt: options.createdAt, + }, + field: options.field ?? DEFAULT_FIELD, + entities, + sets: canonicalSets, + positions, + provenance: { + source: { + kind: "coordinate-sheet-pdf", + ...(options.fileName ? { fileName: options.fileName } : {}), + }, + importer: { name: IMPORTER_NAME, version: IMPORTER_VERSION }, + importedAt: options.createdAt, + references, + }, + extensions: { + "eight2five.coordinateSheet": { + sheets: sheets.map((sheet, index) => ({ + entityId: entityIds[index], + pageNumber: sheet.pageNumber, + sheetIndex: sheet.sheetIndex, + ...(sheet.sourceId ? { sourceId: sheet.sourceId } : {}), + ...(sheet.sourceLabel ? { sourceLabel: sheet.sourceLabel } : {}), + ...(sheet.showTitle ? { showTitle: sheet.showTitle } : {}), + })), + }, + }, + }; +} + +function assignEntityIds( + sheets: readonly ParsedCoordinateSheet[], + diagnostics: ImportDiagnostic[], +): readonly number[] { + const safeSourceIds = sheets.map((sheet) => { + if (!sheet.sourceId || !/^\d+$/.test(sheet.sourceId)) return undefined; + const parsed = Number(sheet.sourceId); + return Number.isSafeInteger(parsed) ? parsed : undefined; + }); + const reservedSafeIds = new Set( + safeSourceIds.filter((id): id is number => id !== undefined), + ); + const usedIds = new Set(); + let nextGeneratedId = 0; + let hadMissingSourceId = false; + let hadDuplicateSafeSourceId = false; + + const ids = safeSourceIds.map((sourceId, index) => { + if (sourceId !== undefined && !usedIds.has(sourceId)) { + usedIds.add(sourceId); + return sourceId; + } + if (!sheets[index].sourceId) hadMissingSourceId = true; + if (sourceId !== undefined && usedIds.has(sourceId)) { + hadDuplicateSafeSourceId = true; + } + while ( + reservedSafeIds.has(nextGeneratedId) || + usedIds.has(nextGeneratedId) + ) { + nextGeneratedId += 1; + } + const generatedId = nextGeneratedId; + usedIds.add(generatedId); + nextGeneratedId += 1; + return generatedId; + }); + + // Pyware can emit numeric source IDs larger than JavaScript's safe integer + // range. Those exact strings are retained in coordinate-sheet provenance, + // while the portable document receives a generated safe numeric ID. This is + // normal conversion behavior and does not warrant a warning by itself. + if (hadMissingSourceId || hadDuplicateSafeSourceId) { + diagnostics.push({ + severity: "warning", + code: "SOURCE_IDS_REASSIGNED", + message: + "One or more source performer IDs were missing or duplicated; generated portable IDs were used where needed. Original source IDs are preserved in coordinate-sheet provenance when available.", + }); + } + return ids; +} + +function sameSetIdentity(left: ParsedSetIdentity, right: ParsedSetIdentity): boolean { + return ( + left.number === right.number && + (left.suffix ?? "") === (right.suffix ?? "") && + left.kind === right.kind + ); +} + +function sameMeasureRange( + left: MeasureRange | undefined, + right: MeasureRange | undefined, +): boolean { + if (!left || !right) return left === right; + return left.start === right.start && left.end === right.end; +} diff --git a/packages/drill-importers/src/index.ts b/packages/drill-importers/src/index.ts new file mode 100644 index 00000000..b9b499e6 --- /dev/null +++ b/packages/drill-importers/src/index.ts @@ -0,0 +1,2 @@ +export * from "./coordinate-sheet"; +export * from "./types"; diff --git a/packages/drill-importers/src/types.ts b/packages/drill-importers/src/types.ts new file mode 100644 index 00000000..aae420c6 --- /dev/null +++ b/packages/drill-importers/src/types.ts @@ -0,0 +1,72 @@ +import type { + DrillDocument, + DrillGridPoint, + FieldDefinition, + MeasureRange, + SetKind, +} from "@eight2five/drill-schema"; + +export interface ExtractedPdfTextItem { + readonly text: string; + readonly x: number; + readonly y: number; + readonly width?: number; + readonly height?: number; +} + +export interface ExtractedPdfPage { + readonly pageNumber: number; + readonly width: number; + readonly height: number; + readonly items: readonly ExtractedPdfTextItem[]; +} + +export interface ParsedSetIdentity { + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; +} + +export interface ParsedCoordinateRow { + readonly set: ParsedSetIdentity; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly rawText: string; +} + +export interface ParsedCoordinateSheet { + readonly pageNumber: number; + readonly sheetIndex: number; + readonly performerName?: string; + readonly sourceSymbol: string; + readonly sourceLabel?: string; + readonly sourceId?: string; + readonly displayLabel: string; + readonly showTitle?: string; + readonly rows: readonly ParsedCoordinateRow[]; +} + +export type ImportDiagnosticSeverity = "warning" | "error"; + +export interface ImportDiagnostic { + readonly severity: ImportDiagnosticSeverity; + readonly code: string; + readonly message: string; + readonly pageNumber?: number; + readonly sheetIndex?: number; + readonly rowText?: string; +} + +export interface CoordinateSheetImportOptions { + readonly title: string; + readonly fileName?: string; + readonly createdAt: string; + readonly field?: FieldDefinition; +} + +export interface CoordinateSheetImportResult { + readonly document?: DrillDocument; + readonly sheets: readonly ParsedCoordinateSheet[]; + readonly diagnostics: readonly ImportDiagnostic[]; +} diff --git a/packages/drill-importers/tsconfig.json b/packages/drill-importers/tsconfig.json new file mode 100644 index 00000000..846c3924 --- /dev/null +++ b/packages/drill-importers/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/drill-schema/drill-document.schema.json b/packages/drill-schema/drill-document.schema.json new file mode 100644 index 00000000..da5d89db --- /dev/null +++ b/packages/drill-schema/drill-document.schema.json @@ -0,0 +1,812 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://eight2five.com/schema/drill/2.0.0", + "title": "Eight2Five Drill Document", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schemaVersion", + "metadata", + "field", + "entities", + "sets", + "positions" + ], + "properties": { + "schema": { + "const": "https://eight2five.com/schema/drill" + }, + "schemaVersion": { + "const": "2.0.0" + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "field": { + "$ref": "#/$defs/fieldDefinition" + }, + "entityRules": { + "$ref": "#/$defs/entityRules" + }, + "entities": { + "type": "array", + "items": { + "$ref": "#/$defs/entity" + } + }, + "sets": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/set" + } + }, + "positions": { + "type": "array", + "items": { + "$ref": "#/$defs/position" + } + }, + "paths": { + "type": "array", + "items": { + "$ref": "#/$defs/path" + } + }, + "provenance": { + "$ref": "#/$defs/provenance" + }, + "extensions": { + "type": "object", + "additionalProperties": true + } + }, + "$defs": { + "nonNegativeSafeInteger": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": ["title", "createdAt"], + "properties": { + "title": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "drillWriter": { + "type": "string", + "minLength": 1 + }, + "ensemble": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "lucideIcon": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + } + } + }, + "measureRange": { + "type": "object", + "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "end": { + "$ref": "#/$defs/nonNegativeSafeInteger" + } + } + }, + "set": { + "type": "object", + "additionalProperties": false, + "required": ["id", "number", "kind", "countsFromPrevious"], + "properties": { + "id": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "number": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "suffix": { + "type": "string", + "pattern": "^(?:[A-Z]|\\.[0-9]+)$" + }, + "kind": { + "enum": ["set", "subset"] + }, + "countsFromPrevious": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "measureRange": { + "$ref": "#/$defs/measureRange" + } + }, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "set" + } + }, + "required": ["kind"] + }, + "then": { + "not": { + "required": ["suffix"] + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "subset" + } + }, + "required": ["kind"] + }, + "then": { + "required": ["suffix"] + } + } + ] + }, + "appearance": { + "type": "object", + "additionalProperties": false, + "properties": { + "icon": { + "enum": [ + "dot", + "square", + "triangle", + "diamond", + "star", + "hexagon", + "cross" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$", + "examples": [ + "#808080", + "#E53935", + "#FB8C00", + "#FDD835", + "#43A047", + "#64B5F6", + "#3C6EC8", + "#1E3A8A", + "#8E44AD", + "#EC4899", + "#000000" + ] + }, + "labelVisible": { + "type": "boolean" + } + } + }, + "propSize": { + "type": "object", + "additionalProperties": false, + "required": ["length", "width", "unit"], + "properties": { + "length": { + "type": "number", + "exclusiveMinimum": 0 + }, + "width": { + "type": "number", + "exclusiveMinimum": 0 + }, + "unit": { + "enum": ["8-to-5-steps", "feet", "inches", "meters"] + } + } + }, + "entity": { + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "symbol", "label"], + "properties": { + "id": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "type": { + "enum": ["performer", "prop"] + }, + "symbol": { + "type": "string", + "minLength": 1, + "maxLength": 16 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "section": { + "type": "string", + "minLength": 1 + }, + "instrument": { + "type": "string", + "minLength": 1 + }, + "size": { + "$ref": "#/$defs/propSize", + "default": { + "length": 1, + "width": 1, + "unit": "8-to-5-steps" + }, + "description": "Prop footprint. If omitted for a prop, the semantic default is 1 by 1 8-to-5 steps; one 8-to-5 step equals 22.5 inches." + }, + "appearance": { + "$ref": "#/$defs/appearance" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "prop" + } + }, + "required": ["type"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["section"] }, + { "required": ["instrument"] } + ] + } + }, + "else": { + "not": { + "required": ["size"] + } + } + } + ] + }, + "entityRuleValues": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "enum": ["performer", "prop"] + }, + "name": { + "type": "string", + "minLength": 1 + }, + "section": { + "type": "string", + "minLength": 1 + }, + "instrument": { + "type": "string", + "minLength": 1 + }, + "size": { + "$ref": "#/$defs/propSize", + "default": { + "length": 1, + "width": 1, + "unit": "8-to-5-steps" + } + }, + "appearance": { + "$ref": "#/$defs/appearance" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "prop" + } + }, + "required": ["type"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["section"] }, + { "required": ["instrument"] } + ] + } + }, + "else": { + "not": { + "required": ["size"] + } + } + } + ] + }, + "entityRules": { + "type": "object", + "additionalProperties": false, + "properties": { + "bySymbol": { + "type": "object", + "propertyNames": { + "minLength": 1, + "maxLength": 16 + }, + "additionalProperties": { + "$ref": "#/$defs/entityRuleValues" + } + }, + "byLabel": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/entityRuleValues" + } + }, + "byId": { + "type": "object", + "propertyNames": { + "pattern": "^(?:0|[1-9][0-9]*)$" + }, + "additionalProperties": { + "$ref": "#/$defs/entityRuleValues" + } + } + } + }, + "gridPoint": { + "type": "object", + "additionalProperties": false, + "required": ["xSteps", "ySteps"], + "properties": { + "xSteps": { + "type": "number" + }, + "ySteps": { + "type": "number" + } + } + }, + "position": { + "type": "object", + "additionalProperties": false, + "required": ["entityId", "setId", "xSteps", "ySteps"], + "properties": { + "entityId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "setId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "xSteps": { + "type": "number" + }, + "ySteps": { + "type": "number" + }, + "facingDegrees": { + "type": "number", + "minimum": 0, + "exclusiveMaximum": 360 + } + } + }, + "path": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["entityId", "fromSetId", "toSetId", "kind"], + "properties": { + "entityId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "fromSetId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "toSetId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "kind": { + "const": "straight" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "entityId", + "fromSetId", + "toSetId", + "kind", + "waypoints" + ], + "properties": { + "entityId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "fromSetId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "toSetId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "kind": { + "const": "polyline" + }, + "waypoints": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/gridPoint" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "entityId", + "fromSetId", + "toSetId", + "kind", + "controlPoints" + ], + "properties": { + "entityId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "fromSetId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "toSetId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "kind": { + "const": "bezier" + }, + "controlPoints": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "$ref": "#/$defs/gridPoint" + } + } + } + } + ] + }, + "physicalBounds": { + "type": "object", + "additionalProperties": false, + "required": ["minXMeters", "maxXMeters", "minYMeters", "maxYMeters"], + "properties": { + "minXMeters": { + "type": "number" + }, + "maxXMeters": { + "type": "number" + }, + "minYMeters": { + "type": "number" + }, + "maxYMeters": { + "type": "number" + } + } + }, + "gridBounds": { + "type": "object", + "additionalProperties": false, + "required": ["minXSteps", "maxXSteps", "minYSteps", "maxYSteps"], + "properties": { + "minXSteps": { + "type": "number" + }, + "maxXSteps": { + "type": "number" + }, + "minYSteps": { + "type": "number" + }, + "maxYSteps": { + "type": "number" + } + } + }, + "physicalReference": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "axis", "coordinateMeters"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "axis": { + "enum": ["x", "y"] + }, + "coordinateMeters": { + "type": "number" + } + } + }, + "gridReference": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "axis", "coordinateSteps"], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "axis": { + "enum": ["x", "y"] + }, + "coordinateSteps": { + "type": "number" + } + } + }, + "fieldMarkings": { + "type": "object", + "additionalProperties": false, + "required": ["yardNumbers", "inboundsHashMarks", "sidelineHashMarks"], + "properties": { + "yardNumbers": { + "type": "object", + "additionalProperties": false, + "required": [ + "heightMeters", + "nominalWidthMeters", + "centerFromFrontSidelineMeters", + "centerFromBackSidelineMeters" + ], + "properties": { + "heightMeters": { "type": "number", "exclusiveMinimum": 0 }, + "nominalWidthMeters": { "type": "number", "exclusiveMinimum": 0 }, + "centerFromFrontSidelineMeters": { "type": "number", "minimum": 0 }, + "centerFromBackSidelineMeters": { "type": "number", "minimum": 0 } + } + }, + "inboundsHashMarks": { + "type": "object", + "additionalProperties": false, + "required": ["lengthMeters", "spacingMeters"], + "properties": { + "lengthMeters": { "type": "number", "exclusiveMinimum": 0 }, + "spacingMeters": { "type": "number", "exclusiveMinimum": 0 } + } + }, + "sidelineHashMarks": { + "type": "object", + "additionalProperties": false, + "required": ["lengthMeters", "spacingMeters", "insetFromSidelineMeters"], + "properties": { + "lengthMeters": { "type": "number", "exclusiveMinimum": 0 }, + "spacingMeters": { "type": "number", "exclusiveMinimum": 0 }, + "insetFromSidelineMeters": { "type": "number", "minimum": 0 } + } + } + } + }, + "fieldDefinition": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "preset"], + "properties": { + "type": { + "const": "preset" + }, + "preset": { + "enum": [ + "football-nfhs", + "football-ncaa", + "football-texas-uil", + "football-nfl" + ] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "name", "physicalGeometry", "marchingGrid", "markings"], + "properties": { + "type": { + "const": "custom" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "physicalGeometry": { + "type": "object", + "additionalProperties": false, + "required": ["bounds", "referenceLines"], + "properties": { + "bounds": { + "$ref": "#/$defs/physicalBounds" + }, + "referenceLines": { + "type": "array", + "minItems": 4, + "items": { + "$ref": "#/$defs/physicalReference" + } + } + } + }, + "marchingGrid": { + "type": "object", + "additionalProperties": false, + "required": ["bounds", "referenceLines"], + "properties": { + "bounds": { + "$ref": "#/$defs/gridBounds" + }, + "referenceLines": { + "type": "array", + "minItems": 4, + "items": { + "$ref": "#/$defs/gridReference" + } + } + } + }, + "markings": { + "$ref": "#/$defs/fieldMarkings" + } + } + } + ] + }, + "sourceTarget": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["type", "setId"], + "properties": { + "type": { + "const": "set" + }, + "setId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "entityId"], + "properties": { + "type": { + "const": "entity" + }, + "entityId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "entityId", "setId"], + "properties": { + "type": { + "const": "position" + }, + "entityId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "setId": { + "$ref": "#/$defs/nonNegativeSafeInteger" + } + } + } + ] + }, + "provenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "source": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "minLength": 1 + }, + "fileName": { + "type": "string", + "minLength": 1 + } + } + }, + "importer": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + } + } + }, + "importedAt": { + "type": "string", + "format": "date-time" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["target"], + "properties": { + "target": { + "$ref": "#/$defs/sourceTarget" + }, + "page": { + "type": "integer", + "minimum": 1 + }, + "rawText": { + "type": "string" + } + } + } + } + } + } + }, + "$comment": "Cross-record semantic invariants (sequential set IDs, unique identities, valid references, consecutive paths, and measure end >= start) are additionally enforced by @eight2five/drill-schema's Zod validator." +} diff --git a/packages/drill-schema/package.json b/packages/drill-schema/package.json new file mode 100644 index 00000000..d3f3c36a --- /dev/null +++ b/packages/drill-schema/package.json @@ -0,0 +1,35 @@ +{ + "name": "@eight2five/drill-schema", + "version": "0.1.0", + "private": true, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./schema": "./drill-document.schema.json" + }, + "files": [ + "src", + "drill-document.schema.json" + ], + "scripts": { + "type-check": "tsc --noEmit -p tsconfig.json", + "test": "jest src --watchAll=false --passWithNoTests --runInBand" + }, + "dependencies": { + "zod": "^3.25.76" + }, + "jest": { + "testEnvironment": "node", + "transform": { + "^.+\\.[jt]sx?$": [ + "babel-jest", + { + "presets": [ + "babel-preset-expo" + ] + } + ] + } + } +} diff --git a/packages/drill-schema/src/__tests__/schema.test.ts b/packages/drill-schema/src/__tests__/schema.test.ts new file mode 100644 index 00000000..29b26e49 --- /dev/null +++ b/packages/drill-schema/src/__tests__/schema.test.ts @@ -0,0 +1,404 @@ +import { + COLOR_PRESETS, + DEFAULT_PROP_SIZE, + FIELD_PRESETS, + FIELD_PRESET_IDS, + countPrimarySets, + convertPropSizeValue, + drillGridToPhysicalPoint, + formatSetName, + getFieldPreset, + getGridReference, + isFieldPresetId, + parseDrillDocument, + physicalPointToDrillGrid, + resolveDrillEntity, + resolveFieldDefinition, + resolveEntityRuleValues, + resolvePropSize, + serializeDrillDocument, + type DrillDocument, +} from ".."; + +const fixture: DrillDocument = { + schema: "https://eight2five.com/schema/drill", + schemaVersion: "2.0.0", + metadata: { + title: "Part 4", + createdAt: "2026-08-02T17:30:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entityRules: { + bySymbol: { + B: { + instrument: "Baritone", + appearance: { color: COLOR_PRESETS.blue }, + }, + }, + byLabel: { + B1: { appearance: { color: COLOR_PRESETS.green } }, + }, + }, + entities: [ + { + id: 1595433022185, + type: "performer", + symbol: "B", + label: "B1", + }, + ], + sets: [ + { + id: 0, + number: 31, + kind: "set", + countsFromPrevious: 0, + measureRange: { start: 122, end: 125 }, + }, + { + id: 1, + number: 31, + suffix: "A", + kind: "subset", + countsFromPrevious: 8, + }, + { + id: 2, + number: 31, + suffix: ".5", + kind: "subset", + countsFromPrevious: 8, + }, + { + id: 3, + number: 32, + kind: "set", + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + }, + ], + positions: [ + { entityId: 1595433022185, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 1595433022185, setId: 1, xSteps: -4, ySteps: 28 }, + { entityId: 1595433022185, setId: 2, xSteps: -2, ySteps: 30 }, + { entityId: 1595433022185, setId: 3, xSteps: 0, ySteps: 32 }, + ], +}; + +describe("drill schema", () => { + it("accepts the current set/subset model and round trips JSON", () => { + const parsed = parseDrillDocument(fixture); + expect(countPrimarySets(parsed.sets)).toBe(2); + expect(formatSetName(parsed.sets[1])).toBe("31A"); + expect(formatSetName(parsed.sets[2])).toBe("31.5"); + expect(parseDrillDocument(JSON.parse(serializeDrillDocument(parsed)))).toEqual( + parsed, + ); + }); + + it("rejects arbitrary labels on sets", () => { + expect(() => + parseDrillDocument({ + ...fixture, + sets: [{ ...fixture.sets[0], label: "Finale" }], + }), + ).toThrow(); + }); + + it("requires set ids to follow array order", () => { + expect(() => + parseDrillDocument({ + ...fixture, + sets: fixture.sets.map((set, index) => + index === 1 ? { ...set, id: 9 } : set, + ), + }), + ).toThrow(/zero-based array index/); + }); + + it("requires subsets to share a number with a primary set", () => { + expect(() => + parseDrillDocument({ + ...fixture, + sets: fixture.sets.map((set, index) => + index === 1 ? { ...set, number: 99 } : set, + ), + }), + ).toThrow(/requires a primary set/); + }); + + it("accepts source symbols beyond A-Z", () => { + expect(() => + parseDrillDocument({ + ...fixture, + entityRules: { bySymbol: { $: { section: "Guard" } } }, + entities: [{ ...fixture.entities[0], symbol: "$", label: "$1" }], + }), + ).not.toThrow(); + }); + + it("resolves entity rules from broad to specific", () => { + const rules = { + ...fixture.entityRules, + byLabel: { + B1: { + name: "Lead Baritone", + appearance: { color: COLOR_PRESETS.green }, + }, + }, + }; + const entity = resolveDrillEntity(fixture.entities[0], rules); + expect(entity.name).toBe("Lead Baritone"); + expect(entity.instrument).toBe("Baritone"); + expect(entity.appearance).toEqual({ + icon: "dot", + color: COLOR_PRESETS.green, + labelVisible: true, + }); + expect(resolveEntityRuleValues(fixture.entities[0], rules)).toMatchObject({ + name: "Lead Baritone", + instrument: "Baritone", + }); + }); + + it("allows names on both performers and props but rejects performer-only prop fields", () => { + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + id: 1595433022185, + type: "prop", + symbol: "P", + label: "P1", + name: "Podium", + }, + ], + }), + ).not.toThrow(); + + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + id: 1595433022185, + type: "prop", + symbol: "P", + label: "P1", + name: "Podium", + section: "Guard", + }, + ], + }), + ).toThrow(/Props cannot define a section/); + + expect(() => + parseDrillDocument({ + ...fixture, + entityRules: { + bySymbol: { + P: { type: "prop", instrument: "Flag" }, + }, + }, + }), + ).toThrow(/Prop rules cannot define an instrument/); + }); + + it("defaults prop size to 1 by 1 8-to-5 steps and supports unit conversion", () => { + const prop = { + ...fixture.entities[0], + type: "prop" as const, + symbol: "P", + label: "P1", + }; + expect(resolvePropSize(prop)).toEqual(DEFAULT_PROP_SIZE); + expect(resolveDrillEntity(prop).size).toEqual(DEFAULT_PROP_SIZE); + expect(convertPropSizeValue(1, "8-to-5-steps", "inches")).toBeCloseTo( + 22.5, + 8, + ); + expect(convertPropSizeValue(1, "8-to-5-steps", "feet")).toBeCloseTo( + 1.875, + 8, + ); + expect(convertPropSizeValue(1, "8-to-5-steps", "meters")).toBeCloseTo( + 0.5715, + 8, + ); + }); + + it.each(["8-to-5-steps", "feet", "inches", "meters"] as const)( + "accepts prop size in %s", + (unit) => { + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + id: 1595433022185, + type: "prop", + symbol: "P", + label: "P1", + size: { length: 2.5, width: 1.25, unit }, + }, + ], + }), + ).not.toThrow(); + }, + ); + + it("rejects size on performers and non-prop rules", () => { + expect(() => + parseDrillDocument({ + ...fixture, + entities: [ + { + ...fixture.entities[0], + size: { length: 1, width: 1, unit: "8-to-5-steps" }, + }, + ], + }), + ).toThrow(/Only props can define a size/); + + expect(() => + parseDrillDocument({ + ...fixture, + entityRules: { + bySymbol: { + B: { + type: "performer", + size: { length: 1, width: 1, unit: "feet" }, + }, + }, + }, + }), + ).toThrow(/Only prop rules can define a size/); + }); + + it("exposes the canonical color presets without indigo or violet", () => { + expect(COLOR_PRESETS).toMatchObject({ + lightBlue: "#64B5F6", + darkBlue: "#1E3A8A", + purple: "#8E44AD", + pink: "#EC4899", + black: "#000000", + }); + expect("indigo" in COLOR_PRESETS).toBe(false); + expect("violet" in COLOR_PRESETS).toBe(false); + }); + + it("exposes all field preset ids from one canonical registry", () => { + expect(FIELD_PRESET_IDS).toEqual(Object.keys(FIELD_PRESETS)); + for (const id of FIELD_PRESET_IDS) expect(isFieldPresetId(id)).toBe(true); + expect(isFieldPresetId("football-made-up")).toBe(false); + }); + + it.each([ + ["football-nfhs", 53 + 4 / 12, 24, 4], + ["football-ncaa", 60, 24, 4], + ["football-texas-uil", 60, 24, 4], + ["football-nfl", 70 + 9 / 12, 39, 8], + ] as const)( + "%s exposes its physical football markings", + (presetId, hashFeet, numberCenterFeet, sidelineInsetInches) => { + const field = getFieldPreset(presetId); + const frontHash = field.physicalGeometry.referenceLines.find( + (line) => line.id === "front-hash", + ); + const backHash = field.physicalGeometry.referenceLines.find( + (line) => line.id === "back-hash", + ); + expect(frontHash?.coordinateMeters).toBeCloseTo(hashFeet * 0.3048, 8); + expect(backHash?.coordinateMeters).toBeCloseTo( + 160 * 0.3048 - hashFeet * 0.3048, + 8, + ); + expect(field.markings).toMatchObject({ + yardNumbers: { + heightMeters: 6 * 0.3048, + nominalWidthMeters: 4 * 0.3048, + centerFromFrontSidelineMeters: numberCenterFeet * 0.3048, + centerFromBackSidelineMeters: numberCenterFeet * 0.3048, + }, + inboundsHashMarks: { + lengthMeters: 2 * 0.3048, + spacingMeters: 0.9144, + }, + sidelineHashMarks: { + lengthMeters: 2 * 0.3048, + spacingMeters: 0.9144, + insetFromSidelineMeters: (sidelineInsetInches / 12) * 0.3048, + }, + }); + expect(field.markings.yardNumbers.centerFromFrontSidelineMeters).toBe( + field.markings.yardNumbers.centerFromBackSidelineMeters, + ); + }, + ); + + it("parses self-describing custom field markings", () => { + const preset = getFieldPreset("football-nfhs"); + const parsed = parseDrillDocument({ + ...fixture, + field: { + type: "custom", + name: "Custom Football Field", + physicalGeometry: preset.physicalGeometry, + marchingGrid: preset.marchingGrid, + markings: preset.markings, + }, + }); + expect(parsed.field).toMatchObject({ + type: "custom", + markings: preset.markings, + }); + expect(resolveFieldDefinition(parsed.field).markings).toEqual( + preset.markings, + ); + }); + + it.each(FIELD_PRESET_IDS)( + "%s uses the canonical 160 by 84 marching-grid bounds", + (preset) => { + expect(getFieldPreset(preset).marchingGrid.bounds).toEqual({ + minXSteps: -80, + maxXSteps: 80, + minYSteps: 0, + maxYSteps: 84, + }); + }, + ); + + it("keeps the conventional NFHS lateral references at 0/28/56/84", () => { + const field = getFieldPreset("football-nfhs"); + expect(getGridReference(field, "front-sideline")?.coordinateSteps).toBe(0); + expect(getGridReference(field, "front-hash")?.coordinateSteps).toBe(28); + expect(getGridReference(field, "back-hash")?.coordinateSteps).toBe(56); + expect(getGridReference(field, "back-sideline")?.coordinateSteps).toBe(84); + }); + + it.each(["football-ncaa", "football-texas-uil"] as const)( + "%s keeps its schema-defined 0/32/52/84 lateral references", + (preset) => { + const field = getFieldPreset(preset); + expect(getGridReference(field, "front-sideline")?.coordinateSteps).toBe(0); + expect(getGridReference(field, "front-hash")?.coordinateSteps).toBe(32); + expect(getGridReference(field, "back-hash")?.coordinateSteps).toBe(52); + expect(getGridReference(field, "back-sideline")?.coordinateSteps).toBe(84); + }, + ); + + it("maps the conventional NFHS front hash to exact physical geometry", () => { + const physical = drillGridToPhysicalPoint( + { xSteps: 0, ySteps: 28 }, + fixture.field, + ); + expect(physical.xMeters).toBeCloseTo(0, 8); + expect(physical.yMeters).toBeCloseTo((53 + 4 / 12) * 0.3048, 8); + + const grid = physicalPointToDrillGrid(physical, fixture.field); + expect(grid.xSteps).toBeCloseTo(0, 8); + expect(grid.ySteps).toBeCloseTo(28, 8); + }); +}); diff --git a/packages/drill-schema/src/entities.ts b/packages/drill-schema/src/entities.ts new file mode 100644 index 00000000..71cfa4e8 --- /dev/null +++ b/packages/drill-schema/src/entities.ts @@ -0,0 +1,169 @@ +import type { + DrillEntity, + EntityAppearance, + EntityRuleValues, + EntityRules, + PropSize, + PropSizeUnit, + ResolvedDrillEntity, + ResolvedEntityAppearance, +} from "./types"; + +export const DEFAULT_ENTITY_COLOR = "#808080" as const; +export const EIGHT_TO_FIVE_STEP_INCHES = 22.5 as const; +export const DEFAULT_PROP_SIZE = Object.freeze({ + length: 1, + width: 1, + unit: "8-to-5-steps", +} as const satisfies PropSize); + +export const COLOR_PRESETS = Object.freeze({ + grey: DEFAULT_ENTITY_COLOR, + red: "#E53935", + orange: "#FB8C00", + yellow: "#FDD835", + green: "#43A047", + lightBlue: "#64B5F6", + blue: "#3C6EC8", + darkBlue: "#1E3A8A", + purple: "#8E44AD", + pink: "#EC4899", + black: "#000000", +} as const); + +export function resolveDrillEntity( + entity: DrillEntity, + rules?: EntityRules, +): ResolvedDrillEntity { + const symbolRule = rules?.bySymbol?.[entity.symbol]; + const labelRule = rules?.byLabel?.[entity.label]; + const idRule = rules?.byId?.[String(entity.id)]; + const mergedRule = resolveEntityRuleValues(entity, rules); + const type = entity.type ?? mergedRule.type ?? "performer"; + const name = entity.name ?? mergedRule.name; + const section = type === "prop" ? undefined : entity.section ?? mergedRule.section; + const instrument = + type === "prop" ? undefined : entity.instrument ?? mergedRule.instrument; + const size = + type === "prop" + ? entity.size ?? mergedRule.size ?? DEFAULT_PROP_SIZE + : undefined; + + return { + ...entity, + type, + ...(name === undefined ? {} : { name }), + ...(section === undefined ? {} : { section }), + ...(instrument === undefined ? {} : { instrument }), + ...(size === undefined ? {} : { size }), + appearance: resolveAppearance( + type, + symbolRule?.appearance, + labelRule?.appearance, + idRule?.appearance, + entity.appearance, + ), + }; +} + +export function resolveEntityRuleValues( + entity: Pick, + rules?: EntityRules, +): EntityRuleValues { + return mergeRuleValues( + rules?.bySymbol?.[entity.symbol], + rules?.byLabel?.[entity.label], + rules?.byId?.[String(entity.id)], + ); +} + +export function resolveEntityAppearance( + entity: DrillEntity, + rules?: EntityRules, +): ResolvedEntityAppearance { + return resolveDrillEntity(entity, rules).appearance; +} + +export function resolvePropSize( + entity: DrillEntity, + rules?: EntityRules, +): PropSize | undefined { + const resolved = resolveDrillEntity(entity, rules); + return resolved.type === "prop" ? resolved.size ?? DEFAULT_PROP_SIZE : undefined; +} + +export function convertPropSizeValue( + value: number, + fromUnit: PropSizeUnit, + toUnit: PropSizeUnit, +): number { + if (fromUnit === toUnit) return value; + const meters = value * propSizeUnitMeters(fromUnit); + return meters / propSizeUnitMeters(toUnit); +} + +function propSizeUnitMeters(unit: PropSizeUnit): number { + switch (unit) { + case "8-to-5-steps": + return EIGHT_TO_FIVE_STEP_INCHES * 0.0254; + case "feet": + return 0.3048; + case "inches": + return 0.0254; + case "meters": + return 1; + } +} + +function resolveAppearance( + type: DrillEntity["type"], + ...appearances: readonly (EntityAppearance | undefined)[] +): ResolvedEntityAppearance { + let resolved: ResolvedEntityAppearance = { + icon: type === "prop" ? "square" : "dot", + color: DEFAULT_ENTITY_COLOR, + labelVisible: true, + }; + for (const appearance of appearances) { + if (!appearance) continue; + resolved = { + icon: appearance.icon ?? resolved.icon, + color: appearance.color ?? resolved.color, + labelVisible: appearance.labelVisible ?? resolved.labelVisible, + }; + } + return resolved; +} + +function mergeRuleValues( + ...values: readonly (EntityRuleValues | undefined)[] +): EntityRuleValues { + let type: EntityRuleValues["type"]; + let name: string | undefined; + let section: string | undefined; + let instrument: string | undefined; + let size: PropSize | undefined; + let appearance: EntityAppearance | undefined; + for (const value of values) { + if (!value) continue; + type = value.type ?? type; + name = value.name ?? name; + section = value.section ?? section; + instrument = value.instrument ?? instrument; + size = value.size ?? size; + if (value.appearance) { + appearance = { + ...(appearance ?? {}), + ...value.appearance, + }; + } + } + return { + ...(type === undefined ? {} : { type }), + ...(name === undefined ? {} : { name }), + ...(section === undefined ? {} : { section }), + ...(instrument === undefined ? {} : { instrument }), + ...(size === undefined ? {} : { size }), + ...(appearance === undefined ? {} : { appearance }), + }; +} diff --git a/packages/drill-schema/src/field-presets.ts b/packages/drill-schema/src/field-presets.ts new file mode 100644 index 00000000..35f35b31 --- /dev/null +++ b/packages/drill-schema/src/field-presets.ts @@ -0,0 +1,243 @@ +import { + FIELD_PRESET_IDS, + type FieldPresetId, + type FieldMarkingDefinition, + type MarchingReferenceLine, + type PhysicalReferenceLine, + type ResolvedFieldDefinition, +} from "./types"; + +const FEET_TO_METERS = 0.3048; +const YARDS_TO_METERS = 0.9144; +const FIELD_HALF_LENGTH_METERS = 50 * YARDS_TO_METERS; +const FIELD_WIDTH_METERS = 160 * FEET_TO_METERS; + +function footballMarkings({ + yardNumberCenterFeet, + sidelineInsetInches, +}: { + yardNumberCenterFeet: number; + sidelineInsetInches: number; +}): FieldMarkingDefinition { + return Object.freeze({ + yardNumbers: Object.freeze({ + heightMeters: 6 * FEET_TO_METERS, + nominalWidthMeters: 4 * FEET_TO_METERS, + centerFromFrontSidelineMeters: yardNumberCenterFeet * FEET_TO_METERS, + centerFromBackSidelineMeters: yardNumberCenterFeet * FEET_TO_METERS, + }), + inboundsHashMarks: Object.freeze({ + lengthMeters: 2 * FEET_TO_METERS, + spacingMeters: YARDS_TO_METERS, + }), + sidelineHashMarks: Object.freeze({ + lengthMeters: 2 * FEET_TO_METERS, + spacingMeters: YARDS_TO_METERS, + insetFromSidelineMeters: (sidelineInsetInches / 12) * FEET_TO_METERS, + }), + }); +} + +function xReferenceLines(): { + physical: readonly PhysicalReferenceLine[]; + marching: readonly MarchingReferenceLine[]; +} { + const physical: PhysicalReferenceLine[] = []; + const marching: MarchingReferenceLine[] = []; + for (let absoluteYards = -50; absoluteYards <= 50; absoluteYards += 5) { + const id = + absoluteYards === 0 + ? "50-yard-line" + : `${absoluteYards < 0 ? "side-1" : "side-2"}-${50 - Math.abs(absoluteYards)}-yard-line`; + const name = + absoluteYards === 0 + ? "50 Yard Line" + : `${absoluteYards < 0 ? "Side 1" : "Side 2"} ${50 - Math.abs(absoluteYards)} Yard Line`; + physical.push({ + id, + name, + axis: "x", + coordinateMeters: absoluteYards * YARDS_TO_METERS, + }); + marching.push({ + id, + name, + axis: "x", + coordinateSteps: (absoluteYards / 5) * 8, + }); + } + return { physical: Object.freeze(physical), marching: Object.freeze(marching) }; +} + +const X_REFERENCES = xReferenceLines(); + +function makeFootballPreset({ + id, + name, + physicalFrontHashFeet, + gridFrontHashSteps, + gridBackHashSteps, + markings, +}: { + id: FieldPresetId; + name: string; + physicalFrontHashFeet: number; + gridFrontHashSteps: number; + gridBackHashSteps: number; + markings: FieldMarkingDefinition; +}): ResolvedFieldDefinition { + const physicalFrontHashMeters = physicalFrontHashFeet * FEET_TO_METERS; + const physicalBackHashMeters = FIELD_WIDTH_METERS - physicalFrontHashMeters; + const physicalYReferences: readonly PhysicalReferenceLine[] = Object.freeze([ + { + id: "front-sideline", + name: "Front Sideline", + axis: "y", + coordinateMeters: 0, + }, + { + id: "front-hash", + name: "Front Hash", + axis: "y", + coordinateMeters: physicalFrontHashMeters, + }, + { + id: "back-hash", + name: "Back Hash", + axis: "y", + coordinateMeters: physicalBackHashMeters, + }, + { + id: "back-sideline", + name: "Back Sideline", + axis: "y", + coordinateMeters: FIELD_WIDTH_METERS, + }, + ]); + const marchingYReferences: readonly MarchingReferenceLine[] = Object.freeze([ + { + id: "front-sideline", + name: "Front Sideline", + axis: "y", + coordinateSteps: 0, + }, + { + id: "front-hash", + name: "Front Hash", + axis: "y", + coordinateSteps: gridFrontHashSteps, + }, + { + id: "back-hash", + name: "Back Hash", + axis: "y", + coordinateSteps: gridBackHashSteps, + }, + { + id: "back-sideline", + name: "Back Sideline", + axis: "y", + coordinateSteps: 84, + }, + ]); + + return Object.freeze({ + id, + name, + physicalGeometry: Object.freeze({ + bounds: Object.freeze({ + minXMeters: -FIELD_HALF_LENGTH_METERS, + maxXMeters: FIELD_HALF_LENGTH_METERS, + minYMeters: 0, + maxYMeters: FIELD_WIDTH_METERS, + }), + referenceLines: Object.freeze([ + ...X_REFERENCES.physical, + ...physicalYReferences, + ]), + }), + marchingGrid: Object.freeze({ + bounds: Object.freeze({ + minXSteps: -80, + maxXSteps: 80, + minYSteps: 0, + maxYSteps: 84, + }), + referenceLines: Object.freeze([ + ...X_REFERENCES.marching, + ...marchingYReferences, + ]), + }), + markings, + }); +} + +const FIELD_PRESET_ID_SET = new Set(FIELD_PRESET_IDS); + +export function isFieldPresetId(value: unknown): value is FieldPresetId { + return ( + typeof value === "string" && + FIELD_PRESET_ID_SET.has(value as FieldPresetId) + ); +} + +/** + * Presets keep exact physical football geometry separate from conventional + * marching-grid references. The grid is intentionally not a literal inches + * measurement: e.g. NFHS hashes are conventionally 28/56 on an 84-step grid. + */ +export const FIELD_PRESETS: Readonly> = + Object.freeze({ + "football-nfhs": makeFootballPreset({ + id: "football-nfhs", + name: "High School (NFHS)", + physicalFrontHashFeet: 53 + 4 / 12, + gridFrontHashSteps: 28, + gridBackHashSteps: 56, + // The 2025 NFHS diagram retains two-foot marks just inside the sideline; + // use the established four-inch inside clearance convention. + markings: footballMarkings({ + yardNumberCenterFeet: 24, + sidelineInsetInches: 4, + }), + }), + "football-ncaa": makeFootballPreset({ + id: "football-ncaa", + name: "College (NCAA)", + physicalFrontHashFeet: 60, + gridFrontHashSteps: 32, + gridBackHashSteps: 52, + markings: footballMarkings({ + yardNumberCenterFeet: 24, + sidelineInsetInches: 4, + }), + }), + "football-texas-uil": makeFootballPreset({ + id: "football-texas-uil", + name: "Texas High School (UIL)", + physicalFrontHashFeet: 60, + gridFrontHashSteps: 32, + gridBackHashSteps: 52, + markings: footballMarkings({ + yardNumberCenterFeet: 24, + sidelineInsetInches: 4, + }), + }), + "football-nfl": makeFootballPreset({ + id: "football-nfl", + name: "Professional (NFL)", + physicalFrontHashFeet: 70 + 9 / 12, + // There is no broadly standardized marching-band NFL grid convention. + // Preserve the physical hash proportions on Eight2Five's 84-step grid. + gridFrontHashSteps: ((70 + 9 / 12) / 160) * 84, + gridBackHashSteps: 84 - ((70 + 9 / 12) / 160) * 84, + markings: footballMarkings({ + yardNumberCenterFeet: 39, + sidelineInsetInches: 8, + }), + }), + }); + +export function getFieldPreset(id: FieldPresetId): ResolvedFieldDefinition { + return FIELD_PRESETS[id]; +} diff --git a/packages/drill-schema/src/field-projection.ts b/packages/drill-schema/src/field-projection.ts new file mode 100644 index 00000000..b5b1fbaf --- /dev/null +++ b/packages/drill-schema/src/field-projection.ts @@ -0,0 +1,160 @@ +import { getFieldPreset } from "./field-presets"; +import type { + CustomFieldDefinition, + DrillGridPoint, + FieldDefinition, + MarchingReferenceLine, + PhysicalFieldPoint, + PhysicalReferenceLine, + ResolvedFieldDefinition, +} from "./types"; + +interface AxisReferencePair { + readonly id: string; + readonly grid: number; + readonly physical: number; +} + +export function resolveFieldDefinition( + field: FieldDefinition, +): ResolvedFieldDefinition { + if (field.type === "preset") return getFieldPreset(field.preset); + return resolveCustomField(field); +} + +function resolveCustomField(field: CustomFieldDefinition): ResolvedFieldDefinition { + return { + id: "custom", + name: field.name, + physicalGeometry: field.physicalGeometry, + marchingGrid: field.marchingGrid, + markings: field.markings, + }; +} + +export function drillGridToPhysicalPoint( + point: DrillGridPoint, + field: FieldDefinition | ResolvedFieldDefinition, +): PhysicalFieldPoint { + const resolved = isResolvedField(field) ? field : resolveFieldDefinition(field); + return { + xMeters: projectAxis( + point.xSteps, + pairReferences(resolved, "x"), + "grid", + "physical", + ), + yMeters: projectAxis( + point.ySteps, + pairReferences(resolved, "y"), + "grid", + "physical", + ), + }; +} + +export function physicalPointToDrillGrid( + point: PhysicalFieldPoint, + field: FieldDefinition | ResolvedFieldDefinition, +): DrillGridPoint { + const resolved = isResolvedField(field) ? field : resolveFieldDefinition(field); + return { + xSteps: projectAxis( + point.xMeters, + pairReferences(resolved, "x"), + "physical", + "grid", + ), + ySteps: projectAxis( + point.yMeters, + pairReferences(resolved, "y"), + "physical", + "grid", + ), + }; +} + +function isResolvedField( + field: FieldDefinition | ResolvedFieldDefinition, +): field is ResolvedFieldDefinition { + return "physicalGeometry" in field && "marchingGrid" in field; +} + +function pairReferences( + field: ResolvedFieldDefinition, + axis: "x" | "y", +): readonly AxisReferencePair[] { + const physicalById = new Map(); + for (const reference of field.physicalGeometry.referenceLines) { + if (reference.axis === axis) physicalById.set(reference.id, reference); + } + + const pairs: AxisReferencePair[] = []; + for (const gridReference of field.marchingGrid.referenceLines) { + if (gridReference.axis !== axis) continue; + const physicalReference = physicalById.get(gridReference.id); + if (!physicalReference) continue; + pairs.push({ + id: gridReference.id, + grid: gridReference.coordinateSteps, + physical: physicalReference.coordinateMeters, + }); + } + + if (pairs.length < 2) { + throw new RangeError( + `Field must define at least two matched ${axis.toUpperCase()} reference lines.`, + ); + } + return pairs.sort((a, b) => a.grid - b.grid); +} + +function projectAxis( + value: number, + pairs: readonly AxisReferencePair[], + from: "grid" | "physical", + to: "grid" | "physical", +): number { + if (!Number.isFinite(value)) { + throw new RangeError("Projected coordinate must be finite."); + } + + const sorted = [...pairs].sort((a, b) => a[from] - b[from]); + const segment = findSegment(value, sorted, from); + const start = segment[0]; + const end = segment[1]; + const fromSpan = end[from] - start[from]; + if (Math.abs(fromSpan) < Number.EPSILON) { + throw new RangeError( + `Field projection has duplicate ${from} coordinates for ${start.id} and ${end.id}.`, + ); + } + const ratio = (value - start[from]) / fromSpan; + return start[to] + ratio * (end[to] - start[to]); +} + +function findSegment( + value: number, + sorted: readonly AxisReferencePair[], + key: "grid" | "physical", +): readonly [AxisReferencePair, AxisReferencePair] { + if (value <= sorted[0][key]) return [sorted[0], sorted[1]]; + const lastIndex = sorted.length - 1; + if (value >= sorted[lastIndex][key]) { + return [sorted[lastIndex - 1], sorted[lastIndex]]; + } + for (let index = 0; index < lastIndex; index += 1) { + if (value >= sorted[index][key] && value <= sorted[index + 1][key]) { + return [sorted[index], sorted[index + 1]]; + } + } + return [sorted[0], sorted[1]]; +} + +export function getGridReference( + field: FieldDefinition | ResolvedFieldDefinition, + id: string, +): MarchingReferenceLine | undefined { + const resolved = isResolvedField(field) ? field : resolveFieldDefinition(field); + return resolved.marchingGrid.referenceLines.find((line) => line.id === id); +} diff --git a/packages/drill-schema/src/index.ts b/packages/drill-schema/src/index.ts new file mode 100644 index 00000000..814b6079 --- /dev/null +++ b/packages/drill-schema/src/index.ts @@ -0,0 +1,6 @@ +export * from "./entities"; +export * from "./field-presets"; +export * from "./field-projection"; +export * from "./schema"; +export * from "./sets"; +export * from "./types"; diff --git a/packages/drill-schema/src/schema.ts b/packages/drill-schema/src/schema.ts new file mode 100644 index 00000000..1b26db2e --- /dev/null +++ b/packages/drill-schema/src/schema.ts @@ -0,0 +1,702 @@ +import { z } from "zod"; + +import { SET_SUFFIX_PATTERN } from "./sets"; +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + FIELD_PRESET_IDS, + PROP_SIZE_UNITS, + type DrillDocument, +} from "./types"; + +const safeNonNegativeInteger = z + .number() + .int() + .min(0) + .max(Number.MAX_SAFE_INTEGER); +const finiteNumber = z.number().finite(); +const positiveFiniteNumber = finiteNumber.gt(0); +const nonNegativeFiniteNumber = finiteNumber.min(0); +const nonEmptyText = z.string().trim().min(1); +const hexColor = z.string().regex(/^#[0-9A-Fa-f]{6}$/); +const symbol = z.string().trim().min(1).max(16); +const lucideIcon = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + +export const measureRangeSchema = z + .object({ + start: safeNonNegativeInteger, + end: safeNonNegativeInteger, + }) + .strict() + .superRefine((range, context) => { + if (range.end < range.start) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["end"], + message: "Measure range end must be greater than or equal to start.", + }); + } + }); + +export const drillSetSchema = z + .object({ + id: safeNonNegativeInteger, + number: safeNonNegativeInteger, + suffix: z.string().regex(SET_SUFFIX_PATTERN).optional(), + kind: z.enum(["set", "subset"]), + countsFromPrevious: safeNonNegativeInteger, + measureRange: measureRangeSchema.optional(), + }) + .strict() + .superRefine((set, context) => { + if (set.kind === "set" && set.suffix !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["suffix"], + message: "Primary sets cannot have a suffix.", + }); + } + if (set.kind === "subset" && set.suffix === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["suffix"], + message: "Subsets must have a letter or decimal suffix.", + }); + } + }); + +export const entityIconSchema = z.enum([ + "dot", + "square", + "triangle", + "diamond", + "star", + "hexagon", + "cross", +]); + +export const entityAppearanceSchema = z + .object({ + icon: entityIconSchema.optional(), + color: hexColor.optional(), + labelVisible: z.boolean().optional(), + }) + .strict(); + +export const propSizeSchema = z + .object({ + length: positiveFiniteNumber, + width: positiveFiniteNumber, + unit: z.enum(PROP_SIZE_UNITS), + }) + .strict(); + +export const drillEntitySchema = z + .object({ + id: safeNonNegativeInteger, + type: z.enum(["performer", "prop"]), + symbol, + label: nonEmptyText, + name: nonEmptyText.optional(), + section: nonEmptyText.optional(), + instrument: nonEmptyText.optional(), + size: propSizeSchema.optional(), + appearance: entityAppearanceSchema.optional(), + }) + .strict() + .superRefine((entity, context) => { + if (entity.type !== "prop") { + if (entity.size !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["size"], + message: "Only props can define a size.", + }); + } + return; + } + if (entity.section !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["section"], + message: "Props cannot define a section.", + }); + } + if (entity.instrument !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["instrument"], + message: "Props cannot define an instrument.", + }); + } + }); + +export const entityRuleValuesSchema = z + .object({ + type: z.enum(["performer", "prop"]).optional(), + name: nonEmptyText.optional(), + section: nonEmptyText.optional(), + instrument: nonEmptyText.optional(), + size: propSizeSchema.optional(), + appearance: entityAppearanceSchema.optional(), + }) + .strict() + .superRefine((rule, context) => { + if (rule.type !== "prop") { + if (rule.size !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["size"], + message: "Only prop rules can define a size.", + }); + } + return; + } + if (rule.section !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["section"], + message: "Prop rules cannot define a section.", + }); + } + if (rule.instrument !== undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["instrument"], + message: "Prop rules cannot define an instrument.", + }); + } + }); + +const ruleMapSchema = z.record(entityRuleValuesSchema); + +export const entityRulesSchema = z + .object({ + bySymbol: ruleMapSchema.optional(), + byLabel: ruleMapSchema.optional(), + byId: ruleMapSchema.optional(), + }) + .strict() + .superRefine((rules, context) => { + for (const key of Object.keys(rules.bySymbol ?? {})) { + if (key.trim().length === 0 || key.length > 16) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["bySymbol", key], + message: "Symbol rule keys must be 1-16 non-whitespace characters.", + }); + } + } + for (const key of Object.keys(rules.byId ?? {})) { + const parsed = Number(key); + if ( + !/^(?:0|[1-9][0-9]*)$/.test(key) || + !Number.isSafeInteger(parsed) || + parsed < 0 + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["byId", key], + message: "ID rule keys must be non-negative safe integer strings.", + }); + } + } + }); + +export const drillGridPointSchema = z + .object({ + xSteps: finiteNumber, + ySteps: finiteNumber, + }) + .strict(); + +export const drillPositionSchema = z + .object({ + entityId: safeNonNegativeInteger, + setId: safeNonNegativeInteger, + xSteps: finiteNumber, + ySteps: finiteNumber, + facingDegrees: finiteNumber.min(0).lt(360).optional(), + }) + .strict(); + +const pathBase = { + entityId: safeNonNegativeInteger, + fromSetId: safeNonNegativeInteger, + toSetId: safeNonNegativeInteger, +}; + +export const drillPathSchema = z.discriminatedUnion("kind", [ + z + .object({ + ...pathBase, + kind: z.literal("straight"), + }) + .strict(), + z + .object({ + ...pathBase, + kind: z.literal("polyline"), + waypoints: z.array(drillGridPointSchema).min(1), + }) + .strict(), + z + .object({ + ...pathBase, + kind: z.literal("bezier"), + controlPoints: z.tuple([drillGridPointSchema, drillGridPointSchema]), + }) + .strict(), +]); + +const physicalBoundsSchema = z + .object({ + minXMeters: finiteNumber, + maxXMeters: finiteNumber, + minYMeters: finiteNumber, + maxYMeters: finiteNumber, + }) + .strict() + .superRefine((bounds, context) => { + if (bounds.maxXMeters <= bounds.minXMeters) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["maxXMeters"], + message: "Physical X bounds must have positive width.", + }); + } + if (bounds.maxYMeters <= bounds.minYMeters) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["maxYMeters"], + message: "Physical Y bounds must have positive height.", + }); + } + }); + +const marchingBoundsSchema = z + .object({ + minXSteps: finiteNumber, + maxXSteps: finiteNumber, + minYSteps: finiteNumber, + maxYSteps: finiteNumber, + }) + .strict() + .superRefine((bounds, context) => { + if (bounds.maxXSteps <= bounds.minXSteps) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["maxXSteps"], + message: "Marching X bounds must have positive width.", + }); + } + if (bounds.maxYSteps <= bounds.minYSteps) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["maxYSteps"], + message: "Marching Y bounds must have positive height.", + }); + } + }); + +const physicalReferenceLineSchema = z + .object({ + id: nonEmptyText, + name: nonEmptyText, + axis: z.enum(["x", "y"]), + coordinateMeters: finiteNumber, + }) + .strict(); + +const marchingReferenceLineSchema = z + .object({ + id: nonEmptyText, + name: nonEmptyText, + axis: z.enum(["x", "y"]), + coordinateSteps: finiteNumber, + }) + .strict(); + +const physicalGeometrySchema = z + .object({ + bounds: physicalBoundsSchema, + referenceLines: z.array(physicalReferenceLineSchema).min(4), + }) + .strict(); + +const marchingGridSchema = z + .object({ + bounds: marchingBoundsSchema, + referenceLines: z.array(marchingReferenceLineSchema).min(4), + }) + .strict(); + +const fieldMarkingsSchema = z + .object({ + yardNumbers: z + .object({ + heightMeters: positiveFiniteNumber, + nominalWidthMeters: positiveFiniteNumber, + centerFromFrontSidelineMeters: nonNegativeFiniteNumber, + centerFromBackSidelineMeters: nonNegativeFiniteNumber, + }) + .strict(), + inboundsHashMarks: z + .object({ + lengthMeters: positiveFiniteNumber, + spacingMeters: positiveFiniteNumber, + }) + .strict(), + sidelineHashMarks: z + .object({ + lengthMeters: positiveFiniteNumber, + spacingMeters: positiveFiniteNumber, + insetFromSidelineMeters: nonNegativeFiniteNumber, + }) + .strict(), + }) + .strict(); + +const presetFieldSchema = z + .object({ + type: z.literal("preset"), + preset: z.enum(FIELD_PRESET_IDS), + }) + .strict(); + +const customFieldSchema = z + .object({ + type: z.literal("custom"), + name: nonEmptyText, + physicalGeometry: physicalGeometrySchema, + marchingGrid: marchingGridSchema, + markings: fieldMarkingsSchema, + }) + .strict() + .superRefine((field, context) => { + const physical = new Map(); + for (const [index, line] of field.physicalGeometry.referenceLines.entries()) { + if (physical.has(line.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["physicalGeometry", "referenceLines", index, "id"], + message: `Duplicate physical reference id ${line.id}.`, + }); + } + physical.set(line.id, { axis: line.axis, coordinate: line.coordinateMeters }); + } + + const matchedByAxis = { x: 0, y: 0 }; + const gridCoordinates = { x: new Set(), y: new Set() }; + const physicalCoordinates = { x: new Set(), y: new Set() }; + const seenGridIds = new Set(); + for (const [index, line] of field.marchingGrid.referenceLines.entries()) { + if (seenGridIds.has(line.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["marchingGrid", "referenceLines", index, "id"], + message: `Duplicate marching reference id ${line.id}.`, + }); + } + seenGridIds.add(line.id); + const physicalLine = physical.get(line.id); + if (!physicalLine) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["marchingGrid", "referenceLines", index, "id"], + message: `No matching physical reference for ${line.id}.`, + }); + continue; + } + if (physicalLine.axis !== line.axis) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["marchingGrid", "referenceLines", index, "axis"], + message: `Reference ${line.id} must use the same axis in both coordinate spaces.`, + }); + continue; + } + matchedByAxis[line.axis] += 1; + if (gridCoordinates[line.axis].has(line.coordinateSteps)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["marchingGrid", "referenceLines", index, "coordinateSteps"], + message: `Matched ${line.axis.toUpperCase()} grid references must use unique coordinates.`, + }); + } + if (physicalCoordinates[line.axis].has(physicalLine.coordinate)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["physicalGeometry", "referenceLines"], + message: `Matched ${line.axis.toUpperCase()} physical references must use unique coordinates.`, + }); + } + gridCoordinates[line.axis].add(line.coordinateSteps); + physicalCoordinates[line.axis].add(physicalLine.coordinate); + } + + for (const axis of ["x", "y"] as const) { + if (matchedByAxis[axis] < 2) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["marchingGrid", "referenceLines"], + message: `Custom fields require at least two matched ${axis.toUpperCase()} references.`, + }); + } + } + }); + +export const fieldDefinitionSchema = z.union([ + presetFieldSchema, + customFieldSchema, +]); + +const sourceReferenceTargetSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("set"), setId: safeNonNegativeInteger }).strict(), + z + .object({ type: z.literal("entity"), entityId: safeNonNegativeInteger }) + .strict(), + z + .object({ + type: z.literal("position"), + entityId: safeNonNegativeInteger, + setId: safeNonNegativeInteger, + }) + .strict(), +]); + +const sourceReferenceSchema = z + .object({ + target: sourceReferenceTargetSchema, + page: z.number().int().positive().optional(), + rawText: z.string().optional(), + }) + .strict(); + +const provenanceSchema = z + .object({ + source: z + .object({ + kind: nonEmptyText, + fileName: nonEmptyText.optional(), + }) + .strict() + .optional(), + importer: z + .object({ + name: nonEmptyText, + version: nonEmptyText, + }) + .strict() + .optional(), + importedAt: z.string().datetime().optional(), + references: z.array(sourceReferenceSchema).optional(), + }) + .strict(); + +const metadataSchema = z + .object({ + title: nonEmptyText, + createdAt: z.string().datetime(), + drillWriter: nonEmptyText.optional(), + ensemble: nonEmptyText.optional(), + description: z.string().optional(), + lucideIcon: lucideIcon.optional(), + }) + .strict(); + +export const drillDocumentSchema: z.ZodType = z + .object({ + schema: z.literal(DRILL_SCHEMA_URL), + schemaVersion: z.literal(DRILL_SCHEMA_VERSION), + metadata: metadataSchema, + field: fieldDefinitionSchema, + entityRules: entityRulesSchema.optional(), + entities: z.array(drillEntitySchema), + sets: z.array(drillSetSchema).min(1), + positions: z.array(drillPositionSchema), + paths: z.array(drillPathSchema).optional(), + provenance: provenanceSchema.optional(), + extensions: z.record(z.unknown()).optional(), + }) + .strict() + .superRefine((document, context) => { + const entityIds = new Set(); + const entityLabels = new Set(); + for (const [index, entity] of document.entities.entries()) { + if (entityIds.has(entity.id)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["entities", index, "id"], + message: `Duplicate entity id ${entity.id}.`, + }); + } + if (entityLabels.has(entity.label)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["entities", index, "label"], + message: `Duplicate entity label ${entity.label}.`, + }); + } + entityIds.add(entity.id); + entityLabels.add(entity.label); + } + + const primaryNumbers = new Set(); + const setIdentities = new Set(); + for (const [index, set] of document.sets.entries()) { + if (set.id !== index) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sets", index, "id"], + message: `Set id must equal its zero-based array index (${index}).`, + }); + } + if (index === 0 && set.countsFromPrevious !== 0) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sets", index, "countsFromPrevious"], + message: "The first set must have zero counts from previous.", + }); + } + const identity = `${set.number}|${set.suffix ?? ""}`; + if (setIdentities.has(identity)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sets", index], + message: `Duplicate display set identity ${set.number}${set.suffix ?? ""}.`, + }); + } + setIdentities.add(identity); + if (set.kind === "set") { + if (primaryNumbers.has(set.number)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sets", index, "number"], + message: `Primary set number ${set.number} may appear only once.`, + }); + } + primaryNumbers.add(set.number); + } + } + for (const [index, set] of document.sets.entries()) { + if (set.kind === "subset" && !primaryNumbers.has(set.number)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["sets", index, "number"], + message: `Subset ${set.number}${set.suffix ?? ""} requires a primary set with number ${set.number}.`, + }); + } + } + + const positionKeys = new Set(); + for (const [index, position] of document.positions.entries()) { + if (!entityIds.has(position.entityId)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["positions", index, "entityId"], + message: `Unknown entity id ${position.entityId}.`, + }); + } + if (position.setId >= document.sets.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["positions", index, "setId"], + message: `Unknown set id ${position.setId}.`, + }); + } + const key = `${position.entityId}|${position.setId}`; + if (positionKeys.has(key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["positions", index], + message: `Duplicate position for entity ${position.entityId} at set ${position.setId}.`, + }); + } + positionKeys.add(key); + } + + const pathKeys = new Set(); + for (const [index, path] of (document.paths ?? []).entries()) { + if (!entityIds.has(path.entityId)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["paths", index, "entityId"], + message: `Unknown entity id ${path.entityId}.`, + }); + } + if ( + path.fromSetId >= document.sets.length || + path.toSetId >= document.sets.length + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["paths", index], + message: "Path set references must exist in this drill.", + }); + } + if (path.toSetId !== path.fromSetId + 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["paths", index, "toSetId"], + message: "Paths may only connect consecutive set entries.", + }); + } + if ( + !positionKeys.has(`${path.entityId}|${path.fromSetId}`) || + !positionKeys.has(`${path.entityId}|${path.toSetId}`) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["paths", index], + message: "A path requires endpoint positions at both referenced sets.", + }); + } + const key = `${path.entityId}|${path.fromSetId}|${path.toSetId}`; + if (pathKeys.has(key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["paths", index], + message: "Only one explicit path is allowed per entity transition.", + }); + } + pathKeys.add(key); + } + + for (const [index, reference] of ( + document.provenance?.references ?? [] + ).entries()) { + const target = reference.target; + if ("entityId" in target && !entityIds.has(target.entityId)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provenance", "references", index, "target", "entityId"], + message: `Unknown provenance entity id ${target.entityId}.`, + }); + } + if ("setId" in target && target.setId >= document.sets.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["provenance", "references", index, "target", "setId"], + message: `Unknown provenance set id ${target.setId}.`, + }); + } + } + }); + +export function parseDrillDocument(value: unknown): DrillDocument { + return drillDocumentSchema.parse(value); +} + +export function safeParseDrillDocument(value: unknown) { + return drillDocumentSchema.safeParse(value); +} + +export function parseDrillDocumentJson(json: string): DrillDocument { + return parseDrillDocument(JSON.parse(json) as unknown); +} + +export function serializeDrillDocument(document: DrillDocument): string { + const validated = parseDrillDocument(document); + return `${JSON.stringify(validated, null, 2)}\n`; +} diff --git a/packages/drill-schema/src/sets.ts b/packages/drill-schema/src/sets.ts new file mode 100644 index 00000000..86938636 --- /dev/null +++ b/packages/drill-schema/src/sets.ts @@ -0,0 +1,18 @@ +import type { DrillSet } from "./types"; + +export const SET_SUFFIX_PATTERN = /^(?:[A-Z]|\.[0-9]+)$/; + +export function formatSetName(set: Pick): string { + return `${set.number}${set.suffix ?? ""}`; +} + +export function countPrimarySets(sets: readonly DrillSet[]): number { + return sets.reduce((count, set) => count + (set.kind === "set" ? 1 : 0), 0); +} + +export function compareSetIdentity( + left: Pick, + right: Pick, +): boolean { + return left.number === right.number && (left.suffix ?? "") === (right.suffix ?? ""); +} diff --git a/packages/drill-schema/src/types.ts b/packages/drill-schema/src/types.ts new file mode 100644 index 00000000..d5a4b89b --- /dev/null +++ b/packages/drill-schema/src/types.ts @@ -0,0 +1,277 @@ +export const DRILL_SCHEMA_URL = "https://eight2five.com/schema/drill" as const; +export const DRILL_SCHEMA_VERSION = "2.0.0" as const; + +export type SetKind = "set" | "subset"; +export type DrillEntityType = "performer" | "prop"; +export const PROP_SIZE_UNITS = Object.freeze([ + "8-to-5-steps", + "feet", + "inches", + "meters", +] as const); +export type PropSizeUnit = (typeof PROP_SIZE_UNITS)[number]; + +export interface PropSize { + readonly length: number; + readonly width: number; + readonly unit: PropSizeUnit; +} + +export type EntityIcon = + | "dot" + | "square" + | "triangle" + | "diamond" + | "star" + | "hexagon" + | "cross"; + +export const FIELD_PRESET_IDS = Object.freeze([ + "football-nfhs", + "football-ncaa", + "football-texas-uil", + "football-nfl", +] as const); +export type FieldPresetId = (typeof FIELD_PRESET_IDS)[number]; + +export interface DrillMetadata { + readonly title: string; + readonly createdAt: string; + readonly drillWriter?: string; + readonly ensemble?: string; + readonly description?: string; + readonly lucideIcon?: string; +} + +export interface MeasureRange { + readonly start: number; + readonly end: number; +} + +export interface DrillSet { + readonly id: number; + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; +} + +export interface EntityAppearance { + readonly icon?: EntityIcon; + readonly color?: string; + readonly labelVisible?: boolean; +} + +export interface DrillEntity { + readonly id: number; + readonly type: DrillEntityType; + readonly symbol: string; + readonly label: string; + readonly name?: string; + readonly section?: string; + readonly instrument?: string; + readonly size?: PropSize; + readonly appearance?: EntityAppearance; +} + +export interface EntityRuleValues { + readonly type?: DrillEntityType; + readonly name?: string; + readonly section?: string; + readonly instrument?: string; + readonly size?: PropSize; + readonly appearance?: EntityAppearance; +} + +export interface EntityRules { + readonly bySymbol?: Readonly>; + readonly byLabel?: Readonly>; + readonly byId?: Readonly>; +} + +export interface DrillGridPoint { + readonly xSteps: number; + readonly ySteps: number; +} + +export interface PhysicalFieldPoint { + readonly xMeters: number; + readonly yMeters: number; +} + +export interface DrillPosition extends DrillGridPoint { + readonly entityId: number; + readonly setId: number; + /** Omission is semantically identical to 0 degrees (front sideline). */ + readonly facingDegrees?: number; +} + +export interface StraightDrillPath { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly kind: "straight"; +} + +export interface PolylineDrillPath { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly kind: "polyline"; + readonly waypoints: readonly DrillGridPoint[]; +} + +export interface BezierDrillPath { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly kind: "bezier"; + readonly controlPoints: readonly [DrillGridPoint, DrillGridPoint]; +} + +export type DrillPath = + | StraightDrillPath + | PolylineDrillPath + | BezierDrillPath; + +export interface PhysicalFieldBounds { + readonly minXMeters: number; + readonly maxXMeters: number; + readonly minYMeters: number; + readonly maxYMeters: number; +} + +export interface MarchingGridBounds { + readonly minXSteps: number; + readonly maxXSteps: number; + readonly minYSteps: number; + readonly maxYSteps: number; +} + +export interface PhysicalReferenceLine { + readonly id: string; + readonly name: string; + readonly axis: "x" | "y"; + readonly coordinateMeters: number; +} + +export interface MarchingReferenceLine { + readonly id: string; + readonly name: string; + readonly axis: "x" | "y"; + readonly coordinateSteps: number; +} + +export interface PhysicalFieldGeometry { + readonly bounds: PhysicalFieldBounds; + readonly referenceLines: readonly PhysicalReferenceLine[]; +} + +export interface MarchingGrid { + readonly bounds: MarchingGridBounds; + readonly referenceLines: readonly MarchingReferenceLine[]; +} + +/** Physical football-marking dimensions consumed by field renderers. */ +export interface FieldMarkingDefinition { + readonly yardNumbers: { + readonly heightMeters: number; + readonly nominalWidthMeters: number; + readonly centerFromFrontSidelineMeters: number; + readonly centerFromBackSidelineMeters: number; + }; + readonly inboundsHashMarks: { + readonly lengthMeters: number; + readonly spacingMeters: number; + }; + readonly sidelineHashMarks: { + readonly lengthMeters: number; + readonly spacingMeters: number; + /** Clear distance from the inside edge of the sideline to each mark. */ + readonly insetFromSidelineMeters: number; + }; +} + +export interface PresetFieldDefinition { + readonly type: "preset"; + readonly preset: FieldPresetId; +} + +export interface CustomFieldDefinition { + readonly type: "custom"; + readonly name: string; + readonly physicalGeometry: PhysicalFieldGeometry; + readonly marchingGrid: MarchingGrid; + readonly markings: FieldMarkingDefinition; +} + +export type FieldDefinition = PresetFieldDefinition | CustomFieldDefinition; + +export interface ResolvedFieldDefinition { + readonly id: FieldPresetId | "custom"; + readonly name: string; + readonly physicalGeometry: PhysicalFieldGeometry; + readonly marchingGrid: MarchingGrid; + readonly markings: FieldMarkingDefinition; +} + +export type SourceReferenceTarget = + | { + readonly type: "set"; + readonly setId: number; + } + | { + readonly type: "entity"; + readonly entityId: number; + } + | { + readonly type: "position"; + readonly entityId: number; + readonly setId: number; + }; + +export interface SourceReference { + readonly target: SourceReferenceTarget; + readonly page?: number; + readonly rawText?: string; +} + +export interface DrillProvenance { + readonly source?: { + readonly kind: string; + readonly fileName?: string; + }; + readonly importer?: { + readonly name: string; + readonly version: string; + }; + readonly importedAt?: string; + readonly references?: readonly SourceReference[]; +} + +export interface DrillDocument { + readonly schema: typeof DRILL_SCHEMA_URL; + readonly schemaVersion: typeof DRILL_SCHEMA_VERSION; + readonly metadata: DrillMetadata; + readonly field: FieldDefinition; + readonly entityRules?: EntityRules; + readonly entities: readonly DrillEntity[]; + readonly sets: readonly DrillSet[]; + readonly positions: readonly DrillPosition[]; + readonly paths?: readonly DrillPath[]; + readonly provenance?: DrillProvenance; + readonly extensions?: Readonly>; +} + +export interface ResolvedEntityAppearance { + readonly icon: EntityIcon; + readonly color: string; + readonly labelVisible: boolean; +} + +export interface ResolvedDrillEntity extends Omit { + readonly section?: string; + readonly instrument?: string; + readonly appearance: ResolvedEntityAppearance; +} diff --git a/packages/drill-schema/tsconfig.json b/packages/drill-schema/tsconfig.json new file mode 100644 index 00000000..846c3924 --- /dev/null +++ b/packages/drill-schema/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 4b865b10..1abc9481 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,12 +1,18 @@ { "name": "@eight2five/mobile", - "version": "0.0.0", + "version": "0.1.0", "private": true, "main": "src/index.ts", "types": "src/index.ts", "exports": { ".": "./src/index.ts", - "./pans-manager": "./src/pans-manager/index.ts" + "./pans-manager": "./src/pans-manager/index.ts", + "./field": "./src/field/index.ts", + "./field/render": "./src/field/render/index.ts", + "./drill": "./src/drill/index.ts", + "./motion": "./src/motion/index.ts", + "./settings": "./src/settings/index.ts", + "./storage": "./src/storage/index.ts" }, "files": [ "src" @@ -15,13 +21,15 @@ "lint": "eslint . --max-warnings 0", "lint:fix": "eslint . --fix", "type-check": "tsc --noEmit -p tsconfig.json", - "test": "jest src/pans-manager --watchAll=false --passWithNoTests --runInBand" + "test": "jest src --watchAll=false --passWithNoTests --runInBand" }, "peerDependencies": { "react": "19.2.3", "react-native": "0.86.2" }, "dependencies": { + "@eight2five/drill-schema": "0.1.0", + "@expo-google-fonts/montserrat": "^0.4.2", "@expo/html-elements": "^0.12.5", "@gluestack-ui/core": "^5.0.15", "@gluestack-ui/utils": "^5.0.6", @@ -52,7 +60,7 @@ "expo-image-picker": "~57.0.6", "expo-keep-awake": "~57.0.1", "expo-linear-gradient": "~57.0.1", - "expo-linking": "~57.0.4", + "expo-linking": "~57.0.5", "expo-local-authentication": "~57.0.2", "expo-location": "~57.0.6", "expo-media-library": "~57.0.3", @@ -60,7 +68,7 @@ "expo-notifications": "~57.0.7", "expo-pans-ble-api": "file:../../modules/expo-pans-ble-api", "expo-print": "~57.0.1", - "expo-router": "~57.0.9", + "expo-router": "~57.0.10", "expo-screen-capture": "~57.0.1", "expo-screen-orientation": "~57.0.1", "expo-secure-store": "~57.0.1", @@ -113,6 +121,10 @@ "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)" ], + "moduleNameMapper": { + "^@eight2five/drill-schema$": "/../drill-schema/src/index.ts", + "^@eight2five/drill-schema/(.*)$": "/../drill-schema/src/$1" + }, "setupFilesAfterEnv": [ "/jest.setup.ts" ] diff --git a/packages/mobile/src/drill/SqliteDrillRepository.ts b/packages/mobile/src/drill/SqliteDrillRepository.ts new file mode 100644 index 00000000..8fd95780 --- /dev/null +++ b/packages/mobile/src/drill/SqliteDrillRepository.ts @@ -0,0 +1,1566 @@ +import { + formatSetName, + isFieldPresetId, + parseDrillDocument, + type DrillDocument, + type DrillMetadata, + type DrillGridPoint, + type FieldPresetId, + type MeasureRange, + type SetKind, +} from "@eight2five/drill-schema"; +import type { SQLiteDatabase } from "expo-sqlite"; + +import { + APP_SETTINGS_TABLE, + DRILLS_TABLE, + DRILL_SETS_TABLE, +} from "../storage/mobileDatabase"; +import { SqliteSettingsRepository } from "../settings/SqliteSettingsRepository"; +import type { AppSettings } from "../settings/types"; +import type { Drill, DrillSet } from "./types"; + +type SqlValue = string | number | null; +type Row = Record; + +export interface CreateDrillInput { + readonly id?: string; + readonly name: string; + readonly createdAt?: number; + readonly updatedAt?: number; + readonly fieldPreset?: FieldPresetId; + /** Small metadata summary kept in columns for list/detail queries. */ + readonly metadata?: DrillMetadata; +} + +/** + * Atomic imported-drill creation input. The repository creates the drill and + * its selected-performer projection in one SQLite transaction. + */ +export interface CreateImportedDrillInput { + readonly id?: string; + /** Must be the document returned by the validated import boundary. */ + readonly sourceDocument: DrillDocument; + readonly selectedPerformerEntityId: number; + readonly createdAt?: number; + readonly updatedAt?: number; +} + +export interface CreateDrillSetDetails { + readonly id?: string; + readonly number: number; + readonly suffix?: string; + readonly kind?: SetKind; + readonly countsFromPrevious?: number; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly facingDegrees?: number; + readonly sourceSetId?: number; +} + +export interface CreateDrillSetInput extends CreateDrillSetDetails { + readonly drillId: string; +} + +export interface UpdateDrillSetInput { + readonly number?: number; + readonly suffix?: string | null; + readonly kind?: SetKind; + readonly countsFromPrevious?: number; + readonly measureRange?: MeasureRange | null; + readonly position?: DrillGridPoint; + readonly facingDegrees?: number | null; +} + +/** + * Updates the list-facing properties of a drill. Imported drills keep their + * canonical document and the query-friendly summary in sync in one + * transaction. + */ +export interface UpdateDrillPropertiesInput { + readonly name?: string; + /** Alias for name for callers that use the portable metadata vocabulary. */ + readonly title?: string; + /** Pass null to remove the optional icon; omit it to keep the current icon. */ + readonly lucideIcon?: string | null; +} + +/** @deprecated Use CreateDrillSetDetails. */ +export type CreateDrillPageDetails = CreateDrillSetDetails; +/** @deprecated Use CreateDrillSetInput. */ +export type CreateDrillPageInput = CreateDrillSetInput; +/** @deprecated Use UpdateDrillSetInput. */ +export type UpdateDrillPageInput = UpdateDrillSetInput; + +export interface DrillRepositoryFactories { + readonly idFactory?: () => string; + readonly timeFactory?: () => number; +} + +export interface DrillRepository { + listDrills(): Promise; + getDrill(id: string): Promise; + createDrill(input: CreateDrillInput | string): Promise; + /** Creates an imported drill and its local selected-performer projection atomically. */ + createImportedDrill(input: CreateImportedDrillInput): Promise; + renameDrill(id: string, name: string, updatedAt?: number): Promise; + updateDrillProperties( + id: string, + input: UpdateDrillPropertiesInput, + updatedAt?: number, + ): Promise; + deleteDrill(id: string): Promise; + getDrillDocument(drillId: string): Promise; + setSelectedPerformer(drillId: string, entityId: number): Promise; + setActiveDrill(id: string | null): Promise; + + listSets(drillId: string): Promise; + getSet(id: string): Promise; + createSet(input: CreateDrillSetInput): Promise; + updateSet(id: string, input: UpdateDrillSetInput): Promise; + deleteSet(id: string): Promise; + insertSet( + drillId: string, + ordinal: number, + details: CreateDrillSetDetails, + ): Promise; + reorderSets( + drillId: string, + orderedSetIds: readonly (string | { readonly id: string })[], + ): Promise; + setSelectedDrillSet(id: string | null): Promise; + + /** @deprecated Compatibility aliases; new callers should use set methods. */ + listPages(drillId: string): Promise; + getPage(id: string): Promise; + createPage(input: CreateDrillSetInput): Promise; + updatePage(id: string, input: UpdateDrillSetInput): Promise; + deletePage(id: string): Promise; + insertPage( + drillId: string, + ordinal: number, + details: CreateDrillSetDetails, + ): Promise; + reorderPages( + drillId: string, + orderedSetIds: readonly (string | { readonly id: string })[], + ): Promise; + setSelectedDrillPage(id: string | null): Promise; +} + +export type DrillRepositoryErrorCode = + | "DRILL_NOT_FOUND" + | "SET_NOT_FOUND" + | "PAGE_NOT_FOUND" + | "INVALID_INPUT" + | "INVALID_SET_ORDER" + | "INVALID_PAGE_ORDER" + | "INVALID_SELECTION"; + +export class DrillRepositoryError extends Error { + readonly code: DrillRepositoryErrorCode; + + constructor(code: DrillRepositoryErrorCode, message: string) { + super(message); + this.name = "DrillRepositoryError"; + this.code = code; + } +} + +export class SqliteDrillRepository implements DrillRepository { + private readonly idFactory: () => string; + private readonly timeFactory: () => number; + private readonly settingsRepository: SqliteSettingsRepository; + + constructor( + private readonly db: SQLiteDatabase, + factories: DrillRepositoryFactories = {}, + ) { + this.idFactory = factories.idFactory ?? defaultIdFactory; + this.timeFactory = factories.timeFactory ?? (() => Date.now()); + this.settingsRepository = new SqliteSettingsRepository(db); + } + + async listDrills(): Promise { + const rows = await this.db.getAllAsync( + `SELECT id, name, field_preset, created_at, updated_at, + metadata_title, metadata_created_at, metadata_drill_writer, + metadata_ensemble, metadata_description, metadata_lucide_icon, + selected_performer_entity_id + FROM ${DRILLS_TABLE} + ORDER BY created_at ASC, id ASC`, + ); + return rows.map(toDrill); + } + + async getDrill(id: string): Promise { + const drillId = assertId(id, "Drill id"); + const row = await this.db.getFirstAsync( + `SELECT id, name, field_preset, created_at, updated_at, + metadata_title, metadata_created_at, metadata_drill_writer, + metadata_ensemble, metadata_description, metadata_lucide_icon, + selected_performer_entity_id + FROM ${DRILLS_TABLE} + WHERE id = ?`, + [drillId], + ); + return row ? toDrill(row) : undefined; + } + + /** + * Read and validate the complete imported document. List/detail drill + * queries intentionally do not select or parse this potentially large JSON + * column; callers opt in through this method when they need source data. + */ + async getDrillDocument(id: string): Promise { + const drillId = assertId(id, "Drill id"); + const row = await this.db.getFirstAsync( + `SELECT source_document_json FROM ${DRILLS_TABLE} WHERE id = ?`, + [drillId], + ); + if (!row) return undefined; + const sourceDocumentJson = row.source_document_json; + if (sourceDocumentJson === null || sourceDocumentJson === undefined) { + return undefined; + } + if (typeof sourceDocumentJson !== "string") { + throw new MobileRowError("drill source_document_json is not a string."); + } + try { + return parseDrillDocument(JSON.parse(sourceDocumentJson) as unknown); + } catch (cause) { + throw new MobileRowError( + `The persisted source document for drill ${drillId} is invalid.`, + cause, + ); + } + } + + async createDrill(inputOrName: CreateDrillInput | string): Promise { + const input: CreateDrillInput = + typeof inputOrName === "string" ? { name: inputOrName } : inputOrName; + const importedFields = input as CreateDrillInput & { + readonly sourceDocument?: DrillDocument; + readonly selectedPerformerEntityId?: number | null; + }; + if ( + importedFields.sourceDocument !== undefined || + importedFields.selectedPerformerEntityId !== undefined + ) { + throw invalidInput( + "Imported drills must be created with createImportedDrill so their projection is atomic.", + ); + } + const name = assertText(input.name, "Drill name"); + const createdAt = assertTimestamp( + input.createdAt ?? this.timeFactory(), + "Drill createdAt", + ); + const updatedAt = assertTimestamp( + input.updatedAt ?? createdAt, + "Drill updatedAt", + ); + const id = assertId(input.id ?? this.idFactory(), "Drill id"); + const fieldPreset = input.fieldPreset ?? "football-nfhs"; + if (!isFieldPresetId(fieldPreset)) { + throw invalidInput(`Unsupported field preset ${String(fieldPreset)}.`); + } + const metadata = normalizeMetadata(input.metadata, name, createdAt); + + await this.insertDrillRow({ + id, + name, + fieldPreset, + createdAt, + updatedAt, + metadata, + }); + return requireValue(await this.getDrill(id), "drill", id); + } + + async createImportedDrill(input: CreateImportedDrillInput): Promise { + // The importer validates this document before crossing the repository + // boundary. Keep the repository on the trusted path so validation happens + // once, while persisted reads still validate untrusted SQLite data. + const sourceDocument = input.sourceDocument; + const selectedPerformerEntityId = assertNonNegativeInteger( + input.selectedPerformerEntityId, + "Selected performer entity id", + ); + const projectedSets = projectSelectedPerformerSets( + sourceDocument, + selectedPerformerEntityId, + ); + const id = assertId(input.id ?? this.idFactory(), "Drill id"); + const metadata = sourceDocument.metadata; + const createdAt = assertTimestamp( + input.createdAt ?? parseDocumentTimestamp(sourceDocument), + "Drill createdAt", + ); + const updatedAt = assertTimestamp( + input.updatedAt ?? createdAt, + "Drill updatedAt", + ); + const fieldPreset = getPresetFromDocument(sourceDocument); + const sourceDocumentJson = serializeValidatedDrillDocument(sourceDocument); + + await this.db.withTransactionAsync(async () => { + await this.insertDrillRow({ + id, + name: sourceDocument.metadata.title, + fieldPreset, + createdAt, + updatedAt, + metadata, + sourceDocumentJson, + selectedPerformerEntityId, + }); + for (const [ordinal, set] of projectedSets.entries()) { + await this.insertSetRow({ + ...normalizeCreateSet({ drillId: id, ...set }), + id: assertId(this.idFactory(), "Drill set id"), + ordinal, + }); + } + await this.validateSetStructure(id); + }); + + return requireValue(await this.getDrill(id), "drill", id); + } + + async renameDrill( + idValue: string, + name: string, + updatedAt?: number, + ): Promise { + const id = assertId(idValue, "Drill id"); + const nextName = assertText(name, "Drill name"); + const nextUpdatedAt = assertTimestamp( + updatedAt ?? this.timeFactory(), + "Drill updatedAt", + ); + await this.requireDrill(id); + const sourceDocument = await this.getDrillDocument(id); + if (sourceDocument) { + const nextDocument: DrillDocument = { + ...sourceDocument, + metadata: { ...sourceDocument.metadata, title: nextName }, + }; + await this.db.withTransactionAsync(async () => { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, source_document_json = ?, updated_at = ? + WHERE id = ?`, + [ + nextName, + nextName, + serializeValidatedDrillDocument(nextDocument), + nextUpdatedAt, + id, + ], + ); + }); + } else { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, updated_at = ? WHERE id = ?`, + [nextName, nextName, nextUpdatedAt, id], + ); + } + return requireValue(await this.getDrill(id), "drill", id); + } + + async updateDrillProperties( + idValue: string, + input: UpdateDrillPropertiesInput, + updatedAt?: number, + ): Promise { + const id = assertId(idValue, "Drill id"); + const current = await this.requireDrill(id); + if (input.name !== undefined && input.title !== undefined) { + throw invalidInput("Use either drill name or title, not both."); + } + const requestedName = input.name ?? input.title; + const name = + requestedName === undefined + ? current.name + : assertText(requestedName, "Drill name"); + const lucideIconChanged = input.lucideIcon !== undefined; + const lucideIcon = normalizeLucideIcon( + lucideIconChanged ? input.lucideIcon : current.metadata?.lucideIcon, + ); + const nextUpdatedAt = assertTimestamp( + updatedAt ?? this.timeFactory(), + "Drill updatedAt", + ); + const sourceDocument = await this.getDrillDocument(id); + const nextMetadata = updateMetadataProperties( + sourceDocument?.metadata ?? current.metadata, + name, + lucideIcon, + lucideIconChanged, + ); + const sourceDocumentJson = sourceDocument + ? serializeValidatedDrillDocument({ + ...sourceDocument, + metadata: nextMetadata, + }) + : undefined; + + await this.db.withTransactionAsync(async () => { + if (sourceDocumentJson === undefined) { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, metadata_lucide_icon = ?, updated_at = ? + WHERE id = ?`, + [ + name, + nextMetadata.title, + nextMetadata.lucideIcon ?? null, + nextUpdatedAt, + id, + ], + ); + } else { + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET name = ?, metadata_title = ?, metadata_lucide_icon = ?, + source_document_json = ?, updated_at = ? + WHERE id = ?`, + [ + name, + nextMetadata.title, + nextMetadata.lucideIcon ?? null, + sourceDocumentJson, + nextUpdatedAt, + id, + ], + ); + } + }); + + return requireValue(await this.getDrill(id), "drill", id); + } + + /** + * Change the selected performer while rebuilding the indexed local set + * projection. The portable source document is never mutated. + */ + async setSelectedPerformer( + drillIdValue: string, + entityIdValue: number, + ): Promise { + const drillId = assertId(drillIdValue, "Drill id"); + const entityId = assertNonNegativeInteger( + entityIdValue, + "Selected performer entity id", + ); + await this.requireDrill(drillId); + const sourceDocument = await this.getDrillDocument(drillId); + if (!sourceDocument) { + throw new DrillRepositoryError( + "INVALID_SELECTION", + "Only imported drills can select a performer.", + ); + } + const projectedSets = projectSelectedPerformerSets( + sourceDocument, + entityId, + ); + + await this.db.withTransactionAsync(async () => { + await this.ensureSettingsRow(); + const settings = await this.db.getFirstAsync<{ + active_drill_id: SqlValue | undefined; + selected_drill_page_id: SqlValue | undefined; + }>( + `SELECT active_drill_id, selected_drill_page_id + FROM ${APP_SETTINGS_TABLE} WHERE singleton_id = ?`, + [1], + ); + const activeDrillId = nullableIdFromSql(settings?.active_drill_id); + const selectedSetId = nullableIdFromSql(settings?.selected_drill_page_id); + const previousSelectedSourceSetId = await this.findSourceSetId( + selectedSetId, + drillId, + ); + + await this.db.runAsync( + `DELETE FROM ${DRILL_SETS_TABLE} WHERE drill_id = ?`, + [drillId], + ); + const localSetIdsBySourceSetId = new Map(); + for (const [ordinal, set] of projectedSets.entries()) { + const localSetId = assertId(this.idFactory(), "Drill set id"); + localSetIdsBySourceSetId.set(set.sourceSetId, localSetId); + await this.insertSetRow({ + ...normalizeCreateSet({ drillId, ...set }), + id: localSetId, + ordinal, + }); + } + + await this.db.runAsync( + `UPDATE ${DRILLS_TABLE} + SET selected_performer_entity_id = ? WHERE id = ?`, + [entityId, drillId], + ); + + if (activeDrillId === drillId) { + const sourceSetId = + previousSelectedSourceSetId !== null && + localSetIdsBySourceSetId.has(previousSelectedSourceSetId) + ? previousSelectedSourceSetId + : projectedSets[0]?.sourceSetId; + const nextSelectedSetId = + sourceSetId === undefined + ? null + : (localSetIdsBySourceSetId.get(sourceSetId) ?? null); + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET selected_drill_page_id = ? WHERE singleton_id = ?`, + [nextSelectedSetId, 1], + ); + } + + await this.validateSetStructure(drillId); + }); + + return requireValue(await this.getDrill(drillId), "drill", drillId); + } + + async deleteDrill(id: string): Promise { + const drillId = assertId(id, "Drill id"); + await this.db.runAsync(`DELETE FROM ${DRILLS_TABLE} WHERE id = ?`, [ + drillId, + ]); + } + + async setActiveDrill(id: string | null): Promise { + const activeDrillId = nullableId(id, "Active drill id"); + await this.db.withTransactionAsync(async () => { + if (activeDrillId !== null) await this.requireDrill(activeDrillId); + await this.ensureSettingsRow(); + const current = await this.db.getFirstAsync<{ + active_drill_id: SqlValue | undefined; + }>( + `SELECT active_drill_id FROM ${APP_SETTINGS_TABLE} WHERE singleton_id = ?`, + [1], + ); + const currentActive = nullableIdFromSql(current?.active_drill_id); + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET active_drill_id = ? + WHERE singleton_id = ?`, + [activeDrillId, 1], + ); + if (currentActive !== activeDrillId || activeDrillId === null) { + let firstSetId: string | null = null; + if (activeDrillId !== null) { + const firstSet = await this.db.getFirstAsync<{ id: SqlValue }>( + `SELECT id FROM ${DRILL_SETS_TABLE} + WHERE drill_id = ? ORDER BY ordinal ASC, id ASC LIMIT 1`, + [activeDrillId], + ); + firstSetId = nullableIdFromSql(firstSet?.id); + } + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET selected_drill_page_id = ? WHERE singleton_id = ?`, + [firstSetId, 1], + ); + } + }); + return await this.settingsRepository.load(); + } + + async listSets(drillId: string): Promise { + const parentId = assertId(drillId, "Drill id"); + const rows = await this.db.getAllAsync( + `${SET_SELECT} WHERE drill_id = ? ORDER BY ordinal ASC, id ASC`, + [parentId], + ); + return rows.map(toSet); + } + + async getSet(id: string): Promise { + const setId = assertId(id, "Drill set id"); + const row = await this.db.getFirstAsync(`${SET_SELECT} WHERE id = ?`, [ + setId, + ]); + return row ? toSet(row) : undefined; + } + + async createSet(input: CreateDrillSetInput): Promise { + const normalized = normalizeCreateSet(input); + await this.requireEditableDrill(normalized.drillId); + const createdId = assertId( + normalized.id ?? this.idFactory(), + "Drill set id", + ); + await this.db.withTransactionAsync(async () => { + await this.requireDrill(normalized.drillId); + const count = await this.setCount(normalized.drillId); + if (count === 0 && normalized.countsFromPrevious !== 0) { + throw invalidInput( + "The first set must have zero counts from previous.", + ); + } + await this.insertSetRow({ ...normalized, id: createdId, ordinal: count }); + await this.validateSetStructure(normalized.drillId); + }); + return requireValue(await this.getSet(createdId), "drill set", createdId); + } + + async updateSet( + idValue: string, + changes: UpdateDrillSetInput, + ): Promise { + const id = assertId(idValue, "Drill set id"); + const current = await this.getSet(id); + if (!current) throw setNotFound(id); + await this.requireEditableDrill(current.drillId); + + const next = normalizeExistingSet(current, changes); + if (next.ordinal === 0 && next.countsFromPrevious !== 0) { + throw invalidInput("The first set must have zero counts from previous."); + } + + await this.requireDrill(current.drillId); + await this.db.withTransactionAsync(async () => { + await this.updateSetRow(next); + await this.validateSetStructure(current.drillId); + }); + return requireValue(await this.getSet(id), "drill set", id); + } + + async deleteSet(idValue: string): Promise { + const id = assertId(idValue, "Drill set id"); + const existing = await this.getSet(id); + if (!existing) return; + await this.requireEditableDrill(existing.drillId); + await this.db.withTransactionAsync(async () => { + const set = await this.getSet(id); + if (!set) return; + await this.db.runAsync(`DELETE FROM ${DRILL_SETS_TABLE} WHERE id = ?`, [ + id, + ]); + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} + SET ordinal = ordinal - 1 + WHERE drill_id = ? AND ordinal > ?`, + [set.drillId, set.ordinal], + ); + const nextFirst = await this.db.getFirstAsync<{ id: string }>( + `SELECT id FROM ${DRILL_SETS_TABLE} + WHERE drill_id = ? ORDER BY ordinal ASC LIMIT 1`, + [set.drillId], + ); + if (nextFirst) { + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} + SET counts_from_previous = 0 WHERE id = ?`, + [nextFirst.id], + ); + } + await this.validateSetStructure(set.drillId); + }); + } + + async insertSet( + drillId: string, + ordinalValue: number, + details: CreateDrillSetDetails, + ): Promise { + const normalized = normalizeCreateSet({ ...details, drillId }); + const ordinal = assertOrdinal(ordinalValue, "Set ordinal"); + await this.requireEditableDrill(normalized.drillId); + const id = assertId(normalized.id ?? this.idFactory(), "Drill set id"); + await this.db.withTransactionAsync(async () => { + await this.requireDrill(normalized.drillId); + const count = await this.setCount(normalized.drillId); + if (ordinal > count) { + throw invalidInput( + `Set ordinal must be between 0 and ${count} when inserting.`, + ); + } + if (ordinal === 0 && normalized.countsFromPrevious !== 0) { + throw invalidInput( + "The first set must have zero counts from previous.", + ); + } + if (count > 0) + await this.shiftSetsForInsertion(normalized.drillId, count, ordinal); + await this.insertSetRow({ ...normalized, id, ordinal }); + await this.validateSetStructure(normalized.drillId); + }); + return requireValue(await this.getSet(id), "drill set", id); + } + + async reorderSets( + drillId: string, + orderedSetIds: readonly (string | { readonly id: string })[], + ): Promise { + const parentId = assertId(drillId, "Drill id"); + const ids = orderedSetIds.map((value) => + assertId(typeof value === "string" ? value : value.id, "Drill set id"), + ); + if (new Set(ids).size !== ids.length) { + throw new DrillRepositoryError( + "INVALID_SET_ORDER", + "A set may appear only once in a reorder operation.", + ); + } + await this.requireEditableDrill(parentId); + + await this.db.withTransactionAsync(async () => { + const rows = await this.db.getAllAsync<{ id: string }>( + `SELECT id FROM ${DRILL_SETS_TABLE} + WHERE drill_id = ? ORDER BY ordinal ASC, id ASC`, + [parentId], + ); + const existingIds = rows.map((row) => row.id); + if ( + existingIds.length !== ids.length || + existingIds.some((id) => !ids.includes(id)) + ) { + throw new DrillRepositoryError( + "INVALID_SET_ORDER", + "A reorder must contain every set in the drill exactly once.", + ); + } + if (ids.length > 0) { + const offset = ids.length + 1; + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ordinal + ? WHERE drill_id = ?`, + [offset, parentId], + ); + for (const [ordinal, setId] of ids.entries()) { + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ? WHERE id = ? AND drill_id = ?`, + [ordinal, setId, parentId], + ); + } + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} SET counts_from_previous = 0 + WHERE drill_id = ? AND ordinal = 0`, + [parentId], + ); + } + await this.validateSetStructure(parentId); + }); + return await this.listSets(parentId); + } + + async setSelectedDrillSet(id: string | null): Promise { + const selectedSetId = nullableId(id, "Selected drill set id"); + await this.db.withTransactionAsync(async () => { + await this.ensureSettingsRow(); + const settings = await this.db.getFirstAsync<{ + active_drill_id: SqlValue | undefined; + }>( + `SELECT active_drill_id FROM ${APP_SETTINGS_TABLE} WHERE singleton_id = ?`, + [1], + ); + const activeDrillId = nullableIdFromSql(settings?.active_drill_id); + if (selectedSetId === null) { + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} SET selected_drill_page_id = NULL WHERE singleton_id = ?`, + [1], + ); + return; + } + if (activeDrillId === null) { + throw new DrillRepositoryError( + "INVALID_SELECTION", + "A drill set cannot be selected without an active drill.", + ); + } + const set = await this.db.getFirstAsync<{ drill_id: string }>( + `SELECT drill_id FROM ${DRILL_SETS_TABLE} WHERE id = ?`, + [selectedSetId], + ); + if (!set || set.drill_id !== activeDrillId) { + throw new DrillRepositoryError( + "INVALID_SELECTION", + "The selected set must belong to the active drill.", + ); + } + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} SET selected_drill_page_id = ? WHERE singleton_id = ?`, + [selectedSetId, 1], + ); + }); + return await this.settingsRepository.load(); + } + + // Compatibility aliases. + listPages(drillId: string) { + return this.listSets(drillId); + } + getPage(id: string) { + return this.getSet(id); + } + createPage(input: CreateDrillSetInput) { + return this.createSet(input); + } + updatePage(id: string, input: UpdateDrillSetInput) { + return this.updateSet(id, input); + } + deletePage(id: string) { + return this.deleteSet(id); + } + insertPage(drillId: string, ordinal: number, details: CreateDrillSetDetails) { + return this.insertSet(drillId, ordinal, details); + } + reorderPages( + drillId: string, + orderedSetIds: readonly (string | { readonly id: string })[], + ) { + return this.reorderSets(drillId, orderedSetIds); + } + setSelectedDrillPage(id: string | null) { + return this.setSelectedDrillSet(id); + } + + private async insertDrillRow(input: InsertDrillRow): Promise { + await this.db.runAsync( + `INSERT INTO ${DRILLS_TABLE} + (id, name, field_preset, created_at, updated_at, + metadata_title, metadata_created_at, metadata_drill_writer, + metadata_ensemble, metadata_description, metadata_lucide_icon, + source_document_json, selected_performer_entity_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + input.id, + input.name, + input.fieldPreset, + input.createdAt, + input.updatedAt, + input.metadata.title, + input.metadata.createdAt, + input.metadata.drillWriter ?? null, + input.metadata.ensemble ?? null, + input.metadata.description ?? null, + input.metadata.lucideIcon ?? null, + input.sourceDocumentJson ?? null, + input.selectedPerformerEntityId ?? null, + ], + ); + } + + private async findSourceSetId( + selectedSetId: string | null, + drillId: string, + ): Promise { + if (selectedSetId === null) return null; + const row = await this.db.getFirstAsync<{ + drill_id: SqlValue | undefined; + source_set_id: SqlValue | undefined; + }>(`SELECT drill_id, source_set_id FROM ${DRILL_SETS_TABLE} WHERE id = ?`, [ + selectedSetId, + ]); + if (rowTextOrNull(row?.drill_id) !== drillId) return null; + return rowNullableInteger(row?.source_set_id, "source_set_id"); + } + + private async requireEditableDrill(drillId: string): Promise { + await this.requireDrill(drillId); + const row = await this.db.getFirstAsync<{ + source_document_json: SqlValue | undefined; + }>(`SELECT source_document_json FROM ${DRILLS_TABLE} WHERE id = ?`, [ + drillId, + ]); + if ( + row?.source_document_json !== null && + row?.source_document_json !== undefined + ) { + throw importedProjectionMutationError(drillId); + } + } + + private async requireDrill(id: string): Promise { + const drill = await this.getDrill(id); + if (!drill) throw drillNotFound(id); + return drill; + } + + private async setCount(drillId: string): Promise { + const row = await this.db.getFirstAsync<{ + set_count: SqlValue | undefined; + }>( + `SELECT COUNT(*) AS set_count FROM ${DRILL_SETS_TABLE} WHERE drill_id = ?`, + [drillId], + ); + const count = Number(row?.set_count ?? 0); + if (!Number.isInteger(count) || count < 0) { + throw invalidInput("The persisted set count is invalid."); + } + return count; + } + + private async insertSetRow( + set: NormalizedCreateSet & { id: string; ordinal: number }, + ) { + await this.db.runAsync( + `INSERT INTO ${DRILL_SETS_TABLE} + (id, drill_id, ordinal, set_number, set_suffix, set_kind, + counts_from_previous, measure_start, measure_end, + x_steps, y_steps, facing_degrees, source_set_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + set.id, + set.drillId, + set.ordinal, + set.number, + set.suffix ?? null, + set.kind, + set.countsFromPrevious, + set.measureRange?.start ?? null, + set.measureRange?.end ?? null, + set.position.xSteps, + set.position.ySteps, + set.facingDegrees ?? null, + set.sourceSetId ?? null, + ], + ); + } + + private async updateSetRow(set: DrillSet): Promise { + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} + SET set_number = ?, set_suffix = ?, set_kind = ?, counts_from_previous = ?, + measure_start = ?, measure_end = ?, x_steps = ?, y_steps = ?, + facing_degrees = ?, source_set_id = ? + WHERE id = ?`, + [ + set.number, + set.suffix ?? null, + set.kind, + set.countsFromPrevious, + set.measureRange?.start ?? null, + set.measureRange?.end ?? null, + set.position.xSteps, + set.position.ySteps, + set.facingDegrees ?? null, + set.sourceSetId ?? null, + set.id, + ], + ); + } + + private async shiftSetsForInsertion( + drillId: string, + count: number, + insertionOrdinal: number, + ): Promise { + const offset = count + 1; + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} SET ordinal = ordinal + ? WHERE drill_id = ?`, + [offset, drillId], + ); + await this.db.runAsync( + `UPDATE ${DRILL_SETS_TABLE} + SET ordinal = CASE + WHEN ordinal >= ? THEN ordinal - ? + 1 + ELSE ordinal - ? + END + WHERE drill_id = ?`, + [offset + insertionOrdinal, offset, offset, drillId], + ); + } + + private async validateSetStructure(drillId: string): Promise { + const sets = await this.listSets(drillId); + const primaryNumbers = new Set(); + const identities = new Set(); + for (const [index, set] of sets.entries()) { + if (set.ordinal !== index) { + throw invalidInput( + "Persisted set ordinals must be contiguous from zero.", + ); + } + if (index === 0 && set.countsFromPrevious !== 0) { + throw invalidInput( + "The first set must have zero counts from previous.", + ); + } + const identity = `${set.number}|${set.suffix ?? ""}`; + if (identities.has(identity)) { + throw invalidInput( + `Set ${formatSetName(set)} already exists in this drill.`, + ); + } + identities.add(identity); + if (set.kind === "set") { + if (primaryNumbers.has(set.number)) { + throw invalidInput(`Primary set ${set.number} may appear only once.`); + } + primaryNumbers.add(set.number); + } + } + for (const set of sets) { + if (set.kind === "subset" && !primaryNumbers.has(set.number)) { + throw invalidInput( + `Subset ${formatSetName(set)} requires primary set ${set.number}.`, + ); + } + } + } + + private async ensureSettingsRow(): Promise { + await this.db.runAsync( + `INSERT OR IGNORE INTO ${APP_SETTINGS_TABLE} (singleton_id) VALUES (?)`, + [1], + ); + } +} + +const SET_SELECT = `SELECT id, drill_id, ordinal, set_number, set_suffix, set_kind, + counts_from_previous, measure_start, measure_end, x_steps, y_steps, + facing_degrees, source_set_id + FROM ${DRILL_SETS_TABLE}`; + +interface NormalizedCreateSet { + readonly id?: string; + readonly drillId: string; + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly facingDegrees?: number; + readonly sourceSetId?: number; +} + +interface InsertDrillRow { + readonly id: string; + readonly name: string; + readonly fieldPreset: FieldPresetId; + readonly createdAt: number; + readonly updatedAt: number; + readonly metadata: DrillMetadata; + readonly sourceDocumentJson?: string; + readonly selectedPerformerEntityId?: number; +} + +function normalizeCreateSet(input: CreateDrillSetInput): NormalizedCreateSet { + const kind = input.kind ?? (input.suffix ? "subset" : "set"); + const suffix = normalizeSuffix(input.suffix, kind); + return { + ...(input.id === undefined + ? {} + : { id: assertId(input.id, "Drill set id") }), + drillId: assertId(input.drillId, "Drill id"), + number: assertNonNegativeInteger(input.number, "Set number"), + ...(suffix === undefined ? {} : { suffix }), + kind, + countsFromPrevious: assertNonNegativeInteger( + input.countsFromPrevious ?? 0, + "countsFromPrevious", + ), + ...(input.measureRange === undefined + ? {} + : { measureRange: assertMeasureRange(input.measureRange) }), + position: assertGridPoint(input.position), + ...(input.facingDegrees === undefined + ? {} + : { facingDegrees: assertFacing(input.facingDegrees) }), + ...(input.sourceSetId === undefined + ? {} + : { + sourceSetId: assertNonNegativeInteger( + input.sourceSetId, + "Source set id", + ), + }), + }; +} + +interface ProjectedSetDetails extends CreateDrillSetDetails { + readonly sourceSetId: number; +} + +function projectSelectedPerformerSets( + document: DrillDocument, + performerEntityId: number, +): readonly ProjectedSetDetails[] { + assertSelectedPerformer(document, performerEntityId); + const positionsBySetId = new Map( + document.positions + .filter((position) => position.entityId === performerEntityId) + .map((position) => [position.setId, position] as const), + ); + return document.sets.map((set) => { + const position = positionsBySetId.get(set.id); + if (!position) { + const performer = document.entities.find( + (entity) => entity.id === performerEntityId, + ); + throw invalidInput( + `Drill position ${set.number}${set.suffix ?? ""} is missing ${performer?.label ?? `entity ${performerEntityId}`}'s coordinate.`, + ); + } + return { + number: set.number, + ...(set.suffix === undefined ? {} : { suffix: set.suffix }), + kind: set.kind, + countsFromPrevious: set.countsFromPrevious, + ...(set.measureRange === undefined + ? {} + : { measureRange: set.measureRange }), + position: { + xSteps: position.xSteps, + ySteps: position.ySteps, + }, + ...(position.facingDegrees === undefined + ? {} + : { facingDegrees: position.facingDegrees }), + sourceSetId: set.id, + }; + }); +} + +function getPresetFromDocument(document: DrillDocument): FieldPresetId { + if (document.field.type !== "preset") { + throw invalidInput( + "Custom field definitions are not supported in the mobile app yet.", + ); + } + return document.field.preset; +} + +function parseDocumentTimestamp(document: DrillDocument): number { + const timestamp = Date.parse(document.metadata.createdAt); + if (!Number.isFinite(timestamp)) { + throw invalidInput("Drill metadata createdAt must be a valid date."); + } + return timestamp; +} + +/** The caller supplies a document already validated at the import boundary. */ +function serializeValidatedDrillDocument(document: DrillDocument): string { + return `${JSON.stringify(document, null, 2)}\n`; +} + +function normalizeMetadata( + metadata: DrillMetadata | undefined, + name: string, + createdAt: number, +): DrillMetadata { + const title = assertText(metadata?.title ?? name, "Drill metadata title"); + const metadataCreatedAt = assertText( + metadata?.createdAt ?? timestampToIso(createdAt), + "Drill metadata createdAt", + ); + return { + title, + createdAt: metadataCreatedAt, + ...(metadata?.drillWriter === undefined + ? {} + : { drillWriter: metadata.drillWriter }), + ...(metadata?.ensemble === undefined + ? {} + : { ensemble: metadata.ensemble }), + ...(metadata?.description === undefined + ? {} + : { description: metadata.description }), + ...(metadata?.lucideIcon === undefined + ? {} + : { lucideIcon: metadata.lucideIcon }), + }; +} + +function updateMetadataProperties( + metadata: DrillMetadata | undefined, + name: string, + lucideIcon: string | undefined, + lucideIconChanged: boolean, +): DrillMetadata { + if (!metadata) { + return { + title: name, + createdAt: timestampToIso(Date.now()), + ...(lucideIcon === undefined ? {} : { lucideIcon }), + }; + } + const existingMetadata = + lucideIconChanged && lucideIcon === undefined + ? (() => { + const { lucideIcon: _ignored, ...rest } = metadata; + return rest; + })() + : metadata; + return { + ...existingMetadata, + title: name, + ...(!lucideIconChanged || lucideIcon === undefined ? {} : { lucideIcon }), + }; +} + +function normalizeLucideIcon(value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if ( + typeof value !== "string" || + !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value.trim()) + ) { + throw invalidInput("Drill Lucide icon must be a kebab-case icon name."); + } + return value.trim(); +} + +function assertSelectedPerformer( + document: DrillDocument, + entityId: number, +): void { + const entity = document.entities.find( + (candidate) => candidate.id === entityId, + ); + if (!entity || entity.type !== "performer") { + throw new DrillRepositoryError( + "INVALID_SELECTION", + `Entity ${entityId} is not a performer in this drill document.`, + ); + } +} + +function normalizeExistingSet( + current: DrillSet, + changes: UpdateDrillSetInput, +): DrillSet { + const kind = changes.kind ?? current.kind; + const rawSuffix = + changes.suffix === undefined + ? current.suffix + : (changes.suffix ?? undefined); + const suffix = normalizeSuffix(rawSuffix, kind); + const measureRange = + changes.measureRange === undefined + ? current.measureRange + : changes.measureRange === null + ? undefined + : assertMeasureRange(changes.measureRange); + const facingDegrees = + changes.facingDegrees === undefined + ? current.facingDegrees + : changes.facingDegrees === null + ? undefined + : assertFacing(changes.facingDegrees); + return { + ...current, + number: + changes.number === undefined + ? current.number + : assertNonNegativeInteger(changes.number, "Set number"), + kind, + ...(suffix === undefined ? { suffix: undefined } : { suffix }), + countsFromPrevious: + changes.countsFromPrevious === undefined + ? current.countsFromPrevious + : assertNonNegativeInteger( + changes.countsFromPrevious, + "countsFromPrevious", + ), + ...(measureRange === undefined + ? { measureRange: undefined } + : { measureRange }), + position: + changes.position === undefined + ? current.position + : assertGridPoint(changes.position), + ...(facingDegrees === undefined + ? { facingDegrees: undefined } + : { facingDegrees }), + }; +} + +function normalizeSuffix( + value: string | undefined, + kind: SetKind, +): string | undefined { + if (kind === "set") { + if (value !== undefined && value.trim().length > 0) { + throw invalidInput("Primary sets cannot have a suffix."); + } + return undefined; + } + if (typeof value !== "string" || !/^(?:[A-Z]|\.[0-9]+)$/.test(value.trim())) { + throw invalidInput( + "A subset suffix must be one capital letter or a decimal such as .5.", + ); + } + return value.trim(); +} + +function assertGridPoint(position: DrillGridPoint): DrillGridPoint { + if ( + !position || + !Number.isFinite(position.xSteps) || + !Number.isFinite(position.ySteps) + ) { + throw invalidInput( + "Drill set position must contain finite xSteps and ySteps.", + ); + } + return { xSteps: position.xSteps, ySteps: position.ySteps }; +} + +function assertMeasureRange(value: MeasureRange): MeasureRange { + const start = assertNonNegativeInteger(value.start, "Measure start"); + const end = assertNonNegativeInteger(value.end, "Measure end"); + if (end < start) + throw invalidInput("Measure end must be at or after measure start."); + return { start, end }; +} + +function assertFacing(value: number): number { + if (!Number.isFinite(value) || value < 0 || value >= 360) { + throw invalidInput("Facing must be at least 0 and less than 360 degrees."); + } + return value; +} + +function assertText(value: unknown, name: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw invalidInput(`${name} must be a non-empty string.`); + } + return value.trim(); +} + +function assertId(value: unknown, name: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw invalidInput(`${name} must be a non-empty string.`); + } + return value.trim(); +} + +function nullableId(value: string | null, name: string): string | null { + if (value === null) return null; + return assertId(value, name); +} + +function assertTimestamp(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw invalidInput(`${name} must be a finite number.`); + } + return value; +} + +function assertNonNegativeInteger(value: unknown, name: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw invalidInput(`${name} must be a non-negative integer.`); + } + return value; +} + +function assertOrdinal(value: unknown, name: string): number { + return assertNonNegativeInteger(value, name); +} + +function nullableIdFromSql(value: SqlValue | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function toDrill(row: Row): Drill { + const fieldPreset = rowText(row.field_preset, "drill field_preset"); + if (!isFieldPresetId(fieldPreset)) { + throw new MobileRowError(`Unsupported mobile field preset ${fieldPreset}.`); + } + const name = rowText(row.name, "drill name"); + const createdAt = rowNumber(row.created_at, "drill created_at"); + const metadataCreatedAt = + typeof row.metadata_created_at === "string" && + row.metadata_created_at.trim().length > 0 + ? row.metadata_created_at + : timestampToIso(createdAt); + const metadataTitle = + typeof row.metadata_title === "string" && + row.metadata_title.trim().length > 0 + ? row.metadata_title + : name; + const selectedPerformerEntityId = rowNullableInteger( + row.selected_performer_entity_id, + "drill selected_performer_entity_id", + ); + const drillWriter = rowOptionalText( + row.metadata_drill_writer, + "drill metadata writer", + ); + const ensemble = rowOptionalText( + row.metadata_ensemble, + "drill metadata ensemble", + ); + const description = rowOptionalText( + row.metadata_description, + "drill metadata description", + ); + const lucideIcon = rowOptionalText( + row.metadata_lucide_icon, + "drill metadata lucide icon", + ); + const metadata: DrillMetadata = { + title: metadataTitle, + createdAt: metadataCreatedAt, + ...(drillWriter === undefined ? {} : { drillWriter }), + ...(ensemble === undefined ? {} : { ensemble }), + ...(description === undefined ? {} : { description }), + ...(lucideIcon === undefined ? {} : { lucideIcon }), + }; + return { + id: rowText(row.id, "drill id"), + name, + fieldPreset, + createdAt, + updatedAt: rowNumber(row.updated_at, "drill updated_at"), + metadata, + ...(selectedPerformerEntityId === null + ? {} + : { selectedPerformerEntityId }), + }; +} + +function toSet(row: Row): DrillSet { + const kind = rowText(row.set_kind, "drill set kind"); + if (kind !== "set" && kind !== "subset") { + throw new MobileRowError(`Invalid drill set kind ${kind}.`); + } + const suffixValue = row.set_suffix; + const suffix = typeof suffixValue === "string" ? suffixValue : undefined; + const measureStart = rowNullableNumber(row.measure_start, "measure_start"); + const measureEnd = rowNullableNumber(row.measure_end, "measure_end"); + const facingDegrees = rowNullableNumber(row.facing_degrees, "facing_degrees"); + const sourceSetId = rowNullableInteger(row.source_set_id, "source_set_id"); + return { + id: rowText(row.id, "drill set id"), + drillId: rowText(row.drill_id, "drill set drill_id"), + ordinal: rowInteger(row.ordinal, "drill set ordinal"), + number: rowInteger(row.set_number, "drill set number"), + ...(suffix === undefined ? {} : { suffix }), + kind, + countsFromPrevious: rowInteger( + row.counts_from_previous, + "drill set counts_from_previous", + ), + ...(measureStart === null || measureEnd === null + ? {} + : { measureRange: { start: measureStart, end: measureEnd } }), + position: { + xSteps: rowNumber(row.x_steps, "drill set x_steps"), + ySteps: rowNumber(row.y_steps, "drill set y_steps"), + }, + ...(facingDegrees === null ? {} : { facingDegrees }), + ...(sourceSetId === null ? {} : { sourceSetId }), + }; +} + +function rowText(value: SqlValue | undefined, name: string): string { + if (typeof value !== "string") + throw new MobileRowError(`${name} is not a string.`); + return value; +} + +function rowTextOrNull(value: SqlValue | undefined): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} + +function rowNumber(value: SqlValue | undefined, name: string): number { + const number = + typeof value === "number" + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : Number.NaN; + if (!Number.isFinite(number)) throw new MobileRowError(`${name} is invalid.`); + return number; +} + +function rowInteger(value: SqlValue | undefined, name: string): number { + const number = rowNumber(value, name); + if (!Number.isSafeInteger(number)) + throw new MobileRowError(`${name} is not an integer.`); + return number; +} + +function rowNullableNumber( + value: SqlValue | undefined, + name: string, +): number | null { + if (value === null || value === undefined) return null; + return rowNumber(value, name); +} + +function timestampToIso(value: number): string { + try { + return new Date(value).toISOString(); + } catch { + return new Date(0).toISOString(); + } +} + +function rowNullableInteger( + value: SqlValue | undefined, + name: string, +): number | null { + if (value === null || value === undefined) return null; + return rowInteger(value, name); +} + +function rowOptionalText( + value: SqlValue | undefined, + name: string, +): string | undefined { + if (value === null || value === undefined) return undefined; + return rowText(value, name); +} + +class MobileRowError extends Error { + readonly cause?: unknown; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = "MobileRowError"; + if (cause !== undefined) this.cause = cause; + } +} + +function requireValue(value: T | undefined, entity: string, id: string): T { + if (value !== undefined) return value; + throw new MobileRowError(`The persisted ${entity} ${id} could not be read.`); +} + +function invalidInput(message: string): DrillRepositoryError { + return new DrillRepositoryError("INVALID_INPUT", message); +} + +function importedProjectionMutationError( + drillId: string, +): DrillRepositoryError { + return invalidInput( + `Imported drill ${drillId} sets are an authoritative source projection and cannot be edited.`, + ); +} + +function drillNotFound(id: string): DrillRepositoryError { + return new DrillRepositoryError( + "DRILL_NOT_FOUND", + `Drill ${id} was not found.`, + ); +} + +function setNotFound(id: string): DrillRepositoryError { + return new DrillRepositoryError( + "SET_NOT_FOUND", + `Drill set ${id} was not found.`, + ); +} + +function defaultIdFactory(): string { + const cryptoApi = globalThis.crypto; + if (cryptoApi?.randomUUID) return cryptoApi.randomUUID(); + return `mobile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`; +} diff --git a/packages/mobile/src/drill/__tests__/analysis.test.ts b/packages/mobile/src/drill/__tests__/analysis.test.ts new file mode 100644 index 00000000..19efc66c --- /dev/null +++ b/packages/mobile/src/drill/__tests__/analysis.test.ts @@ -0,0 +1,137 @@ +import { + analyzeDrillTransition, + analyzeTransition, + type DrillSet, +} from "../index"; + +function set( + id: string, + xSteps: number, + countsFromPrevious: number, + ySteps = 0, +): DrillSet { + const ordinal = Number(id); + return { + id, + drillId: "drill-1", + ordinal, + number: ordinal, + kind: "set", + countsFromPrevious, + position: { xSteps, ySteps }, + }; +} + +describe("drill transition analysis", () => { + test("omits derived rates for the first set", () => { + expect(analyzeDrillTransition(undefined, set("1", -64, 0))).toEqual({ + distanceSteps: 0, + isHalt: false, + yardLineCrossingCounts: [], + }); + }); + + test("omits step size and crossing counts for zero counts", () => { + const analysis = analyzeDrillTransition(set("1", -64, 0), set("2", -48, 0)); + expect(analysis.distanceSteps).toBe(16); + expect(analysis.stepSizeToFive).toBeUndefined(); + expect(analysis.yardLineCrossingCounts).toEqual([]); + }); + + test("recognizes a same-position positive-count halt", () => { + const analysis = analyzeDrillTransition( + set("1", -16, 0), + set("2", -16, 16), + ); + expect(analysis).toMatchObject({ distanceSteps: 0, isHalt: true }); + expect(analysis.stepSizeToFive).toBeUndefined(); + }); + + test.each([ + [8, 8, 8], + [4.5, 8, 14.25], + [16, 13, 6.5], + ])( + "derives %s drill-grid steps over %s counts as %s-to-5", + (distance, counts, expected) => { + expect( + analyzeTransition( + { xSteps: 0, ySteps: 0 }, + { xSteps: distance, ySteps: 0 }, + counts, + ).stepSizeToFive, + ).toBe(expected); + }, + ); + + test("returns the transition count at one and multiple five-yard crossings", () => { + expect( + analyzeTransition( + { xSteps: -64, ySteps: 0 }, + { xSteps: -48, ySteps: 0 }, + 8, + ).yardLineCrossingCounts, + ).toEqual([4]); + expect( + analyzeTransition( + { xSteps: -64, ySteps: 0 }, + { xSteps: -40, ySteps: 0 }, + 16, + ).yardLineCrossingCounts, + ).toEqual([5.333333, 10.666667]); + expect( + analyzeTransition( + { xSteps: -60, ySteps: 0 }, + { xSteps: -44, ySteps: 0 }, + 16, + ).yardLineCrossingCounts, + ).toEqual([4, 12]); + }); + + test("returns crossing counts in time order for reverse movement", () => { + expect( + analyzeTransition( + { xSteps: -40, ySteps: 0 }, + { xSteps: -64, ySteps: 0 }, + 16, + ).yardLineCrossingCounts, + ).toEqual([5.333333, 10.666667]); + }); + + test("excludes exact start and end yard lines", () => { + expect( + analyzeTransition( + { xSteps: -64, ySteps: 0 }, + { xSteps: -56, ySteps: 0 }, + 8, + ).yardLineCrossingCounts, + ).toEqual([]); + }); + + test("cleans floating-point values near integer and half counts", () => { + expect( + analyzeTransition( + { xSteps: -64, ySteps: 0 }, + { xSteps: -48 + 1e-12, ySteps: 0 }, + 8, + ).yardLineCrossingCounts, + ).toEqual([4]); + }); + + test("rejects fractional incoming counts", () => { + expect(() => + analyzeTransition( + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + 2.5, + ), + ).toThrow("integer"); + }); + + test("keeps derived values off persisted set records", () => { + const current = set("2", -48, 8); + analyzeDrillTransition(set("1", -64, 0), current); + expect(current).not.toHaveProperty("stepSizeToFive"); + expect(current).not.toHaveProperty("yardLineCrossingCounts"); + }); +}); diff --git a/packages/mobile/src/drill/__tests__/render-scene.test.ts b/packages/mobile/src/drill/__tests__/render-scene.test.ts new file mode 100644 index 00000000..5fff205b --- /dev/null +++ b/packages/mobile/src/drill/__tests__/render-scene.test.ts @@ -0,0 +1,412 @@ +import { + DRILL_MARKER_COLORS, + DRILL_MARKER_SIZE_METERS, + DRILL_MARKER_SIZE_STEPS, + LIVE_POSITION_MARKER_DIAMETER_METERS, + LIVE_POSITION_MARKER_SIZE_STEPS, +} from "../../field/render/field-render-tokens"; +import { + buildDrillRenderScene, + DEFAULT_PERFORMER_DIAMETER_METERS, + DRILL_RENDER_LAYER_ORDER, + projectTransitionPathGeometry, + resolveSelectedSourceSetId, + resolvePropPhysicalSize, + shouldBuildDrillRenderScene, + type DrillRenderSceneSettings, +} from "../render-scene"; +import { measurePhysicalTransitionGeometry } from "../physical-transition-geometry"; +import { buildTransitionScene } from "../transition-scene"; +import type { DrillDocument, FieldDefinition } from "@eight2five/drill-schema"; +import { + COLOR_PRESETS, + drillGridToPhysicalPoint, + getFieldPreset, +} from "@eight2five/drill-schema"; + +const SETTINGS: DrillRenderSceneSettings = { + showPerformerLabels: true, + showPerformerNames: true, + showPropLabels: true, + showPropNames: true, + markerEnabled: true, + showAll: false, + previousTotalCount: 2, + nextTotalCount: 2, +}; + +const DOCUMENT: DrillDocument = { + schema: "https://eight2five.com/schema/drill", + schemaVersion: "2.0.0", + metadata: { + title: "Entity render fixture", + createdAt: "2026-01-01T00:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entityRules: { + bySymbol: { + performer: { appearance: { color: "#101010", icon: "diamond" } }, + }, + byLabel: { + Flag: { + size: { length: 2, width: 1, unit: "feet" }, + appearance: { color: "#123456", icon: "square" }, + }, + }, + }, + entities: [ + { + id: 1, + type: "performer", + symbol: "performer", + label: "A", + name: "Alice", + }, + { + id: 2, + type: "performer", + symbol: "performer", + label: "B", + name: "Bob", + }, + { + id: 3, + type: "prop", + symbol: "prop", + label: "Flag", + name: "Blue flag", + }, + ], + sets: [ + { + id: 10, + number: 1, + kind: "set", + countsFromPrevious: 0, + }, + { + id: 11, + number: 2, + kind: "set", + countsFromPrevious: 8, + }, + { + id: 12, + number: 3, + kind: "set", + countsFromPrevious: 8, + }, + ], + positions: [ + { entityId: 1, setId: 10, xSteps: 0, ySteps: 0 }, + { entityId: 1, setId: 11, xSteps: 8, ySteps: 8 }, + { entityId: 1, setId: 12, xSteps: 16, ySteps: 8 }, + { entityId: 2, setId: 11, xSteps: 10, ySteps: 30 }, + { entityId: 3, setId: 11, xSteps: -10, ySteps: 20 }, + ], + paths: [ + { + entityId: 1, + fromSetId: 10, + toSetId: 11, + kind: "polyline", + waypoints: [{ xSteps: 4, ySteps: 1 }], + }, + ], +}; + +const NON_UNIFORM_CUSTOM_FIELD: FieldDefinition = { + type: "custom", + name: "Non-uniform test field", + physicalGeometry: { + bounds: { + minXMeters: -10, + maxXMeters: 10, + minYMeters: 0, + maxYMeters: 168, + }, + referenceLines: [ + { id: "x-min", name: "X minimum", axis: "x", coordinateMeters: -10 }, + { id: "x-max", name: "X maximum", axis: "x", coordinateMeters: 10 }, + { id: "y-front", name: "Y front", axis: "y", coordinateMeters: 0 }, + { id: "y-back", name: "Y back", axis: "y", coordinateMeters: 168 }, + ], + }, + marchingGrid: { + bounds: { + minXSteps: -10, + maxXSteps: 10, + minYSteps: 0, + maxYSteps: 84, + }, + referenceLines: [ + { id: "x-min", name: "X minimum", axis: "x", coordinateSteps: -10 }, + { id: "x-max", name: "X maximum", axis: "x", coordinateSteps: 10 }, + { id: "y-front", name: "Y front", axis: "y", coordinateSteps: 0 }, + { id: "y-back", name: "Y back", axis: "y", coordinateSteps: 84 }, + ], + }, + markings: { + yardNumbers: { + heightMeters: 0, + nominalWidthMeters: 0, + centerFromFrontSidelineMeters: 0, + centerFromBackSidelineMeters: 0, + }, + inboundsHashMarks: { lengthMeters: 0, spacingMeters: 1 }, + sidelineHashMarks: { + lengthMeters: 0, + spacingMeters: 1, + insetFromSidelineMeters: 0, + }, + }, +}; + +describe("selected-set drill render scene", () => { + test("uses the drill feature master switch as a scene creation policy", () => { + expect(shouldBuildDrillRenderScene(true)).toBe(true); + expect(shouldBuildDrillRenderScene(false)).toBe(false); + }); + + test("maps an opaque local set row to its nontrivial portable source set id", () => { + expect( + resolveSelectedSourceSetId({ id: "sqlite-set-900", sourceSetId: 11 }), + ).toBe(11); + expect(resolveSelectedSourceSetId({ id: "manual-set" })).toBeUndefined(); + }); + + test("resolves every ordinary entity, projects it, and keeps the selected performer in the target layer", () => { + const scene = buildDrillRenderScene({ + document: DOCUMENT, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + const performer = scene.entities.find((entity) => entity.entityId === 2); + const prop = scene.entities.find((entity) => entity.entityId === 3); + + expect(scene.entities.map((entity) => entity.entityId)).toEqual([2, 3]); + expect(performer).toMatchObject({ + type: "performer", + diameterMeters: DEFAULT_PERFORMER_DIAMETER_METERS, + color: "#101010", + icon: "diamond", + labelText: "B", + nameText: "Bob", + opacity: 1, + }); + expect(prop).toMatchObject({ + type: "prop", + widthMeters: 0.3048, + lengthMeters: 0.6096, + color: "#123456", + icon: "square", + labelText: "Flag", + nameText: "Blue flag", + opacity: 1, + }); + expect(scene.current).toEqual(physicalPoint({ xSteps: 8, ySteps: 8 })); + expect(scene.previous?.geometry.kind).toBe("polyline"); + expect(scene.next?.geometry.kind).toBe("straight"); + expect(scene.previous?.midpoint).toEqual( + measurePhysicalTransitionGeometry(scene.previous!.geometry).midpoint, + ); + expect(scene.previous?.geometry).toMatchObject({ + points: [ + physicalPoint({ xSteps: 0, ySteps: 0 }), + physicalPoint({ xSteps: 4, ySteps: 1 }), + physicalPoint({ xSteps: 8, ySteps: 8 }), + ], + }); + expect(Object.isFrozen(scene)).toBe(true); + expect(Object.isFrozen(scene.entities)).toBe(true); + }); + + test("temporarily treats JSON label visibility as ordinary entity visibility", () => { + const hiddenDocument: DrillDocument = { + ...DOCUMENT, + entities: DOCUMENT.entities.map((entity) => + entity.id === 2 || entity.id === 3 + ? { + ...entity, + appearance: { + ...entity.appearance, + labelVisible: false, + }, + } + : entity, + ), + }; + const scene = buildDrillRenderScene({ + document: hiddenDocument, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + + expect(scene.entities).toEqual([]); + expect(scene.current).toEqual(physicalPoint({ xSteps: 8, ySteps: 8 })); + }); + + test("keeps labels and names independent and marker master only suppresses transition graphics", () => { + const scene = buildDrillRenderScene({ + document: DOCUMENT, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: { + ...SETTINGS, + showPerformerLabels: false, + showPropLabels: false, + markerEnabled: false, + }, + }); + + expect(scene.entities[0].labelText).toBeUndefined(); + expect(scene.entities[0].nameText).toBe("Bob"); + expect(scene.entities[1].labelText).toBeUndefined(); + expect(scene.entities[1].nameText).toBe("Blue flag"); + expect(scene.current).not.toBeNull(); + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([]); + }); + + test("does not invent a selected target or transitions when its source position is missing", () => { + const documentWithoutCurrentPosition: DrillDocument = { + ...DOCUMENT, + positions: DOCUMENT.positions.filter( + (position) => !(position.entityId === 1 && position.setId === 11), + ), + }; + const scene = buildDrillRenderScene({ + document: documentWithoutCurrentPosition, + field: "football-nfhs", + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + + expect(scene.current).toBeNull(); + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([]); + }); + + test("measures midpoint from the projected connector geometry on a nonlinear field", () => { + const documentWithCurve: DrillDocument = { + ...DOCUMENT, + paths: [ + { + entityId: 1, + fromSetId: 10, + toSetId: 11, + kind: "bezier", + controlPoints: [ + { xSteps: 0, ySteps: 30 }, + { xSteps: 8, ySteps: 30 }, + ], + }, + ], + }; + const scene = buildDrillRenderScene({ + document: documentWithCurve, + field: NON_UNIFORM_CUSTOM_FIELD, + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + const previous = scene.previous; + expect(previous?.geometry.kind).toBe("bezier"); + if (!previous || previous.geometry.kind !== "bezier") return; + + const measured = measurePhysicalTransitionGeometry(previous.geometry); + expect(previous.midpoint).toEqual(measured.midpoint); + expect(previous.midpointParameter).toBe(measured.midpointParameter); + + const gridScene = buildTransitionScene({ + document: documentWithCurve, + selectedPerformerEntityId: 1, + selectedSourceSetId: 11, + settings: SETTINGS, + }); + expect(previous.lengthSteps).toBe(gridScene.previous?.lengthSteps); + const projectedGridMidpoint = drillGridToPhysicalPoint( + gridScene.previous!.midpoint, + NON_UNIFORM_CUSTOM_FIELD, + ); + expect(previous.midpoint).not.toEqual(projectedGridMidpoint); + }); + + test("projects path controls and waypoints through the active field preset", () => { + const geometry = projectTransitionPathGeometry( + { + kind: "bezier", + start: { xSteps: -8, ySteps: 4 }, + controlPoints: [ + { xSteps: -2, ySteps: 12 }, + { xSteps: 4, ySteps: 16 }, + ], + end: { xSteps: 8, ySteps: 20 }, + }, + "football-ncaa", + ); + + expect(geometry).toEqual({ + kind: "bezier", + start: physicalPoint({ xSteps: -8, ySteps: 4 }, "football-ncaa"), + controlPoints: [ + physicalPoint({ xSteps: -2, ySteps: 12 }, "football-ncaa"), + physicalPoint({ xSteps: 4, ySteps: 16 }, "football-ncaa"), + ], + end: physicalPoint({ xSteps: 8, ySteps: 20 }, "football-ncaa"), + }); + }); + + test("converts prop sizes and keeps marker size math explicit", () => { + expect( + resolvePropPhysicalSize({ + size: { length: 1, width: 2, unit: "meters" }, + }), + ).toEqual({ widthMeters: 2, lengthMeters: 1 }); + expect(DEFAULT_PERFORMER_DIAMETER_METERS).toBe(0.5715); + expect(DRILL_MARKER_SIZE_STEPS).toEqual({ + currentDiameter: 2, + transitionDiameter: 1, + midpointDiameter: 0.5, + }); + expect(DRILL_MARKER_SIZE_METERS.currentDiameter).toBeCloseTo(1.143); + expect(DRILL_MARKER_SIZE_METERS.transitionDiameter).toBeCloseTo(0.5715); + expect(DRILL_MARKER_SIZE_METERS.midpointDiameter).toBeCloseTo(0.28575); + expect(LIVE_POSITION_MARKER_SIZE_STEPS).toBe(1.5); + expect(LIVE_POSITION_MARKER_DIAMETER_METERS).toBeCloseTo(0.85725); + expect(DRILL_MARKER_COLORS).toEqual({ + yellow: COLOR_PRESETS.yellow, + red: COLOR_PRESETS.red, + green: COLOR_PRESETS.green, + }); + expect(DRILL_RENDER_LAYER_ORDER).toEqual([ + "static", + "anchors", + "entities", + "extra-connectors", + "extra-dots", + "previous", + "next", + "current-target", + "guidance", + "live-position", + ]); + }); +}); + +function physicalPoint( + point: { readonly xSteps: number; readonly ySteps: number }, + preset: "football-nfhs" | "football-ncaa" = "football-nfhs", +) { + return drillGridToPhysicalPoint(point, getFieldPreset(preset)); +} diff --git a/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts new file mode 100644 index 00000000..aa503933 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/sqlite-repository.test.ts @@ -0,0 +1,1120 @@ +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + FIELD_PRESET_IDS, + type DrillDocument, + type FieldPresetId, +} from "@eight2five/drill-schema"; +import type { SQLiteDatabase } from "expo-sqlite"; +import { + SqliteDrillRepository, + type CreateDrillInput, +} from "../SqliteDrillRepository"; + +const IMPORTED_DOCUMENT: DrillDocument = { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Imported Finale", + createdAt: "2026-08-03T18:00:00.000Z", + drillWriter: "A. Writer", + ensemble: "The Ensemble", + description: "Full document round trip", + lucideIcon: "music-2", + }, + field: { type: "preset", preset: "football-nfhs" }, + entityRules: { + bySymbol: { B: { appearance: { color: "#E53935" } } }, + }, + entities: [ + { id: 10, type: "performer", symbol: "B", label: "B1" }, + { id: 11, type: "performer", symbol: "B", label: "B2" }, + { id: 99, type: "prop", symbol: "P", label: "Flag" }, + ], + sets: [ + { id: 0, number: 1, kind: "set", countsFromPrevious: 0 }, + { id: 1, number: 2, kind: "set", countsFromPrevious: 8 }, + ], + positions: [ + { entityId: 10, setId: 0, xSteps: 0, ySteps: 0 }, + { entityId: 10, setId: 1, xSteps: 8, ySteps: 0 }, + { entityId: 11, setId: 0, xSteps: 0, ySteps: 8 }, + { entityId: 11, setId: 1, xSteps: 8, ySteps: 8 }, + { entityId: 99, setId: 0, xSteps: 4, ySteps: 4 }, + { entityId: 99, setId: 1, xSteps: 12, ySteps: 4 }, + ], + paths: [ + { + entityId: 10, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 4, ySteps: 3 }], + }, + { + entityId: 11, + fromSetId: 0, + toSetId: 1, + kind: "bezier", + controlPoints: [ + { xSteps: 2, ySteps: 10 }, + { xSteps: 6, ySteps: 10 }, + ], + }, + ], + provenance: { + source: { kind: "coordinate-sheet", fileName: "finale.pdf" }, + importer: { name: "test", version: "1" }, + importedAt: "2026-08-03T18:01:00.000Z", + references: [{ target: { type: "entity", entityId: 10 }, page: 1 }], + }, + extensions: { custom: { retained: true } }, +}; + +describe("SqliteDrillRepository", () => { + test("uses stable factories and deterministic drill/set ordering", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill-1", "drill-2", "set-1", "set-2", "set-3"]; + const times = [20, 10]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => times.shift()!, + }); + + const first = await repository.createDrill("First"); + const second = await repository.createDrill("Second"); + expect(first).toMatchObject({ + id: "drill-1", + fieldPreset: "football-nfhs", + createdAt: 20, + updatedAt: 20, + }); + expect(second).toMatchObject({ + id: "drill-2", + createdAt: 10, + updatedAt: 10, + }); + expect((await repository.listDrills()).map(({ id }) => id)).toEqual([ + "drill-2", + "drill-1", + ]); + + const firstSet = await repository.createSet({ + drillId: first.id, + number: 31, + position: { xSteps: 0, ySteps: 0 }, + }); + const secondSet = await repository.createSet({ + drillId: first.id, + number: 32, + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + position: { xSteps: 0, ySteps: 32 }, + }); + expect(firstSet).toMatchObject({ + id: "set-1", + ordinal: 0, + number: 31, + kind: "set", + countsFromPrevious: 0, + }); + expect(secondSet).toMatchObject({ + id: "set-2", + ordinal: 1, + number: 32, + countsFromPrevious: 16, + measureRange: { start: 126, end: 129 }, + position: { xSteps: 0, ySteps: 32 }, + }); + expect((await repository.listSets(first.id)).map(({ id }) => id)).toEqual([ + "set-1", + "set-2", + ]); + }); + + test.each(FIELD_PRESET_IDS)( + "round-trips the %s field preset", + async (fieldPreset) => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => `drill-${fieldPreset}`, + timeFactory: () => 1, + }); + + await expect( + repository.createDrill({ name: fieldPreset, fieldPreset }), + ).resolves.toMatchObject({ fieldPreset }); + await expect(repository.listDrills()).resolves.toEqual([ + expect.objectContaining({ fieldPreset }), + ]); + }, + ); + + test("round-trips the full imported document and summary metadata", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["full-0", "full-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + expect(fake.drills.get(drill.id)?.source_document_json).toBe( + `${JSON.stringify(IMPORTED_DOCUMENT, null, 2)}\n`, + ); + expect(drill).toMatchObject({ + name: "Imported Finale", + selectedPerformerEntityId: 10, + metadata: IMPORTED_DOCUMENT.metadata, + }); + expect(await repository.listDrills()).toEqual([ + expect.objectContaining({ + id: "imported", + metadata: IMPORTED_DOCUMENT.metadata, + }), + ]); + + fake.drills.get(drill.id)!.source_document_json = "not-json"; + await expect(repository.listDrills()).resolves.toHaveLength(1); + await expect(repository.getDrillDocument(drill.id)).rejects.toThrow( + "source document", + ); + }); + + test("updates imported properties transactionally in the summary and document", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + timeFactory: () => 123, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + await expect( + repository.updateDrillProperties(drill.id, { + name: "Updated Finale", + lucideIcon: "sparkles", + }), + ).resolves.toMatchObject({ + name: "Updated Finale", + metadata: expect.objectContaining({ + title: "Updated Finale", + lucideIcon: "sparkles", + }), + updatedAt: 123, + }); + expect(await repository.getDrillDocument(drill.id)).toEqual({ + ...IMPORTED_DOCUMENT, + metadata: { + ...IMPORTED_DOCUMENT.metadata, + title: "Updated Finale", + lucideIcon: "sparkles", + }, + }); + expect(fake.drills.get(drill.id)).toMatchObject({ + name: "Updated Finale", + metadata_title: "Updated Finale", + metadata_lucide_icon: "sparkles", + }); + expect(fake.database.withTransactionAsync).toHaveBeenCalled(); + + await repository.updateDrillProperties(drill.id, { lucideIcon: null }); + expect((await repository.getDrillDocument(drill.id))?.metadata).toEqual({ + createdAt: IMPORTED_DOCUMENT.metadata.createdAt, + drillWriter: IMPORTED_DOCUMENT.metadata.drillWriter, + ensemble: IMPORTED_DOCUMENT.metadata.ensemble, + description: IMPORTED_DOCUMENT.metadata.description, + title: "Updated Finale", + }); + }); + + test("rejects invalid drill property values without changing the document", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + await expect( + repository.updateDrillProperties(drill.id, { lucideIcon: "Not An Icon" }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + }); + + test("rejects imported fields passed through manual drill creation", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => "manual", + timeFactory: () => 1, + }); + + await expect( + repository.createDrill({ + name: IMPORTED_DOCUMENT.metadata.title, + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + } as unknown as CreateDrillInput), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + expect(fake.drills.size).toBe(0); + }); + + test("maps local projected sets to source set ids and reprojections preserve source data", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["local-0", "local-1", "reprojected-0", "reprojected-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + + expect( + (await repository.listSets(drill.id)).map((set) => [ + set.id, + set.sourceSetId, + ]), + ).toEqual([ + ["local-0", 0], + ["local-1", 1], + ]); + await repository.setActiveDrill(drill.id); + await repository.setSelectedDrillSet("local-1"); + + await repository.setSelectedPerformer(drill.id, 11); + + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + expect(await repository.getDrill(drill.id)).toMatchObject({ + selectedPerformerEntityId: 11, + }); + expect( + (await repository.listSets(drill.id)).map((set) => ({ + id: set.id, + sourceSetId: set.sourceSetId, + position: set.position, + })), + ).toEqual([ + { + id: "reprojected-0", + sourceSetId: 0, + position: { xSteps: 0, ySteps: 8 }, + }, + { + id: "reprojected-1", + sourceSetId: 1, + position: { xSteps: 8, ySteps: 8 }, + }, + ]); + expect(fake.settings.selected_drill_page_id).toBe("reprojected-1"); + }); + + test("rejects all set mutations for imported projections, including page aliases", async () => { + const fake = new DrillFakeDatabase(); + const repository = new SqliteDrillRepository(fake.database, { + idFactory: (() => { + const ids = ["local-0", "local-1"]; + return () => ids.shift()!; + })(), + timeFactory: () => 1, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + const beforeSets = await repository.listSets(drill.id); + + const expectRejected = async (operation: Promise) => { + await expect(operation).rejects.toMatchObject({ code: "INVALID_INPUT" }); + }; + await expectRejected( + repository.createSet({ + drillId: drill.id, + number: 3, + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.updateSet(beforeSets[0].id, { + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected(repository.deleteSet(beforeSets[0].id)); + await expectRejected( + repository.insertSet(drill.id, 1, { + number: 1, + suffix: "A", + kind: "subset", + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.reorderSets( + drill.id, + beforeSets.map((set) => set.id).reverse(), + ), + ); + + await expectRejected( + repository.createPage({ + drillId: drill.id, + number: 3, + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.updatePage(beforeSets[0].id, { + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected(repository.deletePage(beforeSets[0].id)); + await expectRejected( + repository.insertPage(drill.id, 1, { + number: 1, + suffix: "A", + kind: "subset", + position: { xSteps: 1, ySteps: 1 }, + }), + ); + await expectRejected( + repository.reorderPages( + drill.id, + beforeSets.map((set) => set.id).reverse(), + ), + ); + + expect(await repository.listSets(drill.id)).toEqual(beforeSets); + expect(await repository.getDrillDocument(drill.id)).toEqual( + IMPORTED_DOCUMENT, + ); + }); + + test("rejects an invalid performer entity without changing the projection", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["local-0", "local-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }); + const before = await repository.listSets(drill.id); + + await expect( + repository.setSelectedPerformer(drill.id, 99), + ).rejects.toMatchObject({ + code: "INVALID_SELECTION", + }); + expect(await repository.listSets(drill.id)).toEqual(before); + expect( + (await repository.getDrill(drill.id))?.selectedPerformerEntityId, + ).toBe(10); + }); + + test("rolls back the drill and projection when an imported set insert fails", async () => { + const fake = new DrillFakeDatabase(); + fake.failOnSetInsert = true; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => "set-that-fails", + timeFactory: () => 1, + }); + + await expect( + repository.createImportedDrill({ + id: "imported", + sourceDocument: IMPORTED_DOCUMENT, + selectedPerformerEntityId: 10, + }), + ).rejects.toThrow("set insert failed"); + expect(fake.drills.size).toBe(0); + expect(fake.sets.size).toBe(0); + }); + + test("selects the first set when activating a different drill and clears it on deactivation", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill-a", "drill-b", "set-a-0", "set-b-0", "set-b-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drillA = await repository.createDrill("A"); + const drillB = await repository.createDrill("B"); + const setA = await repository.createSet({ + drillId: drillA.id, + number: 1, + position: { xSteps: 0, ySteps: 0 }, + }); + const setB = await repository.createSet({ + drillId: drillB.id, + number: 1, + position: { xSteps: 1, ySteps: 1 }, + }); + await repository.createSet({ + drillId: drillB.id, + number: 2, + countsFromPrevious: 8, + position: { xSteps: 2, ySteps: 2 }, + }); + + await repository.setActiveDrill(drillA.id); + expect(fake.settings.selected_drill_page_id).toBe(setA.id); + await repository.setActiveDrill(drillB.id); + expect(fake.settings.selected_drill_page_id).toBe(setB.id); + await repository.setActiveDrill(null); + expect(fake.settings.active_drill_id).toBeNull(); + expect(fake.settings.selected_drill_page_id).toBeNull(); + await repository.setActiveDrill(drillA.id); + expect(fake.settings.selected_drill_page_id).toBe(setA.id); + }); + + test("inserts, reorders, updates, and deletes sets through transactions", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill", "set-a", "set-b", "set-inserted"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createDrill({ name: "Practice" }); + await repository.createSet({ + drillId: drill.id, + number: 1, + position: { xSteps: 0, ySteps: 0 }, + }); + await repository.createSet({ + drillId: drill.id, + number: 2, + countsFromPrevious: 8, + position: { xSteps: 8, ySteps: 8 }, + }); + + await repository.insertSet(drill.id, 1, { + number: 1, + suffix: "A", + kind: "subset", + countsFromPrevious: 4, + position: { xSteps: 4, ySteps: 4 }, + }); + expect( + (await repository.listSets(drill.id)).map((set) => [set.id, set.ordinal]), + ).toEqual([ + ["set-a", 0], + ["set-inserted", 1], + ["set-b", 2], + ]); + + await repository.reorderSets(drill.id, ["set-a", "set-inserted", "set-b"]); + expect((await repository.listSets(drill.id)).map(({ id }) => id)).toEqual([ + "set-a", + "set-inserted", + "set-b", + ]); + await repository.updateSet("set-inserted", { + suffix: ".5", + countsFromPrevious: 6, + measureRange: { start: 10, end: 11 }, + position: { xSteps: 6, ySteps: 9 }, + facingDegrees: 90, + }); + expect(await repository.getSet("set-inserted")).toMatchObject({ + number: 1, + suffix: ".5", + kind: "subset", + countsFromPrevious: 6, + measureRange: { start: 10, end: 11 }, + position: { xSteps: 6, ySteps: 9 }, + facingDegrees: 90, + }); + + await repository.deleteSet("set-inserted"); + expect( + (await repository.listSets(drill.id)).map((set) => [set.id, set.ordinal]), + ).toEqual([ + ["set-a", 0], + ["set-b", 1], + ]); + expect(fake.database.withTransactionAsync).toHaveBeenCalled(); + }); + + test("persists active and selected pointers, validates selection, and honors FK deletion contracts", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill-1", "drill-2", "set-1"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const first = await repository.createDrill("First"); + const second = await repository.createDrill("Second"); + const set = await repository.createSet({ + drillId: first.id, + number: 1, + position: { xSteps: 0, ySteps: 0 }, + }); + + await repository.setActiveDrill(first.id); + await expect(repository.setSelectedDrillSet(set.id)).resolves.toMatchObject( + { + activeDrillId: first.id, + selectedDrillSetId: set.id, + }, + ); + await expect( + repository.setSelectedDrillSet("missing"), + ).rejects.toMatchObject({ + code: "INVALID_SELECTION", + }); + await expect(repository.setActiveDrill("missing")).rejects.toMatchObject({ + code: "DRILL_NOT_FOUND", + }); + + await expect(repository.setActiveDrill(second.id)).resolves.toMatchObject({ + activeDrillId: second.id, + selectedDrillSetId: null, + }); + await expect(repository.setSelectedDrillSet(set.id)).rejects.toMatchObject({ + code: "INVALID_SELECTION", + }); + + await repository.setActiveDrill(first.id); + await repository.setSelectedDrillSet(set.id); + await repository.deleteSet(set.id); + expect(fake.settings.selected_drill_page_id).toBeNull(); + + await repository.setActiveDrill(first.id); + await repository.deleteDrill(first.id); + expect(fake.settings.active_drill_id).toBeNull(); + expect(fake.sets.size).toBe(0); + }); + + test("rejects malformed set data and enforces subset/primary structure", async () => { + const fake = new DrillFakeDatabase(); + const ids = ["drill", "set-1", "set-2"]; + const repository = new SqliteDrillRepository(fake.database, { + idFactory: () => ids.shift()!, + timeFactory: () => 1, + }); + const drill = await repository.createDrill("Drill"); + + await expect(repository.createDrill(" ")).rejects.toMatchObject({ + code: "INVALID_INPUT", + }); + await expect( + repository.createSet({ + drillId: drill.id, + number: 1, + countsFromPrevious: 1, + position: { xSteps: 0, ySteps: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + await expect( + repository.createSet({ + drillId: drill.id, + number: 1, + countsFromPrevious: 0, + position: { xSteps: Number.NaN, ySteps: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + + await repository.createSet({ + drillId: drill.id, + number: 1, + position: { xSteps: 0, ySteps: 0 }, + }); + await expect( + repository.createSet({ + drillId: drill.id, + number: 99, + kind: "subset", + suffix: "A", + countsFromPrevious: 8, + position: { xSteps: 8, ySteps: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + await expect( + repository.createSet({ + drillId: drill.id, + number: 2, + countsFromPrevious: 2.5, + position: { xSteps: 8, ySteps: 0 }, + }), + ).rejects.toMatchObject({ code: "INVALID_INPUT" }); + }); +}); + +type FakeDrillRow = { + id: string; + name: string; + field_preset: FieldPresetId; + created_at: number; + updated_at: number; + metadata_title?: string; + metadata_created_at?: string; + metadata_drill_writer?: string | null; + metadata_ensemble?: string | null; + metadata_description?: string | null; + metadata_lucide_icon?: string | null; + source_document_json?: string | null; + selected_performer_entity_id?: number | null; +}; + +type FakeSetRow = { + id: string; + drill_id: string; + ordinal: number; + set_number: number; + set_suffix: string | null; + set_kind: "set" | "subset"; + counts_from_previous: number; + measure_start: number | null; + measure_end: number | null; + x_steps: number; + y_steps: number; + facing_degrees: number | null; + source_set_id: number | null; +}; + +class DrillFakeDatabase { + readonly drills = new Map(); + readonly sets = new Map(); + failOnSetInsert = false; + readonly settings = { + drill_features_enabled: 1, + drill_terminology: "sets", + field_perspective: "director", + default_field_preset: "football-nfhs", + transition_metric_mode: "step-size", + guidance_enabled: 1, + developer_mode_enabled: 0, + show_cached_anchor_geometry: 0, + show_comfortable_anchor_range: 0, + show_perimeter_step_grid: 0, + comfortable_anchor_range_meters: 20, + active_drill_id: null as string | null, + selected_drill_page_id: null as string | null, + }; + readonly database: SQLiteDatabase & { + withTransactionAsync: jest.Mock; + }; + + constructor() { + const database = { + execAsync: jest.fn(async () => undefined), + getFirstAsync: jest.fn((sql: string, params: unknown[] = []) => + this.getFirst(sql, params), + ), + getAllAsync: jest.fn((sql: string, params: unknown[] = []) => + this.getAll(sql, params), + ), + runAsync: jest.fn((sql: string, params: unknown[] = []) => + this.run(sql, params), + ), + withTransactionAsync: jest.fn(async (task: () => Promise) => { + const drills = new Map( + [...this.drills].map(([id, row]) => [id, { ...row }]), + ); + const sets = new Map( + [...this.sets].map(([id, row]) => [id, { ...row }]), + ); + const settings = { ...this.settings }; + try { + await task(); + } catch (error) { + this.drills.clear(); + this.sets.clear(); + for (const [id, row] of drills) this.drills.set(id, row); + for (const [id, row] of sets) this.sets.set(id, row); + Object.assign(this.settings, settings); + throw error; + } + }), + }; + this.database = database as unknown as SQLiteDatabase & { + withTransactionAsync: jest.Mock; + }; + } + + private async getFirst(sql: string, params: unknown[]): Promise { + if (sql.includes("COUNT(*) AS set_count")) { + return { + set_count: [...this.sets.values()].filter( + (set) => set.drill_id === params[0], + ).length, + }; + } + if (sql.includes("FROM drills")) { + const row = this.drills.get(String(params[0])); + return row ? { ...row } : null; + } + if (sql.includes("SELECT id FROM drill_sets") && sql.includes("LIMIT 1")) { + const row = [...this.sets.values()] + .filter((set) => set.drill_id === params[0]) + .sort((left, right) => left.ordinal - right.ordinal)[0]; + return row ? { id: row.id } : null; + } + if (sql.includes("SELECT drill_id FROM drill_sets")) { + const row = this.sets.get(String(params[0])); + return row ? { drill_id: row.drill_id } : null; + } + if (sql.includes("FROM drill_sets")) { + const row = this.sets.get(String(params[0])); + return row ? { ...row } : null; + } + if (sql.includes("FROM app_settings")) return { ...this.settings }; + return null; + } + + private async getAll(sql: string, params: unknown[]): Promise { + if (sql.includes("FROM drills")) { + return [...this.drills.values()] + .sort( + (left, right) => + left.created_at - right.created_at || + left.id.localeCompare(right.id), + ) + .map((row) => ({ ...row })); + } + if (sql.includes("FROM drill_sets")) { + const rows = [...this.sets.values()] + .filter((set) => set.drill_id === params[0]) + .sort( + (left, right) => + left.ordinal - right.ordinal || left.id.localeCompare(right.id), + ); + return rows.map((row) => + /^\s*SELECT id\s+FROM drill_sets/m.test(sql) + ? { id: row.id } + : { ...row }, + ); + } + return []; + } + + private async run(sql: string, params: unknown[]): Promise { + if (sql.includes("INSERT INTO drills")) { + const [ + id, + name, + fieldPreset, + createdAt, + updatedAt, + metadataTitle, + metadataCreatedAt, + metadataWriter, + metadataEnsemble, + metadataDescription, + metadataLucideIcon, + sourceDocumentJson, + selectedPerformerEntityId, + ] = params as [ + string, + string, + FieldPresetId, + number, + number, + string, + string, + string | null, + string | null, + string | null, + string | null, + string | null, + number | null, + ]; + this.drills.set(id, { + id, + name, + field_preset: fieldPreset, + created_at: createdAt, + updated_at: updatedAt, + metadata_title: metadataTitle, + metadata_created_at: metadataCreatedAt, + metadata_drill_writer: metadataWriter, + metadata_ensemble: metadataEnsemble, + metadata_description: metadataDescription, + metadata_lucide_icon: metadataLucideIcon, + source_document_json: sourceDocumentJson, + selected_performer_entity_id: selectedPerformerEntityId, + }); + } else if (sql.includes("INSERT INTO drill_sets")) { + if (this.failOnSetInsert) throw new Error("set insert failed"); + const [ + id, + drillId, + ordinal, + number, + suffix, + kind, + counts, + measureStart, + measureEnd, + xSteps, + ySteps, + facingDegrees, + sourceSetId, + ] = params as [ + string, + string, + number, + number, + string | null, + "set" | "subset", + number, + number | null, + number | null, + number, + number, + number | null, + number | null, + ]; + this.sets.set(id, { + id, + drill_id: drillId, + ordinal, + set_number: number, + set_suffix: suffix, + set_kind: kind, + counts_from_previous: counts, + measure_start: measureStart, + measure_end: measureEnd, + x_steps: xSteps, + y_steps: ySteps, + facing_degrees: facingDegrees, + source_set_id: sourceSetId, + }); + } else if (sql.includes("INSERT OR IGNORE INTO app_settings")) { + // Singleton already exists in this fake. + } else if (sql.includes("DELETE FROM drills")) { + const id = String(params[0]); + this.drills.delete(id); + for (const [setId, set] of this.sets) { + if (set.drill_id === id) { + this.sets.delete(setId); + if (this.settings.selected_drill_page_id === setId) { + this.settings.selected_drill_page_id = null; + } + } + } + if (this.settings.active_drill_id === id) { + this.settings.active_drill_id = null; + this.settings.selected_drill_page_id = null; + } + } else if (sql.includes("DELETE FROM drill_sets")) { + if (sql.includes("WHERE drill_id = ?")) { + const drillId = String(params[0]); + for (const [setId, set] of this.sets) { + if (set.drill_id === drillId) { + this.sets.delete(setId); + if (this.settings.selected_drill_page_id === setId) { + this.settings.selected_drill_page_id = null; + } + } + } + } else { + const id = String(params[0]); + this.sets.delete(id); + if (this.settings.selected_drill_page_id === id) { + this.settings.selected_drill_page_id = null; + } + } + } else if ( + sql.includes("UPDATE drills") && + sql.includes("selected_performer_entity_id") + ) { + const [selectedPerformerEntityId, id] = params as [number, string]; + const row = this.drills.get(id); + if (row) { + this.drills.set(id, { + ...row, + selected_performer_entity_id: selectedPerformerEntityId, + }); + } + } else if ( + sql.includes("UPDATE drills") && + sql.includes("metadata_lucide_icon") + ) { + const row = this.drills.get(String(params[params.length - 1])); + if (row) { + const hasSourceDocument = sql.includes("source_document_json"); + if (hasSourceDocument) { + const [name, metadataTitle, icon, sourceDocumentJson, updatedAt] = + params as [string, string, string | null, string, number, string]; + this.drills.set(row.id, { + ...row, + name, + metadata_title: metadataTitle, + metadata_lucide_icon: icon, + source_document_json: sourceDocumentJson, + updated_at: updatedAt, + }); + } else { + const [name, metadataTitle, icon, updatedAt] = params as [ + string, + string, + string | null, + number, + string, + ]; + this.drills.set(row.id, { + ...row, + name, + metadata_title: metadataTitle, + metadata_lucide_icon: icon, + updated_at: updatedAt, + }); + } + } + } else if (sql.includes("UPDATE drills")) { + const [name, updatedAt, id] = params as [string, number, string]; + const row = this.drills.get(id); + if (row) { + this.drills.set(id, { + ...row, + name, + metadata_title: name, + updated_at: updatedAt, + }); + } + } else if (sql.includes("UPDATE app_settings")) { + this.updateSettings(sql, params); + } else if (sql.includes("UPDATE drill_sets")) { + this.updateSets(sql, params); + } + return { lastInsertRowId: 1, changes: 1 }; + } + + private updateSettings(sql: string, params: unknown[]): void { + if (sql.includes("active_drill_id = ?")) { + const next = params[0] as string | null; + if (this.settings.active_drill_id !== next) { + this.settings.selected_drill_page_id = null; + } + this.settings.active_drill_id = next; + return; + } + if (sql.includes("selected_drill_page_id = NULL")) { + this.settings.selected_drill_page_id = null; + return; + } + if (sql.includes("selected_drill_page_id = ?")) { + this.settings.selected_drill_page_id = params[0] as string; + } + } + + private updateSets(sql: string, params: unknown[]): void { + if (sql.includes("SET ordinal = ordinal + ?")) { + const [offset, drillId] = params as [number, string]; + for (const set of this.sets.values()) { + if (set.drill_id === drillId) set.ordinal += offset; + } + return; + } + if (sql.includes("SET ordinal = CASE")) { + const [threshold, offset, , drillId] = params as [ + number, + number, + number, + string, + ]; + for (const set of this.sets.values()) { + if (set.drill_id === drillId) { + set.ordinal = + set.ordinal >= threshold + ? set.ordinal - offset + 1 + : set.ordinal - offset; + } + } + return; + } + if (sql.includes("SET ordinal = ordinal - 1")) { + const [drillId, ordinal] = params as [string, number]; + for (const set of this.sets.values()) { + if (set.drill_id === drillId && set.ordinal > ordinal) set.ordinal -= 1; + } + return; + } + if (sql.includes("SET ordinal = ?")) { + const [ordinal, id] = params as [number, string, string]; + const set = this.sets.get(id); + if (set) set.ordinal = ordinal; + return; + } + if ( + sql.includes("SET counts_from_previous = 0") && + sql.includes("ordinal = 0") + ) { + const drillId = String(params[0]); + const first = [...this.sets.values()].find( + (set) => set.drill_id === drillId && set.ordinal === 0, + ); + if (first) first.counts_from_previous = 0; + return; + } + if (sql.includes("SET counts_from_previous = 0 WHERE id = ?")) { + const set = this.sets.get(String(params[0])); + if (set) set.counts_from_previous = 0; + return; + } + if (sql.includes("SET set_number = ?")) { + const [ + number, + suffix, + kind, + counts, + measureStart, + measureEnd, + xSteps, + ySteps, + facingDegrees, + sourceSetId, + id, + ] = params as [ + number, + string | null, + "set" | "subset", + number, + number | null, + number | null, + number, + number, + number | null, + number | null, + string, + ]; + const set = this.sets.get(id); + if (set) { + Object.assign(set, { + set_number: number, + set_suffix: suffix, + set_kind: kind, + counts_from_previous: counts, + measure_start: measureStart, + measure_end: measureEnd, + x_steps: xSteps, + y_steps: ySteps, + facing_degrees: facingDegrees, + source_set_id: sourceSetId, + }); + } + } + } +} diff --git a/packages/mobile/src/drill/__tests__/terminology.test.ts b/packages/mobile/src/drill/__tests__/terminology.test.ts new file mode 100644 index 00000000..6a88cbf6 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/terminology.test.ts @@ -0,0 +1,26 @@ +import { getDrillTerms } from "../index"; + +describe("drill terminology", () => { + test("centralizes Page labels", () => { + expect(getDrillTerms("pages")).toEqual({ + singular: "Page", + plural: "Pages", + lowercaseSingular: "page", + lowercasePlural: "pages", + }); + }); + + test("centralizes Set labels", () => { + expect(getDrillTerms("sets")).toEqual({ + singular: "Set", + plural: "Sets", + lowercaseSingular: "set", + lowercasePlural: "sets", + }); + }); + + test("reuses immutable canonical term objects", () => { + expect(getDrillTerms("pages")).toBe(getDrillTerms("pages")); + expect(Object.isFrozen(getDrillTerms("pages"))).toBe(true); + }); +}); diff --git a/packages/mobile/src/drill/__tests__/transition-scene.test.ts b/packages/mobile/src/drill/__tests__/transition-scene.test.ts new file mode 100644 index 00000000..ef683a97 --- /dev/null +++ b/packages/mobile/src/drill/__tests__/transition-scene.test.ts @@ -0,0 +1,438 @@ +import { + areGridPointsEquivalent, + buildTransitionScene, + cubicBezierPoint, + DEFAULT_GRID_POINT_EPSILON_STEPS, + measureTransitionGeometry, + type TransitionSceneSettings, +} from ".."; +import { + DRILL_SCHEMA_URL, + DRILL_SCHEMA_VERSION, + type DrillDocument, + type DrillGridPoint, +} from "@eight2five/drill-schema"; + +const ENTITY_ID = 7; + +function settings( + overrides: Partial = {}, +): TransitionSceneSettings { + return { + markerEnabled: true, + showAll: false, + previousTotalCount: 1, + nextTotalCount: 1, + ...overrides, + }; +} + +function documentFor( + points: readonly DrillGridPoint[], + paths?: DrillDocument["paths"], +): DrillDocument { + return { + schema: DRILL_SCHEMA_URL, + schemaVersion: DRILL_SCHEMA_VERSION, + metadata: { + title: "Transition test", + createdAt: "2026-08-05T00:00:00.000Z", + }, + field: { type: "preset", preset: "football-nfhs" }, + entities: [{ id: ENTITY_ID, type: "performer", symbol: "P", label: "P1" }], + sets: points.map((_, id) => ({ + id, + number: id + 1, + kind: "set" as const, + countsFromPrevious: id === 0 ? 0 : 8, + })), + positions: points.map((point, setId) => ({ + entityId: ENTITY_ID, + setId, + ...point, + })), + ...(paths === undefined ? {} : { paths }), + }; +} + +describe("transition geometry", () => { + test("uses the analytic midpoint for a straight transition", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 10, ySteps: 4 }, + ]), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.current).toEqual({ xSteps: 10, ySteps: 4 }); + expect(scene.previous?.geometry).toEqual({ + kind: "straight", + start: { xSteps: 0, ySteps: 0 }, + end: { xSteps: 10, ySteps: 4 }, + }); + expect(scene.previous?.midpoint).toEqual({ xSteps: 5, ySteps: 2 }); + }); + + test("finds the midpoint by distance on unequal polyline segments", () => { + const scene = buildTransitionScene( + documentFor( + [ + { xSteps: 0, ySteps: 0 }, + { xSteps: 1, ySteps: 3 }, + ], + [ + { + entityId: ENTITY_ID, + fromSetId: 0, + toSetId: 1, + kind: "polyline", + waypoints: [{ xSteps: 1, ySteps: 0 }], + }, + ], + ), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.previous?.geometry.kind).toBe("polyline"); + expect(scene.previous?.lengthSteps).toBe(4); + expect(scene.previous?.midpoint).toEqual({ xSteps: 1, ySteps: 1 }); + }); + + test("uses a true half-arc-length midpoint for an asymmetric Bézier", () => { + const geometry = { + kind: "bezier" as const, + start: { xSteps: 0, ySteps: 0 }, + controlPoints: [ + { xSteps: 0, ySteps: 140 }, + { xSteps: 18, ySteps: -12 }, + ] as const, + end: { xSteps: 100, ySteps: 0 }, + }; + const measured = measureTransitionGeometry(geometry, { tolerance: 1e-6 }); + const parameterHalfPoint = cubicBezierPoint( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + 0.5, + ); + + expect(measured.midpointParameter).not.toBeCloseTo(0.5, 3); + expect(measured.midpoint.xSteps).not.toBeCloseTo( + parameterHalfPoint.xSteps, + 3, + ); + expect(measured.midpoint.ySteps).not.toBeCloseTo( + parameterHalfPoint.ySteps, + 3, + ); + }); + + test("uses the same coarse chord LUT metric for length and midpoint", () => { + const geometry = { + kind: "bezier" as const, + start: { xSteps: 0, ySteps: 0 }, + controlPoints: [ + { xSteps: 0, ySteps: 140 }, + { xSteps: 18, ySteps: -12 }, + ] as const, + end: { xSteps: 100, ySteps: 0 }, + }; + const measured = measureTransitionGeometry(geometry, { + tolerance: 1e-6, + maxSubdivisionDepth: 0, + }); + const expectedMidpoint = cubicBezierPoint( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + 0.5, + ); + + expect(measured.lengthSteps).toBe(100); + expect(measured.midpointParameter).toBe(0.5); + expect(measured.midpoint).toEqual(expectedMidpoint); + }); + + test("falls back to a straight path when no matching path exists", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 2 }, + { xSteps: 8, ySteps: -6 }, + ]), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.previous?.geometry.kind).toBe("straight"); + }); + + test("matches an explicit path by entity and both set IDs", () => { + const scene = buildTransitionScene( + documentFor( + [ + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + { xSteps: 20, ySteps: 0 }, + ], + [ + { + entityId: ENTITY_ID, + fromSetId: 1, + toSetId: 2, + kind: "bezier", + controlPoints: [ + { xSteps: 12, ySteps: 10 }, + { xSteps: 16, ySteps: 10 }, + ], + }, + ], + ), + ENTITY_ID, + 2, + settings(), + ); + + expect(scene.previous?.geometry.kind).toBe("bezier"); + expect(scene.previous?.start).toEqual({ xSteps: 8, ySteps: 0 }); + expect(scene.previous?.end).toEqual({ xSteps: 20, ySteps: 0 }); + }); +}); + +describe("transition scene marker state", () => { + test("suppresses the marker entering a hold", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + ]), + ENTITY_ID, + 1, + settings(), + ); + + expect(scene.previous).toBeDefined(); + expect(scene.next).toBeUndefined(); + }); + + test("suppresses both sides inside a hold chain", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings(), + ); + + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + }); + + test("shows the next marker when moving out of a hold", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings({ previousTotalCount: 0 }), + ); + + expect(scene.next?.fromSetId).toBe(2); + expect(scene.next?.toSetId).toBe(3); + }); + + test("handles the first and last source sets", () => { + const document = documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]); + const first = buildTransitionScene( + document, + ENTITY_ID, + 0, + settings({ nextTotalCount: 0 }), + ); + const last = buildTransitionScene(document, ENTITY_ID, 1, settings()); + + expect(first.previous).toBeUndefined(); + expect(first.next).toBeUndefined(); + expect(last.previous).toBeDefined(); + expect(last.next).toBeUndefined(); + }); + + test("showAll overrides detailed counts", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 1, ySteps: 0 }, + { xSteps: 2, ySteps: 0 }, + { xSteps: 3, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + ]), + ENTITY_ID, + 3, + settings({ showAll: true, previousTotalCount: 0, nextTotalCount: 0 }), + ); + + expect(scene.previous?.fromSetId).toBe(2); + expect( + scene.previousConnectors.map(({ fromSetId, toSetId }) => [ + fromSetId, + toSetId, + ]), + ).toEqual([ + [1, 2], + [0, 1], + ]); + expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1, 0]); + expect(scene.next?.toSetId).toBe(4); + expect(scene.nextConnectors).toEqual([]); + }); + + test("uses total-count windows without backfilling a suppressed immediate marker", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: -8, ySteps: 0 }, + { xSteps: -4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 3, + settings({ previousTotalCount: 2, nextTotalCount: 0 }), + ); + + expect(scene.previous).toBeUndefined(); + expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1]); + expect(scene.previousDots).not.toContainEqual({ + setId: 0, + point: { xSteps: -8, ySteps: 0 }, + }); + }); + + test("deduplicates coincident extras without removing the immediate marker", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 4, + settings({ previousTotalCount: 4, nextTotalCount: 0 }), + ); + + expect(scene.previous?.fromSetId).toBe(3); + expect(scene.previousDots.map((dot) => dot.setId)).toEqual([1]); + }); + + test("deduplicates extras scene-wide with previous extras taking priority", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 8, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings({ previousTotalCount: 3, nextTotalCount: 3 }), + ); + + expect(scene.previous).toBeDefined(); + expect(scene.next).toBeDefined(); + expect( + scene.previousConnectors.map(({ fromSetId, toSetId }) => [ + fromSetId, + toSetId, + ]), + ).toEqual([[0, 1]]); + expect( + scene.nextConnectors.map(({ fromSetId, toSetId }) => [ + fromSetId, + toSetId, + ]), + ).toEqual([[3, 4]]); + expect(scene.previousDots).toEqual([ + { setId: 0, point: { xSteps: 8, ySteps: 0 } }, + ]); + expect(scene.nextDots).toEqual([]); + }); + + test("suppresses an extra that overlaps the opposite immediate endpoint", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 6, ySteps: 0 }, + { xSteps: 0, ySteps: 0 }, + { xSteps: 4, ySteps: 0 }, + { xSteps: 6, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 2, + settings({ previousTotalCount: 3, nextTotalCount: 3 }), + ); + + expect(scene.previous).toBeDefined(); + expect(scene.next).toBeDefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([ + { setId: 4, point: { xSteps: 8, ySteps: 0 } }, + ]); + }); + + test("keeps the current point while hiding all transition markers when disabled", () => { + const scene = buildTransitionScene( + documentFor([ + { xSteps: 0, ySteps: 0 }, + { xSteps: 8, ySteps: 0 }, + ]), + ENTITY_ID, + 1, + settings({ markerEnabled: false, showAll: true }), + ); + + expect(scene.current).toEqual({ xSteps: 8, ySteps: 0 }); + expect(scene.previous).toBeUndefined(); + expect(scene.next).toBeUndefined(); + expect(scene.previousDots).toEqual([]); + expect(scene.nextDots).toEqual([]); + }); + + test("defines epsilon equivalence for hold suppression", () => { + expect(DEFAULT_GRID_POINT_EPSILON_STEPS).toBeGreaterThan(0); + expect( + areGridPointsEquivalent( + { xSteps: 10, ySteps: -3 }, + { xSteps: 10 + DEFAULT_GRID_POINT_EPSILON_STEPS / 2, ySteps: -3 }, + ), + ).toBe(true); + expect( + areGridPointsEquivalent( + { xSteps: 10, ySteps: -3 }, + { xSteps: 10 + DEFAULT_GRID_POINT_EPSILON_STEPS * 2, ySteps: -3 }, + ), + ).toBe(false); + }); +}); diff --git a/packages/mobile/src/drill/analysis.ts b/packages/mobile/src/drill/analysis.ts new file mode 100644 index 00000000..33dc3f3f --- /dev/null +++ b/packages/mobile/src/drill/analysis.ts @@ -0,0 +1,125 @@ +import type { DrillGridPoint } from "@eight2five/drill-schema"; + +import type { DrillSet } from "./types"; + +const POSITION_EPSILON_STEPS = 1e-9; +const NUMBER_EPSILON = 1e-8; +const STANDARD_STEPS_PER_FIVE_YARDS = 8; +const FIVE_YARD_GRID_LINES = Object.freeze( + Array.from({ length: 21 }, (_, index) => -80 + index * 8), +); + +export interface TransitionAnalysis { + readonly distanceSteps: number; + readonly stepSizeToFive?: number; + readonly isHalt: boolean; + readonly yardLineCrossingCounts: readonly number[]; +} + +function assertCounts(counts: number): void { + if (!Number.isInteger(counts) || counts < 0) { + throw new RangeError("Transition counts must be a non-negative integer."); + } +} + +function assertGridPoint(point: DrillGridPoint, name: string): void { + if (!Number.isFinite(point.xSteps) || !Number.isFinite(point.ySteps)) { + throw new RangeError(`${name} must contain finite xSteps and ySteps.`); + } +} + +function cleanNearHalf(value: number): number { + const nearestHalf = Math.round(value * 2) / 2; + if (Math.abs(value - nearestHalf) <= NUMBER_EPSILON) return nearestHalf; + return Number(value.toFixed(6)); +} + +function roundToQuarter(value: number): number { + return Number((Math.round(value * 4) / 4).toFixed(2)); +} + +function isSamePoint(start: DrillGridPoint, end: DrillGridPoint): boolean { + return ( + Math.abs(start.xSteps - end.xSteps) <= POSITION_EPSILON_STEPS && + Math.abs(start.ySteps - end.ySteps) <= POSITION_EPSILON_STEPS + ); +} + +function crossingCounts( + start: DrillGridPoint, + end: DrillGridPoint, + counts: number, +): readonly number[] { + const xDelta = end.xSteps - start.xSteps; + if (Math.abs(xDelta) <= POSITION_EPSILON_STEPS || counts === 0) return []; + + return Object.freeze( + FIVE_YARD_GRID_LINES.map((xSteps) => (xSteps - start.xSteps) / xDelta) + .filter( + (progress) => + progress > NUMBER_EPSILON && progress < 1 - NUMBER_EPSILON, + ) + .sort((left, right) => left - right) + .map((progress) => cleanNearHalf(progress * counts)), + ); +} + +/** + * Derives transition metrics from drill-grid positions and incoming counts. + * Counts remain performer-facing metadata; these convenience metrics do + * not create a musical timeline or persisted step-size field. + */ +export function analyzeTransition( + previousPosition: DrillGridPoint | null | undefined, + currentPosition: DrillGridPoint, + countsFromPrevious: number, +): TransitionAnalysis { + assertGridPoint(currentPosition, "Current position"); + assertCounts(countsFromPrevious); + + if (!previousPosition) { + return Object.freeze({ + distanceSteps: 0, + isHalt: false, + yardLineCrossingCounts: Object.freeze([]), + }); + } + + assertGridPoint(previousPosition, "Previous position"); + const distanceSteps = Math.hypot( + currentPosition.xSteps - previousPosition.xSteps, + currentPosition.ySteps - previousPosition.ySteps, + ); + const isHalt = + countsFromPrevious > 0 && isSamePoint(previousPosition, currentPosition); + const stepSizeToFive = + countsFromPrevious > 0 && distanceSteps > POSITION_EPSILON_STEPS + ? roundToQuarter( + (countsFromPrevious * STANDARD_STEPS_PER_FIVE_YARDS) / distanceSteps, + ) + : undefined; + + return Object.freeze({ + distanceSteps: cleanNearHalf(distanceSteps), + ...(stepSizeToFive === undefined ? {} : { stepSizeToFive }), + isHalt, + yardLineCrossingCounts: crossingCounts( + previousPosition, + currentPosition, + countsFromPrevious, + ), + }); +} + +export function analyzeDrillTransition( + previousSet: DrillSet | null | undefined, + currentSet: DrillSet, +): TransitionAnalysis { + return analyzeTransition( + previousSet?.position, + currentSet.position, + currentSet.countsFromPrevious, + ); +} + +export const calculateTransitionAnalysis = analyzeTransition; diff --git a/packages/mobile/src/drill/index.ts b/packages/mobile/src/drill/index.ts new file mode 100644 index 00000000..ccdfbc5c --- /dev/null +++ b/packages/mobile/src/drill/index.ts @@ -0,0 +1,16 @@ +export { + formatSetName, + type DrillDocument, + type DrillGridPoint, + type DrillMetadata, + type MeasureRange, + type SetKind, +} from "@eight2five/drill-schema"; +export * from "./types"; +export * from "./terminology"; +export * from "./analysis"; +export * from "./transition-geometry"; +export * from "./transition-scene"; +export * from "./render-scene"; +export * from "./physical-transition-geometry"; +export * from "./SqliteDrillRepository"; diff --git a/packages/mobile/src/drill/physical-transition-geometry.ts b/packages/mobile/src/drill/physical-transition-geometry.ts new file mode 100644 index 00000000..ad1016be --- /dev/null +++ b/packages/mobile/src/drill/physical-transition-geometry.ts @@ -0,0 +1,378 @@ +import type { + PhysicalBezierTransitionGeometry, + PhysicalImmediateTransition, + PhysicalTransitionPathGeometry, +} from "./render-scene"; +import type { PhysicalFieldPoint } from "@eight2five/drill-schema"; +import { + DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS, + DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH, + MAX_BEZIER_SUBDIVISION_DEPTH, + type TransitionGeometryOptions, +} from "./transition-geometry"; +import { STANDARD_STEP_METERS } from "../field/units"; + +/** Measured geometry in the same physical coordinate space sent to Skia. */ +export interface MeasuredPhysicalTransitionGeometry { + readonly geometry: PhysicalTransitionPathGeometry; + readonly lengthMeters: number; + readonly midpoint: PhysicalFieldPoint; + readonly midpointParameter?: number; +} + +/** + * Measure the projected geometry, rather than projecting a midpoint measured in + * grid coordinates. This matters for custom fields whose X/Y scales are not + * uniform: the connector and midpoint must use one physical path metric. + */ +export function measurePhysicalTransitionGeometry( + geometry: PhysicalTransitionPathGeometry, + options: TransitionGeometryOptions = {}, +): MeasuredPhysicalTransitionGeometry { + switch (geometry.kind) { + case "straight": { + const lengthMeters = physicalDistance(geometry.start, geometry.end); + return { + geometry, + lengthMeters, + midpoint: interpolatePhysicalPoint(geometry.start, geometry.end, 0.5), + }; + } + case "polyline": { + const measured = measurePhysicalPolyline(geometry.points); + return { + geometry, + lengthMeters: measured.lengthMeters, + midpoint: measured.midpoint, + }; + } + case "bezier": { + const lut = buildPhysicalBezierArcLengthLut( + geometry, + normalizePhysicalBezierOptions(options), + ); + const midpoint = locatePhysicalBezierArcLengthPoint(geometry, lut); + return { + geometry, + lengthMeters: lut.lengthMeters, + midpoint: midpoint.point, + midpointParameter: midpoint.parameter, + }; + } + } +} + +/** Keep Phase 3's semantic grid length while replacing only projected geometry. */ +export function withPhysicalTransitionMidpoint( + transition: Omit< + PhysicalImmediateTransition, + "midpoint" | "midpointParameter" + >, + geometry: PhysicalTransitionPathGeometry, + options: TransitionGeometryOptions = {}, +): PhysicalImmediateTransition { + const measured = measurePhysicalTransitionGeometry(geometry, options); + return Object.freeze({ + ...transition, + geometry: measured.geometry, + midpoint: measured.midpoint, + ...(measured.midpointParameter === undefined + ? {} + : { midpointParameter: measured.midpointParameter }), + }); +} + +interface NormalizedPhysicalBezierOptions { + readonly toleranceMeters: number; + readonly maxSubdivisionDepth: number; +} + +interface PhysicalBezierArcLengthLutNode { + readonly parameter: number; + readonly point: PhysicalFieldPoint; + readonly cumulativeLengthMeters: number; +} + +interface PhysicalBezierArcLengthLut { + readonly nodes: readonly PhysicalBezierArcLengthLutNode[]; + readonly lengthMeters: number; +} + +function measurePhysicalPolyline(points: readonly PhysicalFieldPoint[]): { + readonly lengthMeters: number; + readonly midpoint: PhysicalFieldPoint; +} { + if (points.length === 0) { + throw new RangeError("A polyline must contain at least one point."); + } + if (points.length === 1) { + return { lengthMeters: 0, midpoint: clonePhysicalPoint(points[0]) }; + } + + const segmentLengths = points + .slice(1) + .map((point, index) => physicalDistance(points[index], point)); + const lengthMeters = segmentLengths.reduce((sum, length) => sum + length, 0); + if (lengthMeters === 0) { + return { lengthMeters, midpoint: clonePhysicalPoint(points[0]) }; + } + + const targetDistance = lengthMeters / 2; + let distanceBeforeSegment = 0; + for (const [index, segmentLength] of segmentLengths.entries()) { + const distanceAtSegmentEnd = distanceBeforeSegment + segmentLength; + if ( + targetDistance <= distanceAtSegmentEnd || + index === segmentLengths.length - 1 + ) { + const ratio = + segmentLength === 0 + ? 0 + : (targetDistance - distanceBeforeSegment) / segmentLength; + return { + lengthMeters, + midpoint: interpolatePhysicalPoint( + points[index], + points[index + 1], + ratio, + ), + }; + } + distanceBeforeSegment = distanceAtSegmentEnd; + } + + return { + lengthMeters, + midpoint: clonePhysicalPoint(points[points.length - 1]), + }; +} + +function buildPhysicalBezierArcLengthLut( + geometry: PhysicalBezierTransitionGeometry, + options: NormalizedPhysicalBezierOptions, +): PhysicalBezierArcLengthLut { + const nodes: { parameter: number; point: PhysicalFieldPoint }[] = [ + { parameter: 0, point: clonePhysicalPoint(geometry.start) }, + ]; + subdividePhysicalBezier( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + 0, + 1, + 0, + options, + nodes, + ); + + let cumulativeLengthMeters = 0; + const measuredNodes: PhysicalBezierArcLengthLutNode[] = [ + { + parameter: nodes[0].parameter, + point: nodes[0].point, + cumulativeLengthMeters: 0, + }, + ]; + for (const node of nodes.slice(1)) { + cumulativeLengthMeters += physicalDistance( + measuredNodes[measuredNodes.length - 1].point, + node.point, + ); + measuredNodes.push({ + parameter: node.parameter, + point: node.point, + cumulativeLengthMeters, + }); + } + return { nodes: measuredNodes, lengthMeters: cumulativeLengthMeters }; +} + +function subdividePhysicalBezier( + start: PhysicalFieldPoint, + control1: PhysicalFieldPoint, + control2: PhysicalFieldPoint, + end: PhysicalFieldPoint, + startParameter: number, + endParameter: number, + depth: number, + options: NormalizedPhysicalBezierOptions, + nodes: { parameter: number; point: PhysicalFieldPoint }[], +): void { + const chordLength = physicalDistance(start, end); + const controlPolygonLength = + physicalDistance(start, control1) + + physicalDistance(control1, control2) + + physicalDistance(control2, end); + const flatness = Math.max(0, controlPolygonLength - chordLength); + + if ( + depth >= options.maxSubdivisionDepth || + flatness <= options.toleranceMeters + ) { + nodes.push({ parameter: endParameter, point: clonePhysicalPoint(end) }); + return; + } + + const firstMidpoint = interpolatePhysicalPoint(start, control1, 0.5); + const secondMidpoint = interpolatePhysicalPoint(control1, control2, 0.5); + const thirdMidpoint = interpolatePhysicalPoint(control2, end, 0.5); + const leftMidpoint = interpolatePhysicalPoint( + firstMidpoint, + secondMidpoint, + 0.5, + ); + const rightMidpoint = interpolatePhysicalPoint( + secondMidpoint, + thirdMidpoint, + 0.5, + ); + const curveMidpoint = interpolatePhysicalPoint( + leftMidpoint, + rightMidpoint, + 0.5, + ); + const parameterMidpoint = (startParameter + endParameter) / 2; + + subdividePhysicalBezier( + start, + firstMidpoint, + leftMidpoint, + curveMidpoint, + startParameter, + parameterMidpoint, + depth + 1, + options, + nodes, + ); + subdividePhysicalBezier( + curveMidpoint, + rightMidpoint, + thirdMidpoint, + end, + parameterMidpoint, + endParameter, + depth + 1, + options, + nodes, + ); +} + +function locatePhysicalBezierArcLengthPoint( + geometry: PhysicalBezierTransitionGeometry, + lut: PhysicalBezierArcLengthLut, +): { readonly point: PhysicalFieldPoint; readonly parameter: number } { + if (lut.lengthMeters === 0) { + return { point: clonePhysicalPoint(geometry.start), parameter: 0 }; + } + + const targetDistance = lut.lengthMeters / 2; + let segmentIndex = lut.nodes.length - 2; + for (let index = 0; index < lut.nodes.length - 1; index += 1) { + if ( + targetDistance <= lut.nodes[index + 1].cumulativeLengthMeters || + index === lut.nodes.length - 2 + ) { + segmentIndex = index; + break; + } + } + + const segmentStart = lut.nodes[segmentIndex]; + const segmentEnd = lut.nodes[segmentIndex + 1]; + const segmentLength = + segmentEnd.cumulativeLengthMeters - segmentStart.cumulativeLengthMeters; + if (segmentLength === 0) { + return { + point: clonePhysicalPoint(segmentStart.point), + parameter: segmentStart.parameter, + }; + } + + const segmentRatio = + (targetDistance - segmentStart.cumulativeLengthMeters) / segmentLength; + const parameter = + segmentStart.parameter + + (segmentEnd.parameter - segmentStart.parameter) * segmentRatio; + return { + point: evaluatePhysicalCubic(geometry, parameter), + parameter, + }; +} + +function evaluatePhysicalCubic( + geometry: PhysicalBezierTransitionGeometry, + parameter: number, +): PhysicalFieldPoint { + const oneMinusT = 1 - parameter; + const oneMinusTSquared = oneMinusT * oneMinusT; + const tSquared = parameter * parameter; + const startWeight = oneMinusTSquared * oneMinusT; + const control1Weight = 3 * oneMinusTSquared * parameter; + const control2Weight = 3 * oneMinusT * tSquared; + const endWeight = tSquared * parameter; + return Object.freeze({ + xMeters: + geometry.start.xMeters * startWeight + + geometry.controlPoints[0].xMeters * control1Weight + + geometry.controlPoints[1].xMeters * control2Weight + + geometry.end.xMeters * endWeight, + yMeters: + geometry.start.yMeters * startWeight + + geometry.controlPoints[0].yMeters * control1Weight + + geometry.controlPoints[1].yMeters * control2Weight + + geometry.end.yMeters * endWeight, + }); +} + +function normalizePhysicalBezierOptions( + options: TransitionGeometryOptions, +): NormalizedPhysicalBezierOptions { + const toleranceSteps = + options.tolerance ?? DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS; + if (!Number.isFinite(toleranceSteps) || toleranceSteps <= 0) { + throw new RangeError( + "Bézier arc-length tolerance must be greater than zero.", + ); + } + const maxSubdivisionDepth = + options.maxSubdivisionDepth ?? DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH; + if ( + !Number.isInteger(maxSubdivisionDepth) || + maxSubdivisionDepth < 0 || + maxSubdivisionDepth > MAX_BEZIER_SUBDIVISION_DEPTH + ) { + throw new RangeError( + `Bézier subdivision depth must be an integer from 0 to ${MAX_BEZIER_SUBDIVISION_DEPTH}.`, + ); + } + return { + toleranceMeters: toleranceSteps * STANDARD_STEP_METERS, + maxSubdivisionDepth, + }; +} + +function clonePhysicalPoint(point: PhysicalFieldPoint): PhysicalFieldPoint { + return Object.freeze({ xMeters: point.xMeters, yMeters: point.yMeters }); +} + +function physicalDistance( + first: PhysicalFieldPoint, + second: PhysicalFieldPoint, +): number { + return Math.hypot( + second.xMeters - first.xMeters, + second.yMeters - first.yMeters, + ); +} + +function interpolatePhysicalPoint( + start: PhysicalFieldPoint, + end: PhysicalFieldPoint, + ratio: number, +): PhysicalFieldPoint { + return Object.freeze({ + xMeters: start.xMeters + (end.xMeters - start.xMeters) * ratio, + yMeters: start.yMeters + (end.yMeters - start.yMeters) * ratio, + }); +} diff --git a/packages/mobile/src/drill/render-scene.ts b/packages/mobile/src/drill/render-scene.ts new file mode 100644 index 00000000..b1207520 --- /dev/null +++ b/packages/mobile/src/drill/render-scene.ts @@ -0,0 +1,519 @@ +import { + convertPropSizeValue, + DEFAULT_PROP_SIZE, + drillGridToPhysicalPoint, + resolveDrillEntity, + resolveFieldDefinition, + type DrillDocument, + type DrillEntity, + type DrillGridPoint, + type EntityIcon, + type FieldDefinition, + type FieldPresetId, + type PhysicalFieldPoint, + type ResolvedDrillEntity, + type ResolvedFieldDefinition, +} from "@eight2five/drill-schema"; + +import { standardStepsToMeters } from "../field/units"; +import { + buildTransitionScene, + type ImmediateTransition, + type TransitionDot, + type TransitionSceneSettings, +} from "./transition-scene"; +import type { + TransitionGeometryOptions, + TransitionPathGeometry, +} from "./transition-geometry"; +import type { DrillSet } from "./types"; +import { withPhysicalTransitionMidpoint } from "./physical-transition-geometry"; + +/** + * Visibility settings used while deriving a selected-set render model. + * + * The model intentionally receives the small visibility contract instead of + * the complete AppSettings object. This keeps the domain helper independent of + * persistence and makes its memoization inputs explicit at the field boundary. + */ +export interface DrillRenderSceneSettings extends TransitionSceneSettings { + readonly showPerformerLabels: boolean; + readonly showPerformerNames: boolean; + readonly showPropLabels: boolean; + readonly showPropNames: boolean; +} + +export type DrillRenderFieldInput = + | FieldPresetId + | FieldDefinition + | ResolvedFieldDefinition; + +export interface DrillRenderSceneInput { + readonly document: DrillDocument; + /** The active field definition, not necessarily the document's default. */ + readonly field: DrillRenderFieldInput; + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + readonly settings: DrillRenderSceneSettings; + readonly geometryOptions?: TransitionGeometryOptions; + readonly epsilon?: number; +} + +export interface DrillRenderEntityBase { + readonly entityId: number; + readonly type: DrillEntity["type"]; + readonly symbol: string; + readonly label: string; + readonly name?: string; + readonly icon: EntityIcon; + readonly color: string; + readonly labelVisible: boolean; + readonly labelText?: string; + readonly nameText?: string; + readonly position: PhysicalFieldPoint; + readonly facingDegrees?: number; + /** Ordinary entities intentionally remain fully opaque. */ + readonly opacity: 1; + readonly resolvedEntity: ResolvedDrillEntity; +} + +export interface PerformerRenderEntity extends DrillRenderEntityBase { + readonly type: "performer"; + /** Exactly one standard 8-to-5 step in physical meters. */ + readonly diameterMeters: number; +} + +export interface PropRenderEntity extends DrillRenderEntityBase { + readonly type: "prop"; + readonly widthMeters: number; + readonly lengthMeters: number; +} + +export type DrillRenderEntity = PerformerRenderEntity | PropRenderEntity; + +export interface PhysicalStraightTransitionGeometry { + readonly kind: "straight"; + readonly start: PhysicalFieldPoint; + readonly end: PhysicalFieldPoint; +} + +export interface PhysicalPolylineTransitionGeometry { + readonly kind: "polyline"; + readonly points: readonly PhysicalFieldPoint[]; +} + +export interface PhysicalBezierTransitionGeometry { + readonly kind: "bezier"; + readonly start: PhysicalFieldPoint; + readonly controlPoints: readonly [PhysicalFieldPoint, PhysicalFieldPoint]; + readonly end: PhysicalFieldPoint; +} + +export type PhysicalTransitionPathGeometry = + | PhysicalStraightTransitionGeometry + | PhysicalPolylineTransitionGeometry + | PhysicalBezierTransitionGeometry; + +export interface PhysicalImmediateTransition { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly start: PhysicalFieldPoint; + readonly end: PhysicalFieldPoint; + readonly geometry: PhysicalTransitionPathGeometry; + readonly lengthSteps: number; + readonly midpoint: PhysicalFieldPoint; + readonly midpointParameter?: number; +} + +export interface PhysicalTransitionDot { + readonly setId: number; + readonly point: PhysicalFieldPoint; +} + +export interface DrillRenderScene { + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + /** Null means that the selected performer has no position in this set. */ + readonly current: PhysicalFieldPoint | null; + /** Other performers and props in the selected source set. */ + readonly entities: readonly DrillRenderEntity[]; + readonly previous?: PhysicalImmediateTransition; + readonly next?: PhysicalImmediateTransition; + readonly previousConnectors: readonly PhysicalImmediateTransition[]; + readonly nextConnectors: readonly PhysicalImmediateTransition[]; + readonly previousDots: readonly PhysicalTransitionDot[]; + readonly nextDots: readonly PhysicalTransitionDot[]; +} + +/** + * The renderer's layer contract is kept as data so z-order cannot silently + * drift when a new overlay is added. + */ +export const DRILL_RENDER_LAYER_ORDER = Object.freeze([ + "static", + "anchors", + "entities", + "extra-connectors", + "extra-dots", + "previous", + "next", + "current-target", + "guidance", + "live-position", +] as const); + +/** One standard 8-to-5 step, shared by performer and marker size math. */ +export const DEFAULT_PERFORMER_DIAMETER_METERS = standardStepsToMeters(1); + +/** + * Build all data needed by the Skia field layer for one selected set. + * + * This is deliberately a pure, immutable boundary. Entity resolution, + * position lookup, field projection, path projection, and transition math all + * happen here rather than inside the per-frame Skia tree. + */ +export function buildDrillRenderScene( + input: DrillRenderSceneInput, +): DrillRenderScene { + const field = resolveRenderField(input.field); + const resolvedEntities = input.document.entities.map((entity) => + freezeResolvedEntity( + resolveDrillEntity(entity, input.document.entityRules), + ), + ); + const positionsByEntityId = new Map(); + for (const position of input.document.positions) { + if (position.setId !== input.selectedSourceSetId) continue; + positionsByEntityId.set(position.entityId, position); + } + + const entities: DrillRenderEntity[] = []; + for (const resolved of resolvedEntities) { + // The selected performer is represented by the target/transition layers, + // never by the ordinary-entity layer. + if ( + resolved.type === "performer" && + resolved.id === input.selectedPerformerEntityId + ) { + continue; + } + // TODO: Fix this once the drill schema has an explicit entity visibility + // property. For now, imported label visibility controls whether the + // performer/prop itself is rendered. + if (!resolved.appearance.labelVisible) continue; + const position = positionsByEntityId.get(resolved.id); + if (!position) continue; + entities.push( + createRenderEntity(resolved, position, field, input.settings), + ); + } + + const transitionScene = buildTransitionScene({ + document: input.document, + selectedPerformerEntityId: input.selectedPerformerEntityId, + selectedSourceSetId: input.selectedSourceSetId, + settings: input.settings, + geometryOptions: input.geometryOptions, + epsilon: input.epsilon, + }); + + return freezeScene({ + selectedPerformerEntityId: input.selectedPerformerEntityId, + selectedSourceSetId: input.selectedSourceSetId, + current: transitionScene.current + ? projectPoint(transitionScene.current, field) + : null, + entities, + ...(transitionScene.previous + ? { + previous: projectImmediateTransition( + transitionScene.previous, + field, + input.geometryOptions, + ), + } + : {}), + ...(transitionScene.next + ? { + next: projectImmediateTransition( + transitionScene.next, + field, + input.geometryOptions, + ), + } + : {}), + previousConnectors: transitionScene.previousConnectors.map((transition) => + projectImmediateTransition(transition, field, input.geometryOptions), + ), + nextConnectors: transitionScene.nextConnectors.map((transition) => + projectImmediateTransition(transition, field, input.geometryOptions), + ), + previousDots: transitionScene.previousDots.map((dot) => + projectTransitionDot(dot, field), + ), + nextDots: transitionScene.nextDots.map((dot) => + projectTransitionDot(dot, field), + ), + }); +} + +export const deriveDrillRenderScene = buildDrillRenderScene; +export const buildSelectedSetRenderScene = buildDrillRenderScene; + +/** The Drill settings master switch owns scene creation as well as visibility. */ +export function shouldBuildDrillRenderScene( + drillFeaturesEnabled: boolean, +): boolean { + return drillFeaturesEnabled; +} + +/** + * Imported local rows carry an opaque SQLite id as well as the portable set + * id. Rendering must always use the latter and never accidentally pass the + * local row id into the source document. + */ +export function resolveSelectedSourceSetId( + selectedSet?: Pick, +): number | undefined { + return selectedSet?.sourceSetId; +} + +/** Project a Phase 3 grid path without changing its endpoint semantics. */ +export function projectTransitionPathGeometry( + geometry: TransitionPathGeometry, + fieldInput: DrillRenderFieldInput, +): PhysicalTransitionPathGeometry { + const field = resolveRenderField(fieldInput); + switch (geometry.kind) { + case "straight": + return freezePhysicalGeometry({ + kind: "straight", + start: projectPoint(geometry.start, field), + end: projectPoint(geometry.end, field), + }); + case "polyline": + return freezePhysicalGeometry({ + kind: "polyline", + points: geometry.points.map((point) => projectPoint(point, field)), + }); + case "bezier": + return freezePhysicalGeometry({ + kind: "bezier", + start: projectPoint(geometry.start, field), + controlPoints: [ + projectPoint(geometry.controlPoints[0], field), + projectPoint(geometry.controlPoints[1], field), + ], + end: projectPoint(geometry.end, field), + }); + } +} + +export const projectDrillTransitionPath = projectTransitionPathGeometry; + +/** Resolve a prop's physical dimensions using the schema's unit conversion. */ +export function resolvePropPhysicalSize(entity: Pick): { + readonly widthMeters: number; + readonly lengthMeters: number; +} { + const size = entity.size ?? DEFAULT_PROP_SIZE; + return Object.freeze({ + widthMeters: convertPropSizeValue(size.width, size.unit, "meters"), + lengthMeters: convertPropSizeValue(size.length, size.unit, "meters"), + }); +} + +function createRenderEntity( + entity: ResolvedDrillEntity, + position: DrillPositionForSet, + field: ResolvedFieldDefinition, + settings: DrillRenderSceneSettings, +): DrillRenderEntity { + const labelText = + settingsForEntity(entity.type, settings).showLabels && + entity.appearance.labelVisible + ? entity.label + : undefined; + const nameText = settingsForEntity(entity.type, settings).showNames + ? entity.name + : undefined; + const base = { + entityId: entity.id, + type: entity.type, + symbol: entity.symbol, + label: entity.label, + ...(entity.name === undefined ? {} : { name: entity.name }), + icon: entity.appearance.icon, + color: entity.appearance.color, + labelVisible: entity.appearance.labelVisible, + ...(labelText === undefined ? {} : { labelText }), + ...(nameText === undefined ? {} : { nameText }), + position: projectPoint(position, field), + ...(position.facingDegrees === undefined + ? {} + : { facingDegrees: position.facingDegrees }), + opacity: 1 as const, + resolvedEntity: entity, + }; + + if (entity.type === "prop") { + const size = resolvePropPhysicalSize(entity); + return { + ...base, + type: "prop", + ...size, + }; + } + + return { + ...base, + type: "performer", + diameterMeters: DEFAULT_PERFORMER_DIAMETER_METERS, + }; +} + +function settingsForEntity( + type: DrillEntity["type"], + settings: DrillRenderSceneSettings, +): { readonly showLabels: boolean; readonly showNames: boolean } { + return type === "prop" + ? { + showLabels: settings.showPropLabels, + showNames: settings.showPropNames, + } + : { + showLabels: settings.showPerformerLabels, + showNames: settings.showPerformerNames, + }; +} + +function projectImmediateTransition( + transition: ImmediateTransition, + field: ResolvedFieldDefinition, + geometryOptions?: TransitionGeometryOptions, +): PhysicalImmediateTransition { + const projectedGeometry = projectTransitionPathGeometry( + transition.geometry, + field, + ); + return withPhysicalTransitionMidpoint( + { + entityId: transition.entityId, + fromSetId: transition.fromSetId, + toSetId: transition.toSetId, + start: projectPoint(transition.start, field), + end: projectPoint(transition.end, field), + geometry: projectedGeometry, + lengthSteps: transition.lengthSteps, + }, + projectedGeometry, + geometryOptions, + ); +} + +function projectTransitionDot( + dot: TransitionDot, + field: ResolvedFieldDefinition, +): PhysicalTransitionDot { + return Object.freeze({ + setId: dot.setId, + point: projectPoint(dot.point, field), + }); +} + +function projectPoint( + point: DrillGridPoint, + field: ResolvedFieldDefinition, +): PhysicalFieldPoint { + const projected = drillGridToPhysicalPoint(point, field); + return Object.freeze({ + xMeters: projected.xMeters, + yMeters: projected.yMeters, + }); +} + +function resolveRenderField( + fieldInput: DrillRenderFieldInput, +): ResolvedFieldDefinition { + if (typeof fieldInput === "string") { + return resolveFieldDefinition({ type: "preset", preset: fieldInput }); + } + if ("id" in fieldInput && "physicalGeometry" in fieldInput) { + return fieldInput; + } + return resolveFieldDefinition(fieldInput); +} + +function freezeResolvedEntity( + entity: ResolvedDrillEntity, +): ResolvedDrillEntity { + return Object.freeze({ + ...entity, + ...(entity.size === undefined + ? {} + : { size: Object.freeze({ ...entity.size }) }), + appearance: Object.freeze({ ...entity.appearance }), + }); +} + +function freezeScene(scene: { + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + readonly current: PhysicalFieldPoint | null; + readonly entities: readonly DrillRenderEntity[]; + readonly previous?: PhysicalImmediateTransition; + readonly next?: PhysicalImmediateTransition; + readonly previousConnectors: readonly PhysicalImmediateTransition[]; + readonly nextConnectors: readonly PhysicalImmediateTransition[]; + readonly previousDots: readonly PhysicalTransitionDot[]; + readonly nextDots: readonly PhysicalTransitionDot[]; +}): DrillRenderScene { + return Object.freeze({ + ...scene, + current: scene.current ? Object.freeze({ ...scene.current }) : null, + entities: Object.freeze( + scene.entities.map((entity) => Object.freeze(entity)), + ), + previousConnectors: Object.freeze([...scene.previousConnectors]), + nextConnectors: Object.freeze([...scene.nextConnectors]), + previousDots: Object.freeze([...scene.previousDots]), + nextDots: Object.freeze([...scene.nextDots]), + }); +} + +function freezePhysicalGeometry( + geometry: PhysicalTransitionPathGeometry, +): PhysicalTransitionPathGeometry { + switch (geometry.kind) { + case "straight": + return Object.freeze({ + kind: geometry.kind, + start: Object.freeze({ ...geometry.start }), + end: Object.freeze({ ...geometry.end }), + }); + case "polyline": + return Object.freeze({ + kind: geometry.kind, + points: Object.freeze( + geometry.points.map((point) => Object.freeze({ ...point })), + ), + }); + case "bezier": + return Object.freeze({ + kind: geometry.kind, + start: Object.freeze({ ...geometry.start }), + controlPoints: Object.freeze([ + Object.freeze({ ...geometry.controlPoints[0] }), + Object.freeze({ ...geometry.controlPoints[1] }), + ]) as readonly [PhysicalFieldPoint, PhysicalFieldPoint], + end: Object.freeze({ ...geometry.end }), + }); + } +} + +interface DrillPositionForSet extends DrillGridPoint { + readonly entityId: number; + readonly setId: number; + readonly facingDegrees?: number; +} diff --git a/packages/mobile/src/drill/terminology.ts b/packages/mobile/src/drill/terminology.ts new file mode 100644 index 00000000..3fa452b5 --- /dev/null +++ b/packages/mobile/src/drill/terminology.ts @@ -0,0 +1,31 @@ +/** The user-facing noun chosen for a drill's ordered pages. */ +export type DrillTerminology = "pages" | "sets"; + +/** Literal labels returned by the centralized terminology helper. */ +export interface DrillTerms { + readonly singular: "Page" | "Set"; + readonly plural: "Pages" | "Sets"; + readonly lowercaseSingular: "page" | "set"; + readonly lowercasePlural: "pages" | "sets"; +} + +const PAGE_TERMS: DrillTerms = Object.freeze({ + singular: "Page", + plural: "Pages", + lowercaseSingular: "page", + lowercasePlural: "pages", +}); + +const SET_TERMS: DrillTerms = Object.freeze({ + singular: "Set", + plural: "Sets", + lowercaseSingular: "set", + lowercasePlural: "sets", +}); + +/** Keeps terminology selection in one place so UI labels cannot drift. */ +export function getDrillTerms(terminology: DrillTerminology): DrillTerms { + if (terminology === "pages") return PAGE_TERMS; + if (terminology === "sets") return SET_TERMS; + throw new RangeError(`Unknown drill terminology: ${String(terminology)}.`); +} diff --git a/packages/mobile/src/drill/transition-geometry.ts b/packages/mobile/src/drill/transition-geometry.ts new file mode 100644 index 00000000..76e6925e --- /dev/null +++ b/packages/mobile/src/drill/transition-geometry.ts @@ -0,0 +1,547 @@ +import type { + DrillDocument, + DrillGridPoint, + DrillPath, +} from "@eight2five/drill-schema"; + +/** + * Grid coordinates are imported from external documents, so comparing their + * object identity would make nearly-identical hold positions appear to move. + */ +export const DEFAULT_GRID_POINT_EPSILON_STEPS = 1e-6; + +/** The default maximum error used while flattening a cubic Bézier in steps. */ +export const DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS = 1e-4; + +/** Prevents pathological documents from causing unbounded subdivision. */ +export const DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH = 18; +export const MAX_BEZIER_SUBDIVISION_DEPTH = 24; + +export interface TransitionGeometryOptions { + /** Maximum flattening error, measured in drill-grid steps. */ + readonly tolerance?: number; + /** Maximum recursive depth used by the deterministic cubic subdivision. */ + readonly maxSubdivisionDepth?: number; +} + +export interface StraightTransitionGeometry { + readonly kind: "straight"; + readonly start: DrillGridPoint; + readonly end: DrillGridPoint; +} + +export interface PolylineTransitionGeometry { + readonly kind: "polyline"; + /** Includes the transition's start and end points. */ + readonly points: readonly DrillGridPoint[]; +} + +export interface BezierTransitionGeometry { + readonly kind: "bezier"; + readonly start: DrillGridPoint; + readonly controlPoints: readonly [DrillGridPoint, DrillGridPoint]; + readonly end: DrillGridPoint; +} + +/** + * Geometry is intentionally made only from serializable grid points. Phase 4 + * can use this same DTO to construct a Skia path and to place its midpoint. + */ +export type TransitionPathGeometry = + | StraightTransitionGeometry + | PolylineTransitionGeometry + | BezierTransitionGeometry; + +export interface ResolvedTransitionGeometry { + readonly geometry: TransitionPathGeometry; + readonly lengthSteps: number; + readonly midpoint: DrillGridPoint; + /** Only populated for cubic Bézier geometry. */ + readonly midpointParameter?: number; +} + +/** + * Compare grid points using coordinate tolerance rather than object equality. + */ +export function areGridPointsEquivalent( + a: DrillGridPoint, + b: DrillGridPoint, + epsilon = DEFAULT_GRID_POINT_EPSILON_STEPS, +): boolean { + assertEpsilon(epsilon); + return ( + Math.abs(a.xSteps - b.xSteps) <= epsilon && + Math.abs(a.ySteps - b.ySteps) <= epsilon + ); +} + +/** Evaluate a cubic Bézier at parameter t in [0, 1]. */ +export function evaluateCubicBezier( + start: DrillGridPoint, + control1: DrillGridPoint, + control2: DrillGridPoint, + end: DrillGridPoint, + t: number, +): DrillGridPoint { + if (!Number.isFinite(t) || t < 0 || t > 1) { + throw new RangeError("A cubic Bézier parameter must be between 0 and 1."); + } + + const oneMinusT = 1 - t; + const oneMinusTSquared = oneMinusT * oneMinusT; + const tSquared = t * t; + const startWeight = oneMinusTSquared * oneMinusT; + const control1Weight = 3 * oneMinusTSquared * t; + const control2Weight = 3 * oneMinusT * tSquared; + const endWeight = tSquared * t; + + return { + xSteps: + start.xSteps * startWeight + + control1.xSteps * control1Weight + + control2.xSteps * control2Weight + + end.xSteps * endWeight, + ySteps: + start.ySteps * startWeight + + control1.ySteps * control1Weight + + control2.ySteps * control2Weight + + end.ySteps * endWeight, + }; +} + +/** Alias with the noun-first spelling used by some geometry callers. */ +export const cubicBezierPoint = evaluateCubicBezier; + +/** + * Approximate the arc length of a cubic Bézier using deterministic adaptive + * subdivision. The result is in drill-grid steps. + */ +export function approximateCubicBezierLength( + geometry: BezierTransitionGeometry, + options: TransitionGeometryOptions = {}, +): number { + const subdivision = normalizeSubdivisionOptions(options); + return buildBezierArcLengthLut(geometry, subdivision).lengthSteps; +} + +/** + * Resolve one entity transition from a complete document. + * + * A missing explicit path is deliberately represented as a straight segment; + * endpoint positions still come from the document, so this fallback cannot + * silently connect the wrong entity or set. + */ +export function resolveTransitionGeometry( + document: DrillDocument, + entityId: number, + fromSetId: number, + toSetId: number, + options: TransitionGeometryOptions = {}, +): ResolvedTransitionGeometry | undefined { + const start = findPosition(document, entityId, fromSetId); + const end = findPosition(document, entityId, toSetId); + if (!start || !end) return undefined; + + const geometry = resolvePathWithEndpoints( + document, + entityId, + fromSetId, + toSetId, + start, + end, + ); + return measureTransitionGeometry(geometry, options); +} + +/** + * Resolve only the path shape. Supplying endpoint positions separately keeps + * this helper useful to the scene builder without making path data authoritative + * over the position table. + */ +export function resolveTransitionPath( + document: DrillDocument, + entityId: number, + fromSetId: number, + toSetId: number, +): TransitionPathGeometry | undefined { + const start = findPosition(document, entityId, fromSetId); + const end = findPosition(document, entityId, toSetId); + if (!start || !end) return undefined; + return resolvePathWithEndpoints( + document, + entityId, + fromSetId, + toSetId, + start, + end, + ); +} + +/** Calculate length and the true half-arc-length point for already-resolved geometry. */ +export function measureTransitionGeometry( + geometry: TransitionPathGeometry, + options: TransitionGeometryOptions = {}, +): ResolvedTransitionGeometry { + switch (geometry.kind) { + case "straight": { + const lengthSteps = distanceBetween(geometry.start, geometry.end); + return { + geometry, + lengthSteps, + midpoint: interpolatePoint(geometry.start, geometry.end, 0.5), + }; + } + case "polyline": { + const measured = measurePolyline(geometry.points); + return { + geometry, + lengthSteps: measured.lengthSteps, + midpoint: measured.midpoint, + }; + } + case "bezier": { + const subdivision = normalizeSubdivisionOptions(options); + const lut = buildBezierArcLengthLut(geometry, subdivision); + const midpointResult = locateBezierArcLengthPoint(geometry, lut); + return { + geometry, + lengthSteps: lut.lengthSteps, + midpoint: midpointResult.point, + midpointParameter: midpointResult.parameter, + }; + } + } +} + +interface NormalizedSubdivisionOptions { + readonly tolerance: number; + readonly maxSubdivisionDepth: number; +} + +interface BezierArcLengthLutNode { + readonly parameter: number; + readonly point: DrillGridPoint; + readonly cumulativeLengthSteps: number; +} + +interface BezierArcLengthLut { + readonly nodes: readonly BezierArcLengthLutNode[]; + readonly lengthSteps: number; +} + +function resolvePathWithEndpoints( + document: DrillDocument, + entityId: number, + fromSetId: number, + toSetId: number, + start: DrillGridPoint, + end: DrillGridPoint, +): TransitionPathGeometry { + const explicitPath = document.paths?.find( + (path) => + path.entityId === entityId && + path.fromSetId === fromSetId && + path.toSetId === toSetId, + ); + + return pathToGeometry(explicitPath, start, end); +} + +function pathToGeometry( + path: DrillPath | undefined, + start: DrillGridPoint, + end: DrillGridPoint, +): TransitionPathGeometry { + if (!path || path.kind === "straight") { + return { + kind: "straight", + start: clonePoint(start), + end: clonePoint(end), + }; + } + + if (path.kind === "polyline") { + return { + kind: "polyline", + points: [ + clonePoint(start), + ...path.waypoints.map(clonePoint), + clonePoint(end), + ], + }; + } + + return { + kind: "bezier", + start: clonePoint(start), + controlPoints: [ + clonePoint(path.controlPoints[0]), + clonePoint(path.controlPoints[1]), + ], + end: clonePoint(end), + }; +} + +function findPosition( + document: DrillDocument, + entityId: number, + setId: number, +): DrillGridPoint | undefined { + const position = document.positions.find( + (candidate) => candidate.entityId === entityId && candidate.setId === setId, + ); + return position ? clonePoint(position) : undefined; +} + +function measurePolyline(points: readonly DrillGridPoint[]): { + readonly lengthSteps: number; + readonly midpoint: DrillGridPoint; +} { + if (points.length === 0) { + throw new RangeError("A polyline must contain at least one point."); + } + if (points.length === 1) { + return { lengthSteps: 0, midpoint: clonePoint(points[0]) }; + } + + const segmentLengths = points + .slice(1) + .map((point, index) => distanceBetween(points[index], point)); + const lengthSteps = segmentLengths.reduce((sum, length) => sum + length, 0); + if (lengthSteps === 0) { + return { lengthSteps, midpoint: clonePoint(points[0]) }; + } + + const targetDistance = lengthSteps / 2; + let distanceBeforeSegment = 0; + for (const [index, segmentLength] of segmentLengths.entries()) { + const distanceAtSegmentEnd = distanceBeforeSegment + segmentLength; + if ( + targetDistance <= distanceAtSegmentEnd || + index === segmentLengths.length - 1 + ) { + const ratio = + segmentLength === 0 + ? 0 + : (targetDistance - distanceBeforeSegment) / segmentLength; + return { + lengthSteps, + midpoint: interpolatePoint(points[index], points[index + 1], ratio), + }; + } + distanceBeforeSegment = distanceAtSegmentEnd; + } + + // The loop always returns for a finite, non-empty segment list. + return { lengthSteps, midpoint: clonePoint(points[points.length - 1]) }; +} + +function buildBezierArcLengthLut( + geometry: BezierTransitionGeometry, + options: NormalizedSubdivisionOptions, +): BezierArcLengthLut { + const nodes: { parameter: number; point: DrillGridPoint }[] = [ + { parameter: 0, point: clonePoint(geometry.start) }, + ]; + const [control1, control2] = geometry.controlPoints; + + subdivideBezier( + geometry.start, + control1, + control2, + geometry.end, + 0, + 1, + 0, + options, + nodes, + ); + + let cumulativeLengthSteps = 0; + const measuredNodes: BezierArcLengthLutNode[] = [ + { + parameter: nodes[0].parameter, + point: nodes[0].point, + cumulativeLengthSteps: 0, + }, + ]; + for (const node of nodes.slice(1)) { + cumulativeLengthSteps += distanceBetween( + measuredNodes[measuredNodes.length - 1].point, + node.point, + ); + measuredNodes.push({ + parameter: node.parameter, + point: node.point, + cumulativeLengthSteps, + }); + } + + return { nodes: measuredNodes, lengthSteps: cumulativeLengthSteps }; +} + +function subdivideBezier( + start: DrillGridPoint, + control1: DrillGridPoint, + control2: DrillGridPoint, + end: DrillGridPoint, + startParameter: number, + endParameter: number, + depth: number, + options: NormalizedSubdivisionOptions, + nodes: { parameter: number; point: DrillGridPoint }[], +): void { + const chordLength = distanceBetween(start, end); + const controlPolygonLength = + distanceBetween(start, control1) + + distanceBetween(control1, control2) + + distanceBetween(control2, end); + const flatness = Math.max(0, controlPolygonLength - chordLength); + + if (depth >= options.maxSubdivisionDepth || flatness <= options.tolerance) { + nodes.push({ parameter: endParameter, point: clonePoint(end) }); + return; + } + + const firstMidpoint = interpolatePoint(start, control1, 0.5); + const secondMidpoint = interpolatePoint(control1, control2, 0.5); + const thirdMidpoint = interpolatePoint(control2, end, 0.5); + const leftMidpoint = interpolatePoint(firstMidpoint, secondMidpoint, 0.5); + const rightMidpoint = interpolatePoint(secondMidpoint, thirdMidpoint, 0.5); + const curveMidpoint = interpolatePoint(leftMidpoint, rightMidpoint, 0.5); + const parameterMidpoint = (startParameter + endParameter) / 2; + + subdivideBezier( + start, + firstMidpoint, + leftMidpoint, + curveMidpoint, + startParameter, + parameterMidpoint, + depth + 1, + options, + nodes, + ); + subdivideBezier( + curveMidpoint, + rightMidpoint, + thirdMidpoint, + end, + parameterMidpoint, + endParameter, + depth + 1, + options, + nodes, + ); +} + +function locateBezierArcLengthPoint( + geometry: BezierTransitionGeometry, + lut: BezierArcLengthLut, +): { readonly point: DrillGridPoint; readonly parameter: number } { + if (lut.lengthSteps === 0) { + return { point: clonePoint(geometry.start), parameter: 0 }; + } + + const targetDistance = lut.lengthSteps / 2; + let segmentIndex = lut.nodes.length - 2; + for (let index = 0; index < lut.nodes.length - 1; index += 1) { + if ( + targetDistance <= lut.nodes[index + 1].cumulativeLengthSteps || + index === lut.nodes.length - 2 + ) { + segmentIndex = index; + break; + } + } + + const segmentStart = lut.nodes[segmentIndex]; + const segmentEnd = lut.nodes[segmentIndex + 1]; + const segmentLength = + segmentEnd.cumulativeLengthSteps - segmentStart.cumulativeLengthSteps; + if (segmentLength === 0) { + return { + point: clonePoint(segmentStart.point), + parameter: segmentStart.parameter, + }; + } + + // The LUT's cumulative distances are sums of flattened chord lengths. Use + // that exact same metric for inversion, including deliberately coarse LUTs. + const targetWithinSegment = + targetDistance - segmentStart.cumulativeLengthSteps; + const segmentRatio = targetWithinSegment / segmentLength; + const parameter = + segmentStart.parameter + + (segmentEnd.parameter - segmentStart.parameter) * segmentRatio; + return { + point: evaluateCubicGeometryAt(geometry, parameter), + parameter, + }; +} + +function evaluateCubicGeometryAt( + geometry: BezierTransitionGeometry, + parameter: number, +): DrillGridPoint { + return evaluateCubicBezier( + geometry.start, + geometry.controlPoints[0], + geometry.controlPoints[1], + geometry.end, + parameter, + ); +} + +function normalizeSubdivisionOptions( + options: TransitionGeometryOptions, +): NormalizedSubdivisionOptions { + const tolerance = + options.tolerance ?? DEFAULT_BEZIER_ARC_LENGTH_TOLERANCE_STEPS; + if (!Number.isFinite(tolerance) || tolerance <= 0) { + throw new RangeError( + "Bézier arc-length tolerance must be greater than zero.", + ); + } + + const maxSubdivisionDepth = + options.maxSubdivisionDepth ?? DEFAULT_BEZIER_MAX_SUBDIVISION_DEPTH; + if ( + !Number.isInteger(maxSubdivisionDepth) || + maxSubdivisionDepth < 0 || + maxSubdivisionDepth > MAX_BEZIER_SUBDIVISION_DEPTH + ) { + throw new RangeError( + `Bézier subdivision depth must be an integer from 0 to ${MAX_BEZIER_SUBDIVISION_DEPTH}.`, + ); + } + + return { tolerance, maxSubdivisionDepth }; +} + +function assertEpsilon(epsilon: number): void { + if (!Number.isFinite(epsilon) || epsilon < 0) { + throw new RangeError( + "Grid-point epsilon must be a finite non-negative number.", + ); + } +} + +function clonePoint(point: DrillGridPoint): DrillGridPoint { + return { xSteps: point.xSteps, ySteps: point.ySteps }; +} + +function distanceBetween(a: DrillGridPoint, b: DrillGridPoint): number { + return Math.hypot(b.xSteps - a.xSteps, b.ySteps - a.ySteps); +} + +function interpolatePoint( + start: DrillGridPoint, + end: DrillGridPoint, + ratio: number, +): DrillGridPoint { + return { + xSteps: start.xSteps + (end.xSteps - start.xSteps) * ratio, + ySteps: start.ySteps + (end.ySteps - start.ySteps) * ratio, + }; +} diff --git a/packages/mobile/src/drill/transition-scene.ts b/packages/mobile/src/drill/transition-scene.ts new file mode 100644 index 00000000..94fcfe94 --- /dev/null +++ b/packages/mobile/src/drill/transition-scene.ts @@ -0,0 +1,477 @@ +import type { DrillDocument, DrillGridPoint } from "@eight2five/drill-schema"; + +import { + areGridPointsEquivalent, + DEFAULT_GRID_POINT_EPSILON_STEPS, + resolveTransitionGeometry, + type ResolvedTransitionGeometry, + type TransitionGeometryOptions, + type TransitionPathGeometry, +} from "./transition-geometry"; + +/** Settings names mirror the persisted AppSettings contract. */ +export interface AppTransitionSceneSettings { + readonly showTransitionMarkers: boolean; + readonly showAllTransitionSets: boolean; + readonly previousTransitionSetCount: number; + readonly nextTransitionSetCount: number; +} + +/** A concise equivalent for callers that do not use the persisted settings object. */ +export interface TransitionSceneSettings { + readonly markerEnabled: boolean; + readonly showAll: boolean; + readonly previousTotalCount: number; + readonly nextTotalCount: number; +} + +export type TransitionSceneSettingsInput = + | AppTransitionSceneSettings + | TransitionSceneSettings; + +export interface TransitionSceneInput { + readonly document: DrillDocument; + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + readonly settings: TransitionSceneSettingsInput; + readonly geometryOptions?: TransitionGeometryOptions; + readonly epsilon?: number; +} + +export interface TransitionDot { + readonly setId: number; + readonly point: DrillGridPoint; +} + +export interface ImmediateTransition { + readonly entityId: number; + readonly fromSetId: number; + readonly toSetId: number; + readonly start: DrillGridPoint; + readonly end: DrillGridPoint; + /** The geometry used both for the connector and for midpoint calculation. */ + readonly geometry: TransitionPathGeometry; + readonly lengthSteps: number; + readonly midpoint: DrillGridPoint; + /** Only populated for cubic Bézier transitions. */ + readonly midpointParameter?: number; +} + +export interface TransitionScene { + readonly selectedPerformerEntityId: number; + readonly selectedSourceSetId: number; + /** Null means the selected performer has no position at the selected set. */ + readonly current: DrillGridPoint | null; + readonly previous?: ImmediateTransition; + readonly next?: ImmediateTransition; + /** Depth > 1 connectors are ordered nearest-to-farthest from the selected set. */ + readonly previousConnectors: readonly ImmediateTransition[]; + readonly nextConnectors: readonly ImmediateTransition[]; + /** Extra dots are ordered nearest-to-farthest from the selected set. */ + readonly previousDots: readonly TransitionDot[]; + readonly nextDots: readonly TransitionDot[]; +} + +/** + * Derive all transition marker geometry for one selected performer/set. + * + * Counts are ordinal windows: a count of one includes only the immediate + * neighbor, a count of two includes that neighbor and one extra set, and so + * on. Coincident suppression happens after selecting that raw window; omitted + * positions are never replaced by a farther set. + */ +export function buildTransitionScene( + input: TransitionSceneInput, +): TransitionScene; +export function buildTransitionScene( + document: DrillDocument, + selectedPerformerEntityId: number, + selectedSourceSetId: number, + settings: TransitionSceneSettingsInput, + geometryOptions?: TransitionGeometryOptions, +): TransitionScene; +export function buildTransitionScene( + inputOrDocument: TransitionSceneInput | DrillDocument, + selectedPerformerEntityId?: number, + selectedSourceSetId?: number, + settings?: TransitionSceneSettingsInput, + geometryOptions?: TransitionGeometryOptions, +): TransitionScene { + const input = isTransitionSceneInput(inputOrDocument) + ? inputOrDocument + : { + document: inputOrDocument, + selectedPerformerEntityId: selectedPerformerEntityId as number, + selectedSourceSetId: selectedSourceSetId as number, + settings: settings as TransitionSceneSettingsInput, + geometryOptions, + }; + const normalizedSettings = normalizeSettings(input.settings); + const epsilon = input.epsilon ?? DEFAULT_GRID_POINT_EPSILON_STEPS; + assertEpsilon(epsilon); + + const selectedSetIndex = input.document.sets.findIndex( + (set) => set.id === input.selectedSourceSetId, + ); + const currentPosition = positionAtIndex( + input.document, + input.selectedPerformerEntityId, + selectedSetIndex, + ); + const current = currentPosition?.point ?? null; + + const emptyScene: TransitionScene = { + selectedPerformerEntityId: input.selectedPerformerEntityId, + selectedSourceSetId: input.selectedSourceSetId, + current, + previousConnectors: [], + nextConnectors: [], + previousDots: [], + nextDots: [], + }; + if (!normalizedSettings.markerEnabled || selectedSetIndex < 0 || !current) { + return emptyScene; + } + + const previousIndices = rawWindowIndices( + selectedSetIndex, + input.document.sets.length, + "previous", + normalizedSettings.showAll, + normalizedSettings.previousTotalCount, + ); + const nextIndices = rawWindowIndices( + selectedSetIndex, + input.document.sets.length, + "next", + normalizedSettings.showAll, + normalizedSettings.nextTotalCount, + ); + + const previousPosition = positionAtIndex( + input.document, + input.selectedPerformerEntityId, + previousIndices[0], + ); + const nextPosition = positionAtIndex( + input.document, + input.selectedPerformerEntityId, + nextIndices[0], + ); + + const previous = createImmediateTransition( + input.document, + input.selectedPerformerEntityId, + previousPosition, + currentPosition, + input.geometryOptions, + epsilon, + ); + const next = createImmediateTransition( + input.document, + input.selectedPerformerEntityId, + currentPosition, + nextPosition, + input.geometryOptions, + epsilon, + ); + + const extraConnectors = createExtraTransitionConnectors( + input.document, + input.selectedPerformerEntityId, + previousIndices, + nextIndices, + input.geometryOptions, + epsilon, + ); + const extraDots = createSceneExtraDots( + input.document, + input.selectedPerformerEntityId, + previousIndices.slice(1), + nextIndices.slice(1), + current, + previousPosition, + nextPosition, + epsilon, + ); + + return { + ...emptyScene, + ...(previous ? { previous } : {}), + ...(next ? { next } : {}), + previousConnectors: extraConnectors.previousConnectors, + nextConnectors: extraConnectors.nextConnectors, + previousDots: extraDots.previousDots, + nextDots: extraDots.nextDots, + }; +} + +export const deriveTransitionScene = buildTransitionScene; +export const buildTransitionMarkerScene = buildTransitionScene; + +function createImmediateTransition( + document: DrillDocument, + entityId: number, + fromPosition: PositionAtIndex | undefined, + toPosition: PositionAtIndex | undefined, + geometryOptions: TransitionGeometryOptions | undefined, + epsilon: number, +): ImmediateTransition | undefined { + if (!fromPosition || !toPosition) return undefined; + if (areGridPointsEquivalent(fromPosition.point, toPosition.point, epsilon)) { + return undefined; + } + + const resolved = resolveTransitionGeometry( + document, + entityId, + fromPosition.setId, + toPosition.setId, + geometryOptions, + ); + return resolved + ? makeImmediateTransition( + entityId, + fromPosition.setId, + toPosition.setId, + resolved, + ) + : undefined; +} + +function makeImmediateTransition( + entityId: number, + fromSetId: number, + toSetId: number, + resolved: ResolvedTransitionGeometry, +): ImmediateTransition { + const { geometry } = resolved; + const start = geometryStart(geometry); + const end = geometryEnd(geometry); + return { + entityId, + fromSetId, + toSetId, + start, + end, + geometry, + lengthSteps: resolved.lengthSteps, + midpoint: resolved.midpoint, + ...(resolved.midpointParameter === undefined + ? {} + : { midpointParameter: resolved.midpointParameter }), + }; +} + +function createExtraTransitionConnectors( + document: DrillDocument, + entityId: number, + previousIndices: readonly number[], + nextIndices: readonly number[], + geometryOptions: TransitionGeometryOptions | undefined, + epsilon: number, +): { + readonly previousConnectors: readonly ImmediateTransition[]; + readonly nextConnectors: readonly ImmediateTransition[]; +} { + const createChain = ( + indices: readonly number[], + direction: "previous" | "next", + ): readonly ImmediateTransition[] => { + const connectors: ImmediateTransition[] = []; + for (let depth = 1; depth < indices.length; depth += 1) { + const nearer = positionAtIndex(document, entityId, indices[depth - 1]); + const farther = positionAtIndex(document, entityId, indices[depth]); + const transition = + direction === "previous" + ? createImmediateTransition( + document, + entityId, + farther, + nearer, + geometryOptions, + epsilon, + ) + : createImmediateTransition( + document, + entityId, + nearer, + farther, + geometryOptions, + epsilon, + ); + if (transition) connectors.push(transition); + } + return connectors; + }; + + return { + previousConnectors: createChain(previousIndices, "previous"), + nextConnectors: createChain(nextIndices, "next"), + }; +} + +function createSceneExtraDots( + document: DrillDocument, + entityId: number, + previousExtraIndices: readonly number[], + nextExtraIndices: readonly number[], + current: DrillGridPoint, + previousImmediatePosition: PositionAtIndex | undefined, + nextImmediatePosition: PositionAtIndex | undefined, + epsilon: number, +): { + readonly previousDots: readonly TransitionDot[]; + readonly nextDots: readonly TransitionDot[]; +} { + // Previous extras have deterministic priority, followed by next extras. + // Immediate markers are deliberately not part of `emittedPoints`: even + // coincident previous and next transitions remain semantically distinct. + const emittedPoints: DrillGridPoint[] = []; + const blockedPoints = [ + current, + ...(previousImmediatePosition ? [previousImmediatePosition.point] : []), + ...(nextImmediatePosition ? [nextImmediatePosition.point] : []), + ]; + + const emit = ( + rawExtraIndices: readonly number[], + ): readonly TransitionDot[] => { + const dots: TransitionDot[] = []; + for (const index of rawExtraIndices) { + const position = positionAtIndex(document, entityId, index); + if (!position) continue; + if ( + blockedPoints.some((blocked) => + areGridPointsEquivalent(blocked, position.point, epsilon), + ) || + emittedPoints.some((emitted) => + areGridPointsEquivalent(emitted, position.point, epsilon), + ) + ) { + continue; + } + dots.push({ setId: position.setId, point: position.point }); + emittedPoints.push(position.point); + } + return dots; + }; + + return { + previousDots: emit(previousExtraIndices), + nextDots: emit(nextExtraIndices), + }; +} + +function rawWindowIndices( + selectedSetIndex: number, + setCount: number, + direction: "previous" | "next", + showAll: boolean, + totalCount: number, +): readonly number[] { + const availableCount = + direction === "previous" + ? selectedSetIndex + : setCount - selectedSetIndex - 1; + const windowCount = showAll + ? availableCount + : Math.min(totalCount, availableCount); + return Array.from({ length: windowCount }, (_, offset) => + direction === "previous" + ? selectedSetIndex - offset - 1 + : selectedSetIndex + offset + 1, + ); +} + +interface PositionAtIndex { + readonly setId: number; + readonly point: DrillGridPoint; +} + +function positionAtIndex( + document: DrillDocument, + entityId: number, + index: number | undefined, +): PositionAtIndex | undefined { + if (index === undefined || index < 0 || index >= document.sets.length) { + return undefined; + } + const setId = document.sets[index].id; + const point = findPosition(document, entityId, setId); + return point ? { setId, point } : undefined; +} + +function findPosition( + document: DrillDocument, + entityId: number, + setId: number, +): DrillGridPoint | null { + const position = document.positions.find( + (candidate) => candidate.entityId === entityId && candidate.setId === setId, + ); + return position ? { xSteps: position.xSteps, ySteps: position.ySteps } : null; +} + +function geometryStart(geometry: TransitionPathGeometry): DrillGridPoint { + return geometry.kind === "polyline" ? geometry.points[0] : geometry.start; +} + +function geometryEnd(geometry: TransitionPathGeometry): DrillGridPoint { + return geometry.kind === "polyline" + ? geometry.points[geometry.points.length - 1] + : geometry.end; +} + +function normalizeSettings( + settings: TransitionSceneSettingsInput, +): NormalizedTransitionSceneSettings { + if ("showTransitionMarkers" in settings) { + assertCount(settings.previousTransitionSetCount, "previous"); + assertCount(settings.nextTransitionSetCount, "next"); + return { + markerEnabled: settings.showTransitionMarkers, + showAll: settings.showAllTransitionSets, + previousTotalCount: settings.previousTransitionSetCount, + nextTotalCount: settings.nextTransitionSetCount, + }; + } + + assertCount(settings.previousTotalCount, "previous"); + assertCount(settings.nextTotalCount, "next"); + return { + markerEnabled: settings.markerEnabled, + showAll: settings.showAll, + previousTotalCount: settings.previousTotalCount, + nextTotalCount: settings.nextTotalCount, + }; +} + +interface NormalizedTransitionSceneSettings { + readonly markerEnabled: boolean; + readonly showAll: boolean; + readonly previousTotalCount: number; + readonly nextTotalCount: number; +} + +function assertCount(value: number, direction: string): void { + if (!Number.isInteger(value) || value < 0) { + throw new RangeError( + `${direction} transition total count must be a non-negative integer.`, + ); + } +} + +function assertEpsilon(epsilon: number): void { + if (!Number.isFinite(epsilon) || epsilon < 0) { + throw new RangeError( + "Grid-point epsilon must be a finite non-negative number.", + ); + } +} + +function isTransitionSceneInput( + value: TransitionSceneInput | DrillDocument, +): value is TransitionSceneInput { + return "document" in value && "settings" in value; +} diff --git a/packages/mobile/src/drill/types.ts b/packages/mobile/src/drill/types.ts new file mode 100644 index 00000000..d3c76e37 --- /dev/null +++ b/packages/mobile/src/drill/types.ts @@ -0,0 +1,52 @@ +import type { + DrillGridPoint, + DrillDocument, + DrillMetadata, + MeasureRange, + SetKind, + FieldPresetId, +} from "@eight2five/drill-schema"; + +/** + * The metadata columns stored with a drill are a small, query-friendly summary + * of the portable document metadata. The complete metadata (and the rest of + * the document) is available through DrillRepository.getDrillDocument. + */ +export interface Drill { + readonly id: string; + readonly name: string; + readonly createdAt: number; + readonly updatedAt: number; + readonly fieldPreset: FieldPresetId; + readonly metadata?: DrillMetadata; + readonly selectedPerformerEntityId?: number; +} + +/** + * One ordered target set for the selected performer projection. + * + * `id` is an opaque SQLite row identifier. It intentionally differs from the + * portable schema's zero-based set id: imports map portable set order to local + * rows, while mobile editing can insert/reorder rows without exposing storage + * identity in drill files. Imported rows retain the portable source set id so + * the projection can be rebuilt without changing the authoritative document. + */ +export interface DrillSet { + readonly id: string; + readonly drillId: string; + readonly ordinal: number; + readonly number: number; + readonly suffix?: string; + readonly kind: SetKind; + readonly countsFromPrevious: number; + readonly measureRange?: MeasureRange; + readonly position: DrillGridPoint; + readonly facingDegrees?: number; + readonly sourceSetId?: number; +} + +/** A validated portable document retained for an imported drill. */ +export type SourceDrillDocument = DrillDocument; + +/** @deprecated Use DrillSet. Kept temporarily for source compatibility. */ +export type DrillPage = DrillSet; diff --git a/packages/mobile/src/field/__tests__/anchor-position.test.ts b/packages/mobile/src/field/__tests__/anchor-position.test.ts new file mode 100644 index 00000000..0489f2d1 --- /dev/null +++ b/packages/mobile/src/field/__tests__/anchor-position.test.ts @@ -0,0 +1,345 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; +import { + ANCHOR_POSITION_REFERENCES, + ANCHOR_POSITION_REFERENCE_POINTS, + ANCHOR_POSITION_UNITS, + MAX_ANCHOR_HEIGHT_METERS, + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + anchorFieldPositionFromMarchingCoordinate, + anchorFieldPositionFromStandard, + anchorFieldPositionToStandard, + convertAnchorPositionUnits, + createStandardFootballFieldTemplate, + getAnchorPositionReferencePoint, + metersToAnchorPositionUnits, + drillGridPointToFieldPoint, + parseAnchorPositionDraft, + yardsToMeters, +} from "../index"; + +const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const PRESETS = FIELD_PRESET_IDS; + +describe("shared anchor position domain", () => { + test("defines exactly the standard references and their field points", () => { + expect(ANCHOR_POSITION_REFERENCES).toEqual([ + "center-field", + "center-front-sideline", + "center-back-sideline", + "side-1-front-corner", + "side-1-back-corner", + "side-2-front-corner", + "side-2-back-corner", + "side-1-goal-line-center", + "side-2-goal-line-center", + "front-hash-center", + "back-hash-center", + ]); + expect(Object.keys(ANCHOR_POSITION_REFERENCE_POINTS)).toHaveLength(11); + expect( + ANCHOR_POSITION_REFERENCES.map((reference) => + getAnchorPositionReferencePoint(reference), + ), + ).toEqual([ + { xMeters: 0, yMeters: field.widthMeters / 2 }, + { xMeters: 0, yMeters: 0 }, + { xMeters: 0, yMeters: field.widthMeters }, + { xMeters: field.bounds.minXMeters, yMeters: 0 }, + { xMeters: field.bounds.minXMeters, yMeters: field.widthMeters }, + { xMeters: field.bounds.maxXMeters, yMeters: 0 }, + { xMeters: field.bounds.maxXMeters, yMeters: field.widthMeters }, + { xMeters: field.bounds.minXMeters, yMeters: field.widthMeters / 2 }, + { xMeters: field.bounds.maxXMeters, yMeters: field.widthMeters / 2 }, + { xMeters: 0, yMeters: field.frontHashLine.coordinateMeters }, + { xMeters: 0, yMeters: field.backHashLine.coordinateMeters }, + ]); + expect(getAnchorPositionReferencePoint("center-field")).toEqual({ + xMeters: 0, + yMeters: field.widthMeters / 2, + }); + expect(getAnchorPositionReferencePoint("front-hash-center")).toEqual({ + xMeters: 0, + yMeters: field.frontHashLine.coordinateMeters, + }); + expect(getAnchorPositionReferencePoint("side-2-goal-line-center")).toEqual({ + xMeters: field.bounds.maxXMeters, + yMeters: field.widthMeters / 2, + }); + }); + + test.each(PRESETS)( + "%s derives hash reference points from the active field preset", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + expect( + getAnchorPositionReferencePoint("front-hash-center", preset), + ).toEqual({ + xMeters: 0, + yMeters: template.frontHashLine.coordinateMeters, + }); + expect( + getAnchorPositionReferencePoint("back-hash-center", preset), + ).toEqual({ + xMeters: 0, + yMeters: template.backHashLine.coordinateMeters, + }); + }, + ); + + test("converts all supported units through one canonical meter path", () => { + expect(ANCHOR_POSITION_UNITS).toEqual(["meters", "yards", "feet"]); + const meters = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 1.25, + frontToBackOffset: -2.5, + height: 3, + }); + const yards = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "yards", + sideToSideOffset: 1.25 / 0.9144, + frontToBackOffset: -2.5 / 0.9144, + height: 3 / 0.9144, + }); + const feet = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "feet", + sideToSideOffset: 1.25 / 0.3048, + frontToBackOffset: -2.5 / 0.3048, + height: 3 / 0.3048, + }); + expect(yards).toEqual({ + xMeters: expect.closeTo(meters.xMeters, 10), + yMeters: expect.closeTo(meters.yMeters, 10), + zMeters: expect.closeTo(meters.zMeters, 10), + }); + expect(feet).toEqual({ + xMeters: expect.closeTo(meters.xMeters, 10), + yMeters: expect.closeTo(meters.yMeters, 10), + zMeters: expect.closeTo(meters.zMeters, 10), + }); + expect(convertAnchorPositionUnits(10, "yards", "feet")).toBeCloseTo(30); + expect(metersToAnchorPositionUnits(1, "feet")).toBeCloseTo(1 / 0.3048); + }); + + test("applies signed offsets in the documented field directions", () => { + const center = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 0, + frontToBackOffset: 0, + height: 0, + }); + const shifted = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 2, + frontToBackOffset: -3, + height: 1, + }); + expect(shifted.xMeters).toBe(center.xMeters + 2); + expect(shifted.yMeters).toBe(center.yMeters - 3); + expect(shifted.zMeters).toBe(1); + + const side1 = anchorFieldPositionFromStandard({ + reference: "side-1-front-corner", + unit: "yards", + sideToSideOffset: 5, + frontToBackOffset: 5, + height: 1, + }); + expect(side1.xMeters).toBeCloseTo( + field.bounds.minXMeters + yardsToMeters(5), + ); + expect(side1.yMeters).toBeCloseTo(yardsToMeters(5)); + + const inverse = anchorFieldPositionToStandard( + shifted, + "center-field", + "meters", + ); + expect(inverse).toEqual({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 2, + frontToBackOffset: -3, + height: 1, + }); + }); + + test("reuses marching conversion for horizontal coordinates and adds height", () => { + const coordinate = { + side: { + side: 2 as const, + yardLine: 40, + relation: "inside" as const, + offsetSteps: 1.5, + }, + frontBack: { + reference: "front-hash" as const, + relation: "behind" as const, + offsetSteps: 2.25, + }, + }; + const expected = anchorFieldPositionFromMarchingCoordinate(coordinate, 2.4); + const projected = drillGridPointToFieldPoint({ + xSteps: 16 - 1.5, + ySteps: 28 + 2.25, + }); + expect(expected.xMeters).toBeCloseTo(projected.xMeters); + expect(expected.yMeters).toBeCloseTo(projected.yMeters); + expect(expected.zMeters).toBe(2.4); + }); + + test.each(PRESETS)( + "%s projects marching anchor coordinates with that preset's hash convention", + (preset) => { + const position = anchorFieldPositionFromMarchingCoordinate( + { + side: { + side: "center", + yardLine: 50, + relation: "on", + offsetSteps: 0, + }, + frontBack: { + reference: "front-hash", + relation: "on", + offsetSteps: 0, + }, + }, + 2, + preset, + ); + const template = createStandardFootballFieldTemplate(preset); + expect(position.yMeters).toBeCloseTo( + template.frontHashLine.coordinateMeters, + 8, + ); + expect(position.zMeters).toBe(2); + }, + ); + + test("parses drafts and rejects empty, non-finite, out-of-field, and excessive values", () => { + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "feet", + sideToSideOffset: "3", + frontToBackOffset: "-4", + height: "6", + }), + ).toEqual({ + errors: {}, + value: expect.objectContaining({ + zMeters: expect.closeTo(6 * 0.3048, 10), + }), + }); + + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "", + frontToBackOffset: "", + height: "", + }).errors, + ).toMatchObject({ + sideToSideOffset: expect.any(String), + frontToBackOffset: expect.any(String), + height: expect.any(String), + }); + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "NaN", + frontToBackOffset: "Infinity", + height: "", + }).errors, + ).toMatchObject({ + sideToSideOffset: expect.any(String), + frontToBackOffset: expect.any(String), + height: expect.any(String), + }); + expect( + parseAnchorPositionDraft({ + reference: "side-1-front-corner", + unit: "meters", + sideToSideOffset: "-0.01", + frontToBackOffset: "0", + height: "0", + }).errors.position, + ).toContain("standard field bounds"); + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "0", + frontToBackOffset: "0", + height: "-1", + }).errors.height, + ).toContain("negative"); + expect( + parseAnchorPositionDraft({ + reference: "center-field", + unit: "meters", + sideToSideOffset: "0", + frontToBackOffset: "0", + height: String(MAX_ANCHOR_HEIGHT_METERS + 1), + }).errors.position, + ).toContain("at most"); + }); + + test("throws instead of silently clamping invalid canonical and standard values", () => { + expect(() => + anchorFieldPositionFromStandard({ + reference: "side-1-front-corner", + unit: "meters", + sideToSideOffset: -1, + frontToBackOffset: 0, + height: 0, + }), + ).toThrow("standard field bounds"); + expect(() => + anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 0, + frontToBackOffset: 0, + height: -0.1, + }), + ).toThrow("negative"); + expect(() => + anchorFieldPositionFromMarchingCoordinate( + { + side: { + side: 1, + yardLine: 0, + relation: "outside", + offsetSteps: 1, + }, + frontBack: { + reference: "front-sideline", + relation: "on", + offsetSteps: 0, + }, + }, + 1, + ), + ).toThrow("standard field bounds"); + }); + + test("does not add a quality field to canonical positions", () => { + const position = anchorFieldPositionFromStandard({ + reference: "center-field", + unit: "meters", + sideToSideOffset: 0, + frontToBackOffset: 0, + height: 1, + }); + expect(position).not.toHaveProperty("quality"); + expect(Object.keys(position)).toEqual(["xMeters", "yMeters", "zMeters"]); + }); +}); diff --git a/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts b/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts new file mode 100644 index 00000000..918dc019 --- /dev/null +++ b/packages/mobile/src/field/__tests__/drill-shape-policy.test.ts @@ -0,0 +1,76 @@ +import { + createDrillShapeGeometry, + getDrillLabelTransformPolicy, + getDrillShapeTransformPolicy, +} from "../render/drill-shape-policy"; + +describe("drill icon shape policy", () => { + test.each([ + "square", + "triangle", + "diamond", + "star", + "hexagon", + "cross", + ] as const)("covers the %s path primitive", (icon) => { + const shape = createDrillShapeGeometry(icon, 2, 2); + expect(shape.kind).toBe("path"); + if (shape.kind === "path") expect(shape.points.length).toBeGreaterThan(2); + }); + + test("triangle points upward in world coordinates despite camera scaleY(-1)", () => { + const shape = createDrillShapeGeometry("triangle", 2, 2); + expect(shape).toMatchObject({ kind: "path" }); + if (shape.kind !== "path") return; + + expect(shape.points[0]).toEqual({ x: 0, y: 1 }); + expect(shape.points[1].y).toBe(-1); + expect(shape.points[2].y).toBe(-1); + }); + + test("star's first point is the upright directional point rather than a mirrored bottom point", () => { + const shape = createDrillShapeGeometry("star", 2, 2); + expect(shape.kind).toBe("path"); + if (shape.kind !== "path") return; + + const highestWorldY = Math.max(...shape.points.map((point) => point.y)); + expect(shape.points[0].y).toBe(highestWorldY); + expect(shape.points[0].x).toBeCloseTo(0); + expect(shape.points[0].y).toBeCloseTo(1); + }); + + test("negates field facing rotation for the reflected camera while retaining radians", () => { + expect(getDrillShapeTransformPolicy()).toMatchObject({ + rotationRadians: 0, + origin: { x: 0, y: 0 }, + }); + expect(getDrillShapeTransformPolicy(90).rotationRadians).toBeCloseTo( + -Math.PI / 2, + ); + expect(getDrillShapeTransformPolicy(180).rotationRadians).toBeCloseTo( + -Math.PI, + ); + }); + + test("counter-scales labels for both camera perspectives", () => { + expect(getDrillLabelTransformPolicy(0.25, "director")).toEqual({ + scaleX: 0.25, + scaleY: -0.25, + }); + expect(getDrillLabelTransformPolicy(0.25, "performer")).toEqual({ + scaleX: -0.25, + scaleY: 0.25, + }); + }); + + test("keeps dot and circle primitives circular", () => { + expect(createDrillShapeGeometry("dot", 2, 4)).toEqual({ + kind: "circle", + radius: 1, + }); + expect(createDrillShapeGeometry("circle", 2, 4)).toEqual({ + kind: "circle", + radius: 1, + }); + }); +}); diff --git a/packages/mobile/src/field/__tests__/field-camera-math.test.ts b/packages/mobile/src/field/__tests__/field-camera-math.test.ts new file mode 100644 index 00000000..200bf11e --- /dev/null +++ b/packages/mobile/src/field/__tests__/field-camera-math.test.ts @@ -0,0 +1,182 @@ +import { + applyFieldCameraTransform, + clampFieldViewport, + createFieldPanBaseline, + fieldCameraTransform, + fieldCenterForStationaryWorldPoint, + fieldPanCenter, + fieldScreenToWorld, + fieldWorldToScreen, +} from "../camera/field-camera-math"; +import { + FIELD_CAMERA_BLANK_MARGIN_YARDS, + FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS, + FIELD_GRID_PERIMETER_YARDS, + FIELD_MIN_METERS_PER_PIXEL, + getFieldCameraBounds, + getFieldGridBounds, + getFieldMaximumMetersPerPixel, +} from "../camera/field-camera-policy"; +import { STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE } from "../template"; +import { metersToYards } from "../units"; + +const size = { width: 800, height: 400 }; +const portraitSize = { width: 400, height: 800 }; +const viewport = { + centerXMeters: 45, + centerYMeters: 20, + metersPerPixel: 0.1, +}; + +describe("field camera math", () => { + test("round-trips world and screen points and matches the Skia transform", () => { + const point = { xMeters: 49.25, yMeters: 18.5 }; + const screen = fieldWorldToScreen(point, viewport, size); + + expect(fieldScreenToWorld(screen, viewport, size)).toEqual(point); + expect( + applyFieldCameraTransform(point, fieldCameraTransform(viewport, size)), + ).toEqual(screen); + }); + + test("rotates performer perspective by 180 degrees and round-trips it", () => { + const point = { xMeters: 49.25, yMeters: 18.5 }; + const director = fieldWorldToScreen(point, viewport, size, "director"); + const performer = fieldWorldToScreen(point, viewport, size, "performer"); + + expect(performer.x - size.width / 2).toBeCloseTo( + -(director.x - size.width / 2), + 10, + ); + expect(performer.y - size.height / 2).toBeCloseTo( + -(director.y - size.height / 2), + 10, + ); + expect(fieldScreenToWorld(performer, viewport, size, "performer")).toEqual( + point, + ); + expect( + applyFieldCameraTransform( + point, + fieldCameraTransform(viewport, size, "performer"), + ), + ).toEqual(performer); + }); + + test("preserves the world point beneath a pinch focal point", () => { + const focal = { x: 155, y: 92 }; + const world = fieldScreenToWorld(focal, viewport, size); + const nextScale = 0.05; + const center = fieldCenterForStationaryWorldPoint( + world, + focal, + size, + nextScale, + ); + + const preserved = fieldWorldToScreen( + world, + { + centerXMeters: center.xMeters, + centerYMeters: center.yMeters, + metersPerPixel: nextScale, + }, + size, + ); + expect(preserved.x).toBeCloseTo(focal.x, 10); + expect(preserved.y).toBeCloseTo(focal.y, 10); + }); + + test("clamps only the camera center even when the viewport is oversized", () => { + const bounds = { + minXMeters: 0, + maxXMeters: 100, + minYMeters: 0, + maxYMeters: 50, + }; + expect( + clampFieldViewport( + { centerXMeters: -50, centerYMeters: 100, metersPerPixel: 0.1 }, + size, + bounds, + ), + ).toEqual({ + centerXMeters: 0, + centerYMeters: 50, + metersPerPixel: 0.1, + }); + expect( + clampFieldViewport( + { centerXMeters: 10, centerYMeters: 10, metersPerPixel: 1 }, + size, + bounds, + ), + ).toMatchObject({ centerXMeters: 10, centerYMeters: 10 }); + }); + + test("clamps panning to the exterior camera allowance in both orientations", () => { + const bounds = getFieldCameraBounds(); + const metersPerPixel = 0.1; + + for (const currentSize of [size, portraitSize]) { + const clamped = clampFieldViewport( + { + centerXMeters: bounds.minXMeters - 100, + centerYMeters: bounds.maxYMeters + 100, + metersPerPixel, + }, + currentSize, + bounds, + ); + + expect(clamped.centerXMeters).toBeCloseTo(bounds.minXMeters, 10); + expect(clamped.centerYMeters).toBeCloseTo(bounds.maxYMeters, 10); + } + }); + + test("rebases pan translation after a pinch pointer transition", () => { + const current = { xMeters: 30, yMeters: 12 }; + const rebased = createFieldPanBaseline(current, 84, -20, 0.1); + + expect(fieldPanCenter(rebased, 84, -20)).toEqual(current); + expect(fieldPanCenter(rebased, 94, -15)).toEqual({ + xMeters: 29, + yMeters: 12.5, + }); + }); + + test("keeps zoom limits centralized around the padded field", () => { + const gridBounds = getFieldGridBounds(); + const cameraBounds = getFieldCameraBounds(); + const maximum = getFieldMaximumMetersPerPixel(size, gridBounds); + + expect(FIELD_MIN_METERS_PER_PIXEL).toBe(0.02); + expect(maximum).toBeGreaterThan(FIELD_MIN_METERS_PER_PIXEL); + expect(cameraBounds.minXMeters).toBeLessThan(gridBounds.minXMeters); + expect(cameraBounds.maxYMeters).toBeGreaterThan(gridBounds.maxYMeters); + }); + + test("keeps the rendered grid at 10 yards and allows 30 yards outside the field", () => { + const gridBounds = getFieldGridBounds(); + const cameraBounds = getFieldCameraBounds(); + + expect(FIELD_GRID_PERIMETER_YARDS).toBe(10); + expect(FIELD_CAMERA_BLANK_MARGIN_YARDS).toBe(20); + expect(FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS).toBe(30); + expect( + metersToYards( + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE.bounds.minXMeters - + gridBounds.minXMeters, + ), + ).toBeCloseTo(FIELD_GRID_PERIMETER_YARDS, 10); + expect( + metersToYards( + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE.bounds.minXMeters - + cameraBounds.minXMeters, + ), + ).toBeCloseTo(FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS, 10); + expect( + metersToYards(gridBounds.minXMeters - cameraBounds.minXMeters), + ).toBeCloseTo(FIELD_CAMERA_BLANK_MARGIN_YARDS, 10); + }); +}); diff --git a/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts b/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts new file mode 100644 index 00000000..de6b1187 --- /dev/null +++ b/packages/mobile/src/field/__tests__/field-overlay-policy.test.ts @@ -0,0 +1,92 @@ +import { + getCurrentTargetMarkerSource, + resolveCurrentTargetPosition, + shouldShowFieldGuidance, + shouldShowFieldGuidanceForScene, + shouldShowFieldTarget, + type FieldDrillOverlayState, +} from "../render/field-overlay-types"; + +const visible: FieldDrillOverlayState = { + drillFeaturesEnabled: true, + hasActiveDrill: true, + hasSelectedPage: true, + hasLivePosition: true, + guidanceEnabled: true, +}; + +describe("field drill overlay gating", () => { + test("shows target and guidance only for a complete active selection", () => { + expect(shouldShowFieldTarget(visible)).toBe(true); + expect(shouldShowFieldGuidance(visible)).toBe(true); + }); + + test.each([ + "drillFeaturesEnabled", + "hasActiveDrill", + "hasSelectedPage", + ] as const)("hides target when %s is false", (key) => { + expect(shouldShowFieldTarget({ ...visible, [key]: false })).toBe(false); + }); + + test("hides guidance without live position or when guidance is disabled", () => { + expect( + shouldShowFieldGuidance({ ...visible, hasLivePosition: false }), + ).toBe(false); + expect( + shouldShowFieldGuidance({ ...visible, guidanceEnabled: false }), + ).toBe(false); + }); + + test("does not fall back to a stale target when a complete scene has no current position", () => { + const target = { xMeters: 1, yMeters: 2 }; + const sceneWithoutCurrent = { + fullDrillSceneAvailable: true, + sceneHasCurrent: false, + legacyFallbackAvailable: true, + } as const; + + expect(getCurrentTargetMarkerSource(sceneWithoutCurrent)).toBe("none"); + expect( + resolveCurrentTargetPosition({ + fullDrillSceneAvailable: true, + sceneCurrent: null, + legacyFallback: target, + }), + ).toBeUndefined(); + expect(shouldShowFieldGuidanceForScene(visible, sceneWithoutCurrent)).toBe( + false, + ); + }); + + test("keeps legacy/manual target and guidance behavior without a full scene", () => { + const target = { xMeters: 1, yMeters: 2 }; + const legacy = { + fullDrillSceneAvailable: false, + sceneHasCurrent: false, + legacyFallbackAvailable: true, + } as const; + + expect(getCurrentTargetMarkerSource(legacy)).toBe("legacy-fallback"); + expect( + resolveCurrentTargetPosition({ + fullDrillSceneAvailable: false, + sceneCurrent: undefined, + legacyFallback: target, + }), + ).toEqual(target); + expect(shouldShowFieldGuidanceForScene(visible, legacy)).toBe(true); + }); + + test("uses the complete scene target when both scene and fallback positions exist", () => { + const sceneTarget = { xMeters: 3, yMeters: 4 }; + const fallbackTarget = { xMeters: 1, yMeters: 2 }; + expect( + resolveCurrentTargetPosition({ + fullDrillSceneAvailable: true, + sceneCurrent: sceneTarget, + legacyFallback: fallbackTarget, + }), + ).toBe(sceneTarget); + }); +}); diff --git a/packages/mobile/src/field/__tests__/field-paths.test.ts b/packages/mobile/src/field/__tests__/field-paths.test.ts new file mode 100644 index 00000000..544e253d --- /dev/null +++ b/packages/mobile/src/field/__tests__/field-paths.test.ts @@ -0,0 +1,351 @@ +import { + drillGridToPhysicalPoint, + FIELD_PRESET_IDS, +} from "@eight2five/drill-schema"; + +import { createFieldPaths } from "../render/create-field-paths"; +import { + createStandardFootballFieldTemplate, + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +} from "../template"; +import { yardsToMeters } from "../units"; + +const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const PRESETS = FIELD_PRESET_IDS; + +describe("aggregate field paths", () => { + test("returns one immutable, memoized path set per template", () => { + const first = createFieldPaths(field); + const second = createFieldPaths(field); + + expect(second).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.fieldExtent)).toBe(true); + expect(Object.isFrozen(first.gridExtent)).toBe(true); + expect(Object.isFrozen(first.counts)).toBe(true); + expect(Object.isFrozen(first.counts.stepGrid)).toBe(true); + }); + + test("exposes exact field and ten-yard padded grid extents", () => { + const paths = createFieldPaths(field); + const paddingMeters = yardsToMeters(10); + + expect(paths.fieldExtent).toEqual(field.bounds); + expect(paths.gridExtent).toEqual({ + minXMeters: field.bounds.minXMeters - paddingMeters, + maxXMeters: field.bounds.maxXMeters + paddingMeters, + minYMeters: field.bounds.minYMeters - paddingMeters, + maxYMeters: field.bounds.maxYMeters + paddingMeters, + }); + expect(paths.extents).toEqual({ + field: paths.fieldExtent, + grid: paths.gridExtent, + }); + expect(paths.stepGridSpacingSteps).toBe(1); + expect(paths.fourStepGridSpacingSteps).toBe(4); + expect(paths.counts.stepGrid.spacingSteps).toBe(1); + }); + + test.each(PRESETS)( + "%s projects the canonical 160 by 84 one-step marching grid onto the field", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const bounds = template.fieldDefinition.marchingGrid.bounds; + + expect(bounds).toEqual({ + minXSteps: -80, + maxXSteps: 80, + minYSteps: 0, + maxYSteps: 84, + }); + expect(paths.counts.stepGrid).toMatchObject({ + spacingSteps: 1, + verticalLineCount: 161, + horizontalLineCount: 85, + }); + expect(subpathCount(paths.stepGridPath)).toBe(246); + + const frontHash = gridPoint( + template, + 0, + gridReference(template, "front-hash"), + ); + const backHash = gridPoint( + template, + 0, + gridReference(template, "back-hash"), + ); + const backSideline = gridPoint(template, 0, 84); + expect(frontHash.yMeters).toBeCloseTo( + template.frontHashLine.coordinateMeters, + 8, + ); + expect(backHash.yMeters).toBeCloseTo( + template.backHashLine.coordinateMeters, + 8, + ); + expect(backSideline.yMeters).toBeCloseTo(template.bounds.maxYMeters, 8); + + const frontHashSteps = gridReference(template, "front-hash"); + const backHashSteps = gridReference(template, "back-hash"); + if (Number.isInteger(frontHashSteps)) { + expect(paths.stepGridPath).toContain( + horizontalSegment( + template.bounds.minXMeters, + frontHash.yMeters, + template.bounds.maxXMeters, + ), + ); + } + if (Number.isInteger(backHashSteps)) { + expect(paths.stepGridPath).toContain( + horizontalSegment( + template.bounds.minXMeters, + backHash.yMeters, + template.bounds.maxXMeters, + ), + ); + } + }, + ); + + test("NFHS hashes are exactly 28, 28, 28 marching steps apart", () => { + const template = createStandardFootballFieldTemplate("football-nfhs"); + expect(gridReference(template, "front-sideline")).toBe(0); + expect(gridReference(template, "front-hash")).toBe(28); + expect(gridReference(template, "back-hash")).toBe(56); + expect(gridReference(template, "back-sideline")).toBe(84); + }); + + test.each(PRESETS)( + "%s renders the blue overlay as four marching-step boxes", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const coordinates = parseCoordinates(paths.fourStepGridPath); + + for (const { xMeters, yMeters } of coordinates) { + expect(xMeters).toBeGreaterThanOrEqual( + template.bounds.minXMeters - 1e-6, + ); + expect(xMeters).toBeLessThanOrEqual(template.bounds.maxXMeters + 1e-6); + expect(yMeters).toBeGreaterThanOrEqual( + template.bounds.minYMeters - 1e-6, + ); + expect(yMeters).toBeLessThanOrEqual(template.bounds.maxYMeters + 1e-6); + } + + expect(paths.counts.fourStepGrid).toMatchObject({ + spacingSteps: 4, + verticalSubdivisionCount: 41, + horizontalSubdivisionCount: 22, + segmentCount: 63, + clippedToField: true, + }); + expect(subpathCount(paths.fourStepGridPath)).toBe(63); + expect(paths.fourStepGridPath).toContain( + horizontalSegment( + template.bounds.minXMeters, + gridPoint(template, 0, 84).yMeters, + template.bounds.maxXMeters, + ), + ); + }, + ); + + test.each(PRESETS)( + "%s optional one-step perimeter grid extends beyond every field edge", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const subpaths = parseSubpaths(paths.perimeterStepGridPath); + + expect(paths.counts.perimeterStepGrid.spacingSteps).toBe(1); + expect(paths.counts.perimeterStepGrid.clippedByFieldBackground).toBe( + true, + ); + expect( + subpaths.some( + ({ x1, x2 }) => x1 === x2 && x1 < template.bounds.minXMeters, + ), + ).toBe(true); + expect( + subpaths.some( + ({ x1, x2 }) => x1 === x2 && x1 > template.bounds.maxXMeters, + ), + ).toBe(true); + expect( + subpaths.some( + ({ y1, y2 }) => y1 === y2 && y1 < template.bounds.minYMeters, + ), + ).toBe(true); + expect( + subpaths.some( + ({ y1, y2 }) => y1 === y2 && y1 > template.bounds.maxYMeters, + ), + ).toBe(true); + }, + ); + + test("renders one perpendicular inbounds hash on each full yard line", () => { + const paths = createFieldPaths(field); + const hashSegments = parseSubpaths(paths.hashMarksPath); + + expect(subpathCount(paths.yardLinesPath)).toBe(19); + expect(paths.counts.yardLines.lineCount).toBe(19); + expect(hashSegments).toHaveLength(38); + expect( + hashSegments.every( + ({ x1, y1, x2, y2 }) => + y1 === y2 && + Math.abs( + x2 - + x1 - + field.fieldDefinition.markings.inboundsHashMarks.lengthMeters, + ) < 1e-6, + ), + ).toBe(true); + expect(paths.counts.hashMarks).toMatchObject({ + rowCount: 2, + ticksPerRow: 19, + tickCount: 38, + spacingMeters: field.dimensions.fiveYardLineSpacingMeters, + tickLengthMeters: + field.fieldDefinition.markings.inboundsHashMarks.lengthMeters, + }); + const yardLineCoordinates = new Set( + field.yardLines.map((line) => Number(line.coordinateMeters.toFixed(6))), + ); + expect( + hashSegments.every(({ x1, x2 }) => + yardLineCoordinates.has(Number(((x1 + x2) / 2).toFixed(6))), + ), + ).toBe(true); + expect(paths.hashGuideLinesPath).toBe(""); + expect(subpathCount(paths.hashGuideLinesPath)).toBe(0); + expect(paths.counts.hashGuideLines.lineCount).toBe(0); + expect(subpathCount(paths.boundaryPath)).toBe(1); + expect(paths.boundaryPath.endsWith(" Z")).toBe(true); + expect(paths.counts.boundary.segmentCount).toBe(1); + }); + + test.each(PRESETS)( + "%s renders preset sideline marks at one-yard positions between full yard lines", + (preset) => { + const template = createStandardFootballFieldTemplate(preset); + const paths = createFieldPaths(template); + const segments = parseSubpaths(paths.sidelineHashMarksPath); + const markings = template.fieldDefinition.markings.sidelineHashMarks; + + expect(segments).toHaveLength(160); + expect(paths.counts.sidelineHashMarks).toEqual({ + spacingMeters: markings.spacingMeters, + markLengthMeters: markings.lengthMeters, + insetFromSidelineMeters: markings.insetFromSidelineMeters, + rowCount: 2, + marksPerRow: 80, + markCount: 160, + }); + expect( + segments.every( + ({ x1, y1, x2, y2 }) => + x1 === x2 && + Math.abs(Math.abs(y2 - y1) - markings.lengthMeters) < 1e-6, + ), + ).toBe(true); + expect( + segments.every(({ x1 }) => { + const yardsFromGoalLine = + (x1 - template.bounds.minXMeters) / yardsToMeters(1); + return Math.abs(yardsFromGoalLine % 5) > 1e-6; + }), + ).toBe(true); + const front = segments.find( + ({ y1, y2 }) => + y1 < template.widthMeters / 2 && y2 < template.widthMeters / 2, + )!; + const back = segments.find( + ({ y1, y2 }) => + y1 > template.widthMeters / 2 && y2 > template.widthMeters / 2, + )!; + expect(front.y1 - template.bounds.minYMeters).toBeCloseTo( + markings.insetFromSidelineMeters, + ); + expect(front.y2 - front.y1).toBeCloseTo(markings.lengthMeters); + expect(template.bounds.maxYMeters - back.y2).toBeCloseTo( + markings.insetFromSidelineMeters, + ); + expect(back.y2 - back.y1).toBeCloseTo(markings.lengthMeters); + }, + ); +}); + +function gridReference( + template: ReturnType, + id: string, +): number { + const reference = template.fieldDefinition.marchingGrid.referenceLines.find( + (line) => line.id === id, + ); + if (!reference) throw new Error(`Missing grid reference ${id}.`); + return reference.coordinateSteps; +} + +function gridPoint( + template: ReturnType, + xSteps: number, + ySteps: number, +) { + return drillGridToPhysicalPoint({ xSteps, ySteps }, template.fieldDefinition); +} + +function horizontalSegment( + minXMeters: number, + yMeters: number, + maxXMeters: number, +): string { + return `M ${format(minXMeters)} ${format(yMeters)} L ${format( + maxXMeters, + )} ${format(yMeters)}`; +} + +function parseCoordinates(path: string): { + xMeters: number; + yMeters: number; +}[] { + const values = path.match(/-?\d+(?:\.\d+)?/g)?.map(Number) ?? []; + const coordinates: { xMeters: number; yMeters: number }[] = []; + for (let index = 0; index + 1 < values.length; index += 2) { + coordinates.push({ xMeters: values[index], yMeters: values[index + 1] }); + } + return coordinates; +} + +function parseSubpaths(path: string): { + x1: number; + y1: number; + x2: number; + y2: number; +}[] { + return Array.from( + path.matchAll( + /M (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?) L (-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g, + ), + (match) => ({ + x1: Number(match[1]), + y1: Number(match[2]), + x2: Number(match[3]), + y2: Number(match[4]), + }), + ); +} + +function subpathCount(path: string): number { + return path.length === 0 ? 0 : (path.match(/M /g)?.length ?? 0); +} + +function format(value: number): string { + const rounded = Math.round(value * 1_000_000) / 1_000_000; + return String(Object.is(rounded, -0) ? 0 : rounded); +} diff --git a/packages/mobile/src/field/__tests__/guidance.test.ts b/packages/mobile/src/field/__tests__/guidance.test.ts new file mode 100644 index 00000000..5cc60c08 --- /dev/null +++ b/packages/mobile/src/field/__tests__/guidance.test.ts @@ -0,0 +1,74 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; + +import { calculateFieldGuidance, drillGridPointToFieldPoint } from "../index"; + +const PRESETS = FIELD_PRESET_IDS; + +describe("field guidance", () => { + test("returns signed field-relative axis guidance and straight-line grid distance", () => { + const current = drillGridPointToFieldPoint( + { xSteps: 10, ySteps: 5 }, + "football-nfhs", + ); + const target = drillGridPointToFieldPoint( + { xSteps: 2.5, ySteps: 8 }, + "football-nfhs", + ); + const guidance = calculateFieldGuidance(current, target, "football-nfhs"); + + expect(guidance.xDisplacementSteps).toBeCloseTo(-7.5); + expect(guidance.yDisplacementSteps).toBeCloseTo(3); + expect(guidance.distanceSteps).toBeCloseTo(Math.hypot(7.5, 3)); + expect(guidance.xLabel).toBe("7.5 steps toward Side 1"); + expect(guidance.yLabel).toBe("3 steps toward the back sideline"); + }); + + test("spells a singular displacement as one step", () => { + const guidance = calculateFieldGuidance( + drillGridPointToFieldPoint({ xSteps: 0, ySteps: 0 }), + drillGridPointToFieldPoint({ xSteps: 1, ySteps: 1 }), + ); + expect(guidance.xLabel).toBe("one step toward Side 2"); + expect(guidance.yLabel).toBe("one step toward the back sideline"); + }); + + test("uses front-sideline wording for negative Y and no phone heading", () => { + const guidance = calculateFieldGuidance( + drillGridPointToFieldPoint({ xSteps: 0, ySteps: 3 }), + drillGridPointToFieldPoint({ xSteps: 0, ySteps: 0 }), + ); + expect(guidance.yDisplacementSteps).toBeCloseTo(-3); + expect(guidance.yLabel).toBe("3 steps toward the front sideline"); + expect(guidance).not.toHaveProperty("heading"); + expect(guidance).not.toHaveProperty("bearing"); + }); + + test.each(PRESETS)( + "%s reports exactly 84 marching steps sideline to sideline", + (preset) => { + const front = drillGridPointToFieldPoint( + { xSteps: 0, ySteps: 0 }, + preset, + ); + const back = drillGridPointToFieldPoint( + { xSteps: 0, ySteps: 84 }, + preset, + ); + const guidance = calculateFieldGuidance(front, back, preset); + + expect(guidance.xDisplacementSteps).toBeCloseTo(0); + expect(guidance.yDisplacementSteps).toBeCloseTo(84); + expect(guidance.distanceSteps).toBeCloseTo(84); + }, + ); + + test("returns zero-axis guidance without inventing a direction", () => { + const guidance = calculateFieldGuidance( + { xMeters: 0, yMeters: 0 }, + { xMeters: 0, yMeters: 0 }, + ); + expect(guidance.distanceSteps).toBe(0); + expect(guidance.xLabel).toBe("0 steps"); + expect(guidance.yLabel).toBe("0 steps"); + }); +}); diff --git a/packages/mobile/src/field/__tests__/marching.test.ts b/packages/mobile/src/field/__tests__/marching.test.ts new file mode 100644 index 00000000..023cfbca --- /dev/null +++ b/packages/mobile/src/field/__tests__/marching.test.ts @@ -0,0 +1,211 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + drillGridPointToFieldPoint, + fieldPointToMarchingCoordinate, + formatMarchingCoordinate, + formatMarchingFrontBack, + formatMarchingSide, + marchingCoordinateToDrillGridPoint, + marchingCoordinateToFieldPoint, + standardStepsToMeters, +} from "../index"; + +const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +const PRESETS = FIELD_PRESET_IDS; + +function gridPoint(xSteps: number, ySteps: number) { + return drillGridPointToFieldPoint({ xSteps, ySteps }); +} + +describe("marching coordinate conversion", () => { + test("formats exact side examples in the centered field convention", () => { + expect( + formatMarchingSide(fieldPointToMarchingCoordinate(gridPoint(16, 0)).side), + ).toBe("Side 2: On 40 yd ln"); + expect( + formatMarchingSide( + fieldPointToMarchingCoordinate(gridPoint(-24 + 2, 0)).side, + ), + ).toBe("Side 1: 2 Steps inside 35 yd ln"); + expect( + formatMarchingSide( + fieldPointToMarchingCoordinate(gridPoint(16 + 1.25, 0)).side, + ), + ).toBe("Side 2: 1.25 Steps outside 40 yd ln"); + expect( + formatMarchingSide(fieldPointToMarchingCoordinate(gridPoint(0, 0)).side), + ).toBe("On 50 yd ln"); + }); + + test("uses the conventional 0/28/56/84 NFHS marching grid", () => { + const examples = [ + [0, "On Front Sideline"], + [8, "8 Steps behind Front Sideline"], + [16, "12 Steps in front of HS FH"], + [28, "On HS FH"], + [32, "4 Steps behind HS FH"], + [52.5, "3.5 Steps in front of HS BH"], + [84, "On Back Sideline"], + ] as const; + + for (const [ySteps, expected] of examples) { + expect( + formatMarchingFrontBack( + fieldPointToMarchingCoordinate(gridPoint(0, ySteps)).frontBack, + ), + ).toBe(expected); + } + }); + + test.each(PRESETS)( + "%s round-trips the field-specific front hash through physical space", + (preset) => { + const fieldPoint = marchingCoordinateToFieldPoint( + { + side: { + side: "center", + yardLine: 50, + relation: "on", + offsetSteps: 0, + }, + frontBack: { + reference: "front-hash", + relation: "on", + offsetSteps: 0, + }, + }, + preset, + ); + const roundTrip = fieldPointToMarchingCoordinate(fieldPoint, preset); + expect(roundTrip.frontBack).toMatchObject({ + reference: "front-hash", + relation: "on", + offsetSteps: expect.closeTo(0, 8), + }); + expect(formatMarchingFrontBack(roundTrip.frontBack, preset)).toContain( + "FH", + ); + }, + ); + + test("spells a singular marching step as one step", () => { + expect( + formatMarchingFrontBack( + fieldPointToMarchingCoordinate(gridPoint(0, 1)).frontBack, + ), + ).toBe("One Step behind Front Sideline"); + }); + + test("keeps canonical fractional values while formatting quarter steps", () => { + const coordinate = fieldPointToMarchingCoordinate( + gridPoint(-24 + 1.249999999, 28 + 2.500000001), + ); + expect(coordinate.side.offsetSteps).toBeCloseTo(1.249999999); + expect(formatMarchingSide(coordinate.side)).toBe( + "Side 1: 1.25 Steps inside 35 yd ln", + ); + expect(formatMarchingFrontBack(coordinate.frontBack)).toBe( + "2.5 Steps behind HS FH", + ); + }); + + test("uses centerward references for exact halfway ties", () => { + const sideTie = fieldPointToMarchingCoordinate(gridPoint(-28, 0)); + expect(sideTie.side).toMatchObject({ + side: 1, + yardLine: 35, + relation: "outside", + }); + + const lateralTie = fieldPointToMarchingCoordinate(gridPoint(0, 42)); + expect(lateralTie.frontBack.reference).toBe("front-hash"); + }); + + test("uses a side and outside terminology when the 50 is nearest", () => { + const coordinate = fieldPointToMarchingCoordinate(gridPoint(-1.5, 0)); + expect(formatMarchingSide(coordinate.side)).toBe( + "Side 1: 1.5 Steps outside 50 yd ln", + ); + expect(marchingCoordinateToDrillGridPoint(coordinate).xSteps).toBeCloseTo( + -1.5, + ); + expect(marchingCoordinateToFieldPoint(coordinate).xMeters).toBeCloseTo( + -standardStepsToMeters(1.5), + ); + }); + + test("marks out-of-bounds points explicitly while retaining nearest references", () => { + const coordinate = fieldPointToMarchingCoordinate(gridPoint(-82, 85.25)); + expect(formatMarchingCoordinate(coordinate)).toBe( + "Out of Bounds — Side 1: 2 Steps outside Goal Line; 1.25 Steps behind Back Sideline", + ); + expect(coordinate.outOfBounds).toEqual(["goal-to-goal", "front-back"]); + }); + + test("round trips ordinary finite physical points without display quantization", () => { + const points = [ + gridPoint(-80, 0), + gridPoint(-41.234567, 2.345678), + gridPoint(0, 28), + gridPoint(52.654321, 71.123456), + gridPoint(80, 84), + ]; + for (const point of points) { + const roundTrip = marchingCoordinateToFieldPoint( + fieldPointToMarchingCoordinate(point), + ); + expect(roundTrip.xMeters).toBeCloseTo(point.xMeters, 10); + expect(roundTrip.yMeters).toBeCloseTo(point.yMeters, 10); + } + }); + + test("keeps exact physical NFHS hash geometry separate from the marching grid", () => { + const frontHash = gridPoint(0, 28); + expect(frontHash.xMeters).toBeCloseTo(0); + expect(frontHash.yMeters).toBeCloseTo(field.frontHashLine.coordinateMeters); + expect(field.frontHashLine.coordinateMeters).not.toBeCloseTo( + standardStepsToMeters(28), + 4, + ); + }); + + test("rejects non-finite points", () => { + expect(() => + fieldPointToMarchingCoordinate({ xMeters: Number.NaN, yMeters: 0 }), + ).toThrow("xMeters"); + expect(() => + marchingCoordinateToFieldPoint({ + side: { + side: 1, + yardLine: 35, + offsetSteps: -1, + relation: "inside", + }, + frontBack: { + reference: "front-sideline", + offsetSteps: 0, + relation: "on", + }, + }), + ).toThrow("non-negative"); + }); + + test("rejects contradictory structured coordinates", () => { + expect(() => + marchingCoordinateToFieldPoint({ + side: { + side: 1, + yardLine: 50, + offsetSteps: 1, + relation: "inside", + }, + frontBack: { + reference: "front-sideline", + offsetSteps: 0, + relation: "on", + }, + }), + ).toThrow("50-yard line"); + }); +}); diff --git a/packages/mobile/src/field/__tests__/template.test.ts b/packages/mobile/src/field/__tests__/template.test.ts new file mode 100644 index 00000000..c25d83d8 --- /dev/null +++ b/packages/mobile/src/field/__tests__/template.test.ts @@ -0,0 +1,116 @@ +import { + createStandardFootballFieldTemplate, + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + getStandardFieldDimensionsInFeet, + getStandardFieldDimensionsInYards, +} from "../index"; + +describe("standard high-school field template", () => { + test("contains canonical field dimensions and references", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + expect(field.goalToGoalYards).toBe(100); + expect(field.widthYards).toBeCloseTo(53 + 1 / 3); + expect(field.goalToGoalMeters).toBeCloseTo(91.44); + expect(field.widthMeters).toBeCloseTo(48.768); + expect(field.dimensions.highSchoolHashFromSidelineFeet).toBeCloseTo( + 53 + 4 / 12, + ); + expect(field.bounds.minXMeters).toBeCloseTo(-45.72); + expect(field.bounds.maxXMeters).toBeCloseTo(45.72); + expect(field.frontHashLine.coordinateMeters).toBeCloseTo(16.256); + expect(field.backHashLine.coordinateMeters).toBeCloseTo(32.512); + }); + + test("contains goal lines, sidelines, hashes, and every interior five-yard line", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + expect(field.goalLines.map((line) => line.name)).toEqual([ + "Side 1 Goal Line", + "Side 2 Goal Line", + ]); + expect(field.sidelines.map((line) => line.name)).toEqual([ + "Front Sideline", + "Back Sideline", + ]); + expect(field.hashLines.map((line) => line.name)).toEqual([ + "HS FH", + "HS BH", + ]); + expect(field.fiveYardLines.map((line) => line.yardLineYards)).toEqual( + Array.from({ length: 19 }, (_, index) => -45 + index * 5), + ); + expect(field.fiveYardLines[0].start.xMeters).toBeCloseTo(-41.148); + expect(field.fiveYardLines[18].start.xMeters).toBeCloseTo(41.148); + }); + + test("includes both number rows from goal line zero through the 50", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + expect(field.yardNumbers).toHaveLength(22); + expect( + field.yardNumbers.filter((number) => number.label === "0"), + ).toHaveLength(4); + expect( + field.yardNumbers.filter((number) => number.label === "50"), + ).toHaveLength(2); + expect( + field.yardNumbers + .filter((number) => number.side === "front") + .map((number) => number.label), + ).toEqual(["0", "10", "20", "30", "40", "50", "40", "30", "20", "10", "0"]); + expect( + field.yardNumbers.every( + (number) => + number.xMeters === + field.allFiveYardLines.find( + (line) => line.coordinateMeters === number.xMeters, + )?.coordinateMeters, + ), + ).toBe(true); + expect(field.yardNumbers.every((number) => number.widthMeters > 0)).toBe( + true, + ); + expect(field.yardNumbers.every((number) => number.heightMeters > 0)).toBe( + true, + ); + }); + + test.each([ + ["football-nfhs", 24], + ["football-ncaa", 24], + ["football-texas-uil", 24], + ["football-nfl", 39], + ] as const)("%s uses schema-defined number centers", (preset, centerFeet) => { + const field = createStandardFootballFieldTemplate(preset); + const front = field.yardNumbers.find((number) => number.side === "front")!; + const back = field.yardNumbers.find((number) => number.side === "back")!; + + expect(field.dimensions.yardNumberHeightFeet).toBeCloseTo(6); + expect(field.dimensions.yardNumberCenterFromFrontSidelineFeet).toBeCloseTo( + centerFeet, + ); + expect(field.dimensions.yardNumberCenterFromBackSidelineFeet).toBeCloseTo( + centerFeet, + ); + expect(front.yMeters - field.bounds.minYMeters).toBeCloseTo( + field.fieldDefinition.markings.yardNumbers.centerFromFrontSidelineMeters, + ); + expect(field.bounds.maxYMeters - back.yMeters).toBeCloseTo( + field.fieldDefinition.markings.yardNumbers.centerFromBackSidelineMeters, + ); + }); + + test("is deeply immutable", () => { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE as any; + expect(Object.isFrozen(field)).toBe(true); + expect(Object.isFrozen(field.goalLines)).toBe(true); + expect(Object.isFrozen(field.goalLines[0])).toBe(true); + expect(Object.isFrozen(field.goalLines[0].start)).toBe(true); + expect(() => { + field.goalLines.push(field.goalLines[0]); + }).toThrow(); + }); + + test("offers display-unit dimension helpers", () => { + expect(getStandardFieldDimensionsInFeet().goalToGoalFeet).toBeCloseTo(300); + expect(getStandardFieldDimensionsInYards().widthYards).toBeCloseTo(160 / 3); + }); +}); diff --git a/packages/mobile/src/field/__tests__/units.test.ts b/packages/mobile/src/field/__tests__/units.test.ts new file mode 100644 index 00000000..2b6244d1 --- /dev/null +++ b/packages/mobile/src/field/__tests__/units.test.ts @@ -0,0 +1,43 @@ +import { + FEET_PER_YARD, + METERS_PER_FOOT, + METERS_PER_YARD, + STANDARD_STEP_METERS, + STANDARD_STEPS_PER_FIVE_YARDS, + feetToMeters, + metersToFeet, + metersToStandardSteps, + metersToYards, + standardStepsToMeters, + standardStepsToYards, + yardsToMeters, + yardsToStandardSteps, +} from "../index"; + +describe("field units", () => { + test("uses the exact SI and standard 8-to-5 constants", () => { + expect(METERS_PER_YARD).toBe(0.9144); + expect(METERS_PER_FOOT).toBe(0.3048); + expect(FEET_PER_YARD).toBe(3); + expect(STANDARD_STEP_METERS).toBe(0.5715); + expect(STANDARD_STEPS_PER_FIVE_YARDS).toBe(8); + }); + + test("converts yards, feet, and standard steps", () => { + expect(yardsToMeters(1)).toBe(0.9144); + expect(feetToMeters(1)).toBe(0.3048); + expect(standardStepsToMeters(8)).toBeCloseTo(yardsToMeters(5)); + expect(metersToYards(yardsToMeters(12.5))).toBeCloseTo(12.5); + expect(metersToFeet(feetToMeters(53 + 4 / 12))).toBeCloseTo(53 + 4 / 12); + expect(metersToStandardSteps(standardStepsToMeters(3.25))).toBeCloseTo( + 3.25, + ); + expect(yardsToStandardSteps(5)).toBeCloseTo(8); + expect(standardStepsToYards(8)).toBeCloseTo(5); + }); + + test("rejects non-finite unit values", () => { + expect(() => yardsToMeters(Number.NaN)).toThrow("Yards"); + expect(() => metersToFeet(Number.POSITIVE_INFINITY)).toThrow("Meters"); + }); +}); diff --git a/packages/mobile/src/field/__tests__/yard-number-layout.test.ts b/packages/mobile/src/field/__tests__/yard-number-layout.test.ts new file mode 100644 index 00000000..5e505217 --- /dev/null +++ b/packages/mobile/src/field/__tests__/yard-number-layout.test.ts @@ -0,0 +1,52 @@ +import { createYardNumberTextLayout } from "../render/yard-number-layout"; +import { feetToMeters } from "../units"; + +const bounds = { x: 0.1, y: -0.8, width: 1.4, height: 0.9 }; +const targetHeightMeters = feetToMeters(6); + +describe("yard-number text layout", () => { + test("centers measured glyph bounds at an exact six-foot visual height", () => { + const layout = createYardNumberTextLayout(bounds, targetHeightMeters); + const transformedCorners = [ + { + x: (layout.x + bounds.x) * layout.scaleX, + y: (layout.y + bounds.y) * layout.scaleY, + }, + { + x: (layout.x + bounds.x + bounds.width) * layout.scaleX, + y: (layout.y + bounds.y + bounds.height) * layout.scaleY, + }, + ]; + + expect((transformedCorners[0].x + transformedCorners[1].x) / 2).toBeCloseTo( + 0, + ); + expect((transformedCorners[0].y + transformedCorners[1].y) / 2).toBeCloseTo( + 0, + ); + expect(layout.visualHeightMeters).toBeCloseTo(targetHeightMeters); + expect( + Math.abs(transformedCorners[1].y - transformedCorners[0].y), + ).toBeCloseTo(targetHeightMeters); + }); + + test("counteracts the field Y reflection so every row is upright to the viewer", () => { + const layout = createYardNumberTextLayout(bounds, targetHeightMeters); + + expect(layout.scaleX).toBeGreaterThan(0); + expect(layout.scaleY).toBeLessThan(0); + + // FieldScene applies scaleY(-1). Combining that with this local Y + // reflection produces an ordinary positive-X/positive-Y screen transform. + expect({ x: layout.scaleX, y: -layout.scaleY }).toEqual({ + x: Math.abs(layout.scaleX), + y: Math.abs(layout.scaleY), + }); + }); + + test("rejects unusable visual bounds", () => { + expect(() => + createYardNumberTextLayout({ ...bounds, height: 0 }, targetHeightMeters), + ).toThrow(RangeError); + }); +}); diff --git a/packages/mobile/src/field/anchor-position.ts b/packages/mobile/src/field/anchor-position.ts new file mode 100644 index 00000000..48a4ec41 --- /dev/null +++ b/packages/mobile/src/field/anchor-position.ts @@ -0,0 +1,348 @@ +import type { FieldPresetId } from "@eight2five/drill-schema"; + +import { + marchingCoordinateToFieldPoint, + type MarchingCoordinate, +} from "./marching"; +import { createStandardFootballFieldTemplate } from "./template"; +import type { AnchorFieldPosition, FieldPoint } from "./types"; +import { + feetToMeters, + metersToFeet, + metersToYards, + yardsToMeters, +} from "./units"; + +export const ANCHOR_POSITION_UNITS = ["meters", "yards", "feet"] as const; +export type AnchorPositionUnit = (typeof ANCHOR_POSITION_UNITS)[number]; + +export const ANCHOR_POSITION_REFERENCES = [ + "center-field", + "center-front-sideline", + "center-back-sideline", + "side-1-front-corner", + "side-1-back-corner", + "side-2-front-corner", + "side-2-back-corner", + "side-1-goal-line-center", + "side-2-goal-line-center", + "front-hash-center", + "back-hash-center", +] as const; +export type AnchorPositionReference = + (typeof ANCHOR_POSITION_REFERENCES)[number]; + +export const ANCHOR_POSITION_REFERENCE_LABELS: Readonly< + Record +> = Object.freeze({ + "center-field": "Center of field", + "center-front-sideline": "Center of front sideline", + "center-back-sideline": "Center of back sideline", + "side-1-front-corner": "Side 1/front corner", + "side-1-back-corner": "Side 1/back corner", + "side-2-front-corner": "Side 2/front corner", + "side-2-back-corner": "Side 2/back corner", + "side-1-goal-line-center": "Side 1 goal-line center", + "side-2-goal-line-center": "Side 2 goal-line center", + "front-hash-center": "Front-hash center", + "back-hash-center": "Back-hash center", +}); + +export interface StandardAnchorPositionInput { + readonly reference: AnchorPositionReference; + readonly unit: AnchorPositionUnit; + /** Negative is toward Side 1; positive is toward Side 2. */ + readonly sideToSideOffset: number; + /** Negative is toward the front sideline; positive is toward the back. */ + readonly frontToBackOffset: number; + readonly height: number; +} + +export interface StandardAnchorPositionDraft { + readonly reference: AnchorPositionReference; + readonly unit: AnchorPositionUnit; + readonly sideToSideOffset: string; + readonly frontToBackOffset: string; + readonly height: string; +} + +export type AnchorPositionDraftField = + | "sideToSideOffset" + | "frontToBackOffset" + | "height" + | "position"; +export type AnchorPositionDraftErrors = Partial< + Record +>; + +export interface ParsedAnchorPositionDraft { + readonly errors: AnchorPositionDraftErrors; + readonly value?: AnchorFieldPosition; +} + +export const MAX_ANCHOR_HEIGHT_METERS = 100; + +const REFERENCE_POINTS_CACHE = new Map< + FieldPresetId, + Readonly> +>(); + +const point = (xMeters: number, yMeters: number): FieldPoint => + Object.freeze({ xMeters, yMeters }); + +function getAnchorPositionReferencePoints( + fieldPreset: FieldPresetId, +): Readonly> { + const cached = REFERENCE_POINTS_CACHE.get(fieldPreset); + if (cached) return cached; + + const template = createStandardFootballFieldTemplate(fieldPreset); + const bounds = template.bounds; + const centerXMeters = (bounds.minXMeters + bounds.maxXMeters) / 2; + const centerYMeters = (bounds.minYMeters + bounds.maxYMeters) / 2; + const references = Object.freeze({ + "center-field": point(centerXMeters, centerYMeters), + "center-front-sideline": point(centerXMeters, bounds.minYMeters), + "center-back-sideline": point(centerXMeters, bounds.maxYMeters), + "side-1-front-corner": point(bounds.minXMeters, bounds.minYMeters), + "side-1-back-corner": point(bounds.minXMeters, bounds.maxYMeters), + "side-2-front-corner": point(bounds.maxXMeters, bounds.minYMeters), + "side-2-back-corner": point(bounds.maxXMeters, bounds.maxYMeters), + "side-1-goal-line-center": point(bounds.minXMeters, centerYMeters), + "side-2-goal-line-center": point(bounds.maxXMeters, centerYMeters), + "front-hash-center": point( + centerXMeters, + template.frontHashLine.coordinateMeters, + ), + "back-hash-center": point( + centerXMeters, + template.backHashLine.coordinateMeters, + ), + } satisfies Record); + REFERENCE_POINTS_CACHE.set(fieldPreset, references); + return references; +} + +/** NFHS compatibility snapshot for callers that consume the constant directly. */ +export const ANCHOR_POSITION_REFERENCE_POINTS = + getAnchorPositionReferencePoints("football-nfhs"); + +export function getAnchorPositionReferencePoint( + reference: AnchorPositionReference, + fieldPreset: FieldPresetId = "football-nfhs", +): FieldPoint { + return getAnchorPositionReferencePoints(fieldPreset)[reference]; +} + +export function anchorPositionUnitsToMeters( + value: number, + unit: AnchorPositionUnit, +): number { + assertFinite(value, "Anchor position value"); + if (unit === "yards") return yardsToMeters(value); + if (unit === "feet") return feetToMeters(value); + return value; +} + +export function metersToAnchorPositionUnits( + value: number, + unit: AnchorPositionUnit, +): number { + assertFinite(value, "Anchor position value"); + if (unit === "yards") return metersToYards(value); + if (unit === "feet") return metersToFeet(value); + return value; +} + +export function convertAnchorPositionUnits( + value: number, + from: AnchorPositionUnit, + to: AnchorPositionUnit, +): number { + return metersToAnchorPositionUnits( + anchorPositionUnitsToMeters(value, from), + to, + ); +} + +export function anchorFieldPositionFromStandard( + input: StandardAnchorPositionInput, + fieldPreset: FieldPresetId = "football-nfhs", +): AnchorFieldPosition { + const reference = getAnchorPositionReferencePoint( + input.reference, + fieldPreset, + ); + const position = { + xMeters: + reference.xMeters + + anchorPositionUnitsToMeters(input.sideToSideOffset, input.unit), + yMeters: + reference.yMeters + + anchorPositionUnitsToMeters(input.frontToBackOffset, input.unit), + zMeters: anchorPositionUnitsToMeters(input.height, input.unit), + }; + assertValidAnchorFieldPosition(position, fieldPreset); + return position; +} + +export function anchorFieldPositionToStandard( + position: AnchorFieldPosition, + reference: AnchorPositionReference, + unit: AnchorPositionUnit, + fieldPreset: FieldPresetId = "football-nfhs", +): StandardAnchorPositionInput { + assertValidAnchorFieldPosition(position, fieldPreset); + const origin = getAnchorPositionReferencePoint(reference, fieldPreset); + return { + reference, + unit, + sideToSideOffset: metersToAnchorPositionUnits( + position.xMeters - origin.xMeters, + unit, + ), + frontToBackOffset: metersToAnchorPositionUnits( + position.yMeters - origin.yMeters, + unit, + ), + height: metersToAnchorPositionUnits(position.zMeters, unit), + }; +} + +export function anchorFieldPositionFromMarchingCoordinate( + coordinate: MarchingCoordinate, + heightMeters: number, + fieldPreset: FieldPresetId = "football-nfhs", +): AnchorFieldPosition { + const position = { + ...marchingCoordinateToFieldPoint(coordinate, fieldPreset), + zMeters: heightMeters, + }; + assertValidAnchorFieldPosition(position, fieldPreset); + return position; +} + +export function parseAnchorPositionDraft( + draft: StandardAnchorPositionDraft, + fieldPreset: FieldPresetId = "football-nfhs", +): ParsedAnchorPositionDraft { + const errors: AnchorPositionDraftErrors = {}; + const sideToSideOffset = parseFiniteDraftNumber( + draft.sideToSideOffset, + "Enter a finite side-to-side offset.", + errors, + "sideToSideOffset", + ); + const frontToBackOffset = parseFiniteDraftNumber( + draft.frontToBackOffset, + "Enter a finite front-to-back offset.", + errors, + "frontToBackOffset", + ); + const height = parseFiniteDraftNumber( + draft.height, + "Enter a finite height.", + errors, + "height", + ); + if (height !== undefined && height < 0) { + errors.height = "Height cannot be negative."; + } + if ( + sideToSideOffset === undefined || + frontToBackOffset === undefined || + height === undefined || + Object.keys(errors).length > 0 + ) { + return { errors }; + } + try { + return { + errors, + value: anchorFieldPositionFromStandard( + { + reference: draft.reference, + unit: draft.unit, + sideToSideOffset, + frontToBackOffset, + height, + }, + fieldPreset, + ), + }; + } catch (cause) { + return { + errors: { + ...errors, + position: cause instanceof Error ? cause.message : String(cause), + }, + }; + } +} + +export function validateAnchorFieldPosition( + position: unknown, + fieldPreset: FieldPresetId = "football-nfhs", +): AnchorPositionDraftErrors { + if (!position || typeof position !== "object") { + return { position: "Anchor field position is required." }; + } + const value = position as Partial; + if ( + !Number.isFinite(value.xMeters) || + !Number.isFinite(value.yMeters) || + !Number.isFinite(value.zMeters) + ) { + return { position: "Anchor coordinates must be finite." }; + } + const bounds = createStandardFootballFieldTemplate(fieldPreset).bounds; + if ( + value.xMeters! < bounds.minXMeters || + value.xMeters! > bounds.maxXMeters || + value.yMeters! < bounds.minYMeters || + value.yMeters! > bounds.maxYMeters + ) { + return { + position: "Anchor coordinates must be within the standard field bounds.", + }; + } + if (value.zMeters! < 0) { + return { position: "Anchor height cannot be negative." }; + } + if (value.zMeters! > MAX_ANCHOR_HEIGHT_METERS) { + return { + position: `Anchor height must be at most ${MAX_ANCHOR_HEIGHT_METERS} meters.`, + }; + } + return {}; +} + +export function assertValidAnchorFieldPosition( + position: unknown, + fieldPreset: FieldPresetId = "football-nfhs", +): asserts position is AnchorFieldPosition { + const message = validateAnchorFieldPosition(position, fieldPreset).position; + if (message) throw new RangeError(message); +} + +function parseFiniteDraftNumber( + input: string, + message: string, + errors: AnchorPositionDraftErrors, + field: AnchorPositionDraftField, +): number | undefined { + if (!input.trim()) { + errors[field] = message; + return undefined; + } + const value = Number(input); + if (!Number.isFinite(value)) { + errors[field] = message; + return undefined; + } + return value; +} + +function assertFinite(value: number, label: string): void { + if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`); +} diff --git a/packages/mobile/src/field/camera/field-camera-math.ts b/packages/mobile/src/field/camera/field-camera-math.ts new file mode 100644 index 00000000..428f6e76 --- /dev/null +++ b/packages/mobile/src/field/camera/field-camera-math.ts @@ -0,0 +1,182 @@ +import type { FieldPoint } from "../types"; +import type { + FieldCamera, + FieldCameraBounds, + FieldCameraPerspective, + FieldPanBaseline, + FieldViewport, + FieldViewportSize, +} from "./field-camera-types"; + +export function setFieldCamera( + camera: FieldCamera, + viewport: FieldViewport, +): void { + "worklet"; + camera.centerXMeters.value = viewport.centerXMeters; + camera.centerYMeters.value = viewport.centerYMeters; + camera.metersPerPixel.value = viewport.metersPerPixel; +} + +export function fieldWorldToScreen( + point: FieldPoint, + viewport: FieldViewport, + size: FieldViewportSize, + perspective: FieldCameraPerspective = "director", +): { x: number; y: number } { + "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; + return { + x: + size.width / 2 + + ((point.xMeters - viewport.centerXMeters) / viewport.metersPerPixel) * + xSign, + y: + size.height / 2 + + ((point.yMeters - viewport.centerYMeters) / viewport.metersPerPixel) * + ySign, + }; +} + +export function fieldScreenToWorld( + point: { readonly x: number; readonly y: number }, + viewport: FieldViewport, + size: FieldViewportSize, + perspective: FieldCameraPerspective = "director", +): FieldPoint { + "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; + return { + xMeters: + viewport.centerXMeters + + (point.x - size.width / 2) * viewport.metersPerPixel * xSign, + yMeters: + viewport.centerYMeters + + (point.y - size.height / 2) * viewport.metersPerPixel * ySign, + }; +} + +export function createFieldPanBaseline( + center: FieldPoint, + translationX: number, + translationY: number, + metersPerPixel: number, +): FieldPanBaseline { + "worklet"; + return { center, translationX, translationY, metersPerPixel }; +} + +/** Uses translation deltas from a baseline so a 1→2→1 pointer change can rebase. */ +export function fieldPanCenter( + baseline: FieldPanBaseline, + translationX: number, + translationY: number, + perspective: FieldCameraPerspective = "director", +): FieldPoint { + "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; + return { + xMeters: + baseline.center.xMeters - + (translationX - baseline.translationX) * baseline.metersPerPixel * xSign, + yMeters: + baseline.center.yMeters - + (translationY - baseline.translationY) * baseline.metersPerPixel * ySign, + }; +} + +export function fieldCenterForStationaryWorldPoint( + worldPoint: FieldPoint, + screenPoint: { readonly x: number; readonly y: number }, + size: FieldViewportSize, + metersPerPixel: number, + perspective: FieldCameraPerspective = "director", +): FieldPoint { + "worklet"; + const xSign = perspective === "performer" ? -1 : 1; + const ySign = perspective === "performer" ? 1 : -1; + return { + xMeters: + worldPoint.xMeters - + (screenPoint.x - size.width / 2) * metersPerPixel * xSign, + yMeters: + worldPoint.yMeters - + (screenPoint.y - size.height / 2) * metersPerPixel * ySign, + }; +} + +export function clampFieldCameraAxis( + center: number, + minimum: number, + maximum: number, + _halfVisibleSpan = 0, +): number { + "worklet"; + // Clamp only the camera center. A zoomed-out viewport is intentionally + // allowed to extend beyond the camera bounds; otherwise a large viewport + // collapses the valid pan range to a single centered point. + return Math.min(maximum, Math.max(minimum, center)); +} + +export function clampFieldViewport( + viewport: FieldViewport, + size: FieldViewportSize, + bounds: FieldCameraBounds, +): FieldViewport { + "worklet"; + const halfWidth = (size.width * viewport.metersPerPixel) / 2; + const halfHeight = (size.height * viewport.metersPerPixel) / 2; + return { + ...viewport, + centerXMeters: clampFieldCameraAxis( + viewport.centerXMeters, + bounds.minXMeters, + bounds.maxXMeters, + halfWidth, + ), + centerYMeters: clampFieldCameraAxis( + viewport.centerYMeters, + bounds.minYMeters, + bounds.maxYMeters, + halfHeight, + ), + }; +} + +export interface FieldCameraTransform { + readonly scaleX: number; + readonly scaleY: number; + readonly translateX: number; + readonly translateY: number; +} + +export function fieldCameraTransform( + viewport: FieldViewport, + size: FieldViewportSize, + perspective: FieldCameraPerspective = "director", +): FieldCameraTransform { + "worklet"; + const scale = 1 / viewport.metersPerPixel; + const scaleX = scale * (perspective === "performer" ? -1 : 1); + const scaleY = scale * (perspective === "performer" ? 1 : -1); + return { + scaleX, + scaleY, + translateX: size.width / 2 - viewport.centerXMeters * scaleX, + translateY: size.height / 2 - viewport.centerYMeters * scaleY, + }; +} + +export function applyFieldCameraTransform( + point: FieldPoint, + transform: FieldCameraTransform, +): { x: number; y: number } { + "worklet"; + return { + x: point.xMeters * transform.scaleX + transform.translateX, + y: point.yMeters * transform.scaleY + transform.translateY, + }; +} diff --git a/packages/mobile/src/field/camera/field-camera-policy.ts b/packages/mobile/src/field/camera/field-camera-policy.ts new file mode 100644 index 00000000..b321669f --- /dev/null +++ b/packages/mobile/src/field/camera/field-camera-policy.ts @@ -0,0 +1,82 @@ +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + type StandardFootballFieldTemplate, +} from "../template"; +import { yardsToMeters } from "../units"; +import type { + FieldCameraBounds, + FieldViewport, + FieldViewportSize, +} from "./field-camera-types"; + +export const FIELD_GRID_PERIMETER_YARDS = 10; +export const FIELD_CAMERA_BLANK_MARGIN_YARDS = 20; +export const FIELD_CAMERA_TOTAL_EXTERIOR_ALLOWANCE_YARDS = + FIELD_GRID_PERIMETER_YARDS + FIELD_CAMERA_BLANK_MARGIN_YARDS; +export const FIELD_MIN_METERS_PER_PIXEL = 0.02; +export const FIELD_ZOOM_OUT_BREATHING_ROOM = 1.2; +export const FIELD_INITIAL_BREATHING_ROOM = 1.06; + +export function getFieldGridBounds( + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldCameraBounds { + const padding = yardsToMeters(FIELD_GRID_PERIMETER_YARDS); + return { + minXMeters: template.bounds.minXMeters - padding, + maxXMeters: template.bounds.maxXMeters + padding, + minYMeters: template.bounds.minYMeters - padding, + maxYMeters: template.bounds.maxYMeters + padding, + }; +} + +export function getFieldCameraBounds( + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldCameraBounds { + const gridBounds = getFieldGridBounds(template); + const margin = yardsToMeters(FIELD_CAMERA_BLANK_MARGIN_YARDS); + return { + minXMeters: gridBounds.minXMeters - margin, + maxXMeters: gridBounds.maxXMeters + margin, + minYMeters: gridBounds.minYMeters - margin, + maxYMeters: gridBounds.maxYMeters + margin, + }; +} + +export function fitFieldBoundsMetersPerPixel( + bounds: FieldCameraBounds, + size: FieldViewportSize, +): number { + "worklet"; + if (size.width <= 0 || size.height <= 0) return FIELD_MIN_METERS_PER_PIXEL; + return Math.max( + (bounds.maxXMeters - bounds.minXMeters) / size.width, + (bounds.maxYMeters - bounds.minYMeters) / size.height, + ); +} + +export function getFieldMaximumMetersPerPixel( + size: FieldViewportSize, + gridBounds: FieldCameraBounds, +): number { + "worklet"; + return Math.max( + FIELD_MIN_METERS_PER_PIXEL, + fitFieldBoundsMetersPerPixel(gridBounds, size) * + FIELD_ZOOM_OUT_BREATHING_ROOM, + ); +} + +export function getInitialFieldViewport( + size: FieldViewportSize, + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldViewport { + const bounds = getFieldGridBounds(template); + return { + centerXMeters: (bounds.minXMeters + bounds.maxXMeters) / 2, + centerYMeters: (bounds.minYMeters + bounds.maxYMeters) / 2, + metersPerPixel: Math.max( + FIELD_MIN_METERS_PER_PIXEL, + fitFieldBoundsMetersPerPixel(bounds, size) * FIELD_INITIAL_BREATHING_ROOM, + ), + }; +} diff --git a/packages/mobile/src/field/camera/field-camera-types.ts b/packages/mobile/src/field/camera/field-camera-types.ts new file mode 100644 index 00000000..9408048d --- /dev/null +++ b/packages/mobile/src/field/camera/field-camera-types.ts @@ -0,0 +1,37 @@ +import type { SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "../types"; + +export type FieldCameraPerspective = "director" | "performer"; + +export interface FieldViewportSize { + readonly width: number; + readonly height: number; +} + +export interface FieldViewport { + readonly centerXMeters: number; + readonly centerYMeters: number; + readonly metersPerPixel: number; +} + +export interface FieldCameraBounds { + readonly minXMeters: number; + readonly maxXMeters: number; + readonly minYMeters: number; + readonly maxYMeters: number; +} + +/** UI-thread camera values. Camera motion must not require a React render. */ +export interface FieldCamera { + readonly centerXMeters: SharedValue; + readonly centerYMeters: SharedValue; + readonly metersPerPixel: SharedValue; +} + +export interface FieldPanBaseline { + readonly center: FieldPoint; + readonly translationX: number; + readonly translationY: number; + readonly metersPerPixel: number; +} diff --git a/packages/mobile/src/field/camera/use-field-gestures.ts b/packages/mobile/src/field/camera/use-field-gestures.ts new file mode 100644 index 00000000..bfe7e6f9 --- /dev/null +++ b/packages/mobile/src/field/camera/use-field-gestures.ts @@ -0,0 +1,235 @@ +import React from "react"; +import { Gesture } from "react-native-gesture-handler"; +import { + useDerivedValue, + useSharedValue, + type SharedValue, +} from "react-native-reanimated"; +import { scheduleOnRN } from "react-native-worklets"; + +import type { FieldPoint } from "../types"; +import { + clampFieldCameraAxis, + createFieldPanBaseline, + fieldCenterForStationaryWorldPoint, + fieldPanCenter, + fieldScreenToWorld, + setFieldCamera, +} from "./field-camera-math"; +import { + FIELD_MIN_METERS_PER_PIXEL, + getFieldMaximumMetersPerPixel, +} from "./field-camera-policy"; +import type { + FieldCamera, + FieldCameraBounds, + FieldCameraPerspective, + FieldPanBaseline, + FieldViewport, + FieldViewportSize, +} from "./field-camera-types"; + +interface UseFieldGesturesOptions { + readonly camera: FieldCamera; + readonly canvasSize: SharedValue; + readonly cameraBounds: FieldCameraBounds; + readonly gridBounds: FieldCameraBounds; + readonly perspective?: FieldCameraPerspective; + readonly onViewportChange?: (viewport: FieldViewport) => void; + readonly testID?: string; +} + +function setSharedValue(sharedValue: SharedValue, value: T): void { + "worklet"; + sharedValue.value = value; +} + +export function useFieldGestures({ + camera, + canvasSize, + cameraBounds, + gridBounds, + perspective = "director", + onViewportChange, + testID = "field", +}: UseFieldGesturesOptions) { + const panActive = useSharedValue(false); + const pinchActive = useSharedValue(false); + const panNeedsRebase = useSharedValue(true); + const panBaseline = useSharedValue( + createFieldPanBaseline({ xMeters: 0, yMeters: 0 }, 0, 0, 1), + ); + const pinchInitialized = useSharedValue(false); + const pinchStartScale = useSharedValue(1); + const pinchWorldPoint = useSharedValue({ + xMeters: 0, + yMeters: 0, + }); + const interactionActive = useDerivedValue( + () => panActive.value || pinchActive.value, + ); + + const commitViewport = React.useCallback( + (centerXMeters: number, centerYMeters: number, metersPerPixel: number) => { + onViewportChange?.({ centerXMeters, centerYMeters, metersPerPixel }); + }, + [onViewportChange], + ); + + const scheduleCommit = () => { + "worklet"; + scheduleOnRN( + commitViewport, + camera.centerXMeters.value, + camera.centerYMeters.value, + camera.metersPerPixel.value, + ); + }; + + const pan = Gesture.Pan() + .withTestId(`${testID}-pan-gesture`) + .minDistance(2) + .onStart((event) => { + setSharedValue(panActive, true); + setSharedValue(panNeedsRebase, false); + setSharedValue( + panBaseline, + createFieldPanBaseline( + { + xMeters: camera.centerXMeters.value, + yMeters: camera.centerYMeters.value, + }, + event.translationX, + event.translationY, + camera.metersPerPixel.value, + ), + ); + }) + .onUpdate((event) => { + if (event.numberOfPointers !== 1 || pinchActive.value) { + setSharedValue(panNeedsRebase, true); + return; + } + if (panNeedsRebase.value) { + setSharedValue( + panBaseline, + createFieldPanBaseline( + { + xMeters: camera.centerXMeters.value, + yMeters: camera.centerYMeters.value, + }, + event.translationX, + event.translationY, + camera.metersPerPixel.value, + ), + ); + setSharedValue(panNeedsRebase, false); + return; + } + const next = fieldPanCenter( + panBaseline.value, + event.translationX, + event.translationY, + perspective, + ); + const halfWidth = + (canvasSize.value.width * camera.metersPerPixel.value) / 2; + const halfHeight = + (canvasSize.value.height * camera.metersPerPixel.value) / 2; + setFieldCamera(camera, { + centerXMeters: clampFieldCameraAxis( + next.xMeters, + cameraBounds.minXMeters, + cameraBounds.maxXMeters, + halfWidth, + ), + centerYMeters: clampFieldCameraAxis( + next.yMeters, + cameraBounds.minYMeters, + cameraBounds.maxYMeters, + halfHeight, + ), + metersPerPixel: camera.metersPerPixel.value, + }); + }) + .onEnd(() => { + if (!pinchActive.value) scheduleCommit(); + }) + .onFinalize(() => { + setSharedValue(panActive, false); + setSharedValue(panNeedsRebase, true); + }); + + const pinch = Gesture.Pinch() + .withTestId(`${testID}-pinch-gesture`) + .onStart(() => { + setSharedValue(pinchActive, true); + setSharedValue(pinchInitialized, false); + setSharedValue(panNeedsRebase, true); + }) + .onUpdate((event) => { + if (event.numberOfPointers < 2) return; + const safeScale = Math.max(event.scale, 0.000001); + if (!pinchInitialized.value) { + setSharedValue( + pinchStartScale, + camera.metersPerPixel.value * safeScale, + ); + setSharedValue( + pinchWorldPoint, + fieldScreenToWorld( + { x: event.focalX, y: event.focalY }, + { + centerXMeters: camera.centerXMeters.value, + centerYMeters: camera.centerYMeters.value, + metersPerPixel: camera.metersPerPixel.value, + }, + canvasSize.value, + perspective, + ), + ); + setSharedValue(pinchInitialized, true); + } + const maximumMetersPerPixel = getFieldMaximumMetersPerPixel( + canvasSize.value, + gridBounds, + ); + const nextScale = Math.min( + maximumMetersPerPixel, + Math.max(FIELD_MIN_METERS_PER_PIXEL, pinchStartScale.value / safeScale), + ); + const next = fieldCenterForStationaryWorldPoint( + pinchWorldPoint.value, + { x: event.focalX, y: event.focalY }, + canvasSize.value, + nextScale, + perspective, + ); + setFieldCamera(camera, { + centerXMeters: clampFieldCameraAxis( + next.xMeters, + cameraBounds.minXMeters, + cameraBounds.maxXMeters, + (canvasSize.value.width * nextScale) / 2, + ), + centerYMeters: clampFieldCameraAxis( + next.yMeters, + cameraBounds.minYMeters, + cameraBounds.maxYMeters, + (canvasSize.value.height * nextScale) / 2, + ), + metersPerPixel: nextScale, + }); + }) + .onEnd(scheduleCommit) + .onFinalize(() => { + setSharedValue(pinchInitialized, false); + setSharedValue(pinchActive, false); + setSharedValue(panNeedsRebase, true); + }); + + return { + gesture: Gesture.Simultaneous(pan, pinch), + interactionActive, + } as const; +} diff --git a/packages/mobile/src/field/guidance.ts b/packages/mobile/src/field/guidance.ts new file mode 100644 index 00000000..e4efe625 --- /dev/null +++ b/packages/mobile/src/field/guidance.ts @@ -0,0 +1,63 @@ +import { assertFiniteFieldPoint, type FieldPoint } from "./types"; +import { + fieldPointToDrillGridPoint, + formatMarchingSteps, + type MarchingFieldInput, +} from "./marching"; + +export interface FieldGuidance { + /** Straight-line distance in the active field's marching-grid coordinates. */ + readonly distanceSteps: number; + /** Signed target-minus-current displacement along canonical grid X/Y axes. */ + readonly xDisplacementSteps: number; + readonly yDisplacementSteps: number; + readonly xLabel: string; + readonly yLabel: string; +} + +function formatGuidanceAxis( + steps: number, + negativeDirection: string, + positiveDirection: string, +): string { + if (Math.abs(steps) < 1e-9) return "0 steps"; + const direction = steps < 0 ? negativeDirection : positiveDirection; + const magnitude = formatMarchingSteps(Math.abs(steps)); + return Number(magnitude) === 1 + ? `one step toward ${direction}` + : `${magnitude} steps toward ${direction}`; +} + +/** + * Produces field-relative guidance in the active marching coordinate system. + * Physical meters are projected through the field definition first so an NFHS + * sideline-to-sideline move is exactly 84 grid steps rather than 85 1/3 literal + * 22.5-inch intervals. + */ +export function calculateFieldGuidance( + current: FieldPoint, + target: FieldPoint, + field?: MarchingFieldInput, +): FieldGuidance { + assertFiniteFieldPoint(current, "Current point"); + assertFiniteFieldPoint(target, "Target point"); + const currentGrid = fieldPointToDrillGridPoint(current, field); + const targetGrid = fieldPointToDrillGridPoint(target, field); + const xSteps = targetGrid.xSteps - currentGrid.xSteps; + const ySteps = targetGrid.ySteps - currentGrid.ySteps; + return Object.freeze({ + distanceSteps: Math.hypot(xSteps, ySteps), + xDisplacementSteps: xSteps, + yDisplacementSteps: ySteps, + xLabel: formatGuidanceAxis(xSteps, "Side 1", "Side 2"), + yLabel: formatGuidanceAxis( + ySteps, + "the front sideline", + "the back sideline", + ), + }); +} + +export const getFieldGuidance = calculateFieldGuidance; +export const calculateGuidance = calculateFieldGuidance; +export const getMovementGuidance = calculateFieldGuidance; diff --git a/packages/mobile/src/field/index.ts b/packages/mobile/src/field/index.ts new file mode 100644 index 00000000..eeb60a96 --- /dev/null +++ b/packages/mobile/src/field/index.ts @@ -0,0 +1,13 @@ +export * from "./types"; +export * from "./units"; +export * from "./template"; +export * from "./marching"; +export * from "./anchor-position"; +export * from "./guidance"; +export * from "./live-position"; +export * from "./camera/field-camera-types"; +export * from "./camera/field-camera-math"; +export * from "./camera/field-camera-policy"; +export * from "./render/create-field-paths"; +export * from "./render/field-render-tokens"; +export * from "./render/field-overlay-types"; diff --git a/packages/mobile/src/field/live-position.ts b/packages/mobile/src/field/live-position.ts new file mode 100644 index 00000000..e0605b4f --- /dev/null +++ b/packages/mobile/src/field/live-position.ts @@ -0,0 +1,64 @@ +import type { SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "./types"; + +export type FieldConnectionState = + | "idle" + | "connecting" + | "connected" + | "reconnecting" + | "disconnected" + | "error"; + +/** Describes which source produced the current field position. */ +export type FieldLivePositionSource = + | "uwb" + | "motion-predicted" + | "stationary-hold" + | "prediction-expired"; + +/** + * The high-rate output of the live-position fusion boundary. + * + * UWB is always the authority for `lastUwbPosition`. Motion may only produce + * a short prediction along the velocity learned from accepted UWB samples. + */ +export interface FusedPositionOutput { + readonly position: FieldPoint; + readonly source: FieldLivePositionSource; + readonly fusedAt: number; + readonly freshnessMs: number; + readonly lastUwbAt: number; + readonly lastUwbPosition: FieldPoint; + readonly interpolationActive: boolean; +} + +export interface FieldLivePositionState { + readonly connectionState: FieldConnectionState; + readonly position?: FieldPoint; + readonly receivedAt?: number; + readonly isStale: boolean; + readonly source?: FieldLivePositionSource; + readonly freshnessMs?: number; + readonly lastUwbAt?: number; + readonly lastUwbPosition?: FieldPoint; + readonly interpolationActive?: boolean; + readonly errorMessage?: string; +} + +/** + * Thread 4 can update the shared values on the streaming cadence while + * replacing low-rate state only for connection, stale, and HUD changes. + */ +export interface FieldLivePositionInput { + readonly state: FieldLivePositionState; + readonly positionValue?: SharedValue; + readonly fusionValue?: SharedValue; +} + +export const EMPTY_FIELD_LIVE_POSITION_STATE: FieldLivePositionState = + Object.freeze({ + connectionState: "idle", + isStale: false, + interpolationActive: false, + }); diff --git a/packages/mobile/src/field/marching.ts b/packages/mobile/src/field/marching.ts new file mode 100644 index 00000000..3063b00c --- /dev/null +++ b/packages/mobile/src/field/marching.ts @@ -0,0 +1,557 @@ +import { + drillGridToPhysicalPoint, + getFieldPreset, + getGridReference, + physicalPointToDrillGrid, + resolveFieldDefinition, + type DrillGridPoint, + type FieldDefinition, + type FieldPresetId, + type ResolvedFieldDefinition, +} from "@eight2five/drill-schema"; + +import { + assertFiniteFieldPoint, + type FieldLateralReference, + type FieldPoint, +} from "./types"; +import type { StandardFootballFieldTemplate } from "./template"; + +const EPSILON = 1e-9; +const NFHS_FIELD = getFieldPreset("football-nfhs"); +export const DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS = 0.25; + +export type MarchingFieldInput = + | FieldPresetId + | FieldDefinition + | ResolvedFieldDefinition + | StandardFootballFieldTemplate; + +function resolveMarchingField( + field: MarchingFieldInput = NFHS_FIELD, +): ResolvedFieldDefinition { + if (typeof field === "string") return getFieldPreset(field); + if ("fieldDefinition" in field) return field.fieldDefinition; + if ("type" in field) return resolveFieldDefinition(field); + return field; +} + +export type MarchingSideReference = 1 | 2 | "center"; +export type MarchingSideRelation = "on" | "inside" | "outside"; +export type MarchingFrontBackRelation = "on" | "in-front-of" | "behind"; + +export interface MarchingSideCoordinate { + /** Side 1/2 is a goal-line end; center is the 50-yard reference. */ + readonly side: MarchingSideReference; + /** Side-relative yard line, from 0 through 50. */ + readonly yardLine: number; + /** Non-negative distance from the selected yard-line reference. */ + readonly offsetSteps: number; + readonly relation: MarchingSideRelation; +} + +export interface MarchingFrontBackCoordinate { + readonly reference: FieldLateralReference; + /** Non-negative distance from the selected lateral reference. */ + readonly offsetSteps: number; + readonly relation: MarchingFrontBackRelation; +} + +export interface MarchingCoordinate { + readonly side: MarchingSideCoordinate; + readonly frontBack: MarchingFrontBackCoordinate; + /** Set by conversion when the source point lies outside either boundary. */ + readonly outOfBounds?: readonly ("goal-to-goal" | "front-back")[]; +} + +function assertFinite(value: number, name: string): void { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be a finite number.`); + } +} + +/** + * Round marching-coordinate display values without mutating canonical drill + * coordinates. The app preference controls the increment; quarter-step + * rounding remains the default for callers that do not provide one. + */ +export function formatMarchingSteps( + steps: number, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + assertFinite(steps, "Steps"); + assertFinite(roundingSteps, "Coordinate rounding"); + if (roundingSteps <= 0) { + throw new RangeError("Coordinate rounding must be greater than zero."); + } + const rounded = Math.round(steps / roundingSteps) * roundingSteps; + const cleaned = Math.abs(rounded) < EPSILON ? 0 : rounded; + return Number(cleaned.toFixed(3)).toString(); +} + +function stepWord( + steps: number, + uppercase = true, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + const value = formatMarchingSteps(steps, roundingSteps); + if (Math.abs(Number(value)) === 1) return uppercase ? "One Step" : "one step"; + return uppercase ? `${value} Steps` : `${value} steps`; +} + +function yardLineText(yardLine: number): string { + return yardLine === 0 ? "Goal Line" : `${yardLine} yd ln`; +} + +interface XReference { + readonly xSteps: number; + readonly side: MarchingSideReference; + readonly yardLine: number; +} + +function xReferences(): readonly XReference[] { + return Array.from({ length: 21 }, (_, index) => { + const xSteps = -80 + index * 8; + if (xSteps < 0) { + return { + xSteps, + side: 1, + yardLine: 50 - (Math.abs(xSteps) / 8) * 5, + }; + } + if (xSteps > 0) { + return { + xSteps, + side: 2, + yardLine: 50 - (Math.abs(xSteps) / 8) * 5, + }; + } + return { xSteps: 0, side: "center", yardLine: 50 }; + }); +} + +const X_REFERENCES = xReferences(); + +interface LateralReference { + readonly reference: FieldLateralReference; + readonly ySteps: number; +} + +const LATERAL_REFERENCE_IDS: readonly FieldLateralReference[] = Object.freeze([ + "front-sideline", + "front-hash", + "back-hash", + "back-sideline", +]); + +function lateralReferences( + field: ResolvedFieldDefinition, +): readonly LateralReference[] { + return LATERAL_REFERENCE_IDS.map((reference) => { + const line = getGridReference(field, reference); + if (!line) throw new RangeError(`Field is missing ${reference}.`); + return Object.freeze({ reference, ySteps: line.coordinateSteps }); + }); +} + +/** Deterministic nearest-reference selection avoids display flicker at ties. */ +function nearestReference( + value: number, + references: readonly T[], + center: number, +): T { + let best = references[0]; + let bestDistance = Math.abs(value - best.coordinate); + for (const candidate of references.slice(1)) { + const distance = Math.abs(value - candidate.coordinate); + if (distance < bestDistance - EPSILON) { + best = candidate; + bestDistance = distance; + continue; + } + if (Math.abs(distance - bestDistance) <= EPSILON) { + const candidateCenterDistance = Math.abs(candidate.coordinate - center); + const bestCenterDistance = Math.abs(best.coordinate - center); + if ( + candidateCenterDistance < bestCenterDistance - EPSILON || + (Math.abs(candidateCenterDistance - bestCenterDistance) <= EPSILON && + candidate.coordinate < best.coordinate) + ) { + best = candidate; + bestDistance = distance; + } + } + } + return best; +} + +function sideRelation( + side: MarchingSideReference, + offsetXSteps: number, +): MarchingSideRelation { + if (Math.abs(offsetXSteps) <= EPSILON) return "on"; + if (side === "center") return "outside"; + const towardCenter = side === 1 ? offsetXSteps > 0 : offsetXSteps < 0; + return towardCenter ? "inside" : "outside"; +} + +function frontBackRelation(offsetYSteps: number): MarchingFrontBackRelation { + if (Math.abs(offsetYSteps) <= EPSILON) return "on"; + return offsetYSteps < 0 ? "in-front-of" : "behind"; +} + +function makeSideCoordinate(xSteps: number): MarchingSideCoordinate { + const references = X_REFERENCES.map((reference) => ({ + ...reference, + coordinate: reference.xSteps, + })); + const nearest = nearestReference(xSteps, references, 0); + const offsetXSteps = xSteps - nearest.xSteps; + const side = + nearest.side === "center" && Math.abs(offsetXSteps) > EPSILON + ? xSteps < 0 + ? 1 + : 2 + : nearest.side; + return Object.freeze({ + side, + yardLine: nearest.yardLine, + offsetSteps: Math.abs(offsetXSteps), + relation: sideRelation(side, offsetXSteps), + }); +} + +function makeFrontBackCoordinate( + ySteps: number, + field: ResolvedFieldDefinition, +): MarchingFrontBackCoordinate { + const references = lateralReferences(field).map((reference) => ({ + ...reference, + coordinate: reference.ySteps, + })); + const bounds = field.marchingGrid.bounds; + const nearest = nearestReference( + ySteps, + references, + (bounds.minYSteps + bounds.maxYSteps) / 2, + ); + const offsetYSteps = ySteps - nearest.ySteps; + return Object.freeze({ + reference: nearest.reference, + offsetSteps: Math.abs(offsetYSteps), + relation: frontBackRelation(offsetYSteps), + }); +} + +function getGridOutOfBounds( + point: DrillGridPoint, + field: ResolvedFieldDefinition, +): readonly ("goal-to-goal" | "front-back")[] | undefined { + const outOfBounds: ("goal-to-goal" | "front-back")[] = []; + const bounds = field.marchingGrid.bounds; + if ( + point.xSteps < bounds.minXSteps - EPSILON || + point.xSteps > bounds.maxXSteps + EPSILON + ) { + outOfBounds.push("goal-to-goal"); + } + if ( + point.ySteps < bounds.minYSteps - EPSILON || + point.ySteps > bounds.maxYSteps + EPSILON + ) { + outOfBounds.push("front-back"); + } + return outOfBounds.length > 0 ? Object.freeze(outOfBounds) : undefined; +} + +export function drillGridPointToMarchingCoordinate( + point: DrillGridPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): MarchingCoordinate { + if (!Number.isFinite(point.xSteps) || !Number.isFinite(point.ySteps)) { + throw new RangeError("Drill grid coordinates must be finite."); + } + const field = resolveMarchingField(fieldInput); + const outOfBounds = getGridOutOfBounds(point, field); + return Object.freeze({ + side: makeSideCoordinate(point.xSteps), + frontBack: makeFrontBackCoordinate(point.ySteps, field), + ...(outOfBounds ? { outOfBounds } : {}), + }); +} + +export function fieldPointToMarchingCoordinate( + point: FieldPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): MarchingCoordinate { + assertFiniteFieldPoint(point); + const field = resolveMarchingField(fieldInput); + return drillGridPointToMarchingCoordinate( + physicalPointToDrillGrid(point, field), + field, + ); +} + +function assertYardLine(yardLine: number): void { + assertFinite(yardLine, "Marching yard line"); + if (yardLine < 0 || yardLine > 50) { + throw new RangeError("Marching yard line must be between 0 and 50."); + } + if (Math.abs(yardLine / 5 - Math.round(yardLine / 5)) > EPSILON) { + throw new RangeError("Marching yard line must be a five-yard line."); + } +} + +function assertOffset(offsetSteps: number, name: string): void { + assertFinite(offsetSteps, name); + if (offsetSteps < 0) { + throw new RangeError(`${name} must be non-negative.`); + } +} + +function sideCoordinateToXSteps(coordinate: MarchingSideCoordinate): number { + assertYardLine(coordinate.yardLine); + assertOffset(coordinate.offsetSteps, "Marching side offsetSteps"); + if (coordinate.relation === "on" && coordinate.offsetSteps > EPSILON) { + throw new RangeError('An "on" marching coordinate must have zero offset.'); + } + + if (coordinate.yardLine === 50) { + if (coordinate.side === "center") { + if (coordinate.relation !== "on" || coordinate.offsetSteps > EPSILON) { + throw new RangeError( + 'The center 50-yard reference must be exactly "on".', + ); + } + return 0; + } + if (coordinate.relation === "on") return 0; + if (coordinate.relation !== "outside") { + throw new RangeError( + "An offset from the 50-yard line must be outside on Side 1 or Side 2.", + ); + } + return coordinate.side === 1 + ? -coordinate.offsetSteps + : coordinate.offsetSteps; + } + + if (coordinate.side === "center") { + throw new RangeError( + "Only the 50-yard line can use the center side reference.", + ); + } + + const baseMagnitude = ((50 - coordinate.yardLine) / 5) * 8; + const base = coordinate.side === 1 ? -baseMagnitude : baseMagnitude; + if (coordinate.relation === "on") return base; + if (coordinate.relation !== "inside" && coordinate.relation !== "outside") { + throw new RangeError( + 'A Side 1/2 marching reference must use "on", "inside", or "outside".', + ); + } + const towardCenter = coordinate.relation === "inside"; + if (coordinate.side === 1) { + return ( + base + (towardCenter ? coordinate.offsetSteps : -coordinate.offsetSteps) + ); + } + return ( + base + (towardCenter ? -coordinate.offsetSteps : coordinate.offsetSteps) + ); +} + +function frontBackCoordinateToYSteps( + coordinate: MarchingFrontBackCoordinate, + field: ResolvedFieldDefinition, +): number { + assertOffset(coordinate.offsetSteps, "Marching front/back offsetSteps"); + if (coordinate.relation === "on" && coordinate.offsetSteps > EPSILON) { + throw new RangeError('An "on" marching coordinate must have zero offset.'); + } + const line = getGridReference(field, coordinate.reference); + if (!line) throw new RangeError(`Field is missing ${coordinate.reference}.`); + const lineY = line.coordinateSteps; + if (coordinate.relation === "on") return lineY; + if (coordinate.relation === "in-front-of") { + return lineY - coordinate.offsetSteps; + } + if (coordinate.relation === "behind") { + return lineY + coordinate.offsetSteps; + } + throw new RangeError( + 'A marching front/back reference must use "on", "in-front-of", or "behind".', + ); +} + +export function marchingCoordinateToDrillGridPoint( + coordinate: MarchingCoordinate, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): DrillGridPoint { + if (!coordinate || !coordinate.side || !coordinate.frontBack) { + throw new TypeError( + "A marching coordinate requires side and frontBack values.", + ); + } + const field = resolveMarchingField(fieldInput); + return Object.freeze({ + xSteps: sideCoordinateToXSteps(coordinate.side), + ySteps: frontBackCoordinateToYSteps(coordinate.frontBack, field), + }); +} + +export function marchingCoordinateToFieldPoint( + coordinate: MarchingCoordinate, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): FieldPoint { + const field = resolveMarchingField(fieldInput); + const physical = drillGridToPhysicalPoint( + marchingCoordinateToDrillGridPoint(coordinate, field), + field, + ); + const point = { + xMeters: physical.xMeters, + yMeters: physical.yMeters, + }; + assertFiniteFieldPoint(point, "Converted field point"); + return Object.freeze(point); +} + +export function drillGridPointToFieldPoint( + point: DrillGridPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): FieldPoint { + const physical = drillGridToPhysicalPoint( + point, + resolveMarchingField(fieldInput), + ); + return Object.freeze({ + xMeters: physical.xMeters, + yMeters: physical.yMeters, + }); +} + +export function fieldPointToDrillGridPoint( + point: FieldPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, +): DrillGridPoint { + assertFiniteFieldPoint(point); + return physicalPointToDrillGrid(point, resolveMarchingField(fieldInput)); +} + +export const fieldPointToMarching = fieldPointToMarchingCoordinate; +export const marchingToFieldPoint = marchingCoordinateToFieldPoint; +export const fieldPositionToMarchingCoordinate = fieldPointToMarchingCoordinate; +export const marchingCoordinateToFieldPosition = marchingCoordinateToFieldPoint; + +function formatSideCoordinate( + coordinate: MarchingSideCoordinate, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + const line = yardLineText(coordinate.yardLine); + if (coordinate.relation === "on") { + return coordinate.side === "center" + ? `On ${line}` + : `Side ${coordinate.side}: On ${line}`; + } + const steps = stepWord(coordinate.offsetSteps, true, roundingSteps); + if (coordinate.side === "center") return `On ${line}`; + return `Side ${coordinate.side}: ${steps} ${coordinate.relation} ${line}`; +} + +function hashReferencePrefix(field: ResolvedFieldDefinition): string { + switch (field.id) { + case "football-nfhs": + return "HS"; + case "football-ncaa": + return "NCAA"; + case "football-texas-uil": + return "UIL"; + case "football-nfl": + return "NFL"; + case "custom": + return ""; + } +} + +function lateralReferenceText( + reference: FieldLateralReference, + field: ResolvedFieldDefinition, +): string { + switch (reference) { + case "front-sideline": + return "Front Sideline"; + case "front-hash": { + const prefix = hashReferencePrefix(field); + return prefix ? `${prefix} FH` : "Front Hash"; + } + case "back-hash": { + const prefix = hashReferencePrefix(field); + return prefix ? `${prefix} BH` : "Back Hash"; + } + case "back-sideline": + return "Back Sideline"; + } +} + +function formatFrontBackCoordinate( + coordinate: MarchingFrontBackCoordinate, + field: ResolvedFieldDefinition, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + const reference = lateralReferenceText(coordinate.reference, field); + if (coordinate.relation === "on") return `On ${reference}`; + return `${stepWord(coordinate.offsetSteps, true, roundingSteps)} ${ + coordinate.relation === "behind" ? "behind" : "in front of" + } ${reference}`; +} + +export function formatMarchingSide( + coordinate: MarchingSideCoordinate, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + return formatSideCoordinate(coordinate, roundingSteps); +} + +export const formatMarchingSideCoordinate = formatMarchingSide; + +export function formatMarchingFrontBack( + coordinate: MarchingFrontBackCoordinate, + fieldInput: MarchingFieldInput = NFHS_FIELD, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + return formatFrontBackCoordinate( + coordinate, + resolveMarchingField(fieldInput), + roundingSteps, + ); +} + +export const formatMarchingFrontBackCoordinate = formatMarchingFrontBack; + +export function formatMarchingCoordinate( + coordinateOrPoint: MarchingCoordinate | FieldPoint | DrillGridPoint, + fieldInput: MarchingFieldInput = NFHS_FIELD, + roundingSteps = DEFAULT_MARCHING_COORDINATE_ROUNDING_STEPS, +): string { + const field = resolveMarchingField(fieldInput); + let coordinate: MarchingCoordinate; + if ("side" in coordinateOrPoint) { + coordinate = coordinateOrPoint; + } else if ("xSteps" in coordinateOrPoint) { + coordinate = drillGridPointToMarchingCoordinate(coordinateOrPoint, field); + } else { + coordinate = fieldPointToMarchingCoordinate(coordinateOrPoint, field); + } + const parts = [ + formatSideCoordinate(coordinate.side, roundingSteps), + formatFrontBackCoordinate(coordinate.frontBack, field, roundingSteps), + ]; + const formatted = parts.join("; "); + return coordinate.outOfBounds?.length + ? `Out of Bounds — ${formatted}` + : formatted; +} + +export const formatMarchingPosition = formatMarchingCoordinate; +export const formatFieldPointAsMarching = formatMarchingCoordinate; diff --git a/packages/mobile/src/field/render/create-field-paths.ts b/packages/mobile/src/field/render/create-field-paths.ts new file mode 100644 index 00000000..ba63d975 --- /dev/null +++ b/packages/mobile/src/field/render/create-field-paths.ts @@ -0,0 +1,500 @@ +import { + drillGridToPhysicalPoint, + physicalPointToDrillGrid, +} from "@eight2five/drill-schema"; + +import { + STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, + type StandardFootballFieldTemplate, +} from "../template"; +import { yardsToMeters } from "../units"; + +const GRID_PADDING_YARDS = 10; +const PATH_NUMBER_PRECISION = 1_000_000; +const COORDINATE_EPSILON = 1e-9; +const FOUR_STEP_INTERVAL = 4; + +export interface FieldPathExtent { + readonly minXMeters: number; + readonly maxXMeters: number; + readonly minYMeters: number; + readonly maxYMeters: number; +} + +export interface MarchingGridPathMetadata { + readonly spacingSteps: 1; + readonly verticalLineCount: number; + readonly horizontalLineCount: number; +} + +export interface PerimeterMarchingGridPathMetadata extends MarchingGridPathMetadata { + readonly clippedByFieldBackground: true; +} + +export interface FourStepGridPathMetadata { + readonly spacingSteps: 4; + readonly verticalSubdivisionCount: number; + readonly horizontalSubdivisionCount: number; + readonly segmentCount: number; + readonly clippedToField: true; +} + +export interface YardLinesPathMetadata { + readonly lineCount: number; +} + +export interface HashMarksPathMetadata { + readonly spacingMeters: number; + readonly tickLengthMeters: number; + readonly rowCount: 2; + readonly ticksPerRow: number; + readonly tickCount: number; +} + +export interface HashGuideLinesPathMetadata { + readonly lineCount: 0; +} + +export interface SidelineHashMarksPathMetadata { + readonly spacingMeters: number; + readonly markLengthMeters: number; + readonly insetFromSidelineMeters: number; + readonly rowCount: 2; + readonly marksPerRow: number; + readonly markCount: number; +} + +export interface BoundaryPathMetadata { + readonly segmentCount: 1; +} + +export interface FieldPathCounts { + readonly stepGrid: MarchingGridPathMetadata; + readonly perimeterStepGrid: PerimeterMarchingGridPathMetadata; + readonly fourStepGrid: FourStepGridPathMetadata; + readonly yardLines: YardLinesPathMetadata; + readonly hashMarks: HashMarksPathMetadata; + readonly hashGuideLines: HashGuideLinesPathMetadata; + readonly sidelineHashMarks: SidelineHashMarksPathMetadata; + readonly boundary: BoundaryPathMetadata; +} + +/** Immutable world-space geometry projected from the active marching field. */ +export interface FieldPaths { + /** One marching-grid step, clipped to the physical field. */ + readonly stepGridPath: string; + /** One marching-grid step across the 10-yard camera perimeter. */ + readonly perimeterStepGridPath: string; + /** Four marching-grid steps, clipped to the physical field. */ + readonly fourStepGridPath: string; + readonly yardLinesPath: string; + /** Perpendicular inbounds hashes that remain visible with auxiliaries off. */ + readonly hashMarksPath: string; + readonly hashGuideLinesPath: string; + readonly sidelineHashMarksPath: string; + readonly boundaryPath: string; + readonly fieldExtent: FieldPathExtent; + readonly gridExtent: FieldPathExtent; + readonly stepGridSpacingSteps: 1; + readonly fourStepGridSpacingSteps: 4; + readonly extents: { + readonly field: FieldPathExtent; + readonly grid: FieldPathExtent; + }; + readonly counts: FieldPathCounts; + + readonly stepGrid: string; + readonly perimeterStepGrid: string; + readonly fourStepGrid: string; + readonly yardLines: string; + readonly hashMarks: string; + readonly hashGuideLines: string; + readonly sidelineHashMarks: string; + readonly boundary: string; +} + +const PATH_CACHE = new WeakMap(); + +/** + * Project the active drill schema's marching grid onto the exact physical + * football geometry. Grid steps are abstract drill coordinates; they are not + * assumed to equal 22.5 physical inches on both axes. + */ +export function createFieldPaths( + template: StandardFootballFieldTemplate = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE, +): FieldPaths { + const cached = PATH_CACHE.get(template); + if (cached) return cached; + + const fieldExtent = freezeExtent({ + minXMeters: template.bounds.minXMeters, + maxXMeters: template.bounds.maxXMeters, + minYMeters: template.bounds.minYMeters, + maxYMeters: template.bounds.maxYMeters, + }); + const gridPaddingMeters = yardsToMeters(GRID_PADDING_YARDS); + const gridExtent = freezeExtent({ + minXMeters: fieldExtent.minXMeters - gridPaddingMeters, + maxXMeters: fieldExtent.maxXMeters + gridPaddingMeters, + minYMeters: fieldExtent.minYMeters - gridPaddingMeters, + maxYMeters: fieldExtent.maxYMeters + gridPaddingMeters, + }); + + const marchingBounds = template.fieldDefinition.marchingGrid.bounds; + const stepGridXSteps = integerCoordinates( + marchingBounds.minXSteps, + marchingBounds.maxXSteps, + ); + const stepGridYSteps = integerCoordinates( + marchingBounds.minYSteps, + marchingBounds.maxYSteps, + ); + const stepGridPath = gridPathFromSteps( + template, + stepGridXSteps, + stepGridYSteps, + fieldExtent, + ); + + const perimeterGridBounds = physicalExtentToGridBounds(template, gridExtent); + const perimeterXSteps = integerCoordinates( + Math.floor(perimeterGridBounds.minXSteps), + Math.ceil(perimeterGridBounds.maxXSteps), + ); + const perimeterYSteps = integerCoordinates( + Math.floor(perimeterGridBounds.minYSteps), + Math.ceil(perimeterGridBounds.maxYSteps), + ); + const perimeterStepGridPath = gridPathFromSteps( + template, + perimeterXSteps, + perimeterYSteps, + gridExtent, + ); + + const fourStepXSteps = stepIntervalCoordinates( + marchingBounds.minXSteps, + marchingBounds.maxXSteps, + FOUR_STEP_INTERVAL, + ); + const fourStepYSteps = stepIntervalCoordinates( + marchingBounds.minYSteps, + marchingBounds.maxYSteps, + FOUR_STEP_INTERVAL, + ); + const fourStepGridPath = gridPathFromSteps( + template, + fourStepXSteps, + fourStepYSteps, + fieldExtent, + ); + + const yardLinesPath = template.yardLines + .map((line) => + verticalSegment( + line.coordinateMeters, + fieldExtent.minYMeters, + fieldExtent.maxYMeters, + ), + ) + .join(" "); + + const hashYCoordinates = [ + template.frontHashLine.coordinateMeters, + template.backHashLine.coordinateMeters, + ] as const; + const inboundsMarkings = template.fieldDefinition.markings.inboundsHashMarks; + const hashMarks: string[] = []; + const inboundsXCoordinates = template.yardLines.map( + (line) => line.coordinateMeters, + ); + for (const yMeters of hashYCoordinates) { + for (const xMeters of inboundsXCoordinates) { + hashMarks.push( + horizontalSegment( + xMeters - inboundsMarkings.lengthMeters / 2, + yMeters, + xMeters + inboundsMarkings.lengthMeters / 2, + ), + ); + } + } + const hashMarksPath = hashMarks.join(" "); + // A football hash is a sequence of discrete two-foot ticks, never a + // continuous guide line across the field. Keep the legacy path slot empty + // so older render consumers cannot accidentally resurrect that artifact. + const hashGuideLinesPath = ""; + + const sidelineMarkings = template.fieldDefinition.markings.sidelineHashMarks; + const sidelineXCoordinates = spacedInteriorCoordinates( + fieldExtent.minXMeters, + fieldExtent.maxXMeters, + sidelineMarkings.spacingMeters, + ).filter( + (xMeters) => + !isMultipleOfSpacing( + xMeters - fieldExtent.minXMeters, + template.dimensions.fiveYardLineSpacingMeters, + ), + ); + const sidelineHashMarks: string[] = []; + for (const xMeters of sidelineXCoordinates) { + const frontStart = + fieldExtent.minYMeters + sidelineMarkings.insetFromSidelineMeters; + const backStart = + fieldExtent.maxYMeters - sidelineMarkings.insetFromSidelineMeters; + sidelineHashMarks.push( + verticalSegment( + xMeters, + frontStart, + frontStart + sidelineMarkings.lengthMeters, + ), + verticalSegment( + xMeters, + backStart - sidelineMarkings.lengthMeters, + backStart, + ), + ); + } + const sidelineHashMarksPath = sidelineHashMarks.join(" "); + + const boundaryPath = rectanglePath(fieldExtent); + const extents = Object.freeze({ field: fieldExtent, grid: gridExtent }); + const counts: FieldPathCounts = Object.freeze({ + stepGrid: Object.freeze({ + spacingSteps: 1, + verticalLineCount: stepGridXSteps.length, + horizontalLineCount: stepGridYSteps.length, + }), + perimeterStepGrid: Object.freeze({ + spacingSteps: 1, + verticalLineCount: perimeterXSteps.length, + horizontalLineCount: perimeterYSteps.length, + clippedByFieldBackground: true, + }), + fourStepGrid: Object.freeze({ + spacingSteps: 4, + verticalSubdivisionCount: fourStepXSteps.length, + horizontalSubdivisionCount: fourStepYSteps.length, + segmentCount: fourStepXSteps.length + fourStepYSteps.length, + clippedToField: true, + }), + yardLines: Object.freeze({ lineCount: template.yardLines.length }), + hashMarks: Object.freeze({ + spacingMeters: template.dimensions.fiveYardLineSpacingMeters, + tickLengthMeters: inboundsMarkings.lengthMeters, + rowCount: 2, + ticksPerRow: inboundsXCoordinates.length, + tickCount: hashMarks.length, + }), + hashGuideLines: Object.freeze({ lineCount: 0 }), + sidelineHashMarks: Object.freeze({ + spacingMeters: sidelineMarkings.spacingMeters, + markLengthMeters: sidelineMarkings.lengthMeters, + insetFromSidelineMeters: sidelineMarkings.insetFromSidelineMeters, + rowCount: 2, + marksPerRow: sidelineXCoordinates.length, + markCount: sidelineHashMarks.length, + }), + boundary: Object.freeze({ segmentCount: 1 }), + }); + + const paths: FieldPaths = Object.freeze({ + stepGridPath, + perimeterStepGridPath, + fourStepGridPath, + yardLinesPath, + hashMarksPath, + hashGuideLinesPath, + sidelineHashMarksPath, + boundaryPath, + fieldExtent, + gridExtent, + stepGridSpacingSteps: 1, + fourStepGridSpacingSteps: 4, + extents, + counts, + stepGrid: stepGridPath, + perimeterStepGrid: perimeterStepGridPath, + fourStepGrid: fourStepGridPath, + yardLines: yardLinesPath, + hashMarks: hashMarksPath, + hashGuideLines: hashGuideLinesPath, + sidelineHashMarks: sidelineHashMarksPath, + boundary: boundaryPath, + }); + PATH_CACHE.set(template, paths); + return paths; +} + +export const buildFieldPaths = createFieldPaths; + +function gridPathFromSteps( + template: StandardFootballFieldTemplate, + xSteps: readonly number[], + ySteps: readonly number[], + extent: FieldPathExtent, +): string { + return [ + ...xSteps.map((step) => + verticalSegment( + projectXStep(template, step), + extent.minYMeters, + extent.maxYMeters, + ), + ), + ...ySteps.map((step) => + horizontalSegment( + extent.minXMeters, + projectYStep(template, step), + extent.maxXMeters, + ), + ), + ].join(" "); +} + +function projectXStep( + template: StandardFootballFieldTemplate, + xSteps: number, +): number { + return drillGridToPhysicalPoint( + { + xSteps, + ySteps: template.fieldDefinition.marchingGrid.bounds.minYSteps, + }, + template.fieldDefinition, + ).xMeters; +} + +function projectYStep( + template: StandardFootballFieldTemplate, + ySteps: number, +): number { + return drillGridToPhysicalPoint( + { + xSteps: 0, + ySteps, + }, + template.fieldDefinition, + ).yMeters; +} + +function physicalExtentToGridBounds( + template: StandardFootballFieldTemplate, + extent: FieldPathExtent, +) { + const field = template.fieldDefinition; + const xMin = physicalPointToDrillGrid( + { xMeters: extent.minXMeters, yMeters: template.bounds.minYMeters }, + field, + ).xSteps; + const xMax = physicalPointToDrillGrid( + { xMeters: extent.maxXMeters, yMeters: template.bounds.minYMeters }, + field, + ).xSteps; + const yMin = physicalPointToDrillGrid( + { xMeters: 0, yMeters: extent.minYMeters }, + field, + ).ySteps; + const yMax = physicalPointToDrillGrid( + { xMeters: 0, yMeters: extent.maxYMeters }, + field, + ).ySteps; + return { minXSteps: xMin, maxXSteps: xMax, minYSteps: yMin, maxYSteps: yMax }; +} + +function integerCoordinates( + minimum: number, + maximum: number, +): readonly number[] { + const start = Math.ceil(minimum - COORDINATE_EPSILON); + const end = Math.floor(maximum + COORDINATE_EPSILON); + const coordinates: number[] = []; + for (let value = start; value <= end; value += 1) coordinates.push(value); + return Object.freeze(coordinates); +} + +function stepIntervalCoordinates( + minimum: number, + maximum: number, + interval: number, +): readonly number[] { + const coordinates: number[] = []; + for ( + let value = minimum; + value <= maximum + COORDINATE_EPSILON; + value += interval + ) { + coordinates.push(value); + } + if ( + coordinates.length === 0 || + Math.abs(coordinates[coordinates.length - 1] - maximum) > COORDINATE_EPSILON + ) { + coordinates.push(maximum); + } + return Object.freeze(coordinates); +} + +function spacedInteriorCoordinates( + minimum: number, + maximum: number, + spacing: number, +): readonly number[] { + const coordinates: number[] = []; + for ( + let coordinate = minimum + spacing; + coordinate < maximum - COORDINATE_EPSILON; + coordinate += spacing + ) { + coordinates.push(coordinate); + } + return coordinates; +} + +function isMultipleOfSpacing(distance: number, spacing: number): boolean { + const quotient = distance / spacing; + return Math.abs(quotient - Math.round(quotient)) <= COORDINATE_EPSILON; +} + +function freezeExtent(extent: FieldPathExtent): FieldPathExtent { + return Object.freeze(extent); +} + +function verticalSegment( + xMeters: number, + minYMeters: number, + maxYMeters: number, +): string { + return `M ${formatCoordinate(xMeters)} ${formatCoordinate( + minYMeters, + )} L ${formatCoordinate(xMeters)} ${formatCoordinate(maxYMeters)}`; +} + +function horizontalSegment( + minXMeters: number, + yMeters: number, + maxXMeters: number, +): string { + return `M ${formatCoordinate(minXMeters)} ${formatCoordinate( + yMeters, + )} L ${formatCoordinate(maxXMeters)} ${formatCoordinate(yMeters)}`; +} + +function rectanglePath(extent: FieldPathExtent): string { + return `M ${formatCoordinate(extent.minXMeters)} ${formatCoordinate( + extent.minYMeters, + )} L ${formatCoordinate(extent.maxXMeters)} ${formatCoordinate( + extent.minYMeters, + )} L ${formatCoordinate(extent.maxXMeters)} ${formatCoordinate( + extent.maxYMeters, + )} L ${formatCoordinate(extent.minXMeters)} ${formatCoordinate( + extent.maxYMeters, + )} Z`; +} + +function formatCoordinate(value: number): string { + const rounded = + Math.round(value * PATH_NUMBER_PRECISION) / PATH_NUMBER_PRECISION; + return String(Object.is(rounded, -0) ? 0 : rounded); +} diff --git a/packages/mobile/src/field/render/drill-shape-policy.ts b/packages/mobile/src/field/render/drill-shape-policy.ts new file mode 100644 index 00000000..ece920af --- /dev/null +++ b/packages/mobile/src/field/render/drill-shape-policy.ts @@ -0,0 +1,169 @@ +import type { EntityIcon } from "@eight2five/drill-schema"; +import type { FieldCameraPerspective } from "../camera/field-camera-types"; + +/** Icons that can be rendered as a directional path or as a circle. */ +export type DrillShapeIcon = EntityIcon | "circle"; + +export interface DrillShapePoint { + readonly x: number; + readonly y: number; +} + +export interface DrillPathShape { + readonly kind: "path"; + readonly points: readonly DrillShapePoint[]; +} + +export interface DrillCircleShape { + readonly kind: "circle"; + readonly radius: number; +} + +export type DrillShapeGeometry = DrillPathShape | DrillCircleShape; + +export interface DrillShapeTransformPolicy { + /** Rotation to apply in the world-space Group under the camera reflection. */ + readonly rotationRadians: number; + /** Every shape is constructed around this local origin. */ + readonly origin: Readonly; +} + +export interface DrillLabelTransformPolicy { + readonly scaleX: number; + readonly scaleY: number; +} + +/** + * Label text is drawn in screen pixels inside the world-space camera group. + * The negative Y scale both cancels the camera reflection and keeps the text + * upright while the magnitude keeps its size constant across zoom levels. + */ +export function getDrillLabelTransformPolicy( + metersPerPixel: number, + perspective: FieldCameraPerspective = "director", +): DrillLabelTransformPolicy { + "worklet"; + return perspective === "performer" + ? { scaleX: -metersPerPixel, scaleY: metersPerPixel } + : { scaleX: metersPerPixel, scaleY: -metersPerPixel }; +} + +/** + * Construct a shape in world coordinates that appears upright after the field + * camera's scaleY(-1). A visually upward point therefore has a positive world + * Y coordinate, unlike an ordinary screen-space path. + */ +export function createDrillShapeGeometry( + icon: DrillShapeIcon, + width: number, + height: number, +): DrillShapeGeometry { + assertPositiveFinite(width, "Drill shape width"); + assertPositiveFinite(height, "Drill shape height"); + + if (icon === "dot" || icon === "circle") { + return Object.freeze({ + kind: "circle", + radius: Math.min(width, height) / 2, + }); + } + + const halfWidth = width / 2; + const halfHeight = height / 2; + const radius = Math.min(halfWidth, halfHeight); + let points: readonly DrillShapePoint[]; + + switch (icon) { + case "triangle": + points = [ + { x: 0, y: halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: -halfWidth, y: -halfHeight }, + ]; + break; + case "diamond": + points = [ + { x: 0, y: halfHeight }, + { x: halfWidth, y: 0 }, + { x: 0, y: -halfHeight }, + { x: -halfWidth, y: 0 }, + ]; + break; + case "star": + points = Array.from({ length: 10 }, (_, index) => { + const angle = Math.PI / 2 - (index * Math.PI) / 5; + const pointRadius = index % 2 === 0 ? radius : radius * 0.45; + return { + x: Math.cos(angle) * pointRadius * (halfWidth / radius), + y: Math.sin(angle) * pointRadius * (halfHeight / radius), + }; + }); + break; + case "hexagon": + points = Array.from({ length: 6 }, (_, index) => { + const angle = Math.PI / 2 - (index * Math.PI) / 3; + return { + x: Math.cos(angle) * halfWidth, + y: Math.sin(angle) * halfHeight, + }; + }); + break; + case "cross": { + const armWidth = halfWidth * 0.36; + const armHeight = halfHeight * 0.36; + points = [ + { x: -armWidth, y: halfHeight }, + { x: armWidth, y: halfHeight }, + { x: armWidth, y: armHeight }, + { x: halfWidth, y: armHeight }, + { x: halfWidth, y: -armHeight }, + { x: armWidth, y: -armHeight }, + { x: armWidth, y: -halfHeight }, + { x: -armWidth, y: -halfHeight }, + { x: -armWidth, y: -armHeight }, + { x: -halfWidth, y: -armHeight }, + { x: -halfWidth, y: armHeight }, + { x: -armWidth, y: armHeight }, + ]; + break; + } + case "square": + default: + points = [ + { x: -halfWidth, y: halfHeight }, + { x: halfWidth, y: halfHeight }, + { x: halfWidth, y: -halfHeight }, + { x: -halfWidth, y: -halfHeight }, + ]; + break; + } + + return Object.freeze({ + kind: "path", + points: Object.freeze(points.map((point) => Object.freeze(point))), + }); +} + +/** + * Convert a field-space facing angle to a Skia Group rotation beneath the + * camera's negative Y scale. Negating the angle preserves the same directional + * heading in the user's upright view while keeping rotation in radians. + */ +export function getDrillShapeTransformPolicy( + facingDegrees?: number, +): DrillShapeTransformPolicy { + if (facingDegrees !== undefined && !Number.isFinite(facingDegrees)) { + throw new RangeError("Drill facing degrees must be finite."); + } + return Object.freeze({ + rotationRadians: + facingDegrees === undefined ? 0 : (-facingDegrees * Math.PI) / 180, + origin: Object.freeze({ x: 0, y: 0 }), + }); +} + +function assertPositiveFinite(value: number, name: string): void { + if (!Number.isFinite(value) || value <= 0) { + throw new RangeError(`${name} must be a positive finite number.`); + } +} diff --git a/packages/mobile/src/field/render/field-anchor-layer.tsx b/packages/mobile/src/field/render/field-anchor-layer.tsx new file mode 100644 index 00000000..da9cee3a --- /dev/null +++ b/packages/mobile/src/field/render/field-anchor-layer.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Circle, Group, Path } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { + FieldAnchorGeometry, + FieldAnchorOverlayOptions, +} from "./field-overlay-types"; +import type { FieldRenderPalette } from "./field-render-tokens"; + +export const FieldAnchorLayer = React.memo(function FieldAnchorLayer({ + anchors, + options, + metersPerPixel, + palette, +}: { + readonly anchors: readonly FieldAnchorGeometry[]; + readonly options: FieldAnchorOverlayOptions; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; +}) { + const rangeStrokeWidth = useDerivedValue(() => metersPerPixel.value); + if (!options.visible) return null; + + return ( + <> + {options.showRange && options.rangeMeters > 0 + ? anchors.map((anchor) => ( + + + + + )) + : null} + {anchors.map((anchor) => ( + + ))} + + ); +}); + +function AnchorMarker({ + anchor, + metersPerPixel, + color, +}: { + readonly anchor: FieldAnchorGeometry; + readonly metersPerPixel: SharedValue; + readonly color: string; +}) { + const transform = useDerivedValue(() => [ + { translateX: anchor.position.xMeters }, + { translateY: anchor.position.yMeters }, + { scaleX: metersPerPixel.value }, + { scaleY: -metersPerPixel.value }, + ]); + return ( + + + + ); +} diff --git a/packages/mobile/src/field/render/field-canvas.tsx b/packages/mobile/src/field/render/field-canvas.tsx new file mode 100644 index 00000000..5b70d109 --- /dev/null +++ b/packages/mobile/src/field/render/field-canvas.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { + View, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { Canvas, Fill } from "@shopify/react-native-skia"; +import { GestureDetector } from "react-native-gesture-handler"; +import { useSharedValue, type SharedValue } from "react-native-reanimated"; + +import { setFieldCamera } from "../camera/field-camera-math"; +import type { FieldPresetId } from "@eight2five/drill-schema"; +import type { DrillRenderScene } from "../../drill/render-scene"; + +import { + createStandardFootballFieldTemplate, + type StandardFootballFieldTemplate, +} from "../template"; +import type { FieldPoint } from "../types"; +import { + getFieldCameraBounds, + getFieldGridBounds, + getInitialFieldViewport, +} from "../camera/field-camera-policy"; +import type { + FieldCamera, + FieldCameraPerspective, + FieldViewport, + FieldViewportSize, +} from "../camera/field-camera-types"; +import { useFieldGestures } from "../camera/use-field-gestures"; +import { createFieldPaths } from "./create-field-paths"; +import { FieldScene } from "./field-scene"; +import { + HIDDEN_FIELD_ANCHOR_OVERLAY, + type FieldAnchorGeometry, + type FieldAnchorOverlayOptions, +} from "./field-overlay-types"; +import { + DEFAULT_FIELD_RENDER_PALETTE, + type FieldRenderPalette, +} from "./field-render-tokens"; + +export interface FieldCanvasProps { + readonly template?: StandardFootballFieldTemplate; + readonly fieldPreset?: FieldPresetId; + readonly camera?: FieldCamera; + readonly defaultViewport?: FieldViewport; + readonly onViewportChange?: (viewport: FieldViewport) => void; + readonly palette?: FieldRenderPalette; + readonly perspective?: FieldCameraPerspective; + readonly livePosition?: SharedValue; + readonly targetPosition?: FieldPoint; + readonly drillScene?: DrillRenderScene; + readonly guidanceVisible?: boolean; + readonly anchors?: readonly FieldAnchorGeometry[]; + readonly anchorOverlayOptions?: FieldAnchorOverlayOptions; + readonly showPerimeterStepGrid?: boolean; + readonly showAuxiliaryFieldMarks?: boolean; + readonly style?: StyleProp; + readonly testID?: string; +} + +const EMPTY_FIELD_ANCHORS: readonly FieldAnchorGeometry[] = Object.freeze([]); + +export function FieldCanvas({ + template: explicitTemplate, + fieldPreset = "football-nfhs", + camera: externalCamera, + defaultViewport, + onViewportChange, + palette = DEFAULT_FIELD_RENDER_PALETTE, + perspective = "director", + livePosition: externalLivePosition, + targetPosition, + drillScene, + guidanceVisible = false, + anchors = EMPTY_FIELD_ANCHORS, + anchorOverlayOptions = HIDDEN_FIELD_ANCHOR_OVERLAY, + showPerimeterStepGrid = false, + showAuxiliaryFieldMarks = true, + style, + testID = "field-canvas", +}: FieldCanvasProps) { + const template = React.useMemo( + () => explicitTemplate ?? createStandardFootballFieldTemplate(fieldPreset), + [explicitTemplate, fieldPreset], + ); + const midpoint = { + xMeters: (template.bounds.minXMeters + template.bounds.maxXMeters) / 2, + yMeters: (template.bounds.minYMeters + template.bounds.maxYMeters) / 2, + }; + const centerXMeters = useSharedValue( + defaultViewport?.centerXMeters ?? midpoint.xMeters, + ); + const centerYMeters = useSharedValue( + defaultViewport?.centerYMeters ?? midpoint.yMeters, + ); + const metersPerPixel = useSharedValue( + defaultViewport?.metersPerPixel ?? 0.12, + ); + const internalCamera = React.useMemo( + () => ({ centerXMeters, centerYMeters, metersPerPixel }), + [centerXMeters, centerYMeters, metersPerPixel], + ); + const camera = externalCamera ?? internalCamera; + const canvasSize = useSharedValue({ width: 0, height: 0 }); + const emptyLivePosition = useSharedValue(null); + const livePosition = externalLivePosition ?? emptyLivePosition; + const initialized = React.useRef(Boolean(externalCamera || defaultViewport)); + const paths = React.useMemo(() => createFieldPaths(template), [template]); + const cameraBounds = React.useMemo( + () => getFieldCameraBounds(template), + [template], + ); + const gridBounds = React.useMemo( + () => getFieldGridBounds(template), + [template], + ); + const { gesture } = useFieldGestures({ + camera, + canvasSize, + cameraBounds, + gridBounds, + perspective, + onViewportChange, + testID, + }); + + const onLayout = React.useCallback( + (event: LayoutChangeEvent) => { + if (initialized.current) return; + const size = event.nativeEvent.layout; + if (size.width <= 0 || size.height <= 0) return; + const initial = getInitialFieldViewport(size, template); + setFieldCamera(camera, initial); + initialized.current = true; + onViewportChange?.(initial); + }, + [camera, onViewportChange, template], + ); + + return ( + + + + + + + + + ); +} diff --git a/packages/mobile/src/field/render/field-drill-layer.tsx b/packages/mobile/src/field/render/field-drill-layer.tsx new file mode 100644 index 00000000..cb3a74e2 --- /dev/null +++ b/packages/mobile/src/field/render/field-drill-layer.tsx @@ -0,0 +1,494 @@ +import React from "react"; +import { Montserrat_400Regular } from "@expo-google-fonts/montserrat/400Regular"; +import { + Circle, + DashPathEffect, + Group, + Path, + Skia, + Text, + useFont, + type SkFont, + type SkPath, +} from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; +import type { PhysicalFieldPoint } from "@eight2five/drill-schema"; + +import type { + DrillRenderEntity, + DrillRenderScene, + PhysicalImmediateTransition, + PhysicalTransitionPathGeometry, +} from "../../drill/render-scene"; +import type { FieldCameraPerspective } from "../camera/field-camera-types"; +import type { FieldPoint } from "../types"; +import { resolveCurrentTargetPosition } from "./field-overlay-types"; +import { + createDrillShapeGeometry, + getDrillLabelTransformPolicy, + getDrillShapeTransformPolicy, + type DrillShapeIcon, +} from "./drill-shape-policy"; +import type { FieldRenderPalette } from "./field-render-tokens"; +import { + DRILL_MARKER_COLORS, + DRILL_MARKER_SIZE_METERS, +} from "./field-render-tokens"; +import { STANDARD_STEP_METERS } from "../units"; + +const EMPTY_ENTITIES: readonly DrillRenderEntity[] = Object.freeze([]); +const EMPTY_DOTS = Object.freeze([]) as readonly { + readonly setId: number; + readonly point: PhysicalFieldPoint; +}[]; +const EMPTY_TRANSITIONS = Object.freeze( + [], +) as readonly PhysicalImmediateTransition[]; +const LABEL_FONT_SIZE_PX = 12; +const LABEL_LINE_HEIGHT_PX = 14; +const MARKER_STROKE_METERS = STANDARD_STEP_METERS * 0.12; +const CONNECTOR_STROKE_PX = 1.25; +const DASH_LENGTH_METERS = STANDARD_STEP_METERS * 0.25; +const DASH_GAP_METERS = STANDARD_STEP_METERS * 0.15; +const EXTRA_TRANSITION_OPACITY = 0.68; + +export interface FieldDrillLayerProps { + readonly scene?: DrillRenderScene; + /** Used only for legacy/manual drills that have no complete source document. */ + readonly fallbackTargetPosition?: FieldPoint; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; +} + +/** + * Draws the selected-set model in explicit z-order. Static field and anchors + * are owned by the parent scene; guidance and the live position are drawn + * after this layer so they remain visible above every drill entity. + */ +export const FieldDrillLayer = React.memo(function FieldDrillLayer({ + scene, + fallbackTargetPosition, + metersPerPixel, + palette, + perspective, +}: FieldDrillLayerProps) { + const labelFont = useFont(Montserrat_400Regular, LABEL_FONT_SIZE_PX); + const entities = scene?.entities ?? EMPTY_ENTITIES; + const previousConnectors = scene?.previousConnectors ?? EMPTY_TRANSITIONS; + const nextConnectors = scene?.nextConnectors ?? EMPTY_TRANSITIONS; + const previousDots = scene?.previousDots ?? EMPTY_DOTS; + const nextDots = scene?.nextDots ?? EMPTY_DOTS; + const targetPoint = resolveCurrentTargetPosition({ + fullDrillSceneAvailable: scene !== undefined, + sceneCurrent: scene?.current, + legacyFallback: fallbackTargetPosition, + }); + + return ( + <> + {entities.map((entity) => ( + + ))} + {previousConnectors.map((transition) => ( + + ))} + {nextConnectors.map((transition) => ( + + ))} + {previousDots.map((dot) => ( + + ))} + {nextDots.map((dot) => ( + + ))} + {scene?.previous ? ( + + ) : null} + {scene?.next ? ( + + ) : null} + {targetPoint ? : null} + + ); +}); + +function OrdinaryEntity({ + entity, + labelFont, + metersPerPixel, + palette, + perspective, +}: { + readonly entity: DrillRenderEntity; + readonly labelFont: SkFont | null; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; +}) { + const icon = entity.icon as string; + const width = + entity.type === "prop" ? entity.widthMeters : entity.diameterMeters; + const height = + entity.type === "prop" ? entity.lengthMeters : entity.diameterMeters; + const shapeGeometry = React.useMemo( + () => createDrillShapeGeometry(icon as DrillShapeIcon, width, height), + [height, icon, width], + ); + const shapePath = React.useMemo( + () => + shapeGeometry.kind === "path" + ? createShapePath(shapeGeometry.points) + : null, + [shapeGeometry], + ); + const transformPolicy = React.useMemo( + () => getDrillShapeTransformPolicy(entity.facingDegrees), + [entity.facingDegrees], + ); + const transform = React.useMemo( + () => [ + { translateX: entity.position.xMeters }, + { translateY: entity.position.yMeters }, + ...(transformPolicy.rotationRadians === 0 + ? [] + : [{ rotate: transformPolicy.rotationRadians }]), + ], + [ + entity.position.xMeters, + entity.position.yMeters, + transformPolicy.rotationRadians, + ], + ); + const outlineWidth = useDerivedValue(() => metersPerPixel.value); + + return ( + <> + + {shapePath ? ( + <> + + {entity.type === "prop" ? ( + + ) : null} + + ) : ( + + )} + + + + ); +} + +function EntityLabel({ + entity, + font, + metersPerPixel, + color, + perspective, +}: { + readonly entity: DrillRenderEntity; + readonly font: SkFont | null; + readonly metersPerPixel: SharedValue; + readonly color: string; + readonly perspective: FieldCameraPerspective; +}) { + const lines = React.useMemo( + () => + [ + entity.labelText ? { key: "label", text: entity.labelText } : null, + entity.nameText ? { key: "name", text: entity.nameText } : null, + ].filter((line): line is { key: string; text: string } => line !== null), + [entity.labelText, entity.nameText], + ); + const labelTransform = useDerivedValue(() => { + const labelScale = getDrillLabelTransformPolicy( + metersPerPixel.value, + perspective, + ); + return [{ scaleX: labelScale.scaleX }, { scaleY: labelScale.scaleY }]; + }); + + if (!font || lines.length === 0) return null; + const widths = lines.map((line) => font.measureText(line.text).width); + const startY = -LABEL_LINE_HEIGHT_PX * (lines.length + 0.15); + + return ( + + {lines.map((line, index) => ( + + ))} + + ); +} + +function ExtraDot({ + point, + color, +}: { + readonly point: PhysicalFieldPoint; + readonly color: string; +}) { + const radius = DRILL_MARKER_SIZE_METERS.midpointDiameter / 2; + return ( + + ); +} + +function ExtraTransitionConnector({ + transition, + kind, + metersPerPixel, +}: { + readonly transition: PhysicalImmediateTransition; + readonly kind: "previous" | "next"; + readonly metersPerPixel: SharedValue; +}) { + const connectorPath = React.useMemo( + () => createPhysicalPath(transition.geometry), + [transition.geometry], + ); + const connectorStrokeWidth = useDerivedValue( + () => metersPerPixel.value * CONNECTOR_STROKE_PX, + ); + return ( + + ); +} + +function ImmediateTransitionLayer({ + transition, + kind, + metersPerPixel, +}: { + readonly transition: PhysicalImmediateTransition; + readonly kind: "previous" | "next"; + readonly metersPerPixel: SharedValue; +}) { + const connectorPath = React.useMemo( + () => createPhysicalPath(transition.geometry), + [transition.geometry], + ); + const markerPoint = kind === "previous" ? transition.start : transition.end; + const markerRadius = DRILL_MARKER_SIZE_METERS.transitionDiameter / 2; + const midpointRadius = DRILL_MARKER_SIZE_METERS.midpointDiameter / 2; + const centerRadius = DRILL_MARKER_SIZE_METERS.transitionDiameter * 0.18; + const connectorStrokeWidth = useDerivedValue( + () => metersPerPixel.value * CONNECTOR_STROKE_PX, + ); + const dashIntervals = [DASH_LENGTH_METERS, DASH_GAP_METERS]; + const connectorColor = + kind === "previous" ? DRILL_MARKER_COLORS.red : DRILL_MARKER_COLORS.green; + + return ( + <> + + {kind === "previous" ? ( + + + + ) : ( + <> + + + + )} + + + + ); +} + +function CurrentTargetMarker({ + point, +}: { + readonly point: PhysicalFieldPoint | FieldPoint; +}) { + const radius = DRILL_MARKER_SIZE_METERS.currentDiameter / 2; + const centerRadius = DRILL_MARKER_SIZE_METERS.currentDiameter * 0.14; + + return ( + <> + {/* The ring is intentionally not filled; its interior stays transparent. */} + + + + ); +} + +function createPhysicalPath(geometry: PhysicalTransitionPathGeometry): SkPath { + const builder = Skia.PathBuilder.Make(); + switch (geometry.kind) { + case "straight": + builder + .moveTo(geometry.start.xMeters, geometry.start.yMeters) + .lineTo(geometry.end.xMeters, geometry.end.yMeters); + break; + case "polyline": { + const first = geometry.points[0]; + if (!first) return builder.build(); + builder.moveTo(first.xMeters, first.yMeters); + for (const point of geometry.points.slice(1)) { + builder.lineTo(point.xMeters, point.yMeters); + } + break; + } + case "bezier": + builder + .moveTo(geometry.start.xMeters, geometry.start.yMeters) + .cubicTo( + geometry.controlPoints[0].xMeters, + geometry.controlPoints[0].yMeters, + geometry.controlPoints[1].xMeters, + geometry.controlPoints[1].yMeters, + geometry.end.xMeters, + geometry.end.yMeters, + ); + break; + } + return builder.build(); +} + +function createShapePath( + points: readonly { readonly x: number; readonly y: number }[], +): SkPath { + const builder = Skia.PathBuilder.Make(); + const first = points[0]; + if (!first) return builder.build(); + builder.moveTo(first.x, first.y); + for (const point of points.slice(1)) builder.lineTo(point.x, point.y); + return builder.close().build(); +} diff --git a/packages/mobile/src/field/render/field-guidance-layer.tsx b/packages/mobile/src/field/render/field-guidance-layer.tsx new file mode 100644 index 00000000..140a08a3 --- /dev/null +++ b/packages/mobile/src/field/render/field-guidance-layer.tsx @@ -0,0 +1,45 @@ +import { DashPathEffect, Line } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "../types"; + +export function FieldGuidanceLayer({ + livePosition, + targetPosition, + metersPerPixel, + color, +}: { + readonly livePosition: SharedValue; + readonly targetPosition: FieldPoint; + readonly metersPerPixel: SharedValue; + readonly color: string; +}) { + const livePoint = useDerivedValue(() => { + const position = livePosition.value ?? targetPosition; + return { x: position.xMeters, y: position.yMeters }; + }, [targetPosition.xMeters, targetPosition.yMeters]); + const targetPoint = { + x: targetPosition.xMeters, + y: targetPosition.yMeters, + }; + const opacity = useDerivedValue(() => + livePosition.value === null ? 0 : 0.82, + ); + const strokeWidth = useDerivedValue(() => metersPerPixel.value * 2.4); + const dashIntervals = useDerivedValue(() => [ + metersPerPixel.value * 8, + metersPerPixel.value * 5, + ]); + return ( + + + + ); +} diff --git a/packages/mobile/src/field/render/field-overlay-types.ts b/packages/mobile/src/field/render/field-overlay-types.ts new file mode 100644 index 00000000..9a571705 --- /dev/null +++ b/packages/mobile/src/field/render/field-overlay-types.ts @@ -0,0 +1,99 @@ +import type { FieldPoint, FieldPosition } from "../types"; + +export interface FieldAnchorGeometry { + readonly id: string; + readonly position: FieldPosition; +} + +export interface FieldAnchorOverlayOptions { + readonly visible: boolean; + readonly showRange: boolean; + readonly rangeMeters: number; +} + +export const HIDDEN_FIELD_ANCHOR_OVERLAY: FieldAnchorOverlayOptions = + Object.freeze({ + visible: false, + showRange: false, + rangeMeters: 0, + }); + +export interface FieldDrillOverlayState { + readonly drillFeaturesEnabled: boolean; + readonly hasActiveDrill: boolean; + readonly hasSelectedPage: boolean; + readonly hasLivePosition: boolean; + readonly guidanceEnabled: boolean; +} + +export type CurrentTargetMarkerSource = + | "drill-scene" + | "legacy-fallback" + | "none"; + +export interface CurrentTargetMarkerPolicyInput { + readonly fullDrillSceneAvailable: boolean; + readonly sceneHasCurrent: boolean; + readonly legacyFallbackAvailable: boolean; +} + +/** + * A complete scene owns the selected target, even when its current position is + * missing. This prevents a stale local fallback from drawing a duplicate or + * misleading target on top of a document-backed scene. + */ +export function getCurrentTargetMarkerSource({ + fullDrillSceneAvailable, + sceneHasCurrent, + legacyFallbackAvailable, +}: CurrentTargetMarkerPolicyInput): CurrentTargetMarkerSource { + if (fullDrillSceneAvailable) { + return sceneHasCurrent ? "drill-scene" : "none"; + } + return legacyFallbackAvailable ? "legacy-fallback" : "none"; +} + +export function resolveCurrentTargetPosition({ + fullDrillSceneAvailable, + sceneCurrent, + legacyFallback, +}: { + readonly fullDrillSceneAvailable: boolean; + readonly sceneCurrent?: FieldPoint | null; + readonly legacyFallback?: FieldPoint; +}): FieldPoint | undefined { + const source = getCurrentTargetMarkerSource({ + fullDrillSceneAvailable, + sceneHasCurrent: sceneCurrent !== undefined && sceneCurrent !== null, + legacyFallbackAvailable: legacyFallback !== undefined, + }); + if (source === "drill-scene") return sceneCurrent ?? undefined; + if (source === "legacy-fallback") return legacyFallback; + return undefined; +} + +export function shouldShowFieldGuidanceForScene( + state: FieldDrillOverlayState, + targetPolicy: CurrentTargetMarkerPolicyInput, +): boolean { + return ( + shouldShowFieldGuidance(state) && + getCurrentTargetMarkerSource(targetPolicy) !== "none" + ); +} + +export function shouldShowFieldTarget(state: FieldDrillOverlayState): boolean { + return ( + state.drillFeaturesEnabled && state.hasActiveDrill && state.hasSelectedPage + ); +} + +export function shouldShowFieldGuidance( + state: FieldDrillOverlayState, +): boolean { + return ( + shouldShowFieldTarget(state) && + state.hasLivePosition && + state.guidanceEnabled + ); +} diff --git a/packages/mobile/src/field/render/field-position-layer.tsx b/packages/mobile/src/field/render/field-position-layer.tsx new file mode 100644 index 00000000..5c8696ee --- /dev/null +++ b/packages/mobile/src/field/render/field-position-layer.tsx @@ -0,0 +1,43 @@ +import { Circle } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { FieldPoint } from "../types"; +import { + LIVE_POSITION_MARKER_DIAMETER_METERS, + type FieldRenderPalette, +} from "./field-render-tokens"; +import { STANDARD_STEP_METERS } from "../units"; + +export function FieldPositionLayer({ + livePosition, + palette, +}: { + readonly livePosition: SharedValue; + readonly palette: FieldRenderPalette; +}) { + const cx = useDerivedValue(() => livePosition.value?.xMeters ?? -1_000_000); + const cy = useDerivedValue(() => livePosition.value?.yMeters ?? -1_000_000); + const outerRadius = LIVE_POSITION_MARKER_DIAMETER_METERS / 2; + const innerRadius = outerRadius - STANDARD_STEP_METERS * 0.1; + const liveOpacity = useDerivedValue(() => + livePosition.value === null ? 0 : 1, + ); + return ( + <> + + + + ); +} diff --git a/packages/mobile/src/field/render/field-render-tokens.ts b/packages/mobile/src/field/render/field-render-tokens.ts new file mode 100644 index 00000000..db9c8a3a --- /dev/null +++ b/packages/mobile/src/field/render/field-render-tokens.ts @@ -0,0 +1,66 @@ +import { COLOR_PRESETS } from "@eight2five/drill-schema"; + +import { STANDARD_STEP_METERS } from "../units"; + +export const FIELD_FOUR_STEP_GRID_COLOR = "#6FA0E1"; + +function colorWithOpacity(color: `#${string}`, opacity: number): string { + const red = Number.parseInt(color.slice(1, 3), 16); + const green = Number.parseInt(color.slice(3, 5), 16); + const blue = Number.parseInt(color.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} + +export interface FieldRenderPalette { + readonly canvasBackground: string; + readonly stepGrid: string; + readonly fieldBackground: string; + readonly fourStepGrid: string; + readonly fieldLines: string; + readonly fieldNumbers: string; + readonly livePosition: string; + readonly guidance: string; + readonly anchor: string; + readonly anchorRange: string; +} + +/** Schema-synchronized colors used by the selected performer's markers. */ +export const DRILL_MARKER_COLORS = Object.freeze({ + yellow: COLOR_PRESETS.yellow, + red: COLOR_PRESETS.red, + green: COLOR_PRESETS.green, +}); + +/** Marker diameters are physical sizes expressed in standard 8:5 steps. */ +export const DRILL_MARKER_SIZE_STEPS = Object.freeze({ + currentDiameter: 2, + transitionDiameter: 1, + midpointDiameter: 0.5, +}); + +/** World-space diameters used by the renderer so markers stay locked to the grid. */ +export const DRILL_MARKER_SIZE_METERS = Object.freeze({ + currentDiameter: + DRILL_MARKER_SIZE_STEPS.currentDiameter * STANDARD_STEP_METERS, + transitionDiameter: + DRILL_MARKER_SIZE_STEPS.transitionDiameter * STANDARD_STEP_METERS, + midpointDiameter: + DRILL_MARKER_SIZE_STEPS.midpointDiameter * STANDARD_STEP_METERS, +}); + +export const LIVE_POSITION_MARKER_SIZE_STEPS = 1.5; +export const LIVE_POSITION_MARKER_DIAMETER_METERS = + LIVE_POSITION_MARKER_SIZE_STEPS * STANDARD_STEP_METERS; + +export const DEFAULT_FIELD_RENDER_PALETTE: FieldRenderPalette = Object.freeze({ + canvasBackground: "#E7EAF0", + stepGrid: "rgba(76, 93, 120, 0.22)", + fieldBackground: "rgba(247, 249, 252, 0.90)", + fourStepGrid: FIELD_FOUR_STEP_GRID_COLOR, + fieldLines: "#5D6470", + fieldNumbers: "#69717D", + livePosition: COLOR_PRESETS.blue, + guidance: colorWithOpacity(COLOR_PRESETS.blue, 0.74), + anchor: "#7B5CC7", + anchorRange: "rgba(123, 92, 199, 0.14)", +}); diff --git a/packages/mobile/src/field/render/field-scene.tsx b/packages/mobile/src/field/render/field-scene.tsx new file mode 100644 index 00000000..b77861da --- /dev/null +++ b/packages/mobile/src/field/render/field-scene.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import { Group } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { StandardFootballFieldTemplate } from "../template"; +import type { FieldPoint } from "../types"; +import type { + FieldCamera, + FieldCameraPerspective, + FieldViewportSize, +} from "../camera/field-camera-types"; +import type { FieldPaths } from "./create-field-paths"; +import { FieldStaticLayer } from "./field-static-layer"; +import { FieldAnchorLayer } from "./field-anchor-layer"; +import { FieldGuidanceLayer } from "./field-guidance-layer"; +import { FieldPositionLayer } from "./field-position-layer"; +import type { + FieldAnchorGeometry, + FieldAnchorOverlayOptions, +} from "./field-overlay-types"; +import type { FieldRenderPalette } from "./field-render-tokens"; +import type { DrillRenderScene } from "../../drill/render-scene"; +import { FieldDrillLayer } from "./field-drill-layer"; + +interface FieldSceneProps { + readonly camera: FieldCamera; + readonly canvasSize: SharedValue; + readonly template: StandardFootballFieldTemplate; + readonly paths: FieldPaths; + readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; + readonly livePosition: SharedValue; + readonly targetPosition?: FieldPoint; + readonly drillScene?: DrillRenderScene; + readonly guidanceVisible: boolean; + readonly anchors: readonly FieldAnchorGeometry[]; + readonly anchorOverlayOptions: FieldAnchorOverlayOptions; + readonly showPerimeterStepGrid: boolean; + readonly showAuxiliaryFieldMarks: boolean; +} + +export function FieldScene({ + camera, + canvasSize, + template, + paths, + palette, + perspective, + livePosition, + targetPosition, + drillScene, + guidanceVisible, + anchors, + anchorOverlayOptions, + showPerimeterStepGrid, + showAuxiliaryFieldMarks, +}: FieldSceneProps) { + const cameraTransform = useDerivedValue(() => { + const scale = 1 / camera.metersPerPixel.value; + const performerView = perspective === "performer"; + return [ + { translateX: canvasSize.value.width / 2 }, + { translateY: canvasSize.value.height / 2 }, + { scaleX: performerView ? -scale : scale }, + { scaleY: performerView ? scale : -scale }, + { translateX: -camera.centerXMeters.value }, + { translateY: -camera.centerYMeters.value }, + ]; + }); + + return ( + + + + + {guidanceVisible && targetPosition ? ( + + ) : null} + + + ); +} diff --git a/packages/mobile/src/field/render/field-static-layer.tsx b/packages/mobile/src/field/render/field-static-layer.tsx new file mode 100644 index 00000000..e23c4bac --- /dev/null +++ b/packages/mobile/src/field/render/field-static-layer.tsx @@ -0,0 +1,231 @@ +import React from "react"; +import { Montserrat_600SemiBold } from "@expo-google-fonts/montserrat/600SemiBold"; +import { + Group, + Path, + Rect, + Text, + useFont, + type SkFont, +} from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +import type { FieldCameraPerspective } from "../camera/field-camera-types"; +import type { StandardFootballFieldTemplate } from "../template"; +import type { FieldPaths } from "./create-field-paths"; +import type { FieldRenderPalette } from "./field-render-tokens"; +import { createYardNumberTextLayout } from "./yard-number-layout"; + +const YARD_NUMBER_MEASUREMENT_FONT_SIZE = 100; +const SIDELINE_LABEL_FONT_SIZE_PX = 11; +const SIDELINE_LABEL_INSET_PX = 6; + +interface FieldStaticLayerProps { + readonly template: StandardFootballFieldTemplate; + readonly paths: FieldPaths; + readonly metersPerPixel: SharedValue; + readonly palette: FieldRenderPalette; + readonly perspective: FieldCameraPerspective; + readonly showPerimeterStepGrid: boolean; + readonly showAuxiliaryFieldMarks: boolean; +} + +export const FieldStaticLayer = React.memo(function FieldStaticLayer({ + template, + paths, + metersPerPixel, + palette, + perspective, + showPerimeterStepGrid, + showAuxiliaryFieldMarks, +}: FieldStaticLayerProps) { + const stepGridStroke = useDerivedValue(() => metersPerPixel.value * 0.7); + const fourStepStroke = useDerivedValue(() => metersPerPixel.value * 1.1); + const fieldLineStroke = useDerivedValue(() => metersPerPixel.value * 1.4); + const boundaryStroke = useDerivedValue(() => metersPerPixel.value * 2); + // Measure/draw from a large, fixed reference size and scale into world + // meters afterward. Measuring Montserrat at ~1.83 "font units" (six feet) + // is small enough for hinting/rounding to distort the glyph bounds on some + // platforms, which made the painted numbers undersized and slightly offset. + const numberFont = useFont( + Montserrat_600SemiBold, + YARD_NUMBER_MEASUREMENT_FONT_SIZE, + ); + const sidelineFont = useFont( + Montserrat_600SemiBold, + SIDELINE_LABEL_FONT_SIZE_PX, + ); + const fieldClip = { + x: template.bounds.minXMeters, + y: template.bounds.minYMeters, + width: template.goalToGoalMeters, + height: template.widthMeters, + }; + const perimeterClip = { + x: paths.gridExtent.minXMeters, + y: paths.gridExtent.minYMeters, + width: paths.gridExtent.maxXMeters - paths.gridExtent.minXMeters, + height: paths.gridExtent.maxYMeters - paths.gridExtent.minYMeters, + }; + + return ( + <> + {showPerimeterStepGrid ? ( + + + + ) : null} + + + + + + + + {showAuxiliaryFieldMarks ? ( + + ) : null} + + {sidelineFont ? ( + <> + + + + ) : null} + {numberFont + ? template.yardNumbers.map((number) => { + const layout = createYardNumberTextLayout( + numberFont.measureText(number.label), + number.heightMeters, + ); + return ( + + + + + + ); + }) + : null} + + ); +}); + +function SidelineLabel({ + text, + yMeters, + atTop, + perspective, + metersPerPixel, + font, + color, +}: { + readonly text: string; + readonly yMeters: number; + readonly atTop: boolean; + readonly perspective: FieldCameraPerspective; + readonly metersPerPixel: SharedValue; + readonly font: SkFont; + readonly color: string; +}) { + const width = font.measureText(text).width; + const transform = useDerivedValue(() => { + const scale = metersPerPixel.value; + const uprightScaleX = perspective === "performer" ? -scale : scale; + const uprightScaleY = perspective === "performer" ? scale : -scale; + const orientation = atTop ? 1 : -1; + return [ + { scaleX: uprightScaleX * orientation }, + { scaleY: uprightScaleY * orientation }, + ]; + }); + // Keep the label outside the field with the bottom of the lettering facing + // its sideline. The lower on-screen label is rotated 180 degrees, so the + // same local baseline offset works for both sides. + const baselineOffset = -SIDELINE_LABEL_INSET_PX; + + return ( + + + + ); +} diff --git a/packages/mobile/src/field/render/index.ts b/packages/mobile/src/field/render/index.ts new file mode 100644 index 00000000..9becda4a --- /dev/null +++ b/packages/mobile/src/field/render/index.ts @@ -0,0 +1,8 @@ +export * from "./create-field-paths"; +export * from "./field-render-tokens"; +export * from "./field-canvas"; +export * from "./page-dial-canvas"; +export * from "./field-overlay-types"; +export * from "./yard-number-layout"; +export * from "./field-drill-layer"; +export * from "./drill-shape-policy"; diff --git a/packages/mobile/src/field/render/page-dial-canvas.tsx b/packages/mobile/src/field/render/page-dial-canvas.tsx new file mode 100644 index 00000000..8b65f5ad --- /dev/null +++ b/packages/mobile/src/field/render/page-dial-canvas.tsx @@ -0,0 +1,246 @@ +import React from "react"; +import { Canvas, Circle, Path, Shadow, Skia } from "@shopify/react-native-skia"; +import { useDerivedValue, type SharedValue } from "react-native-reanimated"; + +export interface FieldPageDialPoint { + readonly x: number; + readonly y: number; +} + +export interface FieldPageDialLineSegment { + readonly start: FieldPageDialPoint; + readonly end: FieldPageDialPoint; +} + +export interface FieldPageDialCanvasProps { + readonly diameter: number; + readonly progress: SharedValue; + readonly startAngleDegrees: number; + readonly usableArcDegrees: number; + readonly activeColor: string; + readonly trackColor: string; + readonly innerColor?: string; + readonly backgroundColor?: string; + readonly knobColor?: string; + readonly showKnob?: boolean; + readonly dividerColor?: string; + readonly dividerSegments?: readonly FieldPageDialLineSegment[]; + readonly testID?: string; +} + +const INNER_DISK_DIAMETER_RATIO = 0.86; +const CENTER_DISK_DIAMETER_RATIO = 0.3; +const RING_THICKNESS_RATIO = 0.075; +const KNOB_DIAMETER_RATIO = 0.16; +const CANVAS_OVERSCAN_RATIO = 0.09; +const DIVIDER_STROKE_RATIO = 0.012; + +function pointAtRadius( + diameter: number, + angleDegrees: number, + radius: number, +): FieldPageDialPoint { + const center = diameter / 2; + const angle = (angleDegrees * Math.PI) / 180; + return { + x: center + Math.cos(angle) * radius, + y: center + Math.sin(angle) * radius, + }; +} + +function getDefaultDividerSegments( + diameter: number, +): readonly FieldPageDialLineSegment[] { + const outerRadius = (diameter * INNER_DISK_DIAMETER_RATIO) / 2; + const innerRadius = (diameter * CENTER_DISK_DIAMETER_RATIO) / 2; + return [ + { + start: pointAtRadius(diameter, -135, outerRadius), + end: pointAtRadius(diameter, -135, innerRadius), + }, + { + start: pointAtRadius(diameter, -45, outerRadius), + end: pointAtRadius(diameter, -45, innerRadius), + }, + { + start: pointAtRadius(diameter, 45, innerRadius), + end: pointAtRadius(diameter, 45, outerRadius), + }, + { + start: pointAtRadius(diameter, 135, innerRadius), + end: pointAtRadius(diameter, 135, outerRadius), + }, + ]; +} + +export function FieldPageDialCanvas({ + diameter, + progress, + startAngleDegrees, + usableArcDegrees, + activeColor, + trackColor, + innerColor = "#222222", + backgroundColor = "transparent", + knobColor = "#FFFFFF", + showKnob = true, + dividerColor = "rgba(255,255,255,0.28)", + dividerSegments, + testID = "page-dial-canvas", +}: FieldPageDialCanvasProps) { + const ringThickness = diameter * RING_THICKNESS_RATIO; + const ringRadius = diameter / 2 - ringThickness / 2; + const knobRadius = (diameter * KNOB_DIAMETER_RATIO) / 2; + const canvasOverscan = diameter * CANVAS_OVERSCAN_RATIO; + const canvasDiameter = diameter + canvasOverscan * 2; + const center = canvasDiameter / 2; + const innerDiskRadius = (diameter * INNER_DISK_DIAMETER_RATIO) / 2; + const centerDiskRadius = (diameter * CENTER_DISK_DIAMETER_RATIO) / 2; + const segments = dividerSegments ?? getDefaultDividerSegments(diameter); + const fullCircle = Math.abs(usableArcDegrees) >= 359.999; + + const trackPath = React.useMemo(() => { + const inset = canvasOverscan + ringThickness / 2; + return Skia.PathBuilder.Make() + .addArc( + Skia.XYWHRect( + inset, + inset, + diameter - ringThickness, + diameter - ringThickness, + ), + startAngleDegrees, + usableArcDegrees, + ) + .build(); + }, [ + canvasOverscan, + diameter, + ringThickness, + startAngleDegrees, + usableArcDegrees, + ]); + + const dividerPaths = React.useMemo( + () => + segments.map(({ start, end }) => + Skia.PathBuilder.Make() + .moveTo(start.x + canvasOverscan, start.y + canvasOverscan) + .lineTo(end.x + canvasOverscan, end.y + canvasOverscan) + .build(), + ), + [canvasOverscan, segments], + ); + + const normalizedProgress = useDerivedValue(() => { + const value = progress.value; + return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; + }); + const fullActiveOpacity = useDerivedValue(() => + fullCircle && normalizedProgress.value >= 0.999999 ? 1 : 0, + ); + const knobX = useDerivedValue(() => { + const angle = + ((startAngleDegrees + normalizedProgress.value * usableArcDegrees) * + Math.PI) / + 180; + return center + Math.cos(angle) * ringRadius; + }); + const knobY = useDerivedValue(() => { + const angle = + ((startAngleDegrees + normalizedProgress.value * usableArcDegrees) * + Math.PI) / + 180; + return center + Math.sin(angle) * ringRadius; + }); + return ( + + + + + {/* Paint the complete track first, then overlap it with the active arc. */} + {fullCircle ? ( + + ) : ( + + )} + {fullCircle ? ( + + ) : null} + + + {dividerPaths.map((path, index) => ( + + ))} + + {/* The center disk is drawn last so the X ends exactly at its edge. */} + + + {/* A larger, overscanned knob keeps its soft offset shadow inside the canvas. */} + {showKnob ? ( + + + + ) : null} + + ); +} diff --git a/packages/mobile/src/field/render/yard-number-layout.ts b/packages/mobile/src/field/render/yard-number-layout.ts new file mode 100644 index 00000000..706bee02 --- /dev/null +++ b/packages/mobile/src/field/render/yard-number-layout.ts @@ -0,0 +1,52 @@ +export interface TextVisualBounds { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface YardNumberTextLayout { + /** Baseline origin before the orientation transform is applied. */ + readonly x: number; + readonly y: number; + readonly scaleX: number; + readonly scaleY: number; + readonly visualWidthMeters: number; + readonly visualHeightMeters: number; +} + +/** + * Centers measured glyph bounds at the origin and scales their visual height + * to the schema-defined physical height. The field scene reflects world Y into + * screen Y, so this layer always reflects text Y once more to keep every yard + * number upright to the viewer regardless of which sideline row it belongs to. + */ +export function createYardNumberTextLayout( + bounds: TextVisualBounds, + targetHeightMeters: number, +): YardNumberTextLayout { + if ( + !Number.isFinite(targetHeightMeters) || + targetHeightMeters <= 0 || + !Number.isFinite(bounds.x) || + !Number.isFinite(bounds.y) || + !Number.isFinite(bounds.width) || + bounds.width < 0 || + !Number.isFinite(bounds.height) || + bounds.height <= 0 + ) { + throw new RangeError( + "Yard-number text bounds and height must be finite and positive.", + ); + } + + const scale = targetHeightMeters / bounds.height; + return Object.freeze({ + x: -bounds.x - bounds.width / 2, + y: -bounds.y - bounds.height / 2, + scaleX: scale, + scaleY: -scale, + visualWidthMeters: bounds.width * scale, + visualHeightMeters: bounds.height * scale, + }); +} diff --git a/packages/mobile/src/field/template.ts b/packages/mobile/src/field/template.ts new file mode 100644 index 00000000..8160388a --- /dev/null +++ b/packages/mobile/src/field/template.ts @@ -0,0 +1,354 @@ +import { + getFieldPreset, + type FieldMarkingDefinition, + type FieldPresetId, + type ResolvedFieldDefinition, +} from "@eight2five/drill-schema"; + +import { + feetToMeters, + metersToFeet, + metersToYards, + yardsToMeters, +} from "./units"; +import type { FieldPoint } from "./types"; + +export type FieldLineKind = + | "goal-line" + | "sideline" + | "hash-line" + | "yard-line"; + +export interface FieldLine { + readonly kind: FieldLineKind; + readonly name: string; + readonly axis: "x" | "y"; + readonly coordinateMeters: number; + readonly start: FieldPoint; + readonly end: FieldPoint; + /** Signed yards from the 50, when the line has a longitudinal coordinate. */ + readonly yardLineYards?: number; +} + +export interface FieldYardNumber { + readonly label: string; + /** Side-relative number printed on the field (0, 10, 20, 30, 40, or 50). */ + readonly yardLineYards: number; + readonly xMeters: number; + readonly yMeters: number; + readonly side: "front" | "back"; + /** Reference width only; rendering preserves the font's natural aspect ratio. */ + readonly widthMeters: number; + readonly heightMeters: number; +} + +export interface StandardFootballFieldDimensions { + readonly goalToGoalYards: 100; + readonly widthYards: number; + readonly goalToGoalMeters: number; + readonly widthMeters: number; + readonly fiveYardLineSpacingYards: 5; + readonly fiveYardLineSpacingMeters: number; + readonly hashFromSidelineFeet: number; + readonly hashFromSidelineMeters: number; + /** @deprecated Use hashFromSidelineFeet. */ + readonly highSchoolHashFromSidelineFeet: number; + /** @deprecated Use hashFromSidelineMeters. */ + readonly highSchoolHashFromSidelineMeters: number; + readonly yardNumberCenterFromFrontSidelineFeet: number; + readonly yardNumberCenterFromFrontSidelineMeters: number; + readonly yardNumberCenterFromBackSidelineFeet: number; + readonly yardNumberCenterFromBackSidelineMeters: number; + /** @deprecated Use yardNumberCenterFromFrontSidelineFeet. */ + readonly yardNumberInsetFromSidelineFeet: number; + /** @deprecated Use yardNumberCenterFromFrontSidelineMeters. */ + readonly yardNumberInsetFromSidelineMeters: number; + readonly yardNumberWidthFeet: number; + readonly yardNumberHeightFeet: number; + readonly yardNumberWidthMeters: number; + readonly yardNumberHeightMeters: number; +} + +/** @deprecated Use StandardFootballFieldDimensions. */ +export type StandardHighSchoolFieldDimensions = StandardFootballFieldDimensions; + +export interface StandardFootballFieldTemplate { + readonly name: "standard-football"; + readonly fieldPreset: FieldPresetId; + readonly fieldDefinition: ResolvedFieldDefinition; + readonly dimensions: StandardFootballFieldDimensions; + readonly goalToGoalYards: 100; + readonly widthYards: number; + readonly goalToGoalMeters: number; + readonly widthMeters: number; + readonly bounds: { + readonly minXMeters: number; + readonly maxXMeters: number; + readonly minYMeters: number; + readonly maxYMeters: number; + }; + readonly goalLines: readonly [FieldLine, FieldLine]; + readonly sidelines: readonly [FieldLine, FieldLine]; + readonly hashLines: readonly [FieldLine, FieldLine]; + readonly frontHashLine: FieldLine; + readonly backHashLine: FieldLine; + /** The 19 interior five-yard lines; goal lines are listed separately. */ + readonly fiveYardLines: readonly FieldLine[]; + /** The 21 multiples of five yards, including the two goal lines. */ + readonly allFiveYardLines: readonly FieldLine[]; + readonly yardLines: readonly FieldLine[]; + readonly yardNumbers: readonly FieldYardNumber[]; +} + +/** @deprecated Use StandardFootballFieldTemplate. */ +export type StandardHighSchoolFieldTemplate = StandardFootballFieldTemplate; + +const FIELD_LENGTH_YARDS = 100 as const; +const FIELD_WIDTH_YARDS = 160 / 3; +const FIELD_LENGTH_METERS = yardsToMeters(FIELD_LENGTH_YARDS); +const FIELD_WIDTH_METERS = feetToMeters(160); + +export const STANDARD_FIELD_LENGTH_YARDS = FIELD_LENGTH_YARDS; +export const STANDARD_FIELD_WIDTH_YARDS = FIELD_WIDTH_YARDS; +export const STANDARD_FIELD_LENGTH_METERS = FIELD_LENGTH_METERS; +export const STANDARD_FIELD_WIDTH_METERS = FIELD_WIDTH_METERS; +export const HIGH_SCHOOL_HASH_DISTANCE_FEET = 53 + 4 / 12; +export const HIGH_SCHOOL_HASH_DISTANCE_METERS = feetToMeters( + HIGH_SCHOOL_HASH_DISTANCE_FEET, +); + +const TEMPLATE_CACHE = new Map(); + +function point(xMeters: number, yMeters: number): FieldPoint { + return Object.freeze({ xMeters, yMeters }); +} + +function findReference(field: ResolvedFieldDefinition, id: string): number { + const reference = field.physicalGeometry.referenceLines.find( + (line) => line.id === id, + ); + if (!reference) throw new RangeError(`Field preset is missing ${id}.`); + return reference.coordinateMeters; +} + +function xLine( + bounds: StandardFootballFieldTemplate["bounds"], + kind: FieldLineKind, + name: string, + signedYardsFromCenter: number, +): FieldLine { + const xMeters = yardsToMeters(signedYardsFromCenter); + return Object.freeze({ + kind, + name, + axis: "x", + coordinateMeters: xMeters, + start: point(xMeters, bounds.minYMeters), + end: point(xMeters, bounds.maxYMeters), + yardLineYards: signedYardsFromCenter, + }); +} + +function yLine( + bounds: StandardFootballFieldTemplate["bounds"], + kind: FieldLineKind, + name: string, + yMeters: number, +): FieldLine { + return Object.freeze({ + kind, + name, + axis: "y", + coordinateMeters: yMeters, + start: point(bounds.minXMeters, yMeters), + end: point(bounds.maxXMeters, yMeters), + }); +} + +function makeYardNumbers( + bounds: StandardFootballFieldTemplate["bounds"], + markings: FieldMarkingDefinition, +): readonly FieldYardNumber[] { + const numbers: FieldYardNumber[] = []; + for (const xYards of [-50, -40, -30, -20, -10, 0, 10, 20, 30, 40, 50]) { + const sideRelativeYards = xYards === 0 ? 50 : 50 - Math.abs(xYards); + const label = String(sideRelativeYards); + const xMeters = yardsToMeters(xYards); + for (const side of ["front", "back"] as const) { + const yMeters = + side === "front" + ? bounds.minYMeters + + markings.yardNumbers.centerFromFrontSidelineMeters + : bounds.maxYMeters - + markings.yardNumbers.centerFromBackSidelineMeters; + numbers.push( + Object.freeze({ + label, + yardLineYards: sideRelativeYards, + xMeters, + yMeters, + side, + widthMeters: markings.yardNumbers.nominalWidthMeters, + heightMeters: markings.yardNumbers.heightMeters, + }), + ); + } + } + return Object.freeze(numbers); +} + +function hashPrefix(fieldPreset: FieldPresetId): string { + switch (fieldPreset) { + case "football-nfhs": + return "HS"; + case "football-ncaa": + return "NCAA"; + case "football-texas-uil": + return "UIL"; + case "football-nfl": + return "NFL"; + } +} + +export function createStandardFootballFieldTemplate( + fieldPreset: FieldPresetId, +): StandardFootballFieldTemplate { + const cached = TEMPLATE_CACHE.get(fieldPreset); + if (cached) return cached; + + const fieldDefinition = getFieldPreset(fieldPreset); + const physicalBounds = fieldDefinition.physicalGeometry.bounds; + const bounds = Object.freeze({ + minXMeters: physicalBounds.minXMeters, + maxXMeters: physicalBounds.maxXMeters, + minYMeters: physicalBounds.minYMeters, + maxYMeters: physicalBounds.maxYMeters, + }); + const frontHashMeters = findReference(fieldDefinition, "front-hash"); + const backHashMeters = findReference(fieldDefinition, "back-hash"); + const frontHashFromSidelineMeters = frontHashMeters - bounds.minYMeters; + const markings = fieldDefinition.markings; + const prefix = hashPrefix(fieldPreset); + + const goalLines = Object.freeze([ + xLine(bounds, "goal-line", "Side 1 Goal Line", -50), + xLine(bounds, "goal-line", "Side 2 Goal Line", 50), + ] as const); + const sidelines = Object.freeze([ + yLine(bounds, "sideline", "Front Sideline", bounds.minYMeters), + yLine(bounds, "sideline", "Back Sideline", bounds.maxYMeters), + ] as const); + const hashLines = Object.freeze([ + yLine(bounds, "hash-line", `${prefix} FH`, frontHashMeters), + yLine(bounds, "hash-line", `${prefix} BH`, backHashMeters), + ] as const); + const fiveYardLines = Object.freeze( + Array.from({ length: 19 }, (_, index) => { + const signedYards = -45 + index * 5; + const side = signedYards < 0 ? "Side 1" : signedYards > 0 ? "Side 2" : ""; + const labelYards = signedYards === 0 ? 50 : 50 - Math.abs(signedYards); + return xLine( + bounds, + "yard-line", + signedYards === 0 ? "50 yd Line" : `${side} ${labelYards} yd Line`, + signedYards, + ); + }), + ); + const allFiveYardLines = Object.freeze([ + goalLines[0], + ...fiveYardLines, + goalLines[1], + ]); + const widthMeters = bounds.maxYMeters - bounds.minYMeters; + const goalToGoalMeters = bounds.maxXMeters - bounds.minXMeters; + const dimensions: StandardFootballFieldDimensions = Object.freeze({ + goalToGoalYards: FIELD_LENGTH_YARDS, + widthYards: metersToYards(widthMeters), + goalToGoalMeters, + widthMeters, + fiveYardLineSpacingYards: 5, + fiveYardLineSpacingMeters: yardsToMeters(5), + hashFromSidelineFeet: metersToFeet(frontHashFromSidelineMeters), + hashFromSidelineMeters: frontHashFromSidelineMeters, + highSchoolHashFromSidelineFeet: metersToFeet(frontHashFromSidelineMeters), + highSchoolHashFromSidelineMeters: frontHashFromSidelineMeters, + yardNumberCenterFromFrontSidelineFeet: metersToFeet( + markings.yardNumbers.centerFromFrontSidelineMeters, + ), + yardNumberCenterFromFrontSidelineMeters: + markings.yardNumbers.centerFromFrontSidelineMeters, + yardNumberCenterFromBackSidelineFeet: metersToFeet( + markings.yardNumbers.centerFromBackSidelineMeters, + ), + yardNumberCenterFromBackSidelineMeters: + markings.yardNumbers.centerFromBackSidelineMeters, + yardNumberInsetFromSidelineFeet: metersToFeet( + markings.yardNumbers.centerFromFrontSidelineMeters, + ), + yardNumberInsetFromSidelineMeters: + markings.yardNumbers.centerFromFrontSidelineMeters, + yardNumberWidthFeet: metersToFeet(markings.yardNumbers.nominalWidthMeters), + yardNumberHeightFeet: metersToFeet(markings.yardNumbers.heightMeters), + yardNumberWidthMeters: markings.yardNumbers.nominalWidthMeters, + yardNumberHeightMeters: markings.yardNumbers.heightMeters, + }); + + const template: StandardFootballFieldTemplate = Object.freeze({ + name: "standard-football", + fieldPreset, + fieldDefinition, + dimensions, + goalToGoalYards: FIELD_LENGTH_YARDS, + widthYards: metersToYards(widthMeters), + goalToGoalMeters, + widthMeters, + bounds, + goalLines, + sidelines, + hashLines, + frontHashLine: hashLines[0], + backHashLine: hashLines[1], + fiveYardLines, + allFiveYardLines, + yardLines: fiveYardLines, + yardNumbers: makeYardNumbers(bounds, markings), + }); + TEMPLATE_CACHE.set(fieldPreset, template); + return template; +} + +/** Exact NFHS physical geometry plus its conventional 160 x 84 marching grid. */ +export const STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE = + createStandardFootballFieldTemplate("football-nfhs"); + +export const STANDARD_HIGH_SCHOOL_FIELD = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; +export const STANDARD_FIELD_TEMPLATE = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + +export function getStandardFieldDimensionsInFeet() { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + return Object.freeze({ + goalToGoalFeet: metersToFeet(field.goalToGoalMeters), + widthFeet: metersToFeet(field.widthMeters), + frontHashFromSidelineFeet: metersToFeet( + field.frontHashLine.coordinateMeters - field.bounds.minYMeters, + ), + backHashFromFrontSidelineFeet: metersToFeet( + field.backHashLine.coordinateMeters - field.bounds.minYMeters, + ), + }); +} + +export function getStandardFieldDimensionsInYards() { + const field = STANDARD_HIGH_SCHOOL_FIELD_TEMPLATE; + return Object.freeze({ + goalToGoalYards: metersToYards(field.goalToGoalMeters), + widthYards: metersToYards(field.widthMeters), + frontHashFromFrontSidelineYards: metersToYards( + field.frontHashLine.coordinateMeters - field.bounds.minYMeters, + ), + backHashFromFrontSidelineYards: metersToYards( + field.backHashLine.coordinateMeters - field.bounds.minYMeters, + ), + }); +} diff --git a/packages/mobile/src/field/types.ts b/packages/mobile/src/field/types.ts new file mode 100644 index 00000000..ed68a107 --- /dev/null +++ b/packages/mobile/src/field/types.ts @@ -0,0 +1,101 @@ +/** The two goal-line ends of a football field. */ +export type FieldSide = 1 | 2; + +/** The four named lateral references used by marching coordinates. */ +export type FieldLateralReference = + | "front-sideline" + | "front-hash" + | "back-hash" + | "back-sideline"; + +/** + * A two-dimensional point in Eight2Five's canonical physical field space. + * + * The origin is center field (the 50-yard line) on the front sideline. X is + * negative toward Side 1 and positive toward Side 2. Y is positive toward the + * back sideline. Physical positions use meters; drill positions use the + * separate DrillGridPoint type from @eight2five/drill-schema. + */ +export interface FieldPoint { + readonly xMeters: number; + readonly yMeters: number; +} + +/** A field point with an optional vertical coordinate. Z increases upward. */ +export interface FieldPosition extends FieldPoint { + readonly zMeters?: number; +} + +/** + * The canonical three-dimensional position used for field anchors. + * Coordinates are stored in meters and z increases upward. + */ +export interface AnchorFieldPosition extends FieldPoint { + readonly zMeters: number; +} + +/** The canonical physical origin and axis directions. */ +export interface FieldCoordinateOrigin { + readonly xMeters: 0; + readonly yMeters: 0; + readonly zMeters: 0; + readonly longitudinalReference: "center-field"; + readonly lateralReference: "front-sideline"; +} + +export type FieldOrigin = FieldCoordinateOrigin; + +export const FIELD_ORIGIN: FieldCoordinateOrigin = Object.freeze({ + xMeters: 0, + yMeters: 0, + zMeters: 0, + longitudinalReference: "center-field", + lateralReference: "front-sideline", +}); + +export const FIELD_COORDINATE_ORIGIN = FIELD_ORIGIN; + +export const FIELD_AXIS_DIRECTIONS = Object.freeze({ + xNegative: "toward-side-1", + xPositive: "toward-side-2", + yPositive: "toward-back-sideline", + zPositive: "up", +} as const); + +/** Throws a clear error when a point contains a non-finite coordinate. */ +export function assertFiniteFieldPoint( + point: FieldPoint, + name = "Field point", +): void { + if (point === null || typeof point !== "object") { + throw new TypeError(`${name} must be an object with xMeters and yMeters.`); + } + if (!Number.isFinite(point.xMeters)) { + throw new RangeError(`${name}.xMeters must be a finite number.`); + } + if (!Number.isFinite(point.yMeters)) { + throw new RangeError(`${name}.yMeters must be a finite number.`); + } +} + +/** Throws a clear error when a position contains a non-finite coordinate. */ +export function assertFiniteFieldPosition( + position: FieldPosition, + name = "Field position", +): void { + assertFiniteFieldPoint(position, name); + if (position.zMeters !== undefined && !Number.isFinite(position.zMeters)) { + throw new RangeError(`${name}.zMeters must be a finite number.`); + } +} + +/** Throws a clear error when a canonical anchor position is not finite. */ +export function assertFiniteAnchorFieldPosition( + position: AnchorFieldPosition, + name = "Anchor field position", +): void { + assertFiniteFieldPoint(position, name); + if (!Number.isFinite(position.zMeters)) { + throw new RangeError(`${name}.zMeters must be a finite number.`); + } +} diff --git a/packages/mobile/src/field/units.ts b/packages/mobile/src/field/units.ts new file mode 100644 index 00000000..0611fb61 --- /dev/null +++ b/packages/mobile/src/field/units.ts @@ -0,0 +1,89 @@ +import { assertFiniteFieldPoint } from "./types"; + +/** Exact SI conversion constants used by every field-domain calculation. */ +export const METERS_PER_YARD = 0.9144; +export const METERS_PER_FOOT = 0.3048; +export const FEET_PER_YARD = 3; +export const YARDS_PER_FOOT = 1 / FEET_PER_YARD; + +/** Standard marching is eight steps over five yards (22.5 inches per step). */ +export const STANDARD_STEPS_PER_FIVE_YARDS = 8; +export const STANDARD_STEP_METERS = 0.5715; +export const FIVE_YARDS_IN_STANDARD_STEPS = STANDARD_STEPS_PER_FIVE_YARDS; +export const STANDARD_STEPS_PER_5_YARDS = STANDARD_STEPS_PER_FIVE_YARDS; +export const STANDARD_8_TO_5_STEPS = STANDARD_STEPS_PER_FIVE_YARDS; +export const METERS_PER_STANDARD_STEP = STANDARD_STEP_METERS; + +export const YARDS_PER_STANDARD_STEP = STANDARD_STEP_METERS / METERS_PER_YARD; +export const FEET_PER_STANDARD_STEP = STANDARD_STEP_METERS / METERS_PER_FOOT; +export const STANDARD_STEPS_PER_YARD = 1 / YARDS_PER_STANDARD_STEP; +export const STANDARD_STEPS_PER_FOOT = STANDARD_STEPS_PER_YARD / FEET_PER_YARD; + +function assertFinite(value: number, name: string): void { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be a finite number.`); + } +} + +export function yardsToMeters(yards: number): number { + assertFinite(yards, "Yards"); + return yards * METERS_PER_YARD; +} + +export function metersToYards(meters: number): number { + assertFinite(meters, "Meters"); + return meters / METERS_PER_YARD; +} + +export function feetToMeters(feet: number): number { + assertFinite(feet, "Feet"); + return feet * METERS_PER_FOOT; +} + +export function metersToFeet(meters: number): number { + assertFinite(meters, "Meters"); + return meters / METERS_PER_FOOT; +} + +export function standardStepsToMeters(steps: number): number { + assertFinite(steps, "Standard steps"); + return steps * STANDARD_STEP_METERS; +} + +export function metersToStandardSteps(meters: number): number { + assertFinite(meters, "Meters"); + return meters / STANDARD_STEP_METERS; +} + +export function yardsToStandardSteps(yards: number): number { + assertFinite(yards, "Yards"); + return yards * STANDARD_STEPS_PER_YARD; +} + +export function standardStepsToYards(steps: number): number { + assertFinite(steps, "Standard steps"); + return steps * YARDS_PER_STANDARD_STEP; +} + +export function feetToStandardSteps(feet: number): number { + assertFinite(feet, "Feet"); + return feet * STANDARD_STEPS_PER_FOOT; +} + +export function standardStepsToFeet(steps: number): number { + assertFinite(steps, "Standard steps"); + return steps * FEET_PER_STANDARD_STEP; +} + +/** Returns the signed X/Y displacement between two field points in steps. */ +export function fieldPointDisplacementInStandardSteps( + from: { xMeters: number; yMeters: number }, + to: { xMeters: number; yMeters: number }, +): { xSteps: number; ySteps: number } { + assertFiniteFieldPoint(from, "From point"); + assertFiniteFieldPoint(to, "To point"); + return { + xSteps: metersToStandardSteps(to.xMeters - from.xMeters), + ySteps: metersToStandardSteps(to.yMeters - from.yMeters), + }; +} diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index e7bed94c..b89f00c2 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -1 +1,47 @@ export * from "./pans-manager"; +// PANS map-units already exports METERS_PER_FOOT. Re-export the remaining +// field-unit symbols explicitly so the root barrel remains unambiguous while +// @eight2five/mobile/field exposes the complete field barrel. +export * from "./field/types"; +export { + FEET_PER_STANDARD_STEP, + FEET_PER_YARD, + FIVE_YARDS_IN_STANDARD_STEPS, + METERS_PER_STANDARD_STEP, + METERS_PER_YARD, + STANDARD_8_TO_5_STEPS, + STANDARD_STEP_METERS, + STANDARD_STEPS_PER_5_YARDS, + STANDARD_STEPS_PER_FIVE_YARDS, + STANDARD_STEPS_PER_FOOT, + STANDARD_STEPS_PER_YARD, + YARDS_PER_FOOT, + YARDS_PER_STANDARD_STEP, + feetToMeters, + feetToStandardSteps, + fieldPointDisplacementInStandardSteps, + metersToFeet, + metersToStandardSteps, + metersToYards, + standardStepsToFeet, + standardStepsToMeters, + standardStepsToYards, + yardsToMeters, + yardsToStandardSteps, +} from "./field/units"; +export * from "./field/template"; +export * from "./field/marching"; +export * from "./field/anchor-position"; +export * from "./field/guidance"; +export * from "./field/live-position"; +export * from "./field/camera/field-camera-types"; +export * from "./field/camera/field-camera-math"; +export * from "./field/camera/field-camera-policy"; +export * from "./field/render/create-field-paths"; +export * from "./field/render/field-render-tokens"; +export * from "./field/render/field-overlay-types"; +export * from "./field/render/drill-shape-policy"; +export * from "./drill"; +export * from "./motion"; +export * from "./settings"; +export * from "./storage"; diff --git a/packages/mobile/src/mobile-repositories.ts b/packages/mobile/src/mobile-repositories.ts new file mode 100644 index 00000000..3da20ade --- /dev/null +++ b/packages/mobile/src/mobile-repositories.ts @@ -0,0 +1,60 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { + MOBILE_DB_NAME, + prepareMobileDatabase, +} from "./storage/mobileDatabase"; +import { SqliteDrillRepository } from "./drill/SqliteDrillRepository"; +import { SqliteSettingsRepository } from "./settings/SqliteSettingsRepository"; + +export interface OpenMobileRepositoriesResult { + readonly drillRepository: SqliteDrillRepository; + readonly settingsRepository: SqliteSettingsRepository; + close(): Promise; +} + +/** + * Delete the disposable app-side SQLite database so it can be recreated from + * the current schema on the next open. Callers must close every connection to + * this database before invoking this helper. The separate PANS manager database + * is intentionally unaffected. + */ +export async function deleteMobileDatabase( + databaseName = MOBILE_DB_NAME, +): Promise { + const { deleteDatabaseAsync } = await import("expo-sqlite"); + await deleteDatabaseAsync(databaseName); +} + +/** + * Open the app-side repositories over one database connection. + * + * `expo-sqlite` is imported lazily so consumers of the pure field and drill + * helpers do not load native SQLite at module evaluation time. Schema + * preparation is owned here and runs before either repository is exposed. + */ +export async function openMobileRepositories( + databaseName = MOBILE_DB_NAME, +): Promise { + const { openDatabaseAsync } = await import("expo-sqlite"); + const database = await openDatabaseAsync(databaseName); + try { + await prepareMobileDatabase(database); + } catch (cause) { + await closeQuietly(database); + throw cause; + } + + return { + drillRepository: new SqliteDrillRepository(database), + settingsRepository: new SqliteSettingsRepository(database), + close: async () => await database.closeAsync(), + }; +} + +async function closeQuietly(database: SQLiteDatabase): Promise { + try { + await database.closeAsync(); + } catch { + // Preserve the schema/opening error rather than masking it with close. + } +} diff --git a/packages/mobile/src/motion/__tests__/device-motion.test.ts b/packages/mobile/src/motion/__tests__/device-motion.test.ts new file mode 100644 index 00000000..7a0f91c5 --- /dev/null +++ b/packages/mobile/src/motion/__tests__/device-motion.test.ts @@ -0,0 +1,79 @@ +import type { DeviceMotionMeasurement } from "expo-sensors"; +import { + DEVICE_MOTION_UPDATE_INTERVAL_MS, + ExpoDeviceMotionAdapter, +} from "../device-motion"; + +describe("ExpoDeviceMotionAdapter", () => { + test("falls back without requesting permission when the sensor is unavailable", async () => { + const sensor = createSensor({ available: false }); + const adapter = new ExpoDeviceMotionAdapter(sensor); + + await expect(adapter.start(jest.fn())).resolves.toBe("unavailable"); + expect(sensor.getPermissionsAsync).not.toHaveBeenCalled(); + expect(sensor.addListener).not.toHaveBeenCalled(); + }); + + test("falls back when permission remains denied", async () => { + const sensor = createSensor({ available: true, granted: false }); + const adapter = new ExpoDeviceMotionAdapter(sensor); + + await expect(adapter.start(jest.fn())).resolves.toBe("permission-denied"); + expect(sensor.requestPermissionsAsync).toHaveBeenCalledTimes(1); + expect(sensor.setUpdateInterval).not.toHaveBeenCalled(); + expect(sensor.addListener).not.toHaveBeenCalled(); + }); + + test("normalizes a measurement and removes the subscription", async () => { + let listener!: (measurement: DeviceMotionMeasurement) => void; + const sensor = createSensor({ available: true, granted: true }); + (sensor.addListener as jest.Mock).mockImplementation( + (next: (measurement: DeviceMotionMeasurement) => void) => { + listener = next; + return { remove: sensor.remove }; + }, + ); + const adapter = new ExpoDeviceMotionAdapter(sensor, () => 1234); + const received = jest.fn(); + + await expect(adapter.start(received)).resolves.toBe("active"); + expect(sensor.setUpdateInterval).toHaveBeenCalledWith( + DEVICE_MOTION_UPDATE_INTERVAL_MS, + ); + listener({ + acceleration: { x: 1, y: 2, z: 3, timestamp: 1 }, + accelerationIncludingGravity: { x: 0, y: 0, z: 0, timestamp: 1 }, + rotation: { alpha: 0, beta: 0, gamma: 0, timestamp: 1 }, + rotationRate: { alpha: 4, beta: 5, gamma: 6, timestamp: 1 }, + interval: 50, + orientation: 0, + }); + expect(received).toHaveBeenCalledWith({ + receivedAt: 1234, + acceleration: { x: 1, y: 2, z: 3 }, + rotationRate: { x: 4, y: 5, z: 6 }, + }); + + adapter.stop(); + expect(sensor.remove).toHaveBeenCalledTimes(1); + adapter.stop(); + expect(sensor.remove).toHaveBeenCalledTimes(1); + }); +}); + +function createSensor({ + available, + granted = true, +}: { + available: boolean; + granted?: boolean; +}) { + return { + isAvailableAsync: jest.fn(async () => available), + getPermissionsAsync: jest.fn(async () => ({ granted })), + requestPermissionsAsync: jest.fn(async () => ({ granted })), + setUpdateInterval: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + remove: jest.fn(), + }; +} diff --git a/packages/mobile/src/motion/__tests__/position-fusion.test.ts b/packages/mobile/src/motion/__tests__/position-fusion.test.ts new file mode 100644 index 00000000..e1fe43a1 --- /dev/null +++ b/packages/mobile/src/motion/__tests__/position-fusion.test.ts @@ -0,0 +1,191 @@ +import { + ConservativePositionFusion, + classifyMotionActivity, +} from "../position-fusion"; +import type { DeviceMotionSample } from "../device-motion"; + +describe("ConservativePositionFusion", () => { + test("predicts only along recent accepted UWB velocity and stops at 500ms", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + + expect( + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }), + ).toMatchObject({ + position: { xMeters: 0, yMeters: 0 }, + source: "uwb", + interpolationActive: false, + lastUwbAt: 0, + }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + + const predicted = fusion.acceptMotion(motion(1_200, 2)); + expect(predicted).toMatchObject({ + source: "motion-predicted", + interpolationActive: true, + freshnessMs: 200, + lastUwbAt: 1_000, + }); + expect(predicted?.position.xMeters).toBeCloseTo(0.78, 6); + + expect(fusion.acceptMotion(motion(1_501, 2))).toMatchObject({ + source: "prediction-expired", + interpolationActive: false, + freshnessMs: 501, + position: { xMeters: 0.65, yMeters: 0 }, + }); + }); + + test("stationary motion holds the last UWB fix and damps learned velocity", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + + const held = fusion.acceptMotion(motion(1_100, 0)); + expect(held).toMatchObject({ + source: "stationary-hold", + interpolationActive: false, + position: { xMeters: 0.65, yMeters: 0 }, + }); + + const resumed = fusion.acceptMotion(motion(1_200, 2)); + expect(resumed?.source).toBe("motion-predicted"); + expect(resumed?.position.xMeters).toBeCloseTo(0.676, 6); + }); + + test("rejects an implausible UWB jump instead of seeding prediction", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }); + const rejected = fusion.acceptUwb({ + position: { xMeters: 10, yMeters: 0 }, + receivedAt: 1_000, + }); + + expect(rejected).toMatchObject({ + source: "stationary-hold", + lastUwbAt: 0, + lastUwbPosition: { xMeters: 0, yMeters: 0 }, + }); + expect(fusion.acceptMotion(motion(200, 2))).toMatchObject({ + source: "uwb", + position: { xMeters: 0, yMeters: 0 }, + interpolationActive: false, + }); + }); + + test("accepts sustained movement after rejecting one impossible jump", () => { + const fusion = new ConservativePositionFusion(); + fusion.acceptUwb({ position: { xMeters: 0, yMeters: 0 }, receivedAt: 0 }); + expect( + fusion.acceptUwb({ + position: { xMeters: 10, yMeters: 0 }, + receivedAt: 1_000, + }), + ).toMatchObject({ lastUwbAt: 0 }); + expect( + fusion.acceptUwb({ + position: { xMeters: 10.5, yMeters: 0 }, + receivedAt: 1_100, + }), + ).toMatchObject({ + source: "uwb", + lastUwbAt: 1_100, + position: { xMeters: 10.5, yMeters: 0 }, + }); + }); + + test("a fresh UWB fix corrects a motion prediction", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ position: { xMeters: 0, yMeters: 0 }, receivedAt: 0 }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + expect(fusion.acceptMotion(motion(1_200, 2))?.source).toBe( + "motion-predicted", + ); + expect( + fusion.acceptUwb({ + position: { xMeters: 0.8, yMeters: 0 }, + receivedAt: 1_300, + }), + ).toMatchObject({ + source: "uwb", + lastUwbAt: 1_300, + interpolationActive: false, + }); + }); + + test("does not predict when motion is unavailable or inconclusive", () => { + const fusion = new ConservativePositionFusion(); + fusion.setMotionSensorActive(true); + fusion.acceptUwb({ + position: { xMeters: 0, yMeters: 0 }, + receivedAt: 0, + }); + fusion.acceptUwb({ + position: { xMeters: 1, yMeters: 0 }, + receivedAt: 1_000, + }); + + expect(fusion.acceptMotion(motion(1_200))).toMatchObject({ + source: "uwb", + position: { xMeters: 0.65, yMeters: 0 }, + interpolationActive: false, + }); + fusion.setMotionSensorActive(false); + expect(fusion.acceptMotion(motion(1_300, 2))).toMatchObject({ + source: "uwb", + interpolationActive: false, + }); + }); + + test("classifies only conservative activity magnitudes", () => { + expect(classifyMotionActivity(motion(1, 0))).toBe("stationary"); + expect(classifyMotionActivity(motion(1, 2))).toBe("moving"); + expect(classifyMotionActivity(motion(1))).toBe("unknown"); + expect( + classifyMotionActivity( + { + receivedAt: 1, + acceleration: null, + rotationRate: { x: 50, y: 0, z: 0 }, + }, + "stationary", + ), + ).toBe("unknown"); + }); +}); + +function motion( + receivedAt: number, + accelerationMagnitude?: number, +): DeviceMotionSample { + return { + receivedAt, + acceleration: + accelerationMagnitude === undefined + ? null + : { x: accelerationMagnitude, y: 0, z: 0 }, + rotationRate: + accelerationMagnitude === undefined ? null : { x: 0, y: 0, z: 0 }, + }; +} diff --git a/packages/mobile/src/motion/device-motion.ts b/packages/mobile/src/motion/device-motion.ts new file mode 100644 index 00000000..4a9958db --- /dev/null +++ b/packages/mobile/src/motion/device-motion.ts @@ -0,0 +1,145 @@ +import type { DeviceMotionMeasurement } from "expo-sensors"; + +export interface DeviceMotionVector { + readonly x: number; + readonly y: number; + readonly z: number; +} + +/** A timestamped, platform-neutral motion event used by the fusion boundary. */ +export interface DeviceMotionSample { + /** Receipt time in the same clock as PANS samples. */ + readonly receivedAt: number; + /** Linear acceleration only; never integrated or rotated into field space. */ + readonly acceleration: DeviceMotionVector | null; + /** Angular speed is used only as a conservative activity signal. */ + readonly rotationRate: DeviceMotionVector | null; +} + +export type DeviceMotionStartResult = + | "active" + | "unavailable" + | "permission-denied"; + +export interface DeviceMotionSubscription { + remove(): void; +} + +/** + * Injectable boundary around the native motion sensor. Implementations must + * not turn acceleration into a position estimate. + */ +export interface DeviceMotionAdapter { + start( + listener: (sample: DeviceMotionSample) => void, + ): Promise; + stop(): void; +} + +export interface DeviceMotionSensorLike { + isAvailableAsync(): Promise; + getPermissionsAsync(): Promise<{ readonly granted: boolean }>; + requestPermissionsAsync(): Promise<{ readonly granted: boolean }>; + setUpdateInterval(intervalMs: number): void; + addListener( + listener: (measurement: DeviceMotionMeasurement) => void, + ): DeviceMotionSubscription; +} + +export const DEVICE_MOTION_UPDATE_INTERVAL_MS = 100; + +/** + * Expo's DeviceMotion API is intentionally kept behind this adapter so Jest + * and non-native environments can inject a deterministic source. + */ +export class ExpoDeviceMotionAdapter implements DeviceMotionAdapter { + private subscription?: DeviceMotionSubscription; + private lifecycleToken = 0; + + constructor( + sensor: DeviceMotionSensorLike | undefined = undefined, + private readonly now: () => number = Date.now, + ) { + this.sensor = sensor; + } + + private sensor?: DeviceMotionSensorLike; + + async start( + listener: (sample: DeviceMotionSample) => void, + ): Promise { + if (this.subscription) return "active"; + const token = ++this.lifecycleToken; + + try { + const sensor = await this.getSensor(); + if (token !== this.lifecycleToken) return "unavailable"; + + if (!(await sensor.isAvailableAsync())) return "unavailable"; + if (token !== this.lifecycleToken) return "unavailable"; + + let permission = await sensor.getPermissionsAsync(); + if (token !== this.lifecycleToken) return "unavailable"; + if (!permission.granted) { + permission = await sensor.requestPermissionsAsync(); + } + if (token !== this.lifecycleToken) return "unavailable"; + if (!permission.granted) return "permission-denied"; + + sensor.setUpdateInterval(DEVICE_MOTION_UPDATE_INTERVAL_MS); + this.subscription = sensor.addListener((measurement) => { + listener(normalizeDeviceMotionMeasurement(measurement, this.now())); + }); + return "active"; + } catch { + this.stop(); + return "unavailable"; + } + } + + stop(): void { + ++this.lifecycleToken; + const subscription = this.subscription; + this.subscription = undefined; + subscription?.remove(); + } + + private async getSensor(): Promise { + if (this.sensor) return this.sensor; + const sensors = await import("expo-sensors"); + this.sensor = sensors.DeviceMotion; + return this.sensor; + } +} + +export function createExpoDeviceMotionAdapter( + sensor?: DeviceMotionSensorLike, + now: () => number = Date.now, +): DeviceMotionAdapter { + return new ExpoDeviceMotionAdapter(sensor, now); +} + +function normalizeDeviceMotionMeasurement( + measurement: DeviceMotionMeasurement, + receivedAt: number, +): DeviceMotionSample { + return { + receivedAt, + acceleration: normalizeVector(measurement.acceleration), + rotationRate: normalizeVector(measurement.rotationRate), + }; +} + +function normalizeVector( + value: + | { readonly x: number; readonly y: number; readonly z: number } + | { readonly alpha: number; readonly beta: number; readonly gamma: number } + | null + | undefined, +): DeviceMotionVector | null { + if (!value) return null; + if ("x" in value) { + return { x: value.x, y: value.y, z: value.z }; + } + return { x: value.alpha, y: value.beta, z: value.gamma }; +} diff --git a/packages/mobile/src/motion/index.ts b/packages/mobile/src/motion/index.ts new file mode 100644 index 00000000..b0acc45a --- /dev/null +++ b/packages/mobile/src/motion/index.ts @@ -0,0 +1,2 @@ +export * from "./device-motion"; +export * from "./position-fusion"; diff --git a/packages/mobile/src/motion/position-fusion.ts b/packages/mobile/src/motion/position-fusion.ts new file mode 100644 index 00000000..3b86f8cb --- /dev/null +++ b/packages/mobile/src/motion/position-fusion.ts @@ -0,0 +1,358 @@ +import type { + FieldLivePositionSource, + FusedPositionOutput, + FieldPoint, +} from "../field"; +import type { DeviceMotionSample } from "./device-motion"; + +export type MotionActivity = "unknown" | "stationary" | "moving"; + +export interface UwbFusionSample { + readonly position: FieldPoint; + readonly receivedAt: number; +} + +export interface ConservativePositionFusionOptions { + readonly predictionHorizonMs?: number; + readonly smoothingAlpha?: number; + readonly maxAcceptedSpeedMps?: number; + readonly maxJumpMeters?: number; +} + +export const MAX_MOTION_PREDICTION_MS = 500; +export const DEFAULT_POSITION_SMOOTHING_ALPHA = 0.65; +export const DEFAULT_MAX_ACCEPTED_UWB_SPEED_MPS = 6; +export const DEFAULT_MAX_UWB_JUMP_METERS = 4; + +const MIN_ACCEPTED_UWB_STEP_METERS = 0.75; +const MAX_VELOCITY_INTERVAL_MS = 1_500; + +/** + * Conservative UWB-first fusion. Device motion only gates a short prediction + * along velocity learned from accepted UWB positions. It is deliberately not + * an inertial-navigation implementation: no acceleration is integrated and no + * sensor vector is rotated into the field frame. + */ +export class ConservativePositionFusion { + private readonly predictionHorizonMs: number; + private readonly smoothingAlpha: number; + private readonly maxAcceptedSpeedMps: number; + private readonly maxJumpMeters: number; + private lastUwb?: { + readonly position: FieldPoint; + readonly receivedAt: number; + }; + private filteredPosition?: FieldPoint; + private velocity = { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + private activity: MotionActivity = "unknown"; + private motionSensorActive = false; + private pendingJump?: UwbFusionSample; + + constructor(options: ConservativePositionFusionOptions = {}) { + this.predictionHorizonMs = boundedPositive( + options.predictionHorizonMs, + MAX_MOTION_PREDICTION_MS, + MAX_MOTION_PREDICTION_MS, + ); + this.smoothingAlpha = bounded( + options.smoothingAlpha, + DEFAULT_POSITION_SMOOTHING_ALPHA, + 0.1, + 1, + ); + this.maxAcceptedSpeedMps = boundedPositive( + options.maxAcceptedSpeedMps, + DEFAULT_MAX_ACCEPTED_UWB_SPEED_MPS, + DEFAULT_MAX_ACCEPTED_UWB_SPEED_MPS, + ); + this.maxJumpMeters = boundedPositive( + options.maxJumpMeters, + DEFAULT_MAX_UWB_JUMP_METERS, + DEFAULT_MAX_UWB_JUMP_METERS, + ); + } + + get lastUwbAt(): number | undefined { + return this.lastUwb?.receivedAt; + } + + get lastUwbPosition(): FieldPoint | undefined { + return this.lastUwb?.position; + } + + get motionActivity(): MotionActivity { + return this.activity; + } + + setMotionSensorActive(active: boolean): void { + this.motionSensorActive = active; + if (!active) this.activity = "unknown"; + } + + reset(): void { + this.lastUwb = undefined; + this.filteredPosition = undefined; + this.velocity = { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + this.activity = "unknown"; + this.motionSensorActive = false; + this.pendingJump = undefined; + } + + acceptUwb(sample: UwbFusionSample): FusedPositionOutput | undefined { + if ( + !isFinitePoint(sample.position) || + !Number.isFinite(sample.receivedAt) + ) { + return undefined; + } + + const previous = this.lastUwb; + const previousFiltered = this.filteredPosition; + if (previous && sample.receivedAt <= previous.receivedAt) return undefined; + + if (previous && previousFiltered) { + const elapsedMs = sample.receivedAt - previous.receivedAt; + const jumpMeters = distance(previousFiltered, sample.position); + const allowedJumpMeters = Math.min( + this.maxJumpMeters, + Math.max( + MIN_ACCEPTED_UWB_STEP_METERS, + (elapsedMs / 1_000) * this.maxAcceptedSpeedMps + 0.35, + ), + ); + if (jumpMeters > allowedJumpMeters) { + const pending = this.pendingJump; + const sustainedMovement = + pending !== undefined && + sample.receivedAt > pending.receivedAt && + distance(pending.position, sample.position) <= + Math.max( + MIN_ACCEPTED_UWB_STEP_METERS, + ((sample.receivedAt - pending.receivedAt) / 1_000) * + this.maxAcceptedSpeedMps + + 0.35, + ); + if (sustainedMovement) { + this.filteredPosition = sample.position; + this.velocity = clampVelocity( + { + xMetersPerSecond: + (sample.position.xMeters - previousFiltered.xMeters) / + (elapsedMs / 1_000), + yMetersPerSecond: + (sample.position.yMeters - previousFiltered.yMeters) / + (elapsedMs / 1_000), + }, + this.maxAcceptedSpeedMps, + ); + this.pendingJump = undefined; + this.lastUwb = { + position: sample.position, + receivedAt: sample.receivedAt, + }; + return this.outputAt(sample.receivedAt, "uwb", false); + } + // Keep the last accepted UWB fix authoritative instead of allowing an + // isolated implausible frame to seed either smoothing or prediction. + // A second consistent fix is accepted so sustained real movement is + // never hidden indefinitely by the conservative jump gate. + this.pendingJump = sample; + return this.outputAt(sample.receivedAt, "stationary-hold", false); + } + + this.pendingJump = undefined; + + const filtered = + elapsedMs > MAX_VELOCITY_INTERVAL_MS + ? sample.position + : blend(previousFiltered, sample.position, this.smoothingAlpha); + this.filteredPosition = filtered; + this.velocity = + elapsedMs <= MAX_VELOCITY_INTERVAL_MS + ? clampVelocity( + { + xMetersPerSecond: + (filtered.xMeters - previousFiltered.xMeters) / + (elapsedMs / 1_000), + yMetersPerSecond: + (filtered.yMeters - previousFiltered.yMeters) / + (elapsedMs / 1_000), + }, + this.maxAcceptedSpeedMps, + ) + : { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + } else { + this.filteredPosition = sample.position; + this.velocity = { xMetersPerSecond: 0, yMetersPerSecond: 0 }; + } + + this.lastUwb = { + position: sample.position, + receivedAt: sample.receivedAt, + }; + return this.outputAt(sample.receivedAt, "uwb", false); + } + + acceptMotion(sample: DeviceMotionSample): FusedPositionOutput | undefined { + if (!Number.isFinite(sample.receivedAt)) return undefined; + this.activity = classifyMotionActivity(sample, this.activity); + if (!this.lastUwb || !this.filteredPosition) return undefined; + + if (sample.receivedAt < this.lastUwb.receivedAt) { + return this.outputAt(sample.receivedAt, "uwb", false); + } + const ageMs = sample.receivedAt - this.lastUwb.receivedAt; + if (ageMs > this.predictionHorizonMs) { + return this.outputAt(sample.receivedAt, "prediction-expired", false); + } + if (this.activity === "stationary") { + // A stationary phone is evidence against extrapolation. Holding the last + // accepted UWB fix also damps velocity rather than inventing drift. + this.velocity = { + xMetersPerSecond: this.velocity.xMetersPerSecond * 0.2, + yMetersPerSecond: this.velocity.yMetersPerSecond * 0.2, + }; + return this.outputAt( + sample.receivedAt, + ageMs === 0 ? "uwb" : "stationary-hold", + false, + ); + } + + if ( + !this.motionSensorActive || + this.activity !== "moving" || + Math.hypot( + this.velocity.xMetersPerSecond, + this.velocity.yMetersPerSecond, + ) < 0.05 + ) { + return this.outputAt(sample.receivedAt, "uwb", false); + } + + const elapsedSeconds = Math.min(ageMs, this.predictionHorizonMs) / 1_000; + return this.outputAt(sample.receivedAt, "motion-predicted", true, { + xMeters: + this.filteredPosition.xMeters + + this.velocity.xMetersPerSecond * elapsedSeconds, + yMeters: + this.filteredPosition.yMeters + + this.velocity.yMetersPerSecond * elapsedSeconds, + }); + } + + private outputAt( + fusedAt: number, + source: FieldLivePositionSource, + interpolationActive: boolean, + position = this.filteredPosition, + ): FusedPositionOutput | undefined { + if (!this.lastUwb || !position) return undefined; + return { + position, + source, + fusedAt, + freshnessMs: Math.max(0, fusedAt - this.lastUwb.receivedAt), + lastUwbAt: this.lastUwb.receivedAt, + lastUwbPosition: this.lastUwb.position, + interpolationActive, + }; + } +} + +export function classifyMotionActivity( + sample: DeviceMotionSample, + previous: MotionActivity = "unknown", +): MotionActivity { + const acceleration = vectorMagnitude(sample.acceleration); + const rotationRate = vectorMagnitude(sample.rotationRate); + const hasSignal = acceleration !== undefined || rotationRate !== undefined; + if (!hasSignal) return "unknown"; + + // Rotation alone cannot establish field translation. It is deliberately + // treated as an uncertain signal rather than permission to extrapolate. + const moving = acceleration !== undefined && acceleration >= 1.25; + if (moving) return "moving"; + + if (rotationRate !== undefined && rotationRate >= 45) return "unknown"; + + const stationary = + (acceleration === undefined || acceleration <= 0.35) && + (rotationRate === undefined || rotationRate <= 10); + if (stationary) return "stationary"; + + return previous; +} + +function vectorMagnitude( + vector: { readonly x: number; readonly y: number; readonly z: number } | null, +): number | undefined { + if (!vector) return undefined; + if ( + !Number.isFinite(vector.x) || + !Number.isFinite(vector.y) || + !Number.isFinite(vector.z) + ) { + return undefined; + } + return Math.hypot(vector.x, vector.y, vector.z); +} + +function isFinitePoint(point: FieldPoint): boolean { + return Number.isFinite(point.xMeters) && Number.isFinite(point.yMeters); +} + +function distance(first: FieldPoint, second: FieldPoint): number { + return Math.hypot( + second.xMeters - first.xMeters, + second.yMeters - first.yMeters, + ); +} + +function blend( + first: FieldPoint, + second: FieldPoint, + alpha: number, +): FieldPoint { + return { + xMeters: first.xMeters + (second.xMeters - first.xMeters) * alpha, + yMeters: first.yMeters + (second.yMeters - first.yMeters) * alpha, + }; +} + +function clampVelocity( + velocity: { xMetersPerSecond: number; yMetersPerSecond: number }, + maximumSpeedMps: number, +): { xMetersPerSecond: number; yMetersPerSecond: number } { + const speed = Math.hypot( + velocity.xMetersPerSecond, + velocity.yMetersPerSecond, + ); + if (speed <= maximumSpeedMps || speed === 0) return velocity; + const scale = maximumSpeedMps / speed; + return { + xMetersPerSecond: velocity.xMetersPerSecond * scale, + yMetersPerSecond: velocity.yMetersPerSecond * scale, + }; +} + +function bounded( + value: number | undefined, + fallback: number, + minimum: number, + maximum: number, +): number { + return value !== undefined && Number.isFinite(value) + ? Math.min(maximum, Math.max(minimum, value)) + : fallback; +} + +function boundedPositive( + value: number | undefined, + fallback: number, + maximum: number, +): number { + return value !== undefined && Number.isFinite(value) && value > 0 + ? Math.min(maximum, value) + : fallback; +} diff --git a/packages/mobile/src/pans-manager/PansConfigurationService.ts b/packages/mobile/src/pans-manager/PansConfigurationService.ts index 10edc39c..cc9df510 100644 --- a/packages/mobile/src/pans-manager/PansConfigurationService.ts +++ b/packages/mobile/src/pans-manager/PansConfigurationService.ts @@ -836,11 +836,18 @@ function buildSparseModePatch( changes: HardwareDeviceChanges, current: PansInspectionResult["operationMode"], ): PansOperationModePatch { - return Object.fromEntries( + const explicit = Object.fromEntries( sparseModeFields(changes) .filter((field) => !Object.is(current[field.modeKey], field.requested)) .map((field) => [field.modeKey, field.requested]), ) as PansOperationModePatch; + if (changes.role === "tag" && current.initiatorEnabled) { + explicit.initiatorEnabled = false; + } else if (changes.role === "anchor") { + if (current.lowPowerModeEnabled) explicit.lowPowerModeEnabled = false; + if (current.locationEngineEnabled) explicit.locationEngineEnabled = false; + } + return explicit; } function validateHardwareChanges( diff --git a/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts b/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts index 6bf43872..4f833acb 100644 --- a/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts +++ b/packages/mobile/src/pans-manager/PansDeviceSessionManager.ts @@ -1,4 +1,5 @@ import { + addConnectionStateChangedListener, addLocationDataListener, connect, decodeLocationData, @@ -26,6 +27,7 @@ import { writePersistedPosition, } from "expo-pans-ble-api"; import type { + ConnectionStateChangeEvent, PansAnchorList, PansCharacteristicNotificationEvent, PansClusterInfo, @@ -69,6 +71,9 @@ export interface PansNativeGateway { addLocationDataListener( listener: (event: PansLocationNotification) => void, ): PansLocationSubscription; + addConnectionStateChangedListener?( + listener: (event: PansConnectionStateEvent) => void, + ): PansLocationSubscription; decodeLocationData(payload: number[]): PansLocationData; requestMtu?(deviceId: string, mtu: number): Promise; writePersistedPosition( @@ -93,6 +98,9 @@ export interface PansLocationSubscription { remove(): void; } +/** Manager-safe connection event used by app-level session owners. */ +export type PansConnectionStateEvent = ConnectionStateChangeEvent; + export const defaultPansNativeGateway: PansNativeGateway = { connect, disconnect, @@ -124,6 +132,7 @@ export const defaultPansNativeGateway: PansNativeGateway = { payloadLength: event.payloadLength, }), ), + addConnectionStateChangedListener, decodeLocationData, requestMtu: async (deviceId, mtu) => getCapabilities().supportsMtuRequest @@ -290,6 +299,16 @@ export class PansDeviceSessionManager { if (failure) throw normalizeManagerError(failure.reason); } + addConnectionStateListener( + listener: (event: PansConnectionStateEvent) => void, + ): PansLocationSubscription { + return ( + this.gateway.addConnectionStateChangedListener?.(listener) ?? { + remove() {}, + } + ); + } + private async acquire(deviceId: string, timeoutMs?: number): Promise { if (!deviceId.trim()) { throw new ManagerError("INVALID_CONFIGURATION", "Device ID is required."); diff --git a/packages/mobile/src/pans-manager/PansPositionStreamService.ts b/packages/mobile/src/pans-manager/PansPositionStreamService.ts index c8e6d787..bde4c08d 100644 --- a/packages/mobile/src/pans-manager/PansPositionStreamService.ts +++ b/packages/mobile/src/pans-manager/PansPositionStreamService.ts @@ -271,7 +271,7 @@ export class PansPositionStreamService { private scheduleCounterPublication(active: ActivePositionStream): void { if (!active.options.onCounters) return; - if (active.counterPublicationTimer) return; + if (active.counterPublicationTimer !== undefined) return; active.counterPublicationTimer = setTimeout(() => { active.counterPublicationTimer = undefined; if (this.active?.token === active.token) this.publishCounters(active); @@ -279,7 +279,7 @@ export class PansPositionStreamService { } private publishCounters(active: ActivePositionStream): void { - if (active.counterPublicationTimer) { + if (active.counterPublicationTimer !== undefined) { clearTimeout(active.counterPublicationTimer); active.counterPublicationTimer = undefined; } diff --git a/packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts b/packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts new file mode 100644 index 00000000..585a5c8d --- /dev/null +++ b/packages/mobile/src/pans-manager/__tests__/performer-tag-profile.test.ts @@ -0,0 +1,66 @@ +import { + diffPerformerTagProfile, + PERFORMER_TAG_PROFILE, +} from "../performer-tag-profile"; +import type { PansInspectionResult } from "../types"; + +describe("performer tag profile", () => { + test("produces no writes for an already-correct tag", () => { + expect(diffPerformerTagProfile(inspection())).toEqual({}); + }); + + test("returns a sparse patch and preserves unrelated mode fields", () => { + const current = inspection({ + operationMode: { + ...inspection().operationMode, + ledEnabled: false, + selectedFirmware: 2, + raw: [17, 3], + }, + }); + expect(diffPerformerTagProfile(current)).toEqual({ ledEnabled: true }); + expect(diffPerformerTagProfile(current)).not.toHaveProperty( + "selectedFirmware", + ); + expect(PERFORMER_TAG_PROFILE.lowPowerModeEnabled).toBe(false); + }); + + test("maps stationary detection and location mode explicitly", () => { + expect( + diffPerformerTagProfile( + inspection({ + locationDataMode: 0, + operationMode: { + ...inspection().operationMode, + accelerometerEnabled: true, + }, + }), + ), + ).toEqual({ stationaryDetectionEnabled: false, locationDataMode: 2 }); + }); +}); + +function inspection( + changes: Partial = {}, +): PansInspectionResult { + return { + deviceId: "tag-1", + transportDeviceId: "transport-1", + inspectedAt: 1, + operationMode: { + role: "tag", + uwbMode: "active", + selectedFirmware: 1, + accelerometerEnabled: false, + ledEnabled: true, + firmwareUpdateEnabled: true, + initiatorEnabled: false, + lowPowerModeEnabled: false, + locationEngineEnabled: true, + raw: [0, 0], + }, + locationDataMode: 2, + warnings: [], + ...changes, + }; +} diff --git a/packages/mobile/src/pans-manager/__tests__/session.test.ts b/packages/mobile/src/pans-manager/__tests__/session.test.ts index fb827a45..530415e5 100644 --- a/packages/mobile/src/pans-manager/__tests__/session.test.ts +++ b/packages/mobile/src/pans-manager/__tests__/session.test.ts @@ -35,6 +35,21 @@ function gateway( } describe("PansDeviceSessionManager", () => { + test("exposes one removable native connection-state subscription", () => { + const remove = jest.fn(); + const addConnectionStateChangedListener = jest.fn(() => ({ remove })); + const manager = new PansDeviceSessionManager( + gateway({ addConnectionStateChangedListener }), + ); + const listener = jest.fn(); + + const subscription = manager.addConnectionStateListener(listener); + + expect(addConnectionStateChangedListener).toHaveBeenCalledWith(listener); + subscription.remove(); + expect(remove).toHaveBeenCalledTimes(1); + }); + test("reserves exactly one live session while its connection opens", async () => { let resolve!: (connected: boolean) => void; const connect = jest.fn( diff --git a/packages/mobile/src/pans-manager/__tests__/settings.test.ts b/packages/mobile/src/pans-manager/__tests__/settings.test.ts index 3ee2cf77..8ad74f57 100644 --- a/packages/mobile/src/pans-manager/__tests__/settings.test.ts +++ b/packages/mobile/src/pans-manager/__tests__/settings.test.ts @@ -20,6 +20,15 @@ describe("PANS manager settings compatibility", () => { expect(network).not.toHaveProperty("scanDurationMs"); }); + test("preserves only a non-empty remembered tag identity", () => { + expect( + normalizePansManagerSettings({ rememberedTagDeviceId: "tag-1" }), + ).toMatchObject({ rememberedTagDeviceId: "tag-1" }); + expect( + normalizePansManagerSettings({ rememberedTagDeviceId: "" }), + ).not.toHaveProperty("rememberedTagDeviceId"); + }); + test("defaults and validates map display settings for older records", () => { expect( normalizeManagedNetworkSettings({ diff --git a/packages/mobile/src/pans-manager/index.ts b/packages/mobile/src/pans-manager/index.ts index d83ee289..7537b8ea 100644 --- a/packages/mobile/src/pans-manager/index.ts +++ b/packages/mobile/src/pans-manager/index.ts @@ -6,6 +6,7 @@ export * from "./map-units"; export * from "./device-sections"; export * from "./profile-matching"; export * from "./device-discovery"; +export * from "./performer-tag-profile"; export * from "./PansManagerRepository"; export * from "./InMemoryPansManagerRepository"; export * from "./SqlitePansManagerRepository"; diff --git a/packages/mobile/src/pans-manager/pans-network-grid-camera.ts b/packages/mobile/src/pans-manager/pans-network-grid-camera.ts index ce2374a3..2950c484 100644 --- a/packages/mobile/src/pans-manager/pans-network-grid-camera.ts +++ b/packages/mobile/src/pans-manager/pans-network-grid-camera.ts @@ -4,6 +4,11 @@ import type { GridViewport, } from "./pans-network-grid-math"; import type { PansGridCameraSharedValues } from "./pans-network-grid-types"; +import { + clampFieldCameraAxis, + fieldCenterForStationaryWorldPoint, + fieldScreenToWorld, +} from "../field/camera/field-camera-math"; export function panCameraCenter( startCenter: GridPoint, @@ -25,10 +30,15 @@ export function screenPointToWorld( metersPerPixel: number, ): GridPoint { "worklet"; - return { - xMeters: center.xMeters + (point.x - size.width / 2) * metersPerPixel, - yMeters: center.yMeters - (point.y - size.height / 2) * metersPerPixel, - }; + return fieldScreenToWorld( + point, + { + centerXMeters: center.xMeters, + centerYMeters: center.yMeters, + metersPerPixel, + }, + size, + ); } export function centerForStationaryWorldPoint( @@ -38,12 +48,12 @@ export function centerForStationaryWorldPoint( metersPerPixel: number, ): GridPoint { "worklet"; - return { - xMeters: - worldPoint.xMeters - (screenPoint.x - size.width / 2) * metersPerPixel, - yMeters: - worldPoint.yMeters + (screenPoint.y - size.height / 2) * metersPerPixel, - }; + return fieldCenterForStationaryWorldPoint( + worldPoint, + screenPoint, + size, + metersPerPixel, + ); } export function setGridCamera( @@ -71,8 +81,5 @@ export function clampCameraAxis( minimum >= maximum ) return center; - const minimumCenter = minimum + halfVisibleSpan; - const maximumCenter = maximum - halfVisibleSpan; - if (minimumCenter > maximumCenter) return (minimum + maximum) / 2; - return Math.min(maximumCenter, Math.max(minimumCenter, center)); + return clampFieldCameraAxis(center, minimum, maximum, halfVisibleSpan); } diff --git a/packages/mobile/src/pans-manager/performer-tag-profile.ts b/packages/mobile/src/pans-manager/performer-tag-profile.ts new file mode 100644 index 00000000..b8d40b58 --- /dev/null +++ b/packages/mobile/src/pans-manager/performer-tag-profile.ts @@ -0,0 +1,37 @@ +import type { HardwareDeviceChanges, PansInspectionResult } from "./types"; + +/** Production fields owned by Eight2Five. Unlisted PANS fields are preserved. */ +export const PERFORMER_TAG_PROFILE = Object.freeze({ + role: "tag" as const, + uwbMode: "active" as const, + ledEnabled: true, + firmwareUpdateEnabled: true, + locationEngineEnabled: true, + // PANS exposes low-power; responsive mode is its inverse. + lowPowerModeEnabled: false, + stationaryDetectionEnabled: false, + locationDataMode: 2 as const, +}); + +/** Returns only profile-owned fields whose readable values differ. */ +export function diffPerformerTagProfile( + inspection: PansInspectionResult, +): HardwareDeviceChanges { + const mode = inspection.operationMode; + const current: Record = { + role: mode.role, + uwbMode: mode.uwbMode, + ledEnabled: mode.ledEnabled, + firmwareUpdateEnabled: mode.firmwareUpdateEnabled, + locationEngineEnabled: mode.locationEngineEnabled, + lowPowerModeEnabled: mode.lowPowerModeEnabled, + stationaryDetectionEnabled: mode.accelerometerEnabled, + locationDataMode: inspection.locationDataMode, + }; + return Object.fromEntries( + Object.entries(PERFORMER_TAG_PROFILE).filter( + ([field, requested]) => + !Object.is(current[field as keyof typeof current], requested), + ), + ) as HardwareDeviceChanges; +} diff --git a/packages/mobile/src/pans-manager/types.ts b/packages/mobile/src/pans-manager/types.ts index f9255fc5..2ad351f7 100644 --- a/packages/mobile/src/pans-manager/types.ts +++ b/packages/mobile/src/pans-manager/types.ts @@ -125,7 +125,7 @@ export type ManagedDeviceConfig = ManagedTagConfig | ManagedAnchorConfig; /** App-only fields which may be independently saved without a BLE session. */ export interface LocalDeviceChanges { - /** @deprecated PANS device nicknames are retained for database compatibility only. */ + /** App-only display name. Never written to PANS hardware. */ nickname?: string | undefined; /** @deprecated PANS device notes are retained for database compatibility only. */ notes?: string | undefined; @@ -163,7 +163,7 @@ export interface ManagedDevice { transportDeviceId: string; macAddress?: string; nodeIdHex?: string; - /** @deprecated Retained only for database/import compatibility. */ + /** App-only display name. Never written to PANS hardware. */ nickname?: string; /** Legacy hardware label cache. Prefer lastKnownConfig.label. */ label?: string; @@ -396,13 +396,24 @@ export interface PansManagerSettings { connectionTimeoutMs: number; positionLogMemoryCap: number; positionLogFlushSize: number; + /** Stable local device identity selected by the performer app. */ + rememberedTagDeviceId?: string; + /** Developer-only discovery override. Reset when Developer Mode is disabled. */ + discoveryRssiCutoff: number; + /** Durable selected profile; selecting a profile does not rewrite hardware. */ + activeNetworkId?: string; } +export const DEFAULT_DISCOVERY_RSSI_CUTOFF = -75; +export const MIN_DISCOVERY_RSSI_CUTOFF = -100; +export const MAX_DISCOVERY_RSSI_CUTOFF = -30; + export const DEFAULT_PANS_MANAGER_SETTINGS: PansManagerSettings = { discoveryStaleAfterMs: 10_000, connectionTimeoutMs: 10_000, positionLogMemoryCap: 1_000, positionLogFlushSize: 100, + discoveryRssiCutoff: DEFAULT_DISCOVERY_RSSI_CUTOFF, }; export function normalizePansManagerSettings( @@ -411,6 +422,28 @@ export function normalizePansManagerSettings( const compatible = { ...(settings ?? {}) } as Partial & Record; delete compatible.discoveryScanDurationMs; + if ( + compatible.rememberedTagDeviceId !== undefined && + (typeof compatible.rememberedTagDeviceId !== "string" || + !compatible.rememberedTagDeviceId.trim()) + ) { + delete compatible.rememberedTagDeviceId; + } + if ( + compatible.activeNetworkId !== undefined && + (typeof compatible.activeNetworkId !== "string" || + !compatible.activeNetworkId.trim()) + ) { + delete compatible.activeNetworkId; + } + if ( + typeof compatible.discoveryRssiCutoff !== "number" || + !Number.isInteger(compatible.discoveryRssiCutoff) || + compatible.discoveryRssiCutoff < MIN_DISCOVERY_RSSI_CUTOFF || + compatible.discoveryRssiCutoff > MAX_DISCOVERY_RSSI_CUTOFF + ) { + compatible.discoveryRssiCutoff = DEFAULT_DISCOVERY_RSSI_CUTOFF; + } return { ...DEFAULT_PANS_MANAGER_SETTINGS, ...compatible }; } diff --git a/packages/mobile/src/settings/SqliteSettingsRepository.ts b/packages/mobile/src/settings/SqliteSettingsRepository.ts new file mode 100644 index 00000000..496e32a0 --- /dev/null +++ b/packages/mobile/src/settings/SqliteSettingsRepository.ts @@ -0,0 +1,359 @@ +import type { SQLiteDatabase } from "expo-sqlite"; +import { APP_SETTINGS_TABLE } from "../storage/mobileDatabase"; +import type { + AppSettings, + AppSettingsRepository, + AppSettingsUpdate, +} from "./types"; +import { DEFAULT_APP_SETTINGS, normalizeAppSettings } from "./types"; + +type SqlValue = string | number | null; +type AppSettingsRow = Record; + +/** SQLite implementation for the singleton app settings row. */ +export class SqliteSettingsRepository implements AppSettingsRepository { + constructor(private readonly db: SQLiteDatabase) {} + + async load(): Promise { + const row = await this.readRow(); + if (!row) { + await this.write(DEFAULT_APP_SETTINGS); + return normalizeAppSettings(DEFAULT_APP_SETTINGS); + } + + const settings = fromRow(row); + if (!isCanonicalRow(row, settings)) await this.write(settings); + return settings; + } + + async update(partial: AppSettingsUpdate): Promise { + const current = await this.load(); + const next = normalizeAppSettings({ + ...current, + ...(isRecord(partial) ? partial : {}), + }); + await this.write(next); + return next; + } + + async resetPreferences(): Promise { + await this.load(); + // Deliberately omit both selection columns so resetPreferences preserves + // the user's active drill and selected set. + await this.db.runAsync( + `UPDATE ${APP_SETTINGS_TABLE} + SET appearance_mode = ?, + drill_features_enabled = ?, + drill_terminology = ?, + field_perspective = ?, + default_field_preset = ?, + transition_metric_mode = ?, + count_display_mode = ?, + coordinate_rounding_steps = ?, + guidance_enabled = ?, + developer_mode_enabled = ?, + show_cached_anchor_geometry = ?, + show_comfortable_anchor_range = ?, + show_perimeter_step_grid = ?, + show_auxiliary_field_marks = ?, + show_performer_labels = ?, + show_performer_names = ?, + show_prop_labels = ?, + show_prop_names = ?, + show_transition_markers = ?, + show_all_transition_sets = ?, + previous_transition_set_count = ?, + next_transition_set_count = ?, + distance_green_threshold_steps = ?, + distance_yellow_threshold_steps = ?, + motion_interpolation_enabled = ?, + mock_live_position_enabled = ?, + mock_live_position_x_steps = ?, + mock_live_position_y_steps = ?, + comfortable_anchor_range_meters = ? + WHERE singleton_id = ?`, + [ + DEFAULT_APP_SETTINGS.appearanceMode, + boolToSql(DEFAULT_APP_SETTINGS.drillFeaturesEnabled), + DEFAULT_APP_SETTINGS.drillTerminology, + DEFAULT_APP_SETTINGS.fieldPerspective, + DEFAULT_APP_SETTINGS.defaultFieldPreset, + DEFAULT_APP_SETTINGS.transitionMetricMode, + DEFAULT_APP_SETTINGS.countDisplayMode, + DEFAULT_APP_SETTINGS.coordinateRoundingSteps, + boolToSql(DEFAULT_APP_SETTINGS.guidanceEnabled), + boolToSql(DEFAULT_APP_SETTINGS.developerModeEnabled), + boolToSql(DEFAULT_APP_SETTINGS.showCachedAnchorGeometry), + boolToSql(DEFAULT_APP_SETTINGS.showComfortableAnchorRange), + boolToSql(DEFAULT_APP_SETTINGS.showPerimeterStepGrid), + boolToSql(DEFAULT_APP_SETTINGS.showAuxiliaryFieldMarks), + boolToSql(DEFAULT_APP_SETTINGS.showPerformerLabels), + boolToSql(DEFAULT_APP_SETTINGS.showPerformerNames), + boolToSql(DEFAULT_APP_SETTINGS.showPropLabels), + boolToSql(DEFAULT_APP_SETTINGS.showPropNames), + boolToSql(DEFAULT_APP_SETTINGS.showTransitionMarkers), + boolToSql(DEFAULT_APP_SETTINGS.showAllTransitionSets), + DEFAULT_APP_SETTINGS.previousTransitionSetCount, + DEFAULT_APP_SETTINGS.nextTransitionSetCount, + DEFAULT_APP_SETTINGS.distanceGreenThresholdSteps, + DEFAULT_APP_SETTINGS.distanceYellowThresholdSteps, + boolToSql(DEFAULT_APP_SETTINGS.motionInterpolationEnabled), + boolToSql(DEFAULT_APP_SETTINGS.mockLivePositionEnabled), + DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + DEFAULT_APP_SETTINGS.mockLivePositionYSteps, + DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, + 1, + ], + ); + return await this.load(); + } + + private async readRow(): Promise { + return await this.db.getFirstAsync( + `SELECT + appearance_mode, + drill_features_enabled, + drill_terminology, + field_perspective, + default_field_preset, + transition_metric_mode, + count_display_mode, + coordinate_rounding_steps, + guidance_enabled, + developer_mode_enabled, + show_cached_anchor_geometry, + show_comfortable_anchor_range, + show_perimeter_step_grid, + show_auxiliary_field_marks, + show_performer_labels, + show_performer_names, + show_prop_labels, + show_prop_names, + show_transition_markers, + show_all_transition_sets, + previous_transition_set_count, + next_transition_set_count, + distance_green_threshold_steps, + distance_yellow_threshold_steps, + motion_interpolation_enabled, + mock_live_position_enabled, + mock_live_position_x_steps, + mock_live_position_y_steps, + comfortable_anchor_range_meters, + active_drill_id, + selected_drill_page_id + FROM ${APP_SETTINGS_TABLE} + WHERE singleton_id = ?`, + [1], + ); + } + + private async write(settings: AppSettings): Promise { + const normalized = normalizeAppSettings(settings); + await this.db.runAsync( + `INSERT INTO ${APP_SETTINGS_TABLE} ( + singleton_id, + appearance_mode, + drill_features_enabled, + drill_terminology, + field_perspective, + default_field_preset, + transition_metric_mode, + count_display_mode, + coordinate_rounding_steps, + guidance_enabled, + developer_mode_enabled, + show_cached_anchor_geometry, + show_comfortable_anchor_range, + show_perimeter_step_grid, + show_auxiliary_field_marks, + show_performer_labels, + show_performer_names, + show_prop_labels, + show_prop_names, + show_transition_markers, + show_all_transition_sets, + previous_transition_set_count, + next_transition_set_count, + distance_green_threshold_steps, + distance_yellow_threshold_steps, + motion_interpolation_enabled, + mock_live_position_enabled, + mock_live_position_x_steps, + mock_live_position_y_steps, + comfortable_anchor_range_meters, + active_drill_id, + selected_drill_page_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(singleton_id) DO UPDATE SET + appearance_mode = excluded.appearance_mode, + drill_features_enabled = excluded.drill_features_enabled, + drill_terminology = excluded.drill_terminology, + field_perspective = excluded.field_perspective, + default_field_preset = excluded.default_field_preset, + transition_metric_mode = excluded.transition_metric_mode, + count_display_mode = excluded.count_display_mode, + coordinate_rounding_steps = excluded.coordinate_rounding_steps, + guidance_enabled = excluded.guidance_enabled, + developer_mode_enabled = excluded.developer_mode_enabled, + show_cached_anchor_geometry = excluded.show_cached_anchor_geometry, + show_comfortable_anchor_range = excluded.show_comfortable_anchor_range, + show_perimeter_step_grid = excluded.show_perimeter_step_grid, + show_auxiliary_field_marks = excluded.show_auxiliary_field_marks, + show_performer_labels = excluded.show_performer_labels, + show_performer_names = excluded.show_performer_names, + show_prop_labels = excluded.show_prop_labels, + show_prop_names = excluded.show_prop_names, + show_transition_markers = excluded.show_transition_markers, + show_all_transition_sets = excluded.show_all_transition_sets, + previous_transition_set_count = excluded.previous_transition_set_count, + next_transition_set_count = excluded.next_transition_set_count, + distance_green_threshold_steps = excluded.distance_green_threshold_steps, + distance_yellow_threshold_steps = excluded.distance_yellow_threshold_steps, + motion_interpolation_enabled = excluded.motion_interpolation_enabled, + mock_live_position_enabled = excluded.mock_live_position_enabled, + mock_live_position_x_steps = excluded.mock_live_position_x_steps, + mock_live_position_y_steps = excluded.mock_live_position_y_steps, + comfortable_anchor_range_meters = excluded.comfortable_anchor_range_meters, + active_drill_id = excluded.active_drill_id, + selected_drill_page_id = excluded.selected_drill_page_id`, + [ + 1, + normalized.appearanceMode, + boolToSql(normalized.drillFeaturesEnabled), + normalized.drillTerminology, + normalized.fieldPerspective, + normalized.defaultFieldPreset, + normalized.transitionMetricMode, + normalized.countDisplayMode, + normalized.coordinateRoundingSteps, + boolToSql(normalized.guidanceEnabled), + boolToSql(normalized.developerModeEnabled), + boolToSql(normalized.showCachedAnchorGeometry), + boolToSql(normalized.showComfortableAnchorRange), + boolToSql(normalized.showPerimeterStepGrid), + boolToSql(normalized.showAuxiliaryFieldMarks), + boolToSql(normalized.showPerformerLabels), + boolToSql(normalized.showPerformerNames), + boolToSql(normalized.showPropLabels), + boolToSql(normalized.showPropNames), + boolToSql(normalized.showTransitionMarkers), + boolToSql(normalized.showAllTransitionSets), + normalized.previousTransitionSetCount, + normalized.nextTransitionSetCount, + normalized.distanceGreenThresholdSteps, + normalized.distanceYellowThresholdSteps, + boolToSql(normalized.motionInterpolationEnabled), + boolToSql(normalized.mockLivePositionEnabled), + normalized.mockLivePositionXSteps, + normalized.mockLivePositionYSteps, + normalized.comfortableAnchorRangeMeters, + normalized.activeDrillId, + normalized.selectedDrillSetId, + ], + ); + } +} + +/** Useful when a caller has a raw SQLite settings row outside the repository. */ +export function normalizeAppSettingsRow(row: unknown): AppSettings { + return fromRow(isRecord(row) ? (row as AppSettingsRow) : {}); +} + +function fromRow(row: AppSettingsRow): AppSettings { + return normalizeAppSettings({ + appearanceMode: row.appearance_mode, + drillFeaturesEnabled: sqliteBoolean(row.drill_features_enabled), + drillTerminology: row.drill_terminology, + fieldPerspective: row.field_perspective, + defaultFieldPreset: row.default_field_preset, + transitionMetricMode: row.transition_metric_mode, + countDisplayMode: row.count_display_mode, + coordinateRoundingSteps: row.coordinate_rounding_steps, + guidanceEnabled: sqliteBoolean(row.guidance_enabled), + developerModeEnabled: sqliteBoolean(row.developer_mode_enabled), + showCachedAnchorGeometry: sqliteBoolean(row.show_cached_anchor_geometry), + showComfortableAnchorRange: sqliteBoolean( + row.show_comfortable_anchor_range, + ), + showPerimeterStepGrid: sqliteBoolean(row.show_perimeter_step_grid), + showAuxiliaryFieldMarks: sqliteBoolean(row.show_auxiliary_field_marks), + showPerformerLabels: sqliteBoolean(row.show_performer_labels), + showPerformerNames: sqliteBoolean(row.show_performer_names), + showPropLabels: sqliteBoolean(row.show_prop_labels), + showPropNames: sqliteBoolean(row.show_prop_names), + showTransitionMarkers: sqliteBoolean(row.show_transition_markers), + showAllTransitionSets: sqliteBoolean(row.show_all_transition_sets), + previousTransitionSetCount: row.previous_transition_set_count, + nextTransitionSetCount: row.next_transition_set_count, + distanceGreenThresholdSteps: row.distance_green_threshold_steps, + distanceYellowThresholdSteps: row.distance_yellow_threshold_steps, + motionInterpolationEnabled: sqliteBoolean(row.motion_interpolation_enabled), + mockLivePositionEnabled: sqliteBoolean(row.mock_live_position_enabled), + mockLivePositionXSteps: row.mock_live_position_x_steps, + mockLivePositionYSteps: row.mock_live_position_y_steps, + comfortableAnchorRangeMeters: row.comfortable_anchor_range_meters, + activeDrillId: row.active_drill_id, + selectedDrillSetId: row.selected_drill_page_id, + }); +} + +function isCanonicalRow(row: AppSettingsRow, settings: AppSettings): boolean { + return ( + row.appearance_mode === settings.appearanceMode && + row.drill_features_enabled === boolToSql(settings.drillFeaturesEnabled) && + row.drill_terminology === settings.drillTerminology && + row.field_perspective === settings.fieldPerspective && + row.default_field_preset === settings.defaultFieldPreset && + row.transition_metric_mode === settings.transitionMetricMode && + row.count_display_mode === settings.countDisplayMode && + row.coordinate_rounding_steps === settings.coordinateRoundingSteps && + row.guidance_enabled === boolToSql(settings.guidanceEnabled) && + row.developer_mode_enabled === boolToSql(settings.developerModeEnabled) && + row.show_cached_anchor_geometry === + boolToSql(settings.showCachedAnchorGeometry) && + row.show_comfortable_anchor_range === + boolToSql(settings.showComfortableAnchorRange) && + row.show_perimeter_step_grid === + boolToSql(settings.showPerimeterStepGrid) && + row.show_auxiliary_field_marks === + boolToSql(settings.showAuxiliaryFieldMarks) && + row.show_performer_labels === boolToSql(settings.showPerformerLabels) && + row.show_performer_names === boolToSql(settings.showPerformerNames) && + row.show_prop_labels === boolToSql(settings.showPropLabels) && + row.show_prop_names === boolToSql(settings.showPropNames) && + row.show_transition_markers === boolToSql(settings.showTransitionMarkers) && + row.show_all_transition_sets === + boolToSql(settings.showAllTransitionSets) && + row.previous_transition_set_count === settings.previousTransitionSetCount && + row.next_transition_set_count === settings.nextTransitionSetCount && + row.distance_green_threshold_steps === + settings.distanceGreenThresholdSteps && + row.distance_yellow_threshold_steps === + settings.distanceYellowThresholdSteps && + row.motion_interpolation_enabled === + boolToSql(settings.motionInterpolationEnabled) && + row.mock_live_position_enabled === + boolToSql(settings.mockLivePositionEnabled) && + row.mock_live_position_x_steps === settings.mockLivePositionXSteps && + row.mock_live_position_y_steps === settings.mockLivePositionYSteps && + row.comfortable_anchor_range_meters === + settings.comfortableAnchorRangeMeters && + row.active_drill_id === settings.activeDrillId && + row.selected_drill_page_id === settings.selectedDrillSetId + ); +} + +function sqliteBoolean(value: SqlValue | undefined): unknown { + if (value === 1) return true; + if (value === 0) return false; + return value; +} + +function boolToSql(value: boolean): number { + return value ? 1 : 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/packages/mobile/src/settings/__tests__/repository.test.ts b/packages/mobile/src/settings/__tests__/repository.test.ts new file mode 100644 index 00000000..bb4b0907 --- /dev/null +++ b/packages/mobile/src/settings/__tests__/repository.test.ts @@ -0,0 +1,508 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; +import type { SQLiteDatabase } from "expo-sqlite"; +import { SqliteSettingsRepository } from "../SqliteSettingsRepository"; +import { + DEFAULT_APP_SETTINGS, + getEffectiveAppSettings, + getEffectiveDeveloperOverlaySettings, + normalizeAppSettings, +} from "../types"; + +describe("app settings", () => { + test("loads defaults when the singleton row is absent", async () => { + const fake = new SettingsFakeDatabase(null); + const repository = new SqliteSettingsRepository(fake.database); + + await expect(repository.load()).resolves.toEqual(DEFAULT_APP_SETTINGS); + expect(fake.row).toMatchObject({ + appearance_mode: "system", + drill_features_enabled: 1, + drill_terminology: "sets", + field_perspective: "performer", + default_field_preset: "football-nfhs", + transition_metric_mode: "step-size", + count_display_mode: "counts", + coordinate_rounding_steps: 0.25, + guidance_enabled: 1, + developer_mode_enabled: 0, + show_cached_anchor_geometry: 0, + show_comfortable_anchor_range: 0, + show_perimeter_step_grid: 0, + show_auxiliary_field_marks: 1, + show_performer_labels: 1, + show_performer_names: 0, + show_prop_labels: 1, + show_prop_names: 0, + show_transition_markers: 1, + show_all_transition_sets: 0, + previous_transition_set_count: 1, + next_transition_set_count: 1, + distance_green_threshold_steps: 0.5, + distance_yellow_threshold_steps: 1, + motion_interpolation_enabled: 1, + mock_live_position_enabled: 0, + mock_live_position_x_steps: 0, + mock_live_position_y_steps: 0, + comfortable_anchor_range_meters: 20, + active_drill_id: null, + selected_drill_page_id: null, + }); + }); + + test("normalizes invalid persisted values and rewrites a canonical row", async () => { + const fake = new SettingsFakeDatabase({ + appearance_mode: "sepia", + drill_features_enabled: 2, + drill_terminology: "legacy", + field_perspective: "unknown", + default_field_preset: "unknown", + transition_metric_mode: "unknown", + count_display_mode: "unknown", + coordinate_rounding_steps: 0.3, + guidance_enabled: "yes", + developer_mode_enabled: 0, + show_cached_anchor_geometry: 1, + show_comfortable_anchor_range: 1, + show_perimeter_step_grid: 1, + show_auxiliary_field_marks: "yes", + show_performer_labels: "yes", + show_performer_names: "yes", + show_prop_labels: "yes", + show_prop_names: "yes", + show_transition_markers: "yes", + show_all_transition_sets: "yes", + previous_transition_set_count: 1.5, + next_transition_set_count: Number.NaN, + distance_green_threshold_steps: Number.NaN, + distance_yellow_threshold_steps: 1, + motion_interpolation_enabled: "yes", + mock_live_position_enabled: "yes", + mock_live_position_x_steps: Number.POSITIVE_INFINITY, + mock_live_position_y_steps: Number.NaN, + comfortable_anchor_range_meters: Number.NaN, + active_drill_id: 17, + selected_drill_page_id: "", + }); + const repository = new SqliteSettingsRepository(fake.database); + + const loaded = await repository.load(); + + expect(loaded).toEqual({ + ...DEFAULT_APP_SETTINGS, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + showPerimeterStepGrid: true, + }); + expect(fake.row).toMatchObject({ + appearance_mode: "system", + drill_terminology: "sets", + field_perspective: "performer", + previous_transition_set_count: 1, + next_transition_set_count: 1, + distance_green_threshold_steps: 0.5, + distance_yellow_threshold_steps: 1, + }); + expect(fake.database.runAsync).toHaveBeenCalled(); + expect(getEffectiveAppSettings(loaded)).toMatchObject({ + developerModeEnabled: false, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: false, + showPerimeterStepGrid: false, + }); + }); + + test("updates supplied fields while preserving drill/set selection", async () => { + const fake = new SettingsFakeDatabase({ + appearance_mode: "system", + drill_features_enabled: 1, + drill_terminology: "sets", + field_perspective: "director", + default_field_preset: "football-ncaa", + transition_metric_mode: "step-size", + guidance_enabled: 1, + developer_mode_enabled: 1, + show_cached_anchor_geometry: 1, + show_comfortable_anchor_range: 1, + show_perimeter_step_grid: 1, + show_auxiliary_field_marks: 0, + show_performer_labels: 0, + show_performer_names: 1, + show_prop_labels: 0, + show_prop_names: 1, + show_transition_markers: 0, + show_all_transition_sets: 1, + previous_transition_set_count: 2, + next_transition_set_count: 3, + distance_green_threshold_steps: 0.25, + distance_yellow_threshold_steps: 1.25, + motion_interpolation_enabled: 0, + comfortable_anchor_range_meters: 30, + active_drill_id: "drill-1", + selected_drill_page_id: "set-1", + }); + const repository = new SqliteSettingsRepository(fake.database); + + const updated = await repository.update({ + comfortableAnchorRangeMeters: -1, + }); + + expect(updated).toMatchObject({ + drillFeaturesEnabled: true, + drillTerminology: "sets", + defaultFieldPreset: "football-ncaa", + comfortableAnchorRangeMeters: 20, + activeDrillId: "drill-1", + selectedDrillSetId: "set-1", + selectedDrillPageId: "set-1", + }); + expect(updated.developerModeEnabled).toBe(true); + expect(updated.showCachedAnchorGeometry).toBe(true); + expect(updated.showComfortableAnchorRange).toBe(true); + expect(updated.showPerimeterStepGrid).toBe(true); + }); + + test("round trips every persisted preference field", async () => { + const fake = new SettingsFakeDatabase( + settingsRow({ + active_drill_id: "drill-1", + selected_drill_page_id: "set-1", + }), + ); + const repository = new SqliteSettingsRepository(fake.database); + + const updated = await repository.update({ + appearanceMode: "dark", + drillFeaturesEnabled: false, + drillTerminology: "pages", + fieldPerspective: "director", + defaultFieldPreset: "football-ncaa", + transitionMetricMode: "crossing-counts", + countDisplayMode: "measures", + coordinateRoundingSteps: 0.5, + guidanceEnabled: false, + developerModeEnabled: true, + showCachedAnchorGeometry: true, + showComfortableAnchorRange: true, + showPerimeterStepGrid: true, + showAuxiliaryFieldMarks: false, + showPerformerLabels: false, + showPerformerNames: true, + showPropLabels: false, + showPropNames: true, + showTransitionMarkers: false, + showAllTransitionSets: true, + previousTransitionSetCount: 0, + nextTransitionSetCount: 5, + distanceGreenThresholdSteps: 0.75, + distanceYellowThresholdSteps: 1.5, + motionInterpolationEnabled: false, + mockLivePositionEnabled: true, + mockLivePositionXSteps: 12.5, + mockLivePositionYSteps: 24, + comfortableAnchorRangeMeters: 30, + }); + + await expect(repository.load()).resolves.toEqual(updated); + expect(updated).toMatchObject({ + appearanceMode: "dark", + drillTerminology: "pages", + countDisplayMode: "measures", + coordinateRoundingSteps: 0.5, + previousTransitionSetCount: 0, + nextTransitionSetCount: 5, + distanceGreenThresholdSteps: 0.75, + distanceYellowThresholdSteps: 1.5, + mockLivePositionEnabled: true, + mockLivePositionXSteps: 12.5, + mockLivePositionYSteps: 24, + activeDrillId: "drill-1", + selectedDrillSetId: "set-1", + selectedDrillPageId: "set-1", + }); + }); + + test("resetPreferences restores preference fields but preserves selection", async () => { + const fake = new SettingsFakeDatabase({ + appearance_mode: "dark", + drill_features_enabled: 0, + drill_terminology: "pages", + field_perspective: "performer", + default_field_preset: "football-nfl", + transition_metric_mode: "crossing-counts", + coordinate_rounding_steps: 1, + guidance_enabled: 0, + developer_mode_enabled: 1, + show_cached_anchor_geometry: 1, + show_comfortable_anchor_range: 1, + show_perimeter_step_grid: 1, + show_auxiliary_field_marks: 0, + show_performer_labels: 0, + show_performer_names: 1, + show_prop_labels: 0, + show_prop_names: 1, + show_transition_markers: 0, + show_all_transition_sets: 1, + previous_transition_set_count: 5, + next_transition_set_count: 5, + distance_green_threshold_steps: 0.75, + distance_yellow_threshold_steps: 1.5, + motion_interpolation_enabled: 0, + mock_live_position_enabled: 1, + mock_live_position_x_steps: -8, + mock_live_position_y_steps: 16, + comfortable_anchor_range_meters: 7, + active_drill_id: "drill-1", + selected_drill_page_id: "set-2", + }); + const repository = new SqliteSettingsRepository(fake.database); + + const reset = await repository.resetPreferences(); + + expect(reset).toEqual({ + ...DEFAULT_APP_SETTINGS, + activeDrillId: "drill-1", + selectedDrillSetId: "set-2", + selectedDrillPageId: "set-2", + }); + expect(fake.row?.drill_terminology).toBe("sets"); + }); + + test("normalizes appearance modes and accepts both terminology values", () => { + expect(normalizeAppSettings({ appearanceMode: "light" })).toMatchObject({ + appearanceMode: "light", + }); + expect(normalizeAppSettings({ appearanceMode: "unknown" })).toMatchObject({ + appearanceMode: "system", + }); + expect( + normalizeAppSettings({ drillTerminology: "sets" }).drillTerminology, + ).toBe("sets"); + expect( + normalizeAppSettings({ drillTerminology: "pages" }).drillTerminology, + ).toBe("pages"); + expect(normalizeAppSettings({}).fieldPerspective).toBe("performer"); + expect(normalizeAppSettings({}).coordinateRoundingSteps).toBe(0.25); + expect( + normalizeAppSettings({ coordinateRoundingSteps: 0.125 }) + .coordinateRoundingSteps, + ).toBe(0.125); + expect( + normalizeAppSettings({ coordinateRoundingSteps: 0.3 }) + .coordinateRoundingSteps, + ).toBe(0.25); + }); + + test("bounds transition counts and preserves threshold invariants", () => { + expect( + normalizeAppSettings({ + previousTransitionSetCount: -1, + nextTransitionSetCount: 6, + }), + ).toMatchObject({ + previousTransitionSetCount: 0, + nextTransitionSetCount: 5, + }); + expect( + normalizeAppSettings({ + previousTransitionSetCount: 1.5, + nextTransitionSetCount: Number.NaN, + }), + ).toMatchObject({ + previousTransitionSetCount: 1, + nextTransitionSetCount: 1, + }); + + const normalized = normalizeAppSettings({ + distanceGreenThresholdSteps: 2, + distanceYellowThresholdSteps: 1, + }); + expect(normalized.distanceGreenThresholdSteps).toBeLessThanOrEqual( + normalized.distanceYellowThresholdSteps, + ); + expect(normalized.distanceGreenThresholdSteps).toBeGreaterThanOrEqual(0); + expect( + normalizeAppSettings({ + distanceGreenThresholdSteps: Number.NaN, + distanceYellowThresholdSteps: Number.POSITIVE_INFINITY, + }), + ).toMatchObject({ + distanceGreenThresholdSteps: 0.5, + distanceYellowThresholdSteps: 1, + }); + }); + + test("the pure normalizer treats malformed input as defaults", () => { + expect( + normalizeAppSettings({ + drillFeaturesEnabled: "true", + defaultFieldPreset: "football-made-up", + guidanceEnabled: null, + comfortableAnchorRangeMeters: 0, + activeDrillId: " ", + }), + ).toEqual(DEFAULT_APP_SETTINGS); + }); + + test.each(FIELD_PRESET_IDS)( + "accepts %s as the default marching field", + (defaultFieldPreset) => { + expect( + normalizeAppSettings({ defaultFieldPreset }).defaultFieldPreset, + ).toBe(defaultFieldPreset); + }, + ); + + test("clears an impossible selected set when no drill is active", () => { + expect( + normalizeAppSettings({ + activeDrillId: null, + selectedDrillSetId: "set-1", + }).selectedDrillSetId, + ).toBeNull(); + }); + + test("gates developer overlays and rejects ranges over 200 meters", () => { + expect( + getEffectiveDeveloperOverlaySettings({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: true, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: true, + showPerimeterStepGrid: true, + }), + ).toMatchObject({ + showComfortableAnchorRange: false, + showPerimeterStepGrid: true, + }); + expect( + getEffectiveDeveloperOverlaySettings({ + ...DEFAULT_APP_SETTINGS, + developerModeEnabled: false, + showPerimeterStepGrid: true, + }), + ).toMatchObject({ showPerimeterStepGrid: false }); + expect( + normalizeAppSettings({ comfortableAnchorRangeMeters: 201 }), + ).toMatchObject({ comfortableAnchorRangeMeters: 20 }); + }); +}); + +class SettingsFakeDatabase { + readonly database: SQLiteDatabase; + row: Record | null; + + constructor(row: Record | null) { + this.row = row ? settingsRow(row) : null; + this.database = { + getFirstAsync: jest.fn(async () => (this.row ? { ...this.row } : null)), + runAsync: jest.fn(async (sql: string, params: unknown[]) => { + if (sql.includes("UPDATE app_settings")) { + this.row = { + ...(this.row ?? {}), + appearance_mode: params[0], + drill_features_enabled: params[1], + drill_terminology: params[2], + field_perspective: params[3], + default_field_preset: params[4], + transition_metric_mode: params[5], + count_display_mode: params[6], + coordinate_rounding_steps: params[7], + guidance_enabled: params[8], + developer_mode_enabled: params[9], + show_cached_anchor_geometry: params[10], + show_comfortable_anchor_range: params[11], + show_perimeter_step_grid: params[12], + show_auxiliary_field_marks: params[13], + show_performer_labels: params[14], + show_performer_names: params[15], + show_prop_labels: params[16], + show_prop_names: params[17], + show_transition_markers: params[18], + show_all_transition_sets: params[19], + previous_transition_set_count: params[20], + next_transition_set_count: params[21], + distance_green_threshold_steps: params[22], + distance_yellow_threshold_steps: params[23], + motion_interpolation_enabled: params[24], + mock_live_position_enabled: params[25], + mock_live_position_x_steps: params[26], + mock_live_position_y_steps: params[27], + comfortable_anchor_range_meters: params[28], + }; + } else { + this.row = { + appearance_mode: params[1], + drill_features_enabled: params[2], + drill_terminology: params[3], + field_perspective: params[4], + default_field_preset: params[5], + transition_metric_mode: params[6], + count_display_mode: params[7], + coordinate_rounding_steps: params[8], + guidance_enabled: params[9], + developer_mode_enabled: params[10], + show_cached_anchor_geometry: params[11], + show_comfortable_anchor_range: params[12], + show_perimeter_step_grid: params[13], + show_auxiliary_field_marks: params[14], + show_performer_labels: params[15], + show_performer_names: params[16], + show_prop_labels: params[17], + show_prop_names: params[18], + show_transition_markers: params[19], + show_all_transition_sets: params[20], + previous_transition_set_count: params[21], + next_transition_set_count: params[22], + distance_green_threshold_steps: params[23], + distance_yellow_threshold_steps: params[24], + motion_interpolation_enabled: params[25], + mock_live_position_enabled: params[26], + mock_live_position_x_steps: params[27], + mock_live_position_y_steps: params[28], + comfortable_anchor_range_meters: params[29], + active_drill_id: params[30], + selected_drill_page_id: params[31], + }; + } + return { lastInsertRowId: 1, changes: 1 }; + }), + } as unknown as SQLiteDatabase; + } +} + +function settingsRow(overrides: Record = {}) { + return { + appearance_mode: "system", + drill_features_enabled: 1, + drill_terminology: "sets", + field_perspective: "performer", + default_field_preset: "football-nfhs", + transition_metric_mode: "step-size", + count_display_mode: "counts", + coordinate_rounding_steps: 0.25, + guidance_enabled: 1, + developer_mode_enabled: 0, + show_cached_anchor_geometry: 0, + show_comfortable_anchor_range: 0, + show_perimeter_step_grid: 0, + show_auxiliary_field_marks: 1, + show_performer_labels: 1, + show_performer_names: 0, + show_prop_labels: 1, + show_prop_names: 0, + show_transition_markers: 1, + show_all_transition_sets: 0, + previous_transition_set_count: 1, + next_transition_set_count: 1, + distance_green_threshold_steps: 0.5, + distance_yellow_threshold_steps: 1, + motion_interpolation_enabled: 1, + mock_live_position_enabled: 0, + mock_live_position_x_steps: 0, + mock_live_position_y_steps: 0, + comfortable_anchor_range_meters: 20, + active_drill_id: null, + selected_drill_page_id: null, + ...overrides, + }; +} diff --git a/packages/mobile/src/settings/index.ts b/packages/mobile/src/settings/index.ts new file mode 100644 index 00000000..d741cde7 --- /dev/null +++ b/packages/mobile/src/settings/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./SqliteSettingsRepository"; diff --git a/packages/mobile/src/settings/types.ts b/packages/mobile/src/settings/types.ts new file mode 100644 index 00000000..9a5bf761 --- /dev/null +++ b/packages/mobile/src/settings/types.ts @@ -0,0 +1,394 @@ +import { isFieldPresetId, type FieldPresetId } from "@eight2five/drill-schema"; +import type { DrillTerminology } from "../drill/terminology"; + +export type FieldPerspective = "director" | "performer"; +export type AppearanceMode = "system" | "light" | "dark"; +export type TransitionMetricMode = "step-size" | "crossing-counts"; +export type CountDisplayMode = "counts" | "measures"; +export type CoordinateRoundingSteps = 0.125 | 0.25 | 0.5 | 1; + +export const COORDINATE_ROUNDING_PRESETS = Object.freeze([ + 0.125, 0.25, 0.5, 1, +] as const satisfies readonly CoordinateRoundingSteps[]); + +export const DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS = 20; +export const MAX_COMFORTABLE_ANCHOR_RANGE_METERS = 200; +export const MIN_TRANSITION_SET_COUNT = 0; +export const MAX_TRANSITION_SET_COUNT = 5; +export const DEFAULT_DISTANCE_GREEN_THRESHOLD_STEPS = 0.5; +export const DEFAULT_DISTANCE_YELLOW_THRESHOLD_STEPS = 1; + +/** App preferences plus persisted drill/set selection pointers. */ +export interface AppSettings { + readonly appearanceMode: AppearanceMode; + readonly drillFeaturesEnabled: boolean; + readonly drillTerminology: DrillTerminology; + readonly fieldPerspective: FieldPerspective; + readonly defaultFieldPreset: FieldPresetId; + readonly transitionMetricMode: TransitionMetricMode; + readonly countDisplayMode: CountDisplayMode; + readonly coordinateRoundingSteps: CoordinateRoundingSteps; + readonly guidanceEnabled: boolean; + readonly developerModeEnabled: boolean; + readonly showCachedAnchorGeometry: boolean; + readonly showComfortableAnchorRange: boolean; + readonly showPerimeterStepGrid: boolean; + readonly showAuxiliaryFieldMarks: boolean; + readonly showPerformerLabels: boolean; + readonly showPerformerNames: boolean; + readonly showPropLabels: boolean; + readonly showPropNames: boolean; + readonly showTransitionMarkers: boolean; + readonly showAllTransitionSets: boolean; + readonly previousTransitionSetCount: number; + readonly nextTransitionSetCount: number; + readonly distanceGreenThresholdSteps: number; + readonly distanceYellowThresholdSteps: number; + readonly motionInterpolationEnabled: boolean; + readonly mockLivePositionEnabled: boolean; + readonly mockLivePositionXSteps: number; + readonly mockLivePositionYSteps: number; + readonly comfortableAnchorRangeMeters: number; + readonly activeDrillId: string | null; + readonly selectedDrillSetId: string | null; + /** @deprecated Use selectedDrillSetId. */ + readonly selectedDrillPageId: string | null; +} + +export const DEFAULT_APP_SETTINGS: AppSettings = Object.freeze({ + appearanceMode: "system", + drillFeaturesEnabled: true, + drillTerminology: "sets", + fieldPerspective: "performer", + defaultFieldPreset: "football-nfhs", + transitionMetricMode: "step-size", + countDisplayMode: "counts", + coordinateRoundingSteps: 0.25, + guidanceEnabled: true, + developerModeEnabled: false, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: false, + showPerimeterStepGrid: false, + showAuxiliaryFieldMarks: true, + showPerformerLabels: true, + showPerformerNames: false, + showPropLabels: true, + showPropNames: false, + showTransitionMarkers: true, + showAllTransitionSets: false, + previousTransitionSetCount: 1, + nextTransitionSetCount: 1, + distanceGreenThresholdSteps: DEFAULT_DISTANCE_GREEN_THRESHOLD_STEPS, + distanceYellowThresholdSteps: DEFAULT_DISTANCE_YELLOW_THRESHOLD_STEPS, + motionInterpolationEnabled: true, + mockLivePositionEnabled: false, + mockLivePositionXSteps: 0, + mockLivePositionYSteps: 0, + comfortableAnchorRangeMeters: DEFAULT_COMFORTABLE_ANCHOR_RANGE_METERS, + activeDrillId: null, + selectedDrillSetId: null, + selectedDrillPageId: null, +}); + +export const APP_PREFERENCE_KEYS = Object.freeze([ + "appearanceMode", + "drillFeaturesEnabled", + "drillTerminology", + "fieldPerspective", + "defaultFieldPreset", + "transitionMetricMode", + "countDisplayMode", + "coordinateRoundingSteps", + "guidanceEnabled", + "developerModeEnabled", + "showCachedAnchorGeometry", + "showComfortableAnchorRange", + "showPerimeterStepGrid", + "showAuxiliaryFieldMarks", + "showPerformerLabels", + "showPerformerNames", + "showPropLabels", + "showPropNames", + "showTransitionMarkers", + "showAllTransitionSets", + "previousTransitionSetCount", + "nextTransitionSetCount", + "distanceGreenThresholdSteps", + "distanceYellowThresholdSteps", + "motionInterpolationEnabled", + "mockLivePositionEnabled", + "mockLivePositionXSteps", + "mockLivePositionYSteps", + "comfortableAnchorRangeMeters", +] as const satisfies readonly (keyof AppSettings)[]); + +export type AppPreferenceKey = (typeof APP_PREFERENCE_KEYS)[number]; +export type AppSettingsUpdate = Partial>; + +export interface AppSettingsRepository { + load(): Promise; + update(partial: AppSettingsUpdate): Promise; + resetPreferences(): Promise; +} + +/** Normalize untrusted persisted settings at the storage boundary. */ +export function normalizeAppSettings(value?: unknown): AppSettings { + const candidate = isRecord(value) ? value : {}; + const activeDrillId = nullableIdOrNull(candidate.activeDrillId); + const distanceThresholds = normalizeDistanceThresholds( + candidate.distanceGreenThresholdSteps, + candidate.distanceYellowThresholdSteps, + ); + return { + appearanceMode: + candidate.appearanceMode === "system" || + candidate.appearanceMode === "light" || + candidate.appearanceMode === "dark" + ? candidate.appearanceMode + : DEFAULT_APP_SETTINGS.appearanceMode, + drillFeaturesEnabled: booleanOrDefault( + candidate.drillFeaturesEnabled, + DEFAULT_APP_SETTINGS.drillFeaturesEnabled, + ), + drillTerminology: + candidate.drillTerminology === "sets" || + candidate.drillTerminology === "pages" + ? candidate.drillTerminology + : DEFAULT_APP_SETTINGS.drillTerminology, + fieldPerspective: + candidate.fieldPerspective === "director" || + candidate.fieldPerspective === "performer" + ? candidate.fieldPerspective + : DEFAULT_APP_SETTINGS.fieldPerspective, + defaultFieldPreset: isFieldPresetId(candidate.defaultFieldPreset) + ? candidate.defaultFieldPreset + : DEFAULT_APP_SETTINGS.defaultFieldPreset, + transitionMetricMode: + candidate.transitionMetricMode === "step-size" || + candidate.transitionMetricMode === "crossing-counts" + ? candidate.transitionMetricMode + : DEFAULT_APP_SETTINGS.transitionMetricMode, + countDisplayMode: + candidate.countDisplayMode === "counts" || + candidate.countDisplayMode === "measures" + ? candidate.countDisplayMode + : DEFAULT_APP_SETTINGS.countDisplayMode, + coordinateRoundingSteps: isCoordinateRoundingSteps( + candidate.coordinateRoundingSteps, + ) + ? candidate.coordinateRoundingSteps + : DEFAULT_APP_SETTINGS.coordinateRoundingSteps, + guidanceEnabled: booleanOrDefault( + candidate.guidanceEnabled, + DEFAULT_APP_SETTINGS.guidanceEnabled, + ), + developerModeEnabled: booleanOrDefault( + candidate.developerModeEnabled, + DEFAULT_APP_SETTINGS.developerModeEnabled, + ), + showCachedAnchorGeometry: booleanOrDefault( + candidate.showCachedAnchorGeometry, + DEFAULT_APP_SETTINGS.showCachedAnchorGeometry, + ), + showComfortableAnchorRange: booleanOrDefault( + candidate.showComfortableAnchorRange, + DEFAULT_APP_SETTINGS.showComfortableAnchorRange, + ), + showPerimeterStepGrid: booleanOrDefault( + candidate.showPerimeterStepGrid, + DEFAULT_APP_SETTINGS.showPerimeterStepGrid, + ), + showAuxiliaryFieldMarks: booleanOrDefault( + candidate.showAuxiliaryFieldMarks, + DEFAULT_APP_SETTINGS.showAuxiliaryFieldMarks, + ), + showPerformerLabels: booleanOrDefault( + candidate.showPerformerLabels, + DEFAULT_APP_SETTINGS.showPerformerLabels, + ), + showPerformerNames: booleanOrDefault( + candidate.showPerformerNames, + DEFAULT_APP_SETTINGS.showPerformerNames, + ), + showPropLabels: booleanOrDefault( + candidate.showPropLabels, + DEFAULT_APP_SETTINGS.showPropLabels, + ), + showPropNames: booleanOrDefault( + candidate.showPropNames, + DEFAULT_APP_SETTINGS.showPropNames, + ), + showTransitionMarkers: booleanOrDefault( + candidate.showTransitionMarkers, + DEFAULT_APP_SETTINGS.showTransitionMarkers, + ), + showAllTransitionSets: booleanOrDefault( + candidate.showAllTransitionSets, + DEFAULT_APP_SETTINGS.showAllTransitionSets, + ), + previousTransitionSetCount: boundedIntegerOrDefault( + candidate.previousTransitionSetCount, + DEFAULT_APP_SETTINGS.previousTransitionSetCount, + MIN_TRANSITION_SET_COUNT, + MAX_TRANSITION_SET_COUNT, + ), + nextTransitionSetCount: boundedIntegerOrDefault( + candidate.nextTransitionSetCount, + DEFAULT_APP_SETTINGS.nextTransitionSetCount, + MIN_TRANSITION_SET_COUNT, + MAX_TRANSITION_SET_COUNT, + ), + distanceGreenThresholdSteps: distanceThresholds.green, + distanceYellowThresholdSteps: distanceThresholds.yellow, + motionInterpolationEnabled: booleanOrDefault( + candidate.motionInterpolationEnabled, + DEFAULT_APP_SETTINGS.motionInterpolationEnabled, + ), + mockLivePositionEnabled: booleanOrDefault( + candidate.mockLivePositionEnabled, + DEFAULT_APP_SETTINGS.mockLivePositionEnabled, + ), + mockLivePositionXSteps: finiteOrDefault( + candidate.mockLivePositionXSteps, + DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + ), + mockLivePositionYSteps: finiteOrDefault( + candidate.mockLivePositionYSteps, + DEFAULT_APP_SETTINGS.mockLivePositionYSteps, + ), + comfortableAnchorRangeMeters: positiveFiniteOrDefault( + candidate.comfortableAnchorRangeMeters, + DEFAULT_APP_SETTINGS.comfortableAnchorRangeMeters, + ), + activeDrillId, + selectedDrillSetId: + activeDrillId === null + ? null + : nullableIdOrNull( + candidate.selectedDrillSetId ?? candidate.selectedDrillPageId, + ), + selectedDrillPageId: + activeDrillId === null + ? null + : nullableIdOrNull( + candidate.selectedDrillSetId ?? candidate.selectedDrillPageId, + ), + }; +} + +export function getEffectiveAppSettings(value: AppSettings): AppSettings { + const normalized = normalizeAppSettings(value); + if (normalized.developerModeEnabled) return normalized; + return { + ...normalized, + showCachedAnchorGeometry: false, + showComfortableAnchorRange: false, + showPerimeterStepGrid: false, + mockLivePositionEnabled: false, + mockLivePositionXSteps: DEFAULT_APP_SETTINGS.mockLivePositionXSteps, + mockLivePositionYSteps: DEFAULT_APP_SETTINGS.mockLivePositionYSteps, + }; +} + +export const selectEffectiveSettings = getEffectiveAppSettings; +export const getEffectiveSettings = getEffectiveAppSettings; +export const selectEffectiveAppSettings = getEffectiveAppSettings; + +export interface EffectiveDeveloperOverlaySettings { + readonly showCachedAnchorGeometry: boolean; + readonly showComfortableAnchorRange: boolean; + readonly showPerimeterStepGrid: boolean; +} + +export function getEffectiveDeveloperOverlaySettings( + value: AppSettings, +): EffectiveDeveloperOverlaySettings { + const settings = getEffectiveAppSettings(value); + return { + showCachedAnchorGeometry: settings.showCachedAnchorGeometry, + showComfortableAnchorRange: + settings.showCachedAnchorGeometry && settings.showComfortableAnchorRange, + showPerimeterStepGrid: settings.showPerimeterStepGrid, + }; +} + +export const selectEffectiveDeveloperOverlaySettings = + getEffectiveDeveloperOverlaySettings; + +export function selectShowCachedAnchorGeometry(value: AppSettings): boolean { + return getEffectiveAppSettings(value).showCachedAnchorGeometry; +} + +export function selectShowComfortableAnchorRange(value: AppSettings): boolean { + return getEffectiveAppSettings(value).showComfortableAnchorRange; +} + +export function selectShowPerimeterStepGrid(value: AppSettings): boolean { + return getEffectiveAppSettings(value).showPerimeterStepGrid; +} + +function isCoordinateRoundingSteps( + value: unknown, +): value is CoordinateRoundingSteps { + return ( + typeof value === "number" && + COORDINATE_ROUNDING_PRESETS.includes(value as CoordinateRoundingSteps) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function booleanOrDefault(value: unknown, fallback: boolean): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function finiteOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +function positiveFiniteOrDefault(value: unknown, fallback: number): number { + return typeof value === "number" && + Number.isFinite(value) && + value > 0 && + value <= MAX_COMFORTABLE_ANCHOR_RANGE_METERS + ? value + : fallback; +} + +function boundedIntegerOrDefault( + value: unknown, + fallback: number, + minimum: number, + maximum: number, +): number { + if (typeof value !== "number" || !Number.isInteger(value)) return fallback; + return Math.min(maximum, Math.max(minimum, value)); +} + +function normalizeDistanceThresholds( + greenValue: unknown, + yellowValue: unknown, +): { green: number; yellow: number } { + const green = nonNegativeFiniteOrDefault( + greenValue, + DEFAULT_APP_SETTINGS.distanceGreenThresholdSteps, + ); + const yellow = nonNegativeFiniteOrDefault( + yellowValue, + DEFAULT_APP_SETTINGS.distanceYellowThresholdSteps, + ); + return { green: Math.min(green, yellow), yellow }; +} + +function nonNegativeFiniteOrDefault(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.max(0, value); +} + +function nullableIdOrNull(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim(); + return normalized.length > 0 ? normalized : null; +} diff --git a/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts new file mode 100644 index 00000000..a8be6f38 --- /dev/null +++ b/packages/mobile/src/storage/__tests__/mobileDatabase.test.ts @@ -0,0 +1,141 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; +import type { SQLiteDatabase } from "expo-sqlite"; +import { + MOBILE_DB_NAME, + MOBILE_SCHEMA_VERSION, + prepareMobileDatabase, +} from "../mobileDatabase"; + +describe("mobile app SQLite schema preparation", () => { + test("creates the current disposable development schema", async () => { + const executed: string[] = []; + const database = fakeDatabase(0, executed); + + await prepareMobileDatabase(database); + + const sql = executed.join("\n"); + expect(MOBILE_DB_NAME).toBe("eight2five-mobile.db"); + expect(MOBILE_SCHEMA_VERSION).toBe(9); + expect(sql).toContain("PRAGMA journal_mode = WAL"); + expect(sql).toContain("PRAGMA foreign_keys = OFF"); + expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); + expect(sql).toContain("DROP TABLE IF EXISTS drill_pages"); + expect(sql).toContain("CREATE TABLE drills"); + expect(sql).toContain("CREATE TABLE drill_sets"); + expect(sql).toContain("field_preset TEXT NOT NULL DEFAULT 'football-nfhs'"); + for (const fieldPreset of FIELD_PRESET_IDS) { + expect(sql).toContain(`'${fieldPreset}'`); + } + expect(sql).toContain("set_number INTEGER NOT NULL"); + expect(sql).toContain("x_steps REAL NOT NULL"); + expect(sql).not.toContain("x_meters REAL"); + expect(sql).toContain("metadata_title TEXT NOT NULL"); + expect(sql).toContain("source_document_json TEXT"); + expect(sql).toContain("selected_performer_entity_id INTEGER"); + expect(sql).toContain("source_set_id INTEGER"); + expect(sql).not.toContain("y_meters REAL"); + expect(sql).toContain("CREATE TABLE app_settings"); + expect(sql).toContain("appearance_mode TEXT NOT NULL DEFAULT 'system'"); + expect(sql).toContain("drill_terminology IN ('sets', 'pages')"); + expect(sql).toContain( + "field_perspective TEXT NOT NULL DEFAULT 'performer'", + ); + expect(sql).toContain("default_field_preset TEXT NOT NULL"); + expect(sql).toContain("show_perimeter_step_grid INTEGER NOT NULL"); + expect(sql).toContain("count_display_mode TEXT NOT NULL DEFAULT 'counts'"); + expect(sql).toContain( + "coordinate_rounding_steps REAL NOT NULL DEFAULT 0.25", + ); + expect(sql).toContain("coordinate_rounding_steps IN (0.125, 0.25, 0.5, 1)"); + expect(sql).toContain( + "previous_transition_set_count INTEGER NOT NULL DEFAULT 1", + ); + expect(sql).toContain( + "next_transition_set_count INTEGER NOT NULL DEFAULT 1", + ); + expect(sql).toContain("previous_transition_set_count <= 5"); + expect(sql).toContain("next_transition_set_count <= 5"); + expect(sql).toContain( + "distance_green_threshold_steps REAL NOT NULL DEFAULT 0.5", + ); + expect(sql).toContain( + "distance_yellow_threshold_steps REAL NOT NULL DEFAULT 1", + ); + expect(sql).toContain( + "motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1", + ); + expect(sql).toContain( + "mock_live_position_enabled INTEGER NOT NULL DEFAULT 0", + ); + expect(sql).toContain("mock_live_position_x_steps REAL NOT NULL DEFAULT 0"); + expect(sql).toContain("mock_live_position_y_steps REAL NOT NULL DEFAULT 0"); + expect( + sql.indexOf("motion_interpolation_enabled INTEGER NOT NULL"), + ).toBeLessThan( + sql.indexOf( + "distance_green_threshold_steps <= distance_yellow_threshold_steps", + ), + ); + expect(sql).toContain("REFERENCES drills(id) ON DELETE CASCADE"); + expect(sql).toContain("REFERENCES drill_sets(id) ON DELETE SET NULL"); + expect(sql).toContain(`PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}`); + expect(database.withTransactionAsync).toHaveBeenCalledTimes(1); + }); + + test("destructively rebuilds an older development layout without migrations", async () => { + const executed: string[] = []; + const database = fakeDatabase(MOBILE_SCHEMA_VERSION - 1, executed); + + await prepareMobileDatabase(database); + + const sql = executed.join("\n"); + expect(sql).toContain("DROP TABLE IF EXISTS app_settings"); + expect(sql).toContain("DROP TABLE IF EXISTS drill_sets"); + expect(sql).toContain("DROP TABLE IF EXISTS drills"); + expect(sql).toContain("CREATE TABLE app_settings"); + expect(sql).not.toContain("ALTER TABLE"); + expect(sql).not.toContain("mobile_schema_migrations ("); + expect(database.withTransactionAsync).toHaveBeenCalledTimes(1); + }); + + test("keeps a current schema without rebuilding it", async () => { + const executed: string[] = []; + const database = fakeDatabase(MOBILE_SCHEMA_VERSION, executed); + + await prepareMobileDatabase(database); + + const sql = executed.join("\n"); + expect(sql).toContain("PRAGMA journal_mode = WAL"); + expect(sql).toContain("PRAGMA foreign_keys = ON"); + expect(sql).not.toContain("DROP TABLE"); + expect(sql).not.toContain("CREATE TABLE"); + expect(database.withTransactionAsync).not.toHaveBeenCalled(); + }); + + test("rejects a database newer than the package schema", async () => { + const executed: string[] = []; + const database = fakeDatabase(MOBILE_SCHEMA_VERSION + 1, executed); + + await expect(prepareMobileDatabase(database)).rejects.toThrow( + `Unsupported mobile database version ${MOBILE_SCHEMA_VERSION + 1}`, + ); + expect(database.withTransactionAsync).not.toHaveBeenCalled(); + expect(executed.join("\n")).not.toContain("DROP TABLE"); + }); +}); + +function fakeDatabase(version: number, executed: string[]) { + return { + execAsync: jest.fn(async (sql: string) => { + executed.push(sql); + }), + getFirstAsync: jest.fn(async () => ({ user_version: version })), + runAsync: jest.fn(async () => ({ lastInsertRowId: 1, changes: 1 })), + withTransactionAsync: jest.fn( + async (task: () => Promise) => await task(), + ), + } as unknown as SQLiteDatabase & { + runAsync: jest.Mock; + withTransactionAsync: jest.Mock; + }; +} diff --git a/packages/mobile/src/storage/index.ts b/packages/mobile/src/storage/index.ts new file mode 100644 index 00000000..a74b2c79 --- /dev/null +++ b/packages/mobile/src/storage/index.ts @@ -0,0 +1,2 @@ +export * from "./mobileDatabase"; +export * from "../mobile-repositories"; diff --git a/packages/mobile/src/storage/mobileDatabase.ts b/packages/mobile/src/storage/mobileDatabase.ts new file mode 100644 index 00000000..57c31b18 --- /dev/null +++ b/packages/mobile/src/storage/mobileDatabase.ts @@ -0,0 +1,245 @@ +import { FIELD_PRESET_IDS } from "@eight2five/drill-schema"; +import type { SQLiteDatabase } from "expo-sqlite"; + +/** The app database is deliberately separate from the PANS manager database. */ +export const MOBILE_DB_NAME = "eight2five-mobile.db"; +export const MOBILE_DATABASE_NAME = MOBILE_DB_NAME; + +/** + * The app-side schema is still under active development. Until it is declared + * stable, a version mismatch intentionally rebuilds this disposable database + * rather than carrying migration code for development-only layouts. + */ +export const MOBILE_SCHEMA_VERSION = 9; + +export const DRILLS_TABLE = "drills"; +export const DRILL_SETS_TABLE = "drill_sets"; +export const APP_SETTINGS_TABLE = "app_settings"; + +const FIELD_PRESET_SQL_LIST = FIELD_PRESET_IDS.map((id) => `'${id}'`).join( + ", ", +); + +export class MobileStorageError extends Error { + readonly cause?: unknown; + + constructor(message: string, options?: { cause?: unknown }) { + super(message); + this.name = "MobileStorageError"; + if (options?.cause !== undefined) this.cause = options.cause; + } +} + +/** + * Prepare the app-side database for use. + * + * During active schema development, any older local layout is discarded and + * recreated from the current definition. The separate PANS manager database is + * not touched by this routine. + */ +export async function prepareMobileDatabase(db: SQLiteDatabase): Promise { + await db.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); + + const row = await db.getFirstAsync<{ user_version: number | string }>( + "PRAGMA user_version", + ); + const currentVersion = parseSchemaVersion(row?.user_version); + if (currentVersion > MOBILE_SCHEMA_VERSION) { + throw new MobileStorageError( + `Unsupported mobile database version ${currentVersion}.`, + ); + } + + if (currentVersion !== MOBILE_SCHEMA_VERSION) { + await rebuildMobileDatabase(db); + } + + // Foreign-key enforcement is connection-local, so enable it on every open. + await db.execAsync("PRAGMA foreign_keys = ON;"); +} + +async function rebuildMobileDatabase(db: SQLiteDatabase): Promise { + await db.execAsync("PRAGMA foreign_keys = OFF;"); + try { + await db.withTransactionAsync(async () => { + await db.execAsync(` + DROP TABLE IF EXISTS ${APP_SETTINGS_TABLE}; + DROP TABLE IF EXISTS drill_pages; + DROP TABLE IF EXISTS ${DRILL_SETS_TABLE}; + DROP TABLE IF EXISTS ${DRILLS_TABLE}; + DROP TABLE IF EXISTS mobile_schema_migrations; + `); + await createCurrentSchema(db); + }); + } finally { + await db.execAsync("PRAGMA foreign_keys = ON;"); + } +} + +async function createCurrentSchema(db: SQLiteDatabase): Promise { + await db.execAsync(` + CREATE TABLE ${DRILLS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL CHECK (length(trim(name)) > 0), + field_preset TEXT NOT NULL DEFAULT 'football-nfhs' + CHECK (field_preset IN (${FIELD_PRESET_SQL_LIST})), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata_title TEXT NOT NULL CHECK (length(trim(metadata_title)) > 0), + metadata_created_at TEXT NOT NULL, + metadata_drill_writer TEXT, + metadata_ensemble TEXT, + metadata_description TEXT, + metadata_lucide_icon TEXT, + source_document_json TEXT, + selected_performer_entity_id INTEGER + CHECK ( + selected_performer_entity_id IS NULL OR + (selected_performer_entity_id >= 0 AND + selected_performer_entity_id = CAST(selected_performer_entity_id AS INTEGER)) + ) + ); + + CREATE INDEX idx_drills_created_at + ON ${DRILLS_TABLE}(created_at, id); + + CREATE TABLE ${DRILL_SETS_TABLE} ( + id TEXT PRIMARY KEY NOT NULL, + drill_id TEXT NOT NULL + REFERENCES ${DRILLS_TABLE}(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL + CHECK (ordinal >= 0 AND ordinal = CAST(ordinal AS INTEGER)), + set_number INTEGER NOT NULL CHECK (set_number >= 0), + set_suffix TEXT, + set_kind TEXT NOT NULL CHECK (set_kind IN ('set', 'subset')), + counts_from_previous INTEGER NOT NULL + CHECK ( + counts_from_previous >= 0 AND + counts_from_previous = CAST(counts_from_previous AS INTEGER) + ), + measure_start INTEGER, + measure_end INTEGER, + x_steps REAL NOT NULL CHECK (x_steps = x_steps), + y_steps REAL NOT NULL CHECK (y_steps = y_steps), + facing_degrees REAL + CHECK (facing_degrees IS NULL OR (facing_degrees >= 0 AND facing_degrees < 360)), + source_set_id INTEGER + CHECK (source_set_id IS NULL OR (source_set_id >= 0 AND source_set_id = CAST(source_set_id AS INTEGER))), + CHECK ( + (set_kind = 'set' AND set_suffix IS NULL) OR + (set_kind = 'subset' AND set_suffix IS NOT NULL) + ), + CHECK ( + (measure_start IS NULL AND measure_end IS NULL) OR + (measure_start IS NOT NULL AND measure_end IS NOT NULL AND + measure_start >= 0 AND measure_end >= measure_start) + ), + UNIQUE (drill_id, ordinal), + UNIQUE (drill_id, set_number, set_suffix) + ); + + CREATE INDEX idx_drill_sets_drill + ON ${DRILL_SETS_TABLE}(drill_id, ordinal, id); + + CREATE INDEX idx_drill_sets_source + ON ${DRILL_SETS_TABLE}(drill_id, source_set_id); + + CREATE TABLE ${APP_SETTINGS_TABLE} ( + singleton_id INTEGER PRIMARY KEY NOT NULL CHECK (singleton_id = 1), + appearance_mode TEXT NOT NULL DEFAULT 'system' + CHECK (appearance_mode IN ('system', 'light', 'dark')), + drill_features_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (drill_features_enabled IN (0, 1)), + drill_terminology TEXT NOT NULL DEFAULT 'sets' + CHECK (drill_terminology IN ('sets', 'pages')), + field_perspective TEXT NOT NULL DEFAULT 'performer' + CHECK (field_perspective IN ('director', 'performer')), + default_field_preset TEXT NOT NULL DEFAULT 'football-nfhs' + CHECK (default_field_preset IN (${FIELD_PRESET_SQL_LIST})), + transition_metric_mode TEXT NOT NULL DEFAULT 'step-size' + CHECK (transition_metric_mode IN ('step-size', 'crossing-counts')), + count_display_mode TEXT NOT NULL DEFAULT 'counts' + CHECK (count_display_mode IN ('counts', 'measures')), + coordinate_rounding_steps REAL NOT NULL DEFAULT 0.25 + CHECK (coordinate_rounding_steps IN (0.125, 0.25, 0.5, 1)), + guidance_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (guidance_enabled IN (0, 1)), + developer_mode_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (developer_mode_enabled IN (0, 1)), + show_cached_anchor_geometry INTEGER NOT NULL DEFAULT 0 + CHECK (show_cached_anchor_geometry IN (0, 1)), + show_comfortable_anchor_range INTEGER NOT NULL DEFAULT 0 + CHECK (show_comfortable_anchor_range IN (0, 1)), + show_perimeter_step_grid INTEGER NOT NULL DEFAULT 0 + CHECK (show_perimeter_step_grid IN (0, 1)), + show_auxiliary_field_marks INTEGER NOT NULL DEFAULT 1 + CHECK (show_auxiliary_field_marks IN (0, 1)), + show_performer_labels INTEGER NOT NULL DEFAULT 1 + CHECK (show_performer_labels IN (0, 1)), + show_performer_names INTEGER NOT NULL DEFAULT 0 + CHECK (show_performer_names IN (0, 1)), + show_prop_labels INTEGER NOT NULL DEFAULT 1 + CHECK (show_prop_labels IN (0, 1)), + show_prop_names INTEGER NOT NULL DEFAULT 0 + CHECK (show_prop_names IN (0, 1)), + show_transition_markers INTEGER NOT NULL DEFAULT 1 + CHECK (show_transition_markers IN (0, 1)), + show_all_transition_sets INTEGER NOT NULL DEFAULT 0 + CHECK (show_all_transition_sets IN (0, 1)), + previous_transition_set_count INTEGER NOT NULL DEFAULT 1 + CHECK ( + previous_transition_set_count >= 0 AND + previous_transition_set_count <= 5 AND + previous_transition_set_count = CAST(previous_transition_set_count AS INTEGER) + ), + next_transition_set_count INTEGER NOT NULL DEFAULT 1 + CHECK ( + next_transition_set_count >= 0 AND + next_transition_set_count <= 5 AND + next_transition_set_count = CAST(next_transition_set_count AS INTEGER) + ), + distance_green_threshold_steps REAL NOT NULL DEFAULT 0.5 + CHECK ( + distance_green_threshold_steps >= 0 AND + distance_green_threshold_steps = distance_green_threshold_steps + ), + distance_yellow_threshold_steps REAL NOT NULL DEFAULT 1 + CHECK ( + distance_yellow_threshold_steps >= 0 AND + distance_yellow_threshold_steps = distance_yellow_threshold_steps + ), + motion_interpolation_enabled INTEGER NOT NULL DEFAULT 1 + CHECK (motion_interpolation_enabled IN (0, 1)), + mock_live_position_enabled INTEGER NOT NULL DEFAULT 0 + CHECK (mock_live_position_enabled IN (0, 1)), + mock_live_position_x_steps REAL NOT NULL DEFAULT 0 + CHECK (mock_live_position_x_steps = mock_live_position_x_steps), + mock_live_position_y_steps REAL NOT NULL DEFAULT 0 + CHECK (mock_live_position_y_steps = mock_live_position_y_steps), + comfortable_anchor_range_meters REAL NOT NULL DEFAULT 20 + CHECK (comfortable_anchor_range_meters > 0), + active_drill_id TEXT + REFERENCES ${DRILLS_TABLE}(id) ON DELETE SET NULL, + selected_drill_page_id TEXT + REFERENCES ${DRILL_SETS_TABLE}(id) ON DELETE SET NULL, + CHECK ( + distance_green_threshold_steps <= distance_yellow_threshold_steps + ) + ); + + INSERT INTO ${APP_SETTINGS_TABLE} (singleton_id) VALUES (1); + + PRAGMA user_version = ${MOBILE_SCHEMA_VERSION}; + `); +} + +function parseSchemaVersion(value: number | string | undefined): number { + if (value === undefined) return 0; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new MobileStorageError( + `Invalid mobile database version ${String(value)}.`, + ); + } + return parsed; +} diff --git a/packages/ui/components/accordion/index.tsx b/packages/ui/components/accordion/index.tsx index f7ff96d9..c75a7b3e 100644 --- a/packages/ui/components/accordion/index.tsx +++ b/packages/ui/components/accordion/index.tsx @@ -24,7 +24,7 @@ const accordionItemStyle = tva({ }); const accordionTitleTextStyle = tva({ - base: 'text-foreground font-medium flex-1 text-left text-sm', + base: 'text-foreground font-body-medium flex-1 text-left text-sm', }); const accordionIconStyle = tva({ @@ -32,7 +32,7 @@ const accordionIconStyle = tva({ }); const accordionContentTextStyle = tva({ - base: 'text-foreground text-sm font-normal', + base: 'text-foreground text-sm font-body', }); const accordionHeaderStyle = tva({ diff --git a/packages/ui/components/actionsheet/index.tsx b/packages/ui/components/actionsheet/index.tsx index 09bcf942..cf0288aa 100644 --- a/packages/ui/components/actionsheet/index.tsx +++ b/packages/ui/components/actionsheet/index.tsx @@ -73,13 +73,13 @@ const actionsheetItemStyle = tva({ }); const actionsheetItemTextStyle = tva({ - base: 'text-foreground font-normal text-sm', + base: 'text-foreground font-body text-sm', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', @@ -119,13 +119,13 @@ const actionsheetSectionListStyle = tva({ }); const actionsheetSectionHeaderTextStyle = tva({ - base: 'leading-5 font-semibold my-0 text-muted-foreground p-3 uppercase text-xs', + base: 'leading-5 font-body-semibold my-0 text-muted-foreground p-3 uppercase text-xs', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/alert/index.tsx b/packages/ui/components/alert/index.tsx index b2c6e2d4..b9fcdd42 100644 --- a/packages/ui/components/alert/index.tsx +++ b/packages/ui/components/alert/index.tsx @@ -20,7 +20,7 @@ const alertStyle = tva({ }); const alertTextStyle = tva({ - base: 'font-medium tracking-tight text-sm flex-1', + base: 'font-body-medium tracking-tight text-sm flex-1', parentVariants: { variant: { default: 'text-card-foreground', diff --git a/packages/ui/components/avatar/index.tsx b/packages/ui/components/avatar/index.tsx index 08ff16d2..13d23cbb 100644 --- a/packages/ui/components/avatar/index.tsx +++ b/packages/ui/components/avatar/index.tsx @@ -19,7 +19,7 @@ const avatarStyle = tva({ }); const avatarFallbackTextStyle = tva({ - base: 'text-foreground text-xs font-medium text-transform:uppercase', + base: 'text-foreground text-xs font-body-medium text-transform:uppercase', }); const avatarGroupStyle = tva({ diff --git a/packages/ui/components/badge/index.tsx b/packages/ui/components/badge/index.tsx index ba479256..3ed46cb3 100644 --- a/packages/ui/components/badge/index.tsx +++ b/packages/ui/components/badge/index.tsx @@ -23,7 +23,7 @@ const badgeStyle = tva({ }); const badgeTextStyle = tva({ - base: 'text-xs font-medium tracking-normal uppercase', + base: 'text-xs font-body-medium tracking-normal uppercase', parentVariants: { variant: { default: 'text-primary-foreground', diff --git a/packages/ui/components/bottomsheet/index.tsx b/packages/ui/components/bottomsheet/index.tsx index bbd3d962..2cf2efab 100644 --- a/packages/ui/components/bottomsheet/index.tsx +++ b/packages/ui/components/bottomsheet/index.tsx @@ -49,7 +49,7 @@ const bottomSheetItemStyle = tva({ base: 'p-3 flex-row items-center rounded-sm w-full disabled:opacity-40 web:pointer-events-auto disabled:cursor-not-allowed hover:bg-accent/40 active:bg-accent/50 data-[focus=true]:bg-accent/20 web:data-[focus-visible=true]:bg-accent/40', }); const bottomSheetItemTextStyle = tva({ - base: 'text-foreground font-normal text-sm', + base: 'text-foreground font-body text-sm', }); const bottomSheetFooterStyle = tva({ @@ -57,7 +57,7 @@ const bottomSheetFooterStyle = tva({ }); const bottomSheetTextInputStyle = tva({ - base: 'flex-1 text-foreground text-sm md:text-sm py-1 placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text h-9 w-full flex-row items-center rounded-md border border-border dark:bg-input/30 bg-transparent shadow-xs overflow-hidden px-3 gap-2', + base: 'flex-1 text-foreground text-sm md:text-sm font-body py-1 placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text h-9 w-full flex-row items-center rounded-md border border-border dark:bg-input/30 bg-transparent shadow-xs overflow-hidden px-3 gap-2', }); type BottomSheetContextValue = { diff --git a/packages/ui/components/button/index.tsx b/packages/ui/components/button/index.tsx index 5ad32817..b2c7a41e 100644 --- a/packages/ui/components/button/index.tsx +++ b/packages/ui/components/button/index.tsx @@ -10,7 +10,6 @@ import { import { withUniwind } from 'uniwind'; import React from 'react'; import { ActivityIndicator, Pressable, Text, View } from 'react-native'; -import { eight2FiveFonts } from '../../theme'; const SCOPE = 'BUTTON'; const Root = withStyleContext(Pressable, SCOPE); const StyledUIIcon = withUniwind(UIIcon); @@ -37,7 +36,7 @@ const buttonStyle = tva({ link: 'text-primary underline-offset-4 data-[hover=true]:underline', }, size: { - default: 'px-4 py-2', + default: 'min-h-10 px-4 py-2', sm: 'min-h-8 rounded-md px-3 text-xs', lg: 'min-h-10 rounded-md px-8', icon: 'min-h-9 min-w-9', @@ -45,7 +44,7 @@ const buttonStyle = tva({ }, }); const buttonTextStyle = tva({ - base: 'web:select-none font-sans', + base: 'web:select-none text-center font-heading-semibold', parentVariants: { variant: { default: 'text-primary-foreground', @@ -150,7 +149,10 @@ const ButtonText = React.forwardRef< ( - {label} + {label} )} > @@ -137,7 +137,7 @@ const CalendarHeaderMonthSelectRoot = React.forwardRef< onPress={() => onValueChange?.(item.value)} > {item.label} @@ -160,7 +160,7 @@ const CalendarHeaderYearSelectRoot = React.forwardRef< offset={4} trigger={({ ...triggerProps }) => ( - {label} + {label} )} > @@ -171,7 +171,7 @@ const CalendarHeaderYearSelectRoot = React.forwardRef< onPress={() => onValueChange?.(item.value)} > {item.label} diff --git a/packages/ui/components/calendar/styles.tsx b/packages/ui/components/calendar/styles.tsx index 5a7a03c5..2a5f72ed 100644 --- a/packages/ui/components/calendar/styles.tsx +++ b/packages/ui/components/calendar/styles.tsx @@ -28,7 +28,7 @@ export const calendarHeaderButtonStyle = tva({ }); export const calendarHeaderTitleStyle = tva({ - base: 'text-foreground text-base font-semibold', + base: 'text-foreground text-base font-heading-semibold', variants: {}, }); @@ -44,7 +44,7 @@ export const calendarWeekDayStyle = tva({ }); export const calendarWeekDayTextStyle = tva({ - base: 'text-muted-foreground text-xs font-medium uppercase', + base: 'text-muted-foreground text-xs font-body-medium uppercase', variants: {}, parentVariants: {}, }); @@ -83,7 +83,7 @@ export const calendarDayStyle = tva({ }); export const calendarDayTextStyle = tva({ - base: 'text-foreground text-sm font-normal z-10', + base: 'text-foreground text-sm font-body z-10', variants: { state: { 'default': 'text-foreground', @@ -91,8 +91,8 @@ export const calendarDayTextStyle = tva({ 'today': 'text-accent-foreground', 'disabled': 'text-muted-foreground', 'outside-month': 'text-muted-foreground', - 'range-start': 'text-primary-foreground font-semibold', - 'range-end': 'text-primary-foreground font-semibold', + 'range-start': 'text-primary-foreground font-body-semibold', + 'range-end': 'text-primary-foreground font-body-semibold', 'range-middle': 'text-foreground', }, }, @@ -122,7 +122,7 @@ export const calendarWeekNumberStyle = tva({ }); export const calendarWeekNumberTextStyle = tva({ - base: 'text-muted-foreground text-xs font-normal', + base: 'text-muted-foreground text-xs font-body', variants: {}, parentVariants: {}, }); diff --git a/packages/ui/components/chat-ai/ChatInput.tsx b/packages/ui/components/chat-ai/ChatInput.tsx index 6fbf3b4a..a2e2b56e 100644 --- a/packages/ui/components/chat-ai/ChatInput.tsx +++ b/packages/ui/components/chat-ai/ChatInput.tsx @@ -24,7 +24,7 @@ const sendButtonStyle = tva({ }); const sendButtonTextStyle = tva({ - base: 'text-primary-foreground font-medium', + base: 'text-primary-foreground font-body-medium', }); interface ChatInputProps extends ViewProps { diff --git a/packages/ui/components/chat-ai/attatchments.tsx b/packages/ui/components/chat-ai/attatchments.tsx index a139b430..dca73002 100644 --- a/packages/ui/components/chat-ai/attatchments.tsx +++ b/packages/ui/components/chat-ai/attatchments.tsx @@ -150,7 +150,7 @@ export const Attachment = ({ className={` group relative ${variant === 'grid' ? 'w-24 h-24 overflow-hidden rounded-lg' : ''} - ${variant === 'inline' ? 'flex h-8 items-center gap-1.5 rounded-md border border-border px-1.5 text-sm font-medium' : ''} + ${variant === 'inline' ? 'flex h-8 items-center gap-1.5 rounded-md border border-border px-1.5 text-sm font-body-medium' : ''} ${variant === 'list' ? 'flex w-full items-center gap-3 rounded-lg border p-3' : ''} ${className} `} diff --git a/packages/ui/components/chat-ai/conversation.tsx b/packages/ui/components/chat-ai/conversation.tsx index 61f55881..fb527be4 100644 --- a/packages/ui/components/chat-ai/conversation.tsx +++ b/packages/ui/components/chat-ai/conversation.tsx @@ -47,7 +47,7 @@ export const ConversationEmptyState = ({ className, }: ConversationEmptyStateProps) => ( - {title} + {title} ); diff --git a/packages/ui/components/chat-ai/conversation.web.tsx b/packages/ui/components/chat-ai/conversation.web.tsx index c42cd5bc..86b2cdc8 100644 --- a/packages/ui/components/chat-ai/conversation.web.tsx +++ b/packages/ui/components/chat-ai/conversation.web.tsx @@ -50,7 +50,7 @@ export const ConversationEmptyState = ({ {icon ?? ( )} - {title} + {title} {description} diff --git a/packages/ui/components/chat-ai/message.tsx b/packages/ui/components/chat-ai/message.tsx index 8ce4eb1b..30c2d1da 100644 --- a/packages/ui/components/chat-ai/message.tsx +++ b/packages/ui/components/chat-ai/message.tsx @@ -178,7 +178,7 @@ export const MessageResponse = memo(({ message }: { message: UIMessage }) => { }, strong: (node, children) => ( - + {children} ), @@ -247,6 +247,7 @@ export const MessageResponse = memo(({ message }: { message: UIMessage }) => { Message attachment diff --git a/packages/ui/components/chat-ai/model-selector.tsx b/packages/ui/components/chat-ai/model-selector.tsx index 552a6cd1..ba3f0ffe 100644 --- a/packages/ui/components/chat-ai/model-selector.tsx +++ b/packages/ui/components/chat-ai/model-selector.tsx @@ -180,7 +180,7 @@ export const ModelSelectorGroup = ({ }: ComponentProps & { heading?: string }) => ( {heading && ( - + {heading} )} @@ -220,7 +220,7 @@ export const ModelSelectorSeparator = ({ export const ModelSelectorLogo = ({ provider }: { provider: string }) => ( - + {provider.slice(0, 2).toUpperCase()} diff --git a/packages/ui/components/checkbox/index.tsx b/packages/ui/components/checkbox/index.tsx index 71cef74e..f2ef4c25 100644 --- a/packages/ui/components/checkbox/index.tsx +++ b/packages/ui/components/checkbox/index.tsx @@ -49,7 +49,7 @@ const checkboxIndicatorStyle = tva({ }); const checkboxLabelStyle = tva({ - base: 'text-foreground text-sm font-medium font-body web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', + base: 'text-foreground text-sm font-body-medium web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', }); const checkboxIconStyle = tva({ diff --git a/packages/ui/components/date-time-picker/index.tsx b/packages/ui/components/date-time-picker/index.tsx index 5bd5c9c1..f3d1dc6b 100644 --- a/packages/ui/components/date-time-picker/index.tsx +++ b/packages/ui/components/date-time-picker/index.tsx @@ -417,11 +417,11 @@ function IOSDateTimePicker({ - + Cancel - + {mode === 'date' ? 'Select Date' : mode === 'time' @@ -429,7 +429,7 @@ function IOSDateTimePicker({ : 'Select Date & Time'} - Done + Done & React.ComponentPropsWithoutRef & { @@ -212,7 +211,7 @@ const Heading = memo( highlight: highlight as boolean, class: className, })} - style={[{ fontFamily: eight2FiveFonts.styleBold }, style]} + style={style} {...props} /> ); @@ -222,7 +221,7 @@ const Heading = memo( diff --git a/packages/ui/components/heading/styles.tsx b/packages/ui/components/heading/styles.tsx index 3e583127..d18ac4c1 100644 --- a/packages/ui/components/heading/styles.tsx +++ b/packages/ui/components/heading/styles.tsx @@ -1,17 +1,17 @@ import { tva } from '@gluestack-ui/utils/nativewind-utils'; import { isWeb } from '@gluestack-ui/utils/nativewind-utils'; const baseStyle = isWeb - ? 'font-sans tracking-sm bg-transparent border-0 box-border display-inline list-none margin-0 padding-0 position-relative text-start no-underline whitespace-pre-wrap word-wrap-break-word' + ? 'tracking-sm bg-transparent border-0 box-border display-inline list-none margin-0 padding-0 position-relative text-start no-underline whitespace-pre-wrap word-wrap-break-word' : ''; export const headingStyle = tva({ - base: `text-foreground font-bold font-heading tracking-sm my-0 ${baseStyle}`, + base: `text-foreground font-heading-bold tracking-sm my-0 ${baseStyle}`, variants: { isTruncated: { true: 'truncate', }, bold: { - true: 'font-bold', + true: 'font-heading-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/image-viewer/index.tsx b/packages/ui/components/image-viewer/index.tsx index 523f0f84..2c332126 100644 --- a/packages/ui/components/image-viewer/index.tsx +++ b/packages/ui/components/image-viewer/index.tsx @@ -63,7 +63,7 @@ const imageViewerCounterStyle = tva({ }); const imageViewerCounterTextStyle = tva({ - base: 'text-white text-sm font-medium bg-black/60 px-4 py-2 rounded-full', + base: 'text-white text-sm font-body-medium bg-black/60 px-4 py-2 rounded-full', }); interface ImageItem { @@ -671,7 +671,7 @@ const ImageViewerCloseButton = React.forwardRef< accessibilityRole="button" {...props} > - + ); }); @@ -697,7 +697,7 @@ const ImageViewerNavigation = React.forwardRef( accessibilityLabel="Previous image" accessibilityRole="button" > - + )} @@ -708,7 +708,7 @@ const ImageViewerNavigation = React.forwardRef( accessibilityLabel="Next image" accessibilityRole="button" > - + )} diff --git a/packages/ui/components/image-viewer/index.web.tsx b/packages/ui/components/image-viewer/index.web.tsx index 286e61ca..fd906573 100644 --- a/packages/ui/components/image-viewer/index.web.tsx +++ b/packages/ui/components/image-viewer/index.web.tsx @@ -33,7 +33,7 @@ const imageViewerContentStyle = tva({ }); const imageViewerCloseButtonStyle = tva({ - base: 'absolute top-4 right-4 z-50 w-10 h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-xl font-bold cursor-pointer backdrop-blur-sm', + base: 'absolute top-4 right-4 z-50 w-10 h-10 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-xl font-body-bold cursor-pointer backdrop-blur-sm', }); const imageViewerNavigationStyle = tva({ @@ -41,7 +41,7 @@ const imageViewerNavigationStyle = tva({ }); const imageViewerNavButtonStyle = tva({ - base: 'w-12 h-12 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-2xl font-bold cursor-pointer pointer-events-auto backdrop-blur-sm', + base: 'w-12 h-12 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center text-white text-2xl font-body-bold cursor-pointer pointer-events-auto backdrop-blur-sm', }); const imageViewerCounterStyle = tva({ @@ -49,7 +49,7 @@ const imageViewerCounterStyle = tva({ }); const imageViewerCounterTextStyle = tva({ - base: 'text-white text-sm font-medium bg-black/60 px-4 py-2 rounded-full', + base: 'text-white text-sm font-body-medium bg-black/60 px-4 py-2 rounded-full', }); // Context for ImageViewer - with default values to prevent errors when used outside provider diff --git a/packages/ui/components/input/index.tsx b/packages/ui/components/input/index.tsx index 03b0b2fd..29139136 100644 --- a/packages/ui/components/input/index.tsx +++ b/packages/ui/components/input/index.tsx @@ -33,7 +33,7 @@ const inputSlotStyle = tva({ }); const inputFieldStyle = tva({ - base: 'flex-1 text-foreground text-sm md:text-sm py-1 h-full placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed', + base: 'flex-1 text-foreground text-sm md:text-sm font-body py-1 h-full placeholder:text-muted-foreground web:outline-none ios:leading-[0px] web:cursor-text web:data-[disabled=true]:cursor-not-allowed', }); type IInputProps = React.ComponentProps & diff --git a/packages/ui/components/link/index.tsx b/packages/ui/components/link/index.tsx index 27d4a600..f778b8d5 100644 --- a/packages/ui/components/link/index.tsx +++ b/packages/ui/components/link/index.tsx @@ -18,14 +18,14 @@ const linkStyle = tva({ }); const linkTextStyle = tva({ - base: 'underline text-primary data-[hover=true]:text-primary/80 data-[hover=true]:no-underline data-[active=true]:text-destructive/80 font-normal font-body web:font-sans web:tracking-sm web:my-0 web:bg-transparent web:border-0 web:box-border web:inline web:list-none web:m-0 web:p-0 web:relative web:text-start web:whitespace-pre-wrap web:break-words', + base: 'underline text-primary data-[hover=true]:text-primary/80 data-[hover=true]:no-underline data-[active=true]:text-destructive/80 font-body web:font-sans web:tracking-sm web:my-0 web:bg-transparent web:border-0 web:box-border web:inline web:list-none web:m-0 web:p-0 web:relative web:text-start web:whitespace-pre-wrap web:break-words', variants: { isTruncated: { true: 'web:truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/menu/index.tsx b/packages/ui/components/menu/index.tsx index 5b766d3a..0376e0c1 100644 --- a/packages/ui/components/menu/index.tsx +++ b/packages/ui/components/menu/index.tsx @@ -27,14 +27,14 @@ const menuSeparatorStyle = tva({ }); const menuItemLabelStyle = tva({ - base: 'text-popover-foreground font-normal font-body', + base: 'text-popover-foreground font-body', variants: { isTruncated: { true: 'web:truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/modal/index.tsx b/packages/ui/components/modal/index.tsx index fc91f695..f8c1c229 100644 --- a/packages/ui/components/modal/index.tsx +++ b/packages/ui/components/modal/index.tsx @@ -47,7 +47,7 @@ const modalBackdropStyle = tva({ }); const modalContentStyle = tva({ - base: 'bg-background rounded-md overflow-hidden border border-border/80 shadow-hard-2 p-6', + base: 'bg-background rounded-2xl overflow-hidden border border-border/80 shadow-hard-2 p-6', parentVariants: { size: { xs: 'w-[60%] max-w-[360px]', @@ -128,7 +128,7 @@ const ModalBackdrop = React.forwardRef< const ModalContent = React.forwardRef< React.ComponentRef, IModalContentProps ->(function ModalContent({ className, size, ...props }, ref) { +>(function ModalContent({ className, size, style, ...props }, ref) { const { size: parentSize } = useStyleContext(SCOPE); return ( @@ -139,6 +139,7 @@ const ModalContent = React.forwardRef< })} exiting={FadeOut.duration(200)} {...props} + style={[{ borderCurve: 'continuous' }, style]} className={modalContentStyle({ parentVariants: { size: parentSize, diff --git a/packages/ui/components/radio/index.tsx b/packages/ui/components/radio/index.tsx index d10faa5e..21177e63 100644 --- a/packages/ui/components/radio/index.tsx +++ b/packages/ui/components/radio/index.tsx @@ -62,7 +62,7 @@ const radioIndicatorStyle = tva({ }); const radioLabelStyle = tva({ - base: 'text-foreground text-sm font-medium web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 font-body', + base: 'text-foreground text-sm font-body-medium web:select-none web:cursor-pointer data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', parentVariants: { size: { '2xs': 'text-2xs', diff --git a/packages/ui/components/select/index.tsx b/packages/ui/components/select/index.tsx index 89355980..5d5219b7 100644 --- a/packages/ui/components/select/index.tsx +++ b/packages/ui/components/select/index.tsx @@ -72,7 +72,7 @@ const selectTriggerStyle = tva({ }); const selectInputStyle = tva({ - base: 'px-3 placeholder:text-foreground/50 web:w-full h-full text-foreground/90 pointer-events-none web:outline-none ios:leading-[0px] py-0', + base: 'px-3 placeholder:text-foreground/50 web:w-full h-full text-foreground/90 font-body pointer-events-none web:outline-none ios:leading-[0px] py-0', parentVariants: { size: { xl: 'text-xl', diff --git a/packages/ui/components/select/select-actionsheet.tsx b/packages/ui/components/select/select-actionsheet.tsx index fe90d4b5..b39da633 100644 --- a/packages/ui/components/select/select-actionsheet.tsx +++ b/packages/ui/components/select/select-actionsheet.tsx @@ -76,13 +76,13 @@ const actionsheetItemStyle = tva({ }); const actionsheetItemTextStyle = tva({ - base: 'text-foreground/70 font-normal font-body tracking-md text-left mx-2', + base: 'text-foreground/70 font-body tracking-md text-left mx-2', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', @@ -138,13 +138,13 @@ const actionsheetSectionListStyle = tva({ }); const actionsheetSectionHeaderTextStyle = tva({ - base: 'leading-5 font-bold font-heading my-0 text-foreground/50 p-3 uppercase', + base: 'leading-5 font-heading-bold my-0 text-foreground/50 p-3 uppercase', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-heading-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/table/styles.tsx b/packages/ui/components/table/styles.tsx index 143c6774..43ce760c 100644 --- a/packages/ui/components/table/styles.tsx +++ b/packages/ui/components/table/styles.tsx @@ -20,7 +20,7 @@ export const tableFooterStyle = tva({ }); export const tableHeadStyle = tva({ - base: 'flex-1 px-6 py-[14px] text-left font-bold text-[16px] leading-[22px] text-foreground/80 font-roboto', + base: 'flex-1 px-6 py-[14px] text-left font-body-bold text-[16px] leading-[22px] text-foreground/80', }); export const tableRowStyleStyle = tva({ @@ -36,9 +36,9 @@ export const tableRowStyleStyle = tva({ }); export const tableDataStyle = tva({ - base: 'flex-1 px-6 py-[14px] text-left text-[16px] font-medium leading-[22px] text-foreground/80 font-roboto', + base: 'flex-1 px-6 py-[14px] text-left text-[16px] font-body-medium leading-[22px] text-foreground/80', }); export const tableCaptionStyle = tva({ - base: `${captionTableStyle} px-6 py-[14px] text-[16px] font-normal leading-[22px] text-foreground/90 bg-background/90 font-roboto`, + base: `${captionTableStyle} px-6 py-[14px] text-[16px] font-body leading-[22px] text-foreground/90 bg-background/90`, }); diff --git a/packages/ui/components/tabs/index.tsx b/packages/ui/components/tabs/index.tsx index 0963e3d0..73659ea9 100644 --- a/packages/ui/components/tabs/index.tsx +++ b/packages/ui/components/tabs/index.tsx @@ -49,7 +49,7 @@ const tabsTriggerStyle = tva({ }); const tabsTriggerTextStyle = tva({ - base: 'text-foreground/70 data-[selected=true]:text-foreground font-medium data-[hover=true]:text-foreground/90 ', + base: 'text-foreground/70 data-[selected=true]:text-foreground font-body-medium data-[hover=true]:text-foreground/90 ', }); diff --git a/packages/ui/components/text/index.tsx b/packages/ui/components/text/index.tsx index 5bda4b4b..241632ec 100644 --- a/packages/ui/components/text/index.tsx +++ b/packages/ui/components/text/index.tsx @@ -3,7 +3,6 @@ import React from 'react'; import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; import { Text as RNText } from 'react-native'; import { textStyle } from './styles'; -import { eight2FiveFonts } from '../../theme'; type ITextProps = React.ComponentProps & VariantProps; @@ -38,7 +37,7 @@ const Text = React.forwardRef, ITextProps>( highlight: highlight as boolean, class: className, })} - style={[{ fontFamily: eight2FiveFonts.utilityRegular }, style]} + style={style} {...props} ref={ref} /> diff --git a/packages/ui/components/text/styles.tsx b/packages/ui/components/text/styles.tsx index 49e143f7..92236b79 100644 --- a/packages/ui/components/text/styles.tsx +++ b/packages/ui/components/text/styles.tsx @@ -13,7 +13,7 @@ export const textStyle = tva({ true: 'web:truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/textarea/index.tsx b/packages/ui/components/textarea/index.tsx index 36d5bd05..792dc729 100644 --- a/packages/ui/components/textarea/index.tsx +++ b/packages/ui/components/textarea/index.tsx @@ -33,7 +33,7 @@ const textareaStyle = tva({ }); const textareaInputStyle = tva({ - base: 'p-2 web:outline-0 web:outline-none flex-1 text-foreground placeholder:text-foreground/60 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', + base: 'p-2 web:outline-0 web:outline-none flex-1 text-foreground font-body placeholder:text-foreground/60 web:cursor-text web:data-[disabled=true]:cursor-not-allowed', parentVariants: { size: { sm: 'text-sm', diff --git a/packages/ui/components/toast/index.tsx b/packages/ui/components/toast/index.tsx index 968afba7..5476f1f1 100644 --- a/packages/ui/components/toast/index.tsx +++ b/packages/ui/components/toast/index.tsx @@ -29,13 +29,13 @@ const toastStyle = tva({ }); const toastTitleStyle = tva({ - base: 'font-medium font-body tracking-md text-left', + base: 'font-body-medium tracking-md text-left', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', @@ -125,13 +125,13 @@ const toastTitleStyle = tva({ }); const toastDescriptionStyle = tva({ - base: 'font-normal font-body tracking-md text-left', + base: 'font-body tracking-md text-left', variants: { isTruncated: { true: '', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/components/tooltip/index.tsx b/packages/ui/components/tooltip/index.tsx index 854243f8..0363d65f 100644 --- a/packages/ui/components/tooltip/index.tsx +++ b/packages/ui/components/tooltip/index.tsx @@ -36,14 +36,14 @@ const tooltipContentStyle = tva({ }); const tooltipTextStyle = tva({ - base: 'font-normal tracking-normal web:select-none text-xs text-foreground/90', + base: 'font-body tracking-normal web:select-none text-xs text-foreground/90', variants: { isTruncated: { true: 'line-clamp-1 truncate', }, bold: { - true: 'font-bold', + true: 'font-body-bold', }, underline: { true: 'underline', diff --git a/packages/ui/package.json b/packages/ui/package.json index 15e713b9..f3121b11 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@eight2five/ui", - "version": "0.0.0", + "version": "0.1.0", "private": true, "exports": { "./components/box": { @@ -72,6 +72,7 @@ "./theme": "./theme/index.tsx" }, "dependencies": { + "@eight2five/drill-schema": "0.1.0", "@expo-google-fonts/montserrat": "^0.4.2", "@expo-google-fonts/source-sans-3": "^0.4.1", "@expo/html-elements": "^0.12.5", @@ -86,9 +87,9 @@ "@react-stately/toggle": "^3.10.1", "ai": "^6.0.214", "expo-document-picker": "~57.0.1", + "expo-font": "~57.0.1", "expo-glass-effect": "~57.0.1", "expo-image-picker": "~57.0.6", - "expo-font": "~57.0.1", "lucide-react-native": "^1.22.0", "react-dom": "^19.2.3", "react-native-gesture-handler": "~2.32.0", diff --git a/packages/ui/theme/index.tsx b/packages/ui/theme/index.tsx index 91c32855..5b2a36f8 100644 --- a/packages/ui/theme/index.tsx +++ b/packages/ui/theme/index.tsx @@ -3,13 +3,18 @@ import { Montserrat_500Medium } from '@expo-google-fonts/montserrat/500Medium'; import { Montserrat_600SemiBold } from '@expo-google-fonts/montserrat/600SemiBold'; import { Montserrat_700Bold } from '@expo-google-fonts/montserrat/700Bold'; import { SourceSans3_400Regular } from '@expo-google-fonts/source-sans-3/400Regular'; +import { SourceSans3_500Medium } from '@expo-google-fonts/source-sans-3/500Medium'; import { SourceSans3_600SemiBold } from '@expo-google-fonts/source-sans-3/600SemiBold'; import { SourceSans3_700Bold } from '@expo-google-fonts/source-sans-3/700Bold'; +import { COLOR_PRESETS } from '@eight2five/drill-schema'; import { useFonts } from 'expo-font'; -import { useColorScheme } from 'react-native'; +import React from 'react'; +import { useColorScheme, type ColorSchemeName } from 'react-native'; + +export const eight2FiveDrillColors = COLOR_PRESETS; export const eight2FiveBaseColors = { - blue: '#3C6EC8', + blue: eight2FiveDrillColors.blue, blueSecondary: '#3264BE', white: '#FFFFFF', black: '#000000', @@ -71,17 +76,25 @@ export const eight2FiveSpacing = { } as const; export const eight2FiveFonts = { - style: 'Montserrat', - utility: 'Source Sans 3', + style: 'Montserrat_400Regular', + utility: 'SourceSans3_400Regular', styleRegular: 'Montserrat_400Regular', styleMedium: 'Montserrat_500Medium', styleSemibold: 'Montserrat_600SemiBold', styleBold: 'Montserrat_700Bold', utilityRegular: 'SourceSans3_400Regular', + utilityMedium: 'SourceSans3_500Medium', utilitySemibold: 'SourceSans3_600SemiBold', utilityBold: 'SourceSans3_700Bold', } as const; +function colorWithOpacity(color: `#${string}`, opacity: number): string { + const red = Number.parseInt(color.slice(1, 3), 16); + const green = Number.parseInt(color.slice(3, 5), 16); + const blue = Number.parseInt(color.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${opacity})`; +} + export const eight2FiveThemes = { light: { raw: eight2FiveLightColors, @@ -96,7 +109,7 @@ export const eight2FiveThemes = { border: eight2FiveLightColors.secondary, accent: eight2FiveLightColors.blue, accentPressed: eight2FiveLightColors.blueSecondary, - accentSoft: 'rgba(60, 110, 200, 0.12)', + accentSoft: colorWithOpacity(eight2FiveDrillColors.blue, 0.12), danger: eight2FiveLightColors.danger, dangerSoft: 'rgba(200, 60, 60, 0.12)', warning: eight2FiveLightColors.warning, @@ -119,7 +132,7 @@ export const eight2FiveThemes = { border: eight2FiveDarkColors.primary, accent: eight2FiveDarkColors.blue, accentPressed: eight2FiveDarkColors.blueSecondary, - accentSoft: 'rgba(60, 110, 200, 0.20)', + accentSoft: colorWithOpacity(eight2FiveDrillColors.blue, 0.2), danger: eight2FiveDarkColors.danger, dangerSoft: 'rgba(200, 60, 60, 0.18)', warning: eight2FiveDarkColors.warning, @@ -133,9 +146,48 @@ export const eight2FiveThemes = { export type Eight2FiveThemeName = keyof typeof eight2FiveThemes; export type Eight2FiveTheme = (typeof eight2FiveThemes)[Eight2FiveThemeName]; +export type Eight2FiveThemeMode = Eight2FiveThemeName | 'system'; + +const Eight2FiveThemeNameContext = React.createContext< + Eight2FiveThemeName | undefined +>(undefined); + +export function resolveEight2FiveThemeName( + mode: Eight2FiveThemeMode, + systemColorScheme: ColorSchemeName | null | undefined +): Eight2FiveThemeName { + if (mode !== 'system') return mode; + return systemColorScheme === 'dark' ? 'dark' : 'light'; +} + +export function Eight2FiveThemeProvider({ + mode, + children, +}: { + mode: Eight2FiveThemeMode; + children: React.ReactNode; +}) { + const systemColorScheme = useColorScheme(); + const themeName = resolveEight2FiveThemeName(mode, systemColorScheme); + + return ( + + {children} + + ); +} + +export function useEight2FiveThemeName(): Eight2FiveThemeName { + const providedThemeName = React.useContext(Eight2FiveThemeNameContext); + const systemColorScheme = useColorScheme(); + return ( + providedThemeName ?? + resolveEight2FiveThemeName('system', systemColorScheme) + ); +} export function useEight2FiveTheme(): Eight2FiveTheme { - return eight2FiveThemes[useColorScheme() === 'dark' ? 'dark' : 'light']; + return eight2FiveThemes[useEight2FiveThemeName()]; } export function useEight2FiveFonts(): [boolean, Error | null] { @@ -145,6 +197,7 @@ export function useEight2FiveFonts(): [boolean, Error | null] { Montserrat_600SemiBold, Montserrat_700Bold, SourceSans3_400Regular, + SourceSans3_500Medium, SourceSans3_600SemiBold, SourceSans3_700Bold, }); diff --git a/packages/ui/theme/theme.css b/packages/ui/theme/theme.css index b95a57f9..11767ddf 100644 --- a/packages/ui/theme/theme.css +++ b/packages/ui/theme/theme.css @@ -1,6 +1,11 @@ +:root { + /* COLOR_PRESETS.blue (#3C6EC8), shared by drill entities and UI accents. */ + --eight2five-drill-blue: 60 110 200; +} + @layer theme { :where(.light, .light *) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 255 255 255; --secondary: 218 218 218; @@ -14,14 +19,14 @@ --foreground: 30 30 30; --border: 218 218 218; --input: 243 243 243; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 243 243 243; --accent-foreground: 30 30 30; } @media (prefers-color-scheme: light) { :root:not(:where(.light, .light *, .dark, .dark *)) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 255 255 255; --secondary: 218 218 218; @@ -35,14 +40,14 @@ --foreground: 30 30 30; --border: 218 218 218; --input: 243 243 243; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 243 243 243; --accent-foreground: 30 30 30; } } :where(.dark, .dark *) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 36 36 36; --secondary: 51 51 51; @@ -56,14 +61,14 @@ --foreground: 255 255 255; --border: 51 51 51; --input: 51 51 51; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 51 51 51; --accent-foreground: 255 255 255; } @media (prefers-color-scheme: dark) { :root:not(:where(.light, .light *, .dark, .dark *)) { - --primary: 60 110 200; + --primary: var(--eight2five-drill-blue); --primary-foreground: 255 255 255; --card: 36 36 36; --secondary: 51 51 51; @@ -77,7 +82,7 @@ --foreground: 255 255 255; --border: 51 51 51; --input: 51 51 51; - --ring: 60 110 200; + --ring: var(--eight2five-drill-blue); --accent: 51 51 51; --accent-foreground: 255 255 255; } @@ -103,6 +108,13 @@ --color-accent: rgb(var(--accent)); --color-accent-foreground: rgb(var(--accent-foreground)); --font-heading: Montserrat_700Bold; + --font-heading-regular: Montserrat_400Regular; + --font-heading-medium: Montserrat_500Medium; + --font-heading-semibold: Montserrat_600SemiBold; + --font-heading-bold: Montserrat_700Bold; --font-body: SourceSans3_400Regular; + --font-body-medium: SourceSans3_500Medium; + --font-body-semibold: SourceSans3_600SemiBold; + --font-body-bold: SourceSans3_700Bold; --font-sans: SourceSans3_400Regular; } diff --git a/tsconfig.json b/tsconfig.json index ec9c8b4c..3049a380 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,10 @@ "skipLibCheck": true, "strict": true, "paths": { + "@eight2five/drill-schema": ["./packages/drill-schema/src/index"], + "@eight2five/drill-schema/*": ["./packages/drill-schema/src/*"], + "@eight2five/drill-importers": ["./packages/drill-importers/src/index"], + "@eight2five/drill-importers/*": ["./packages/drill-importers/src/*"], "@eight2five/mobile": ["./packages/mobile/src/index"], "@eight2five/mobile/*": ["./packages/mobile/src/*"] }