diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index dceb7fe0..3750079a 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,6 +8,18 @@ on: workflow_dispatch: jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + + - run: bun install --frozen-lockfile + - run: timeout 12s bun pre-commit + - run: bun run test + - run: ./check-lines.sh + build: runs-on: ubuntu-latest timeout-minutes: 1 @@ -16,7 +28,6 @@ jobs: - uses: oven-sh/setup-bun@v1 - run: bun install --frozen-lockfile - - run: bun pre-commit - run: bun run build - name: Upload built project @@ -27,15 +38,10 @@ jobs: retention-days: 1 name: build-artifacts-${{ github.run_id }} - # deploy - name: Deploy to Cloudflare Pages - id: cloudflare-publish if: github.ref == 'refs/heads/master' && github.repository == 'commaai/new-connect' - uses: cloudflare/pages-action@v1.5.0 + uses: cloudflare/wrangler-action@v3 with: - directory: dist - branch: new-connect - projectName: connect accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} - + command: pages deploy dist --project-name=connect --branch=new-connect --commit-dirty=true diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index aeac06cb..e9e79c77 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -16,6 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 - name: Download build artifacts uses: actions/download-artifact@v4 @@ -29,6 +30,7 @@ jobs: id: pr uses: actions/github-script@v7 with: + retries: 3 script: | const response = await github.rest.search.issuesAndPullRequests({ q: 'repo:${{ github.repository }} is:pr sha:${{ github.event.workflow_run.head_sha }}', @@ -40,19 +42,15 @@ jobs: return } const pullRequestNumber = items[0].number - console.info("Pull request number is", pullRequestNumber) + console.info('Pull request number is', pullRequestNumber) return pullRequestNumber - # deploy - name: Deploy to Cloudflare Pages - id: cloudflare-publish - uses: cloudflare/pages-action@v1.5.0 + uses: cloudflare/wrangler-action@v3 with: - directory: dist - branch: ${{ steps.pr.outputs.result }} - projectName: connect accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} + command: pages deploy dist --project-name=connect --branch=${{ steps.pr.outputs.result }} --commit-dirty=true - name: Comment URL on PR uses: thollander/actions-comment-pull-request@v2 diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 08a53ca0..00000000 --- a/.prettierrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "endOfLine": "lf", - "printWidth": 100, - "semi": false, - "singleQuote": true -} diff --git a/README.md b/README.md index 47d7d5dc..7f5e9815 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,10 @@ A few constraints: These are the minimum features for parity with connect. Drives -- [ ] list -- [ ] show map -- [ ] play qcams +- [x] list +- [x] show map +- [x] play qcams +- [x] engagement timeline - [ ] file uploads Navigation @@ -35,10 +36,10 @@ Navigation - [ ] manage home, work, and favorites Misc -- [ ] demo mode +- [x] demo mode - [ ] snapshot - [ ] comma prime sign up + management -- [ ] pairing to an openpilot device +- [ ] pairing to a new device - [ ] PWA: splash, icon, offline mode, etc. And some eventual features beyond connect's current feature set: diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index ee79c34b..f6ca9684 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/check-lines.sh b/check-lines.sh new file mode 100755 index 00000000..12484e10 --- /dev/null +++ b/check-lines.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" +cd $DIR + +COUNT="$(find src/ -type f | xargs wc -l | tail -n 1 | awk '{print $1}')" +echo "$COUNT total lines" + +if [ "$COUNT" -gt 5000 ]; then + echo "Exceeded line limit!" + exit 1 +fi diff --git a/eslint.config.js b/eslint.config.js index 9778288b..1ac023f4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,10 @@ -/* eslint-disable */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ import globals from 'globals' import js from '@eslint/js' import ts from 'typescript-eslint' import tailwind from 'eslint-plugin-tailwindcss' import solid from 'eslint-plugin-solid/configs/typescript.js' +import stylistic from '@stylistic/eslint-plugin' export default [ { languageOptions: { globals: globals.browser } }, @@ -20,6 +21,31 @@ export default [ }, ...tailwind.configs['flat/recommended'], { - ignores: ['node_modules', 'dist'] + plugins: { + '@stylistic': stylistic, + }, + rules: { + '@stylistic/brace-style': ['error', '1tbs'], + '@stylistic/comma-dangle': ['error', 'always-multiline'], + '@stylistic/eol-last': ['error', 'always'], + '@stylistic/indent': ['error', 2], + '@stylistic/indent-binary-ops': ['error', 2], + '@stylistic/jsx-indent': ['error', 2, { indentLogicalExpressions: true }], + '@stylistic/jsx-indent-props': ['error', 2], + '@stylistic/jsx-quotes': ['error', 'prefer-double'], + '@stylistic/linebreak-style': ['error', 'unix'], + '@stylistic/max-len': ['error', { + code: 120, + ignoreComments: true, + ignoreStrings: true, + ignoreTemplateLiterals: true, + ignoreRegExpLiterals: true, + }], + '@stylistic/no-extra-parens': ['error', 'functions'], + '@stylistic/no-extra-semi': 'error', + '@stylistic/quotes': ['error', 'single', { avoidEscape: true }], + '@stylistic/semi': ['error', 'never'], + }, + ignores: ['node_modules', 'dist'], }, ] diff --git a/package.json b/package.json index 80c6a720..cd40aa19 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,17 @@ "dev": "vite", "serve": "vite prefix", "lint": "eslint", - "lint:fix": "eslint --fix", "prepare": "husky", - "pre-commit": "bun lint" + "pre-commit": "bun lint", + "test": "vitest run" }, "packageManager": "bun@1.1.13", "type": "module", "devDependencies": { + "@solidjs/testing-library": "^0.8.8", + "@stylistic/eslint-plugin": "^2.1.0", + "@testing-library/jest-dom": "^6.4.6", + "@testing-library/user-event": "^14.5.2", "@types/eslint__js": "^8.42.3", "@types/mapbox__polyline": "^1.0.5", "@typescript-eslint/eslint-plugin": "^7.13.0", @@ -30,20 +34,25 @@ "eslint-plugin-tailwindcss": "^3.17.3", "globals": "^15.4.0", "husky": "^9.0.11", + "jsdom": "^24.1.0", "postcss": "^8.4.38", "solid-devtools": "^0.30.1", "tailwindcss": "^3.4.4", "typescript": "^5.4.5", "typescript-eslint": "^7.13.0", "vite": "^5.2.13", - "vite-plugin-solid": "^2.10.2" + "vite-plugin-solid": "^2.10.2", + "vitest": "^1.6.0", + "wrangler": "^3.60.2" }, "dependencies": { "@mapbox/polyline": "^1.2.1", "@solidjs/router": "^0.13.5", + "@tanstack/solid-virtual": "^3.5.1", "clsx": "^1.2.1", "dayjs": "^1.11.11", "hls.js": "^1.5.11", + "mapbox-gl": "^3.4.0", "solid-js": "^1.8.17" }, "engines": { diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 00000000..812507cc --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,13 @@ +import { beforeAll, expect, test } from 'vitest' +import { configure, render, screen } from '@solidjs/testing-library' + +import App from './App' + +beforeAll(() => { + configure({ asyncUtilTimeout: 2000 }) +}) + +test('Show login page', async () => { + render(() => ) + expect(await screen.findByText('Sign in with Google')).not.toBeUndefined() +}) diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..24bce32f --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,22 @@ +import { Suspense, lazy, type VoidComponent } from 'solid-js' +import { Router, Route } from '@solidjs/router' + +const Login = lazy(() => import('./pages/auth/login')) +const Logout = lazy(() => import('./pages/auth/logout')) +const Auth = lazy(() => import('./pages/auth/auth')) + +const Dashboard = lazy(() => import('./pages/dashboard')) + +const App: VoidComponent = () => { + return ( + {props.children}}> + + + + + + + ) +} + +export default App diff --git a/src/api/derived.ts b/src/api/derived.ts index 0340b476..6d834068 100644 --- a/src/api/derived.ts +++ b/src/api/derived.ts @@ -1,4 +1,5 @@ import type { Route } from '~/types' +import { getRouteDuration } from '~/utils/date' export interface GPSPathPoint { t: number @@ -106,9 +107,7 @@ const generateTimelineEvents = ( route: Route, events: DriveEvent[], ): TimelineEvent[] => { - const routeDuration = - route.segment_end_times[route.segment_end_times.length - 1] - - route.segment_start_times[0] + const routeDuration = getRouteDuration(route)?.asMilliseconds() ?? 0 // sort events by timestamp events.sort((a, b) => { @@ -218,9 +217,7 @@ const generateTimelineStatistics = ( return { engagedDuration, userFlags, - duration: - route.segment_end_times[route.segment_end_times.length - 1] - - route.segment_start_times[0], + duration: getRouteDuration(route)?.asMilliseconds() ?? 0, } } diff --git a/src/api/route.ts b/src/api/route.ts index f6658ea3..60bd0852 100644 --- a/src/api/route.ts +++ b/src/api/route.ts @@ -1,13 +1,12 @@ import { fetcher } from '.' import { BASE_URL } from './config' -import type { Device, Route } from '~/types' +import type { Device, Route, RouteShareSignature } from '~/types' export class RouteName { // dongle ID date str // 0123456789abcdef|2023-02-15--15-25-00 - static readonly regex = - /^([0-9a-f]{16})\|(\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2})$/ + static readonly regex = /^([0-9a-f]{16})\|(\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2})$/ static readonly regexGroup = { dongleId: 1, dateStr: 2, @@ -18,10 +17,7 @@ export class RouteName { if (!match) { return null } - return new RouteName( - match[RouteName.regexGroup.dongleId], - match[RouteName.regexGroup.dateStr], - ) + return new RouteName(match[RouteName.regexGroup.dongleId], match[RouteName.regexGroup.dateStr]) } readonly dongleId: Device['dongle_id'] @@ -40,27 +36,16 @@ export class RouteName { export const getRoute = (routeName: Route['fullname']): Promise => fetcher(`/v1/route/${routeName}/`) -interface RouteShareSignature extends Record { - exp: NonNullable - sig: NonNullable -} - -export const getRouteShareSignature = ( - routeName: string, -): Promise => +export const getRouteShareSignature = (routeName: string): Promise => fetcher(`/v1/route/${routeName}/share_signature`) export const createQCameraStreamUrl = ( routeName: Route['fullname'], signature: RouteShareSignature, ): string => - `${BASE_URL}/v1/route/${routeName}/qcamera.m3u8?${new URLSearchParams( - signature, - ).toString()}` + `${BASE_URL}/v1/route/${routeName}/qcamera.m3u8?${new URLSearchParams(signature).toString()}` -export const getQCameraStreamUrl = ( - routeName: Route['fullname'], -): Promise => +export const getQCameraStreamUrl = (routeName: Route['fullname']): Promise => getRouteShareSignature(routeName).then((signature) => createQCameraStreamUrl(routeName, signature), ) diff --git a/src/components/DeviceStatistics.tsx b/src/components/DeviceStatistics.tsx index 9477113a..3632be91 100644 --- a/src/components/DeviceStatistics.tsx +++ b/src/components/DeviceStatistics.tsx @@ -5,8 +5,6 @@ import clsx from 'clsx' import { getDeviceStats } from '~/api/devices' import { formatDistance, formatDuration } from '~/utils/date' -import Typography from '~/components/material/Typography' - type DeviceStatisticsProps = { class?: string dongleId: string @@ -19,28 +17,18 @@ const DeviceStatistics: VoidComponent = (props) => { return (
- - Distance - - - {formatDistance(allTime()?.distance)} - + Distance + {formatDistance(allTime()?.distance)}
- - Duration - - - {formatDuration(allTime()?.minutes)} - + Duration + {formatDuration(allTime()?.minutes)}
- - Routes - - {allTime()?.routes ?? 0} + Routes + {allTime()?.routes ?? 0}
) diff --git a/src/components/RouteCard.tsx b/src/components/RouteCard.tsx index a12f3b71..ed505467 100644 --- a/src/components/RouteCard.tsx +++ b/src/components/RouteCard.tsx @@ -1,24 +1,24 @@ -import { Suspense, type VoidComponent } from 'solid-js' +import { createSignal, createEffect, Suspense, type VoidComponent } from 'solid-js' import dayjs from 'dayjs' import Avatar from '~/components/material/Avatar' -import Card, { CardContent, CardHeader } from '~/components/material/Card' +import { CardContent, CardHeader } from '~/components/material/Card' import Icon from '~/components/material/Icon' import RouteStaticMap from '~/components/RouteStaticMap' import RouteStatistics from '~/components/RouteStatistics' +import Timeline from './Timeline' +import type { RouteSegments } from '~/types' + +import { reverseGeocode } from '~/map' import type { Route } from '~/types' -const RouteHeader = (props: { route: Route }) => { - const startTime = () => dayjs(props.route.segment_start_times[0]) - const endTime = () => - dayjs( - props.route.segment_end_times[props.route.segment_end_times.length - 1], - ) +const RouteHeader = (props: { route?: RouteSegments }) => { + const startTime = () => props?.route?.segment_start_times ? dayjs(props.route.segment_start_times[0]) : null + const endTime = () => props?.route?.segment_end_times ? dayjs(props.route.segment_end_times.at(-1)) : null - const headline = () => startTime().format('ddd, MMM D, YYYY') - const subhead = () => - `${startTime().format('h:mm A')} to ${endTime().format('h:mm A')}` + const headline = () => startTime()?.format('ddd, MMM D, YYYY') + const subhead = () => `${startTime()?.format('h:mm A')} to ${endTime()?.format('h:mm A')}` return ( { ) } -interface RouteCardProps { - route: Route +interface GeoResult { + features?: Array<{ + properties?: { + context?: { + neighborhood?: string | null, + region?: string | null, + place?: string | null + } + } + }> } -const RouteCard: VoidComponent = (props) => { +interface LocationContext { + neighborhood?: { + name: string | null, + }, + region?: { + region_code: string | null, + }, + place?: { + name: string | null, + } +} + +const RouteRevGeo = (props: { route?: Route }) => { + const [startLocation, setStartLocation] = createSignal<{ + neighborhood?: string | null, + region?: string | null + }>({ neighborhood: null, region: null }) + + const [endLocation, setEndLocation] = createSignal<{ + neighborhood?: string | null, + region?: string | null + }>({ neighborhood: null, region: null }) + + const [error, setError] = createSignal(null) + + createEffect(() => { + if (!props.route) return + + const { start_lng, start_lat, end_lng, end_lat } = props.route + + if (!start_lng || !start_lat || !end_lng || !end_lat) return + + const fetchGeoData = async () => { + try { + const start_revGeoResult = await reverseGeocode(start_lng, start_lat) as GeoResult + const end_revGeoResult = await reverseGeocode(end_lng, end_lat) as GeoResult + + if (start_revGeoResult instanceof Error) { + setError(start_revGeoResult as Error) + console.error(start_revGeoResult) + return + } + + if (end_revGeoResult instanceof Error) { + setError(end_revGeoResult as Error) + console.error(end_revGeoResult) + return + } + + const { neighborhood: startNeighborhood, region: startRegion, place: startPlace } = + (start_revGeoResult?.features?.[0]?.properties?.context || {}) as LocationContext + + const { neighborhood: endNeighborhood, region: endRegion, place: endPlace } = + (end_revGeoResult?.features?.[0]?.properties?.context || {}) as LocationContext + + setStartLocation({ + neighborhood: startNeighborhood?.name || startPlace?.name, + region: startRegion?.region_code, + }) + setEndLocation({ + neighborhood: endNeighborhood?.name || endPlace?.name, + region: endRegion?.region_code, + }) + } catch (error) { + setError(error as Error) + console.error(error) + } + } + + fetchGeoData().catch((error) => { + console.error('An error occurred while fetching geolocation data:', error) + }) + }) + return ( - - - -
- } - > - - +
+ {error() &&
Error: {error()?.message}
} +
+ {startLocation() &&
{startLocation()?.neighborhood}, {startLocation()?.region}
} + + arrow_right_alt + + {endLocation() &&
{endLocation()?.neighborhood}, {endLocation()?.region}
}
+
+ ) +} + +type RouteCardProps = { + route?: Route; +} + +const RouteCard: VoidComponent = (props) => { + const route = () => props.route - - - - + return ( + +
+
+ } + > + + +
+ +
+ + + + + + + +
+
+
) } diff --git a/src/components/RouteDynamicMap.tsx b/src/components/RouteDynamicMap.tsx new file mode 100644 index 00000000..da8d0b56 --- /dev/null +++ b/src/components/RouteDynamicMap.tsx @@ -0,0 +1,203 @@ +/* eslint-disable */ +/* tslint:disable */ +import { createResource, createEffect, createSignal, onCleanup, onMount } from 'solid-js' +import type { VoidComponent } from 'solid-js' +import clsx from 'clsx' + +import { videoTimeStore, speedStore } from './store/driveReplayStore' + +import { + MAPBOX_TOKEN, +} from '~/map/config' + +import { getCoords } from '~/api/derived' +import type { Route } from '~/types' + +import mapboxgl from 'mapbox-gl' + +type RouteDynamicMapProps = { + class?: string + route: Route | undefined +} + +const RouteDynamicMap: VoidComponent = (props) => { + const [coords] = createResource(() => props.route, getCoords) + + let [mapContainer, setMapContainer] = createSignal(null) + let map: mapboxgl.Map | null = null + + const { videoTime } = videoTimeStore() + const { setSpeed } = speedStore() + + createEffect(() => { + // Cleanup function to remove the map when the dependencies change + onCleanup(() => { + if (map) { + map.remove() + map = null + } + setSpeed(0) + }) + + const coordinates = coords(); + if (coordinates && mapContainer()) { + const { lng: initLng, lat: initLat } = coordinates[0] + mapboxgl.accessToken = MAPBOX_TOKEN + map = new mapboxgl.Map({ + container: mapContainer(), + style: 'mapbox://styles/mapbox/dark-v11', + center: [initLng, initLat], + zoom: 9, + attributionControl: false, + }); + + if (coordinates) { + const line = { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: coordinates.map(({ lng, lat }) => [lng, lat]), + }, + } + + map.on('load', () => { + const canvas = map.getCanvasContainer().firstChild as HTMLCanvasElement + canvas.style.borderRadius = '10px' + canvas.style.width = '100%' + + map.addSource('route', { + type: 'geojson', + data: line, + }) + + map.addLayer({ + id: 'route', + type: 'line', + source: 'route', + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { 'line-color': '#888', 'line-width': 8 }, + }) + + // Add a circle at the start of the line + map.addSource('startPoint', { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: line.geometry.coordinates[0], + }, + }, + }) + + map.addLayer({ + id: 'startPoint', + type: 'circle', + source: 'startPoint', + paint: { + 'circle-radius': 7, + 'circle-color': '#f00', + }, + }) + + createEffect(() => { + const currentVideoTime = videoTime() + const roundedVideoTime = Math.round(currentVideoTime) + const currentCoordsIndex = coordinates.findIndex(item => item.t === roundedVideoTime) + if (currentCoordsIndex !== undefined && currentCoordsIndex < coordinates.length - 1 && map) { + const currentCoords = coordinates[currentCoordsIndex] + const nextCoords = coordinates[currentCoordsIndex + 1] + + if (currentCoords && nextCoords) { + const coordWindowSize = 10 + // Calculate the bearing between the current point and the next point + const bearing = calculateAverageBearing(coordinates.slice(currentCoordsIndex, currentCoordsIndex + coordWindowSize)) + + map.getSource('startPoint').setData({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [currentCoords.lng, currentCoords.lat], + }, + }) + + setSpeed(currentCoords.speed) + + map.flyTo({ center: [currentCoords.lng, currentCoords.lat], bearing: bearing, pitch: 60, zoom: 15 }) + } + } + }) + interface Coordinate { + lat: number; + lng: number; + } + + // Function to calculate the bearing between two points + function calculateBearing(start: Coordinate, end: Coordinate) { + const startLat = radians(start.lat) + const startLng = radians(start.lng) + const endLat = radians(end.lat) + const endLng = radians(end.lng) + let dLng = endLng - startLng + + const dPhi = Math.log(Math.tan(endLat / 2.0 + Math.PI / 4.0) / Math.tan(startLat / 2.0 + Math.PI / 4.0)) + if (Math.abs(dLng) > Math.PI) { + dLng = dLng > 0.0 ? -(2.0 * Math.PI - dLng) : (2.0 * Math.PI + dLng) + } + + return (degrees(Math.atan2(dLng, dPhi)) + 360.0) % 360.0 + } + + // Function to calculate the average bearing over a window of points + function calculateAverageBearing(points: Coordinate[]) { + const bearings = [] + for (let i = 0; i < points.length - 1; i++) { + const bearing = calculateBearing(points[i], points[i + 1]) + bearings.push(bearing) + } + const sum = bearings.reduce((a, b) => a + b, 0) + return sum / bearings.length + } + + function radians(degrees: number) { + return degrees * Math.PI / 180.0; + } + + function degrees(radians: number) { + return radians * 180.0 / Math.PI + } + + // Zoom map to fit the line bounds + const bounds = line.geometry.coordinates.reduce( + (bounds, coord) => bounds.extend(coord), + new mapboxgl.LngLatBounds( + line.geometry.coordinates[0], + line.geometry.coordinates[0] + ) + ) + map.fitBounds(bounds, { padding: 20 }) + }) + } + } + }) + + onCleanup(() => { + if (map) { + map.remove() + } + }) + + return ( +
+ {/* ...existing code, will be added later on... */} +
+ ) +} + +export default RouteDynamicMap diff --git a/src/components/RouteStaticMap.tsx b/src/components/RouteStaticMap.tsx index 8bfa23d6..1a010bea 100644 --- a/src/components/RouteStaticMap.tsx +++ b/src/components/RouteStaticMap.tsx @@ -8,7 +8,6 @@ import { getThemeId } from '~/theme' import type { Route } from '~/types' import Icon from '~/components/material/Icon' -import Typography from '~/components/material/Typography' const loadImage = (url: string | undefined): Promise => { if (!url) { @@ -26,11 +25,13 @@ const getStaticMapUrl = (gpsPoints: GPSPathPoint[]): string | undefined => { if (gpsPoints.length === 0) { return undefined } + const path: Coords = [] gpsPoints.forEach(({ lng, lat }) => { path.push([lng, lat]) }) const themeId = getThemeId() + return getPathStaticMapUrl(themeId, path, 380, 192, true) } @@ -42,11 +43,11 @@ const State = (props: { return (
- {props.children} + {props.children} {props.trailing}
) @@ -65,7 +66,7 @@ const RouteStaticMap: VoidComponent = (props) => { return (
@@ -82,7 +83,7 @@ const RouteStaticMap: VoidComponent = (props) => { diff --git a/src/components/RouteStatistics.tsx b/src/components/RouteStatistics.tsx index adb3420e..437efa59 100644 --- a/src/components/RouteStatistics.tsx +++ b/src/components/RouteStatistics.tsx @@ -6,8 +6,6 @@ import { TimelineStatistics, getTimelineStatistics } from '~/api/derived' import type { Route } from '~/types' import { formatRouteDistance, formatRouteDuration } from '~/utils/date' -import Typography from '~/components/material/Typography' - const formatEngagement = (timeline?: TimelineStatistics): string => { if (!timeline) return '' const { engagedDuration, duration } = timeline @@ -27,44 +25,28 @@ const RouteStatistics: VoidComponent = (props) => { const [timeline] = createResource(() => props.route, getTimelineStatistics) return ( -
+
- - Distance - - - {formatRouteDistance(props.route)} - + Distance + {formatRouteDistance(props.route)}
- - Duration - - - {formatRouteDuration(props.route)} - + Duration + {formatRouteDuration(props.route)}
- - Engaged - + Engaged - - {formatEngagement(timeline())} - + {formatEngagement(timeline())}
- - User flags - + User flags - - {formatUserFlags(timeline())} - + {formatUserFlags(timeline())}
diff --git a/src/components/RouteVideoPlayer.tsx b/src/components/RouteVideoPlayer.tsx index cc1d2001..609d32c4 100644 --- a/src/components/RouteVideoPlayer.tsx +++ b/src/components/RouteVideoPlayer.tsx @@ -1,10 +1,12 @@ -import { createEffect, createResource, onCleanup, onMount } from 'solid-js' +import { createEffect, createResource, createSignal, onCleanup, onMount } from 'solid-js' import type { VoidComponent } from 'solid-js' import clsx from 'clsx' import Hls from 'hls.js' import { getQCameraStreamUrl } from '~/api/route' +import { videoTimeStore, speedStore } from './store/driveReplayStore' + type RouteVideoPlayerProps = { class?: string routeName: string @@ -15,8 +17,20 @@ const RouteVideoPlayer: VoidComponent = (props) => { const [streamUrl] = createResource(() => props.routeName, getQCameraStreamUrl) let video: HTMLVideoElement + const { setVideoTime } = videoTimeStore() + const { speed } = speedStore() + + const [speedText, setSpeedText] = createSignal('') + + createEffect(() => { + setSpeedText(`${Math.round(speed() * 2.23694)}`) + }) + onMount(() => { - const timeUpdate = () => props.onProgress?.(video.currentTime) + const timeUpdate = () => { + props.onProgress?.(video.currentTime) + setVideoTime(video.currentTime) + } video.addEventListener('timeupdate', timeUpdate) onCleanup(() => video.removeEventListener('timeupdate', timeUpdate)) }) @@ -41,10 +55,11 @@ const RouteVideoPlayer: VoidComponent = (props) => { return (
+ {

{speedText()} mph

}