From 073abe67e85d581662e15ff04c7626c7356f9ef0 Mon Sep 17 00:00:00 2001 From: Nicolai Ommer Date: Sat, 14 Feb 2026 17:16:04 +0100 Subject: [PATCH 1/2] feat: Serve log files from local folder --- cmd/ssl-vision-client/main.go | 9 ++++++--- cmd/ssl-vision-client/routes.go | 2 ++ cmd/ssl-vision-client/server.go | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/cmd/ssl-vision-client/main.go b/cmd/ssl-vision-client/main.go index f4427af..43d13ad 100644 --- a/cmd/ssl-vision-client/main.go +++ b/cmd/ssl-vision-client/main.go @@ -2,12 +2,13 @@ package main import ( "flag" - "github.com/RoboCup-SSL/ssl-vision-client/internal/gc" - "github.com/RoboCup-SSL/ssl-vision-client/internal/tracked" - "github.com/RoboCup-SSL/ssl-vision-client/internal/vision" "log" "net/http" "strings" + + "github.com/RoboCup-SSL/ssl-vision-client/internal/gc" + "github.com/RoboCup-SSL/ssl-vision-client/internal/tracked" + "github.com/RoboCup-SSL/ssl-vision-client/internal/vision" ) var address = flag.String("address", ":8082", "The address on which the UI and API is served, default: :8082") @@ -15,6 +16,7 @@ var visionAddress = flag.String("visionAddress", "224.5.23.2:10006", "The multic var trackedAddress = flag.String("trackedAddress", "224.5.23.2:10010", "The multicast address of trackers, default: 224.5.23.2:10010") var refereeAddress = flag.String("refereeAddress", "224.5.23.1:10003", "The multicast address of the game controller, default: 224.5.23.1:10003") var skipInterfaces = flag.String("skipInterfaces", "", "Comma separated list of interface names to ignore when receiving multicast packets") +var logfileFolder = flag.String("logfileFolder", "logs", "Logfile folder where completed logs are served from") var verbose = flag.Bool("verbose", false, "Verbose output") func main() { @@ -52,6 +54,7 @@ func setupServer() *http.Server { trackedReceiver.TrackedFrames, vision.GeometryProvider(visionReceiver), refereeReceiver.RefereeMsg, + *logfileFolder, ) return &http.Server{ Addr: *address, diff --git a/cmd/ssl-vision-client/routes.go b/cmd/ssl-vision-client/routes.go index 82150e7..50647c9 100644 --- a/cmd/ssl-vision-client/routes.go +++ b/cmd/ssl-vision-client/routes.go @@ -14,6 +14,7 @@ func addRoutes( TrackerProvider func() map[string]*tracked.TrackerWrapperPacket, GeometryProvider func() *vision.SSL_GeometryData, RefereeProvider func() *gc.Referee, + logfileFolder string, ) { mux.Handle("/", frontend.HandleFrontend()) mux.Handle("/api/tracker/sources", tracked.HandleTrackerSources(TrackerProvider)) @@ -21,4 +22,5 @@ func addRoutes( mux.Handle("/api/vision/detection", vision.HandleVisionDetection(DetectionProvider)) mux.Handle("/api/vision/geometry", vision.HandleVisionGeometry(GeometryProvider)) mux.Handle("/api/referee", gc.HandleReferee(RefereeProvider)) + mux.Handle("/logs/", http.StripPrefix("/logs/", http.FileServer(http.Dir(logfileFolder)))) } diff --git a/cmd/ssl-vision-client/server.go b/cmd/ssl-vision-client/server.go index 050ba55..150044f 100644 --- a/cmd/ssl-vision-client/server.go +++ b/cmd/ssl-vision-client/server.go @@ -12,6 +12,7 @@ func NewServer( TrackerProvider func() map[string]*tracked.TrackerWrapperPacket, GeometryProvider func() *vision.SSL_GeometryData, RefereeProvider func() *gc.Referee, + logfileFolder string, ) http.Handler { mux := http.NewServeMux() addRoutes( @@ -20,6 +21,7 @@ func NewServer( TrackerProvider, GeometryProvider, RefereeProvider, + logfileFolder, ) return mux } From d2b6dd9b3bf3eb86519677d1c7d088aeb6364a05 Mon Sep 17 00:00:00 2001 From: Nicolai Ommer Date: Mon, 6 Apr 2026 09:31:33 +0200 Subject: [PATCH 2/2] feat: Add VisualizerPlayer --- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 209 ++++++++++++ frontend/src/App.vue | 6 +- frontend/src/assets/main.css | 2 + frontend/src/components/LogUrlInput.vue | 50 +++ .../composables/__tests__/logManifest.spec.ts | 107 +++++++ .../__tests__/logPlayerPosition.spec.ts | 259 +++++++++++++++ .../__tests__/logPlayerSources.spec.ts | 50 +++ .../src/composables/__tests__/logUrl.spec.ts | 79 +++++ .../src/composables/__tests__/logfile.spec.ts | 303 ++++++++++++++++++ frontend/src/composables/logManifest.ts | 37 +++ frontend/src/composables/logPlayerIndices.ts | 63 ++++ frontend/src/composables/logPlayerMessages.ts | 110 +++++++ frontend/src/composables/logPlayerPosition.ts | 163 ++++++++++ frontend/src/composables/logPlayerSources.ts | 32 ++ frontend/src/composables/logUrl.ts | 65 ++++ frontend/src/composables/logfile.ts | 290 +++++++++++++++++ frontend/src/composables/vision.ts | 11 +- frontend/src/main.ts | 3 +- frontend/src/router.ts | 14 + .../FieldVisualizerVision.vue | 0 frontend/src/views/VisualizerPlayer.vue | 172 ++++++++++ frontend/vite.config.ts | 3 + 23 files changed, 2024 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/LogUrlInput.vue create mode 100644 frontend/src/composables/__tests__/logManifest.spec.ts create mode 100644 frontend/src/composables/__tests__/logPlayerPosition.spec.ts create mode 100644 frontend/src/composables/__tests__/logPlayerSources.spec.ts create mode 100644 frontend/src/composables/__tests__/logUrl.spec.ts create mode 100644 frontend/src/composables/__tests__/logfile.spec.ts create mode 100644 frontend/src/composables/logManifest.ts create mode 100644 frontend/src/composables/logPlayerIndices.ts create mode 100644 frontend/src/composables/logPlayerMessages.ts create mode 100644 frontend/src/composables/logPlayerPosition.ts create mode 100644 frontend/src/composables/logPlayerSources.ts create mode 100644 frontend/src/composables/logUrl.ts create mode 100644 frontend/src/composables/logfile.ts create mode 100644 frontend/src/router.ts rename frontend/src/{components => views}/FieldVisualizerVision.vue (100%) create mode 100644 frontend/src/views/VisualizerPlayer.vue diff --git a/frontend/package.json b/frontend/package.json index a8c511a..ee360e4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,9 @@ "dependencies": { "@bufbuild/protobuf": "^2.11.0", "@vueuse/core": "^14.2.1", - "vue": "^3.5.32" + "axios": "^1.9.0", + "vue": "^3.5.32", + "vue-router": "^4.5.1" }, "devDependencies": { "@tsconfig/node24": "^24.0.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0ec1f44..7658f66 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@vueuse/core': specifier: ^14.2.1 version: 14.2.1(vue@3.5.32(typescript@6.0.2)) + axios: + specifier: ^1.9.0 + version: 1.14.0 vue: specifier: ^3.5.32 version: 3.5.32(typescript@6.0.2) + vue-router: + specifier: ^4.5.1 + version: 4.6.4(vue@3.5.32(typescript@6.0.2)) devDependencies: '@tsconfig/node24': specifier: ^24.0.4 @@ -499,6 +505,9 @@ packages: '@vue/compiler-ssr@3.5.32': resolution: {integrity: sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/eslint-config-prettier@10.2.0': resolution: {integrity: sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==} peerDependencies: @@ -603,6 +612,12 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.14.0: + resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -627,6 +642,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -638,6 +657,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + commander@10.0.1: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} @@ -683,10 +706,18 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -709,9 +740,25 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -849,15 +896,39 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -871,6 +942,22 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -1049,6 +1136,10 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -1064,6 +1155,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -1186,6 +1285,10 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1436,6 +1539,11 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + vue-tsc@3.2.6: resolution: {integrity: sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==} hasBin: true @@ -1933,6 +2041,8 @@ snapshots: '@vue/compiler-dom': 3.5.32 '@vue/shared': 3.5.32 + '@vue/devtools-api@6.6.4': {} + '@vue/eslint-config-prettier@10.2.0(eslint@10.2.0(jiti@2.6.1))(prettier@3.8.1)': dependencies: eslint: 10.2.0(jiti@2.6.1) @@ -2041,6 +2151,16 @@ snapshots: assertion-error@2.0.1: {} + asynckit@0.4.0: {} + + axios@1.14.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -2063,6 +2183,11 @@ snapshots: dependencies: fill-range: 7.1.1 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + chai@6.2.2: {} color-convert@2.0.1: @@ -2071,6 +2196,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@10.0.1: {} config-chain@1.1.13: @@ -2110,8 +2239,16 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + detect-libc@2.1.2: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + eastasianwidth@0.2.0: {} editorconfig@1.0.7: @@ -2129,8 +2266,23 @@ snapshots: entities@7.0.1: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + escape-string-regexp@4.0.0: {} eslint-config-prettier@10.1.8(eslint@10.2.0(jiti@2.6.1)): @@ -2277,14 +2429,44 @@ snapshots: flatted@3.4.2: {} + follow-redirects@1.15.11: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -2302,6 +2484,18 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: dependencies: '@exodus/bytes': 1.15.0 @@ -2454,6 +2648,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} memorystream@0.3.1: {} @@ -2465,6 +2661,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@10.2.4: dependencies: brace-expansion: 5.0.5 @@ -2571,6 +2773,8 @@ snapshots: proto-list@1.2.4: {} + proxy-from-env@2.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -2784,6 +2988,11 @@ snapshots: transitivePeerDependencies: - supports-color + vue-router@4.6.4(vue@3.5.32(typescript@6.0.2)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.32(typescript@6.0.2) + vue-tsc@3.2.6(typescript@6.0.2): dependencies: '@volar/typescript': 2.4.28 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index acdaf9b..613fac0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,7 +1,5 @@ - + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 2a2e215..807c258 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,5 +1,7 @@ +html, body { margin: 0; + overflow: hidden; } #app { diff --git a/frontend/src/components/LogUrlInput.vue b/frontend/src/components/LogUrlInput.vue new file mode 100644 index 0000000..cc4ddcf --- /dev/null +++ b/frontend/src/components/LogUrlInput.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/composables/__tests__/logManifest.spec.ts b/frontend/src/composables/__tests__/logManifest.spec.ts new file mode 100644 index 0000000..643798d --- /dev/null +++ b/frontend/src/composables/__tests__/logManifest.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref } from 'vue' + +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + }, +})) + +import axios from 'axios' +import { useLogManifest } from '../logManifest' +import type { LogManifest } from '../logfile' + +const mockedAxios = vi.mocked(axios) + +describe('useLogManifest', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('finds manifest entry by type', async () => { + const metadata: LogManifest = { + indices: [ + { type: 'vision_detection', path: 'det.idx' }, + { type: 'vision_geometry', path: 'geo.idx' }, + { type: 'refbox', path: 'ref.idx' }, + ], + } + + mockedAxios.get.mockResolvedValue({ data: metadata }) + + const logUrl = ref('http://example.com/test.log') + const manifest = useLogManifest(logUrl) + + await vi.waitFor(() => { + expect(manifest.manifest.value.indices.length).toBe(3) + }) + + expect(manifest.findManifestEntry('vision_geometry')).toEqual({ + type: 'vision_geometry', + path: 'geo.idx', + }) + expect(manifest.findManifestEntry('nonexistent')).toBeUndefined() + }) + + it('constructs manifest index URL', async () => { + const metadata: LogManifest = { + indices: [{ type: 'refbox', path: 'ref.idx' }], + } + + mockedAxios.get.mockResolvedValue({ data: metadata }) + + const logUrl = ref('http://example.com/test.log') + const manifest = useLogManifest(logUrl) + + await vi.waitFor(() => { + expect(manifest.manifest.value.indices.length).toBe(1) + }) + + expect(manifest.manifestIndexUrl('refbox')).toBe('http://example.com/ref.idx') + expect(manifest.manifestIndexUrl('nonexistent')).toBe('') + }) + + it('filters manifest entries by type', async () => { + const metadata: LogManifest = { + indices: [ + { type: 'vision_detection', path: 'cam0.idx', source: 'cam0' }, + { type: 'vision_detection', path: 'cam1.idx', source: 'cam1' }, + { type: 'refbox', path: 'ref.idx' }, + ], + } + + mockedAxios.get.mockResolvedValue({ data: metadata }) + + const logUrl = ref('http://example.com/test.log') + const manifest = useLogManifest(logUrl) + + await vi.waitFor(() => { + expect(manifest.manifest.value.indices.length).toBe(3) + }) + + const detectionEntries = manifest.manifestEntriesOfType('vision_detection') + expect(detectionEntries.value).toHaveLength(2) + expect(detectionEntries.value[0]!.source).toBe('cam0') + }) + + it('finds manifest entry by type and source', async () => { + const metadata: LogManifest = { + indices: [ + { type: 'tracker', path: 'tracker_a.idx', source: 'tracker_a' }, + { type: 'tracker', path: 'tracker_b.idx', source: 'tracker_b' }, + ], + } + + mockedAxios.get.mockResolvedValue({ data: metadata }) + + const logUrl = ref('http://example.com/test.log') + const manifest = useLogManifest(logUrl) + + await vi.waitFor(() => { + expect(manifest.manifest.value.indices.length).toBe(2) + }) + + expect(manifest.findManifestEntry('tracker', 'tracker_b')?.path).toBe('tracker_b.idx') + expect(manifest.findManifestEntry('tracker', 'nonexistent')).toBeUndefined() + }) +}) diff --git a/frontend/src/composables/__tests__/logPlayerPosition.spec.ts b/frontend/src/composables/__tests__/logPlayerPosition.spec.ts new file mode 100644 index 0000000..03cef3b --- /dev/null +++ b/frontend/src/composables/__tests__/logPlayerPosition.spec.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { defineComponent, ref, nextTick } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import { mount } from '@vue/test-utils' +import type { IndexEntry } from '../logfile' + +const mockRoute = { query: {} as Record } + +vi.mock('vue-router', () => ({ + useRoute: () => mockRoute, +})) + +const mockStorage = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => mockStorage.get(key) ?? null, + setItem: (key: string, value: string) => mockStorage.set(key, value), + removeItem: (key: string) => mockStorage.delete(key), +}) + +import { useLogPlayerPosition } from '../logPlayerPosition' + +function withSetup( + globalMinTimestamp: MaybeRefOrGetter, + globalMaxTimestamp: MaybeRefOrGetter, + primaryIndex: MaybeRefOrGetter, + logUrl: MaybeRefOrGetter, +) { + let result!: ReturnType + const wrapper = mount( + defineComponent({ + setup() { + result = useLogPlayerPosition(globalMinTimestamp, globalMaxTimestamp, primaryIndex, logUrl) + return () => {} + }, + }), + ) + return { result, wrapper } +} + +describe('useLogPlayerPosition', () => { + let wrapper: ReturnType | undefined + + beforeEach(() => { + mockRoute.query = {} + mockStorage.clear() + }) + + afterEach(() => { + wrapper?.unmount() + wrapper = undefined + }) + + const makeIndex = (timestamps: bigint[]): IndexEntry[] => + timestamps.map((ts, i) => ({ timestamp: ts, offset: BigInt(i * 100) })) + + it('starts with zero timestamp', () => { + const { result, wrapper: w } = withSetup( + ref(BigInt(0)), + ref(BigInt(0)), + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + expect(result.currentTimestamp.value).toBe(BigInt(0)) + expect(result.sliderValue.value).toBe(0) + expect(result.sliderMax.value).toBe(0) + }) + + it('computes sliderMax from global timestamp range', () => { + const { result, wrapper: w } = withSetup( + ref(100n), + ref(500n), + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + expect(result.sliderMax.value).toBe(400) + }) + + it('maps sliderValue to currentTimestamp', () => { + const { result, wrapper: w } = withSetup( + ref(1000n), + ref(5000n), + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + result.sliderValue.value = 2000 + expect(result.currentTimestamp.value).toBe(3000n) + }) + + it('maps currentTimestamp to sliderValue', () => { + const { result, wrapper: w } = withSetup( + ref(1000n), + ref(5000n), + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + result.currentTimestamp.value = 3000n + expect(result.sliderValue.value).toBe(2000) + }) + + it('clamps sliderValue to valid range', () => { + const { result, wrapper: w } = withSetup( + ref(100n), + ref(500n), + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + // Set timestamp beyond max + result.currentTimestamp.value = 9999n + expect(result.sliderValue.value).toBe(400) + + // Set timestamp below min + result.currentTimestamp.value = 0n + expect(result.sliderValue.value).toBe(0) + }) + + it('restores timestamp from localStorage', async () => { + mockStorage.set('ssl-vision-timestamp', '300') + const globalMax = ref(BigInt(0)) + const { result, wrapper: w } = withSetup( + ref(100n), + globalMax, + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + // Simulate indices loading + globalMax.value = 500n + await nextTick() + expect(result.currentTimestamp.value).toBe(300n) + }) + + it('restores timestamp from query param', async () => { + mockRoute.query = { ts: '250' } + const globalMax = ref(BigInt(0)) + const { result, wrapper: w } = withSetup( + ref(100n), + globalMax, + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + globalMax.value = 500n + await nextTick() + expect(result.currentTimestamp.value).toBe(250n) + }) + + it('does not crash on invalid BigInt in query param', async () => { + mockRoute.query = { ts: 'not-a-number' } + const globalMax = ref(BigInt(0)) + const { result, wrapper: w } = withSetup( + ref(100n), + globalMax, + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + globalMax.value = 500n + await nextTick() + expect(result.currentTimestamp.value).toBe(BigInt(0)) + }) + + it('persists timestamp to localStorage', async () => { + const { result, wrapper: w } = withSetup( + ref(100n), + ref(500n), + ref(undefined), + ref('http://example.com/test.log'), + ) + wrapper = w + + result.currentTimestamp.value = 300n + await nextTick() + expect(mockStorage.get('ssl-vision-timestamp')).toBe('300') + }) + + it('resets timestamp when logUrl changes', async () => { + const logUrl = ref('http://example.com/test.log') + const { result, wrapper: w } = withSetup(ref(100n), ref(500n), ref(undefined), logUrl) + wrapper = w + + result.currentTimestamp.value = 300n + await nextTick() + + logUrl.value = 'http://example.com/other.log' + await nextTick() + expect(result.currentTimestamp.value).toBe(BigInt(0)) + }) + + it('steps forward through primaryIndex frames', () => { + const index = ref(makeIndex([100n, 200n, 300n, 400n])) + const { result, wrapper: w } = withSetup( + ref(100n), + ref(400n), + index, + ref('http://example.com/test.log'), + ) + wrapper = w + + result.currentTimestamp.value = 200n + result.stepForward() + expect(result.currentTimestamp.value).toBe(300n) + + result.stepForward() + expect(result.currentTimestamp.value).toBe(400n) + + // At end, stays at last + result.stepForward() + expect(result.currentTimestamp.value).toBe(400n) + }) + + it('steps backward through primaryIndex frames', () => { + const index = ref(makeIndex([100n, 200n, 300n, 400n])) + const { result, wrapper: w } = withSetup( + ref(100n), + ref(400n), + index, + ref('http://example.com/test.log'), + ) + wrapper = w + + result.currentTimestamp.value = 300n + result.stepBackward() + expect(result.currentTimestamp.value).toBe(200n) + + result.stepBackward() + expect(result.currentTimestamp.value).toBe(100n) + + // At start, stays at first + result.stepBackward() + expect(result.currentTimestamp.value).toBe(100n) + }) + + it('steps to nearest frame when between frames', () => { + const index = ref(makeIndex([100n, 200n, 300n])) + const { result, wrapper: w } = withSetup( + ref(100n), + ref(300n), + index, + ref('http://example.com/test.log'), + ) + wrapper = w + + // Between 100n and 200n, floor semantics → at index 0 (100n), step to index 1 (200n) + result.currentTimestamp.value = 150n + result.stepForward() + expect(result.currentTimestamp.value).toBe(200n) + }) +}) diff --git a/frontend/src/composables/__tests__/logPlayerSources.spec.ts b/frontend/src/composables/__tests__/logPlayerSources.spec.ts new file mode 100644 index 0000000..5e7d1b8 --- /dev/null +++ b/frontend/src/composables/__tests__/logPlayerSources.spec.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { ref, nextTick } from 'vue' +import { useLogPlayerSources } from '../logPlayerSources' +import type { ManifestEntry } from '../logfile' +import { SOURCE_VISION } from '../vision' + +describe('useLogPlayerSources', () => { + it('defaults activeSource to vision', () => { + const entries = ref([]) + const { activeSource } = useLogPlayerSources(entries) + expect(activeSource.value).toBe(SOURCE_VISION) + }) + + it('builds sources map from tracker manifest entries', () => { + const entries = ref([ + { type: 'tracker', path: 'a.idx', source: 'tracker_a' }, + { type: 'tracker', path: 'b.idx', source: 'tracker_b' }, + ]) + const { sources } = useLogPlayerSources(entries) + expect(sources.value).toEqual({ + [SOURCE_VISION]: SOURCE_VISION, + tracker_a: 'tracker_a', + tracker_b: 'tracker_b', + }) + }) + + it('auto-selects first tracker source when entries arrive', async () => { + const entries = ref([]) + const { activeSource } = useLogPlayerSources(entries) + expect(activeSource.value).toBe(SOURCE_VISION) + + entries.value = [ + { type: 'tracker', path: 'a.idx', source: 'tracker_a' }, + { type: 'tracker', path: 'b.idx', source: 'tracker_b' }, + ] + await nextTick() + expect(activeSource.value).toBe('tracker_a') + }) + + it('allows switching between sources', () => { + const entries = ref([{ type: 'tracker', path: 'a.idx', source: 'tracker_a' }]) + const { activeSource } = useLogPlayerSources(entries) + + activeSource.value = 'tracker_a' + expect(activeSource.value).toBe('tracker_a') + + activeSource.value = SOURCE_VISION + expect(activeSource.value).toBe(SOURCE_VISION) + }) +}) diff --git a/frontend/src/composables/__tests__/logUrl.spec.ts b/frontend/src/composables/__tests__/logUrl.spec.ts new file mode 100644 index 0000000..e1dbb86 --- /dev/null +++ b/frontend/src/composables/__tests__/logUrl.spec.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockRoute = { query: {} as Record } +const mockReplace = vi.fn() + +vi.mock('vue-router', () => ({ + useRoute: () => mockRoute, + useRouter: () => ({ replace: mockReplace }), +})) + +const mockStorage = new Map() +vi.stubGlobal('localStorage', { + getItem: (key: string) => mockStorage.get(key) ?? null, + setItem: (key: string, value: string) => mockStorage.set(key, value), + removeItem: (key: string) => mockStorage.delete(key), +}) + +import { useLogUrl, getLogBase, getLogsBaseUrl } from '../logUrl' + +describe('useLogUrl', () => { + beforeEach(() => { + mockRoute.query = {} + mockReplace.mockClear() + mockStorage.clear() + }) + + it('validates URL must end with .log', () => { + const { logUrlInput, urlError } = useLogUrl() + logUrlInput.value = 'http://example.com/test.txt' + expect(urlError.value).toBe('URL must end with .log') + }) + + it('has no error for valid .log URL', () => { + const { logUrlInput, urlError } = useLogUrl() + logUrlInput.value = 'http://example.com/test.log' + expect(urlError.value).toBe('') + }) + + it('has no error for empty input', () => { + const { urlError } = useLogUrl() + expect(urlError.value).toBe('') + }) + + it('returns valid URL for .log input', () => { + const { logUrlInput, validLogUrl } = useLogUrl() + logUrlInput.value = 'http://example.com/data/test.log' + expect(validLogUrl.value).toBe('http://example.com/data/test.log') + }) + + it('returns empty validLogUrl for invalid input', () => { + const { logUrlInput, validLogUrl } = useLogUrl() + logUrlInput.value = 'http://example.com/test.txt' + expect(validLogUrl.value).toBe('') + }) +}) + +describe('getLogBase', () => { + it('removes .log extension', () => { + expect(getLogBase('http://example.com/data/test.log')).toBe('http://example.com/data/test') + }) + + it('returns empty for empty input', () => { + expect(getLogBase('')).toBe('') + }) +}) + +describe('getLogsBaseUrl', () => { + it('extracts directory part of URL', () => { + expect(getLogsBaseUrl('http://example.com/data/test.log')).toBe('http://example.com/data/') + }) + + it('returns empty for empty input', () => { + expect(getLogsBaseUrl('')).toBe('') + }) + + it('returns empty for URL without slash', () => { + expect(getLogsBaseUrl('test.log')).toBe('') + }) +}) diff --git a/frontend/src/composables/__tests__/logfile.spec.ts b/frontend/src/composables/__tests__/logfile.spec.ts new file mode 100644 index 0000000..cb9f900 --- /dev/null +++ b/frontend/src/composables/__tests__/logfile.spec.ts @@ -0,0 +1,303 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref, nextTick } from 'vue' +import { + findClosestEntry, + findClosestIndex, + useLogFileMultiIndex, + useLogFileMessagesAtTimestamp, +} from '../logfile' +import type { IndexEntry, ManifestEntry } from '../logfile' + +// Mock axios +vi.mock('axios', () => ({ + default: { + get: vi.fn(), + }, +})) + +import axios from 'axios' +const mockedAxios = vi.mocked(axios) + +function createIndexBuffer(entries: { timestamp: bigint; offset: bigint }[]): ArrayBuffer { + const buffer = new ArrayBuffer(entries.length * 16) + const view = new DataView(buffer) + for (let i = 0; i < entries.length; i++) { + view.setBigInt64(i * 16, entries[i]!.timestamp, false) + view.setBigInt64(i * 16 + 8, entries[i]!.offset, false) + } + return buffer +} + +function createMessageBuffer( + timestamp: bigint, + messageType: number, + payload: Uint8Array, +): ArrayBuffer { + const buffer = new ArrayBuffer(16 + payload.length) + const view = new DataView(buffer) + view.setBigInt64(0, timestamp) + view.setInt32(8, messageType) + view.setInt32(12, payload.length) + new Uint8Array(buffer).set(payload, 16) + return buffer +} + +describe('findClosestEntry', () => { + const entries: IndexEntry[] = [ + { timestamp: 100n, offset: 0n }, + { timestamp: 200n, offset: 100n }, + { timestamp: 300n, offset: 200n }, + { timestamp: 400n, offset: 300n }, + ] + + it('returns undefined for empty index', () => { + expect(findClosestEntry([], 100n)).toBeUndefined() + }) + + it('returns undefined when timestamp is before all entries', () => { + expect(findClosestEntry(entries, 50n)).toBeUndefined() + }) + + it('returns exact match', () => { + expect(findClosestEntry(entries, 200n)).toEqual({ timestamp: 200n, offset: 100n }) + }) + + it('returns closest entry not in the future', () => { + expect(findClosestEntry(entries, 250n)).toEqual({ timestamp: 200n, offset: 100n }) + }) + + it('returns last entry when timestamp is beyond all entries', () => { + expect(findClosestEntry(entries, 500n)).toEqual({ timestamp: 400n, offset: 300n }) + }) + + it('returns first entry when timestamp equals first entry', () => { + expect(findClosestEntry(entries, 100n)).toEqual({ timestamp: 100n, offset: 0n }) + }) +}) + +describe('findClosestIndex', () => { + const entries: IndexEntry[] = [ + { timestamp: 100n, offset: 0n }, + { timestamp: 200n, offset: 100n }, + { timestamp: 300n, offset: 200n }, + { timestamp: 400n, offset: 300n }, + ] + + it('returns 0 for empty index', () => { + expect(findClosestIndex([], 100n)).toBe(0) + }) + + it('returns 0 when timestamp is before all entries', () => { + expect(findClosestIndex(entries, 50n)).toBe(0) + }) + + it('returns exact match index', () => { + expect(findClosestIndex(entries, 200n)).toBe(1) + }) + + it('returns floor index (last entry not in the future)', () => { + expect(findClosestIndex(entries, 250n)).toBe(1) + }) + + it('returns last index when timestamp is beyond all entries', () => { + expect(findClosestIndex(entries, 500n)).toBe(3) + }) + + it('returns 0 when timestamp equals first entry', () => { + expect(findClosestIndex(entries, 100n)).toBe(0) + }) +}) + +describe('useLogFileMultiIndex', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns empty map and undefined primaryIndex for empty entries', async () => { + const baseUrl = ref('') + const entries = ref([]) + const { indices, primaryIndex } = useLogFileMultiIndex(baseUrl, entries) + + await nextTick() + await nextTick() + + expect(indices.value.size).toBe(0) + expect(primaryIndex.value).toBeUndefined() + }) + + it('loads multiple camera indices', async () => { + const cam0Index = createIndexBuffer([ + { timestamp: 100n, offset: 0n }, + { timestamp: 200n, offset: 50n }, + ]) + const cam1Index = createIndexBuffer([ + { timestamp: 110n, offset: 0n }, + { timestamp: 210n, offset: 60n }, + ]) + + mockedAxios.get.mockImplementation((url: string) => { + if (url.includes('cam0')) return Promise.resolve({ data: cam0Index }) + if (url.includes('cam1')) return Promise.resolve({ data: cam1Index }) + return Promise.reject(new Error('unknown url')) + }) + + const baseUrl = ref('http://example.com/logs/') + const entries = ref([ + { type: 'vision_detection', path: 'test.cam0.idx', source: 'cam0' }, + { type: 'vision_detection', path: 'test.cam1.idx', source: 'cam1' }, + ]) + + const { indices, primaryIndex } = useLogFileMultiIndex(baseUrl, entries) + + // Wait for computedAsync to resolve + await vi.waitFor(() => { + expect(indices.value.size).toBe(2) + }) + + expect(indices.value.has('cam0')).toBe(true) + expect(indices.value.has('cam1')).toBe(true) + expect(indices.value.get('cam0')).toHaveLength(2) + expect(indices.value.get('cam1')).toHaveLength(2) + expect(primaryIndex.value).toHaveLength(4) + expect(primaryIndex.value![0]!.timestamp).toBe(100n) + expect(primaryIndex.value![1]!.timestamp).toBe(110n) + expect(primaryIndex.value![2]!.timestamp).toBe(200n) + expect(primaryIndex.value![3]!.timestamp).toBe(210n) + }) + + it('handles failed index loads gracefully', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const cam0Index = createIndexBuffer([{ timestamp: 100n, offset: 0n }]) + + mockedAxios.get.mockImplementation((url: string) => { + if (url.includes('cam0')) return Promise.resolve({ data: cam0Index }) + return Promise.reject(new Error('network error')) + }) + + const baseUrl = ref('http://example.com/logs/') + const entries = ref([ + { type: 'vision_detection', path: 'test.cam0.idx', source: 'cam0' }, + { type: 'vision_detection', path: 'test.cam1.idx', source: 'cam1' }, + ]) + + const { indices } = useLogFileMultiIndex(baseUrl, entries) + + await vi.waitFor(() => { + expect(indices.value.size).toBe(1) + }) + + expect(indices.value.has('cam0')).toBe(true) + expect(indices.value.has('cam1')).toBe(false) + expect(errorSpy).toHaveBeenCalledWith('Failed to load index:', 'cam1', expect.any(Error)) + errorSpy.mockRestore() + }) +}) + +describe('useLogFileMessagesAtTimestamp', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns empty array for empty indices', async () => { + const logUrl = ref('http://example.com/test.log') + const indices = ref(new Map()) + const timestamp = ref(100n) + + const { messages } = useLogFileMessagesAtTimestamp(logUrl, indices, timestamp) + + await nextTick() + await nextTick() + + expect(messages.value).toEqual([]) + }) + + it('fetches messages from multiple camera indices', async () => { + const payload0 = new Uint8Array([1, 2, 3]) + const payload1 = new Uint8Array([4, 5, 6]) + const msg0 = createMessageBuffer(100n, 1, payload0) + const msg1 = createMessageBuffer(110n, 1, payload1) + + mockedAxios.get.mockImplementation( + (_url: string, config?: import('axios').AxiosRequestConfig) => { + const range = config?.headers?.range as string + if (!range) return Promise.reject(new Error('no range')) + + // Parse the range header to determine which request this is + const match = range.match(/bytes=(\d+)-(\d+)/) + if (!match) return Promise.reject(new Error('bad range')) + const start = Number(match[1]) + const end = Number(match[2]) + + // Header request (16 bytes) vs full request + if (end - start === 15) { + // Header-only request - return based on offset + if (start === 0) return Promise.resolve({ data: msg0.slice(0, 16) }) + if (start === 100) return Promise.resolve({ data: msg1.slice(0, 16) }) + } else { + // Full message request + if (start === 0) return Promise.resolve({ data: msg0 }) + if (start === 100) return Promise.resolve({ data: msg1 }) + } + return Promise.reject(new Error('unknown range')) + }, + ) + + const cam0Index: IndexEntry[] = [{ timestamp: 100n, offset: 0n }] + const cam1Index: IndexEntry[] = [{ timestamp: 110n, offset: 100n }] + + const logUrl = ref('http://example.com/test.log') + const indices = ref( + new Map([ + ['cam0', cam0Index], + ['cam1', cam1Index], + ]), + ) + const timestamp = ref(200n) + + const { messages } = useLogFileMessagesAtTimestamp(logUrl, indices, timestamp) + + await vi.waitFor(() => { + expect(messages.value).toHaveLength(2) + }) + + expect(messages.value[0]!.data.byteLength).toBe(3) + expect(messages.value[1]!.data.byteLength).toBe(3) + }) + + it('skips cameras with no matching entry for timestamp', async () => { + const payload = new Uint8Array([1, 2, 3]) + const msg = createMessageBuffer(200n, 1, payload) + + mockedAxios.get.mockImplementation( + (_url: string, config?: import('axios').AxiosRequestConfig) => { + const range = config?.headers?.range as string + const match = range.match(/bytes=(\d+)-(\d+)/) + if (!match) return Promise.reject(new Error('bad range')) + const end = Number(match[1]) + const endB = Number(match[2]) + if (endB - end === 15) return Promise.resolve({ data: msg.slice(0, 16) }) + return Promise.resolve({ data: msg }) + }, + ) + + const cam0Index: IndexEntry[] = [{ timestamp: 200n, offset: 0n }] + const cam1Index: IndexEntry[] = [ + { timestamp: 300n, offset: 100n }, // all entries in the future + ] + + const logUrl = ref('http://example.com/test.log') + const indices = ref( + new Map([ + ['cam0', cam0Index], + ['cam1', cam1Index], + ]), + ) + const timestamp = ref(250n) + + const { messages } = useLogFileMessagesAtTimestamp(logUrl, indices, timestamp) + + await vi.waitFor(() => { + expect(messages.value).toHaveLength(1) + }) + }) +}) diff --git a/frontend/src/composables/logManifest.ts b/frontend/src/composables/logManifest.ts new file mode 100644 index 0000000..365efb8 --- /dev/null +++ b/frontend/src/composables/logManifest.ts @@ -0,0 +1,37 @@ +import { computed, toValue } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import { useLogFileManifest } from '@/composables/logfile.ts' +import type { ManifestEntry } from '@/composables/logfile.ts' +import { getLogBase, getLogsBaseUrl } from '@/composables/logUrl.ts' + +export const useLogManifest = (logUrl: MaybeRefOrGetter) => { + const { manifest } = useLogFileManifest( + computed(() => { + const url = toValue(logUrl) + const base = getLogBase(url) + return base ? `${base}.log.manifest.json` : '' + }), + ) + + const findManifestEntry = (type: string, source?: string): ManifestEntry | undefined => { + return manifest.value.indices.find( + (e) => e.type === type && (source === undefined || e.source === source), + ) + } + + const manifestIndexUrl = (type: string, source?: string): string => { + const entry = findManifestEntry(type, source) + if (!entry) return '' + return `${getLogsBaseUrl(toValue(logUrl))}${entry.path}` + } + + const manifestEntriesOfType = (type: string) => + computed(() => manifest.value.indices.filter((e) => e.type === type)) + + return { + manifest, + findManifestEntry, + manifestIndexUrl, + manifestEntriesOfType, + } +} diff --git a/frontend/src/composables/logPlayerIndices.ts b/frontend/src/composables/logPlayerIndices.ts new file mode 100644 index 0000000..4e35d7d --- /dev/null +++ b/frontend/src/composables/logPlayerIndices.ts @@ -0,0 +1,63 @@ +import { computed, toValue } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import { useLogFileMultiIndex } from '@/composables/logfile.ts' +import type { IndexEntry } from '@/composables/logfile.ts' +import type { useLogManifest } from '@/composables/logManifest.ts' +import { getLogsBaseUrl } from '@/composables/logUrl.ts' +import { SOURCE_VISION } from '@/composables/vision.ts' + +const indexBounds = (index: IndexEntry[] | undefined): { min: bigint; max: bigint } | undefined => { + if (!index || index.length === 0) return undefined + return { min: index[0]!.timestamp, max: index[index.length - 1]!.timestamp } +} + +export const useLogPlayerIndices = ( + logUrl: MaybeRefOrGetter, + manifest: ReturnType, + activeSource: MaybeRefOrGetter, +) => { + const logsBaseUrl = computed(() => getLogsBaseUrl(toValue(logUrl))) + + const { indices: visionDetectionIndices, primaryIndex: visionDetectionPrimaryIndex } = + useLogFileMultiIndex(logsBaseUrl, manifest.manifestEntriesOfType('vision_detection')) + + // Load ALL tracker indices unconditionally for global timeline bounds + const { primaryIndex: trackerPrimaryIndex } = useLogFileMultiIndex( + logsBaseUrl, + manifest.manifestEntriesOfType('tracker'), + ) + + // Primary index switches based on active source (used for step mode) + const primaryIndex = computed(() => { + if (toValue(activeSource) === SOURCE_VISION) { + return visionDetectionPrimaryIndex.value + } + return trackerPrimaryIndex.value + }) + + // Global timestamp bounds across ALL loaded indices + const globalMinTimestamp = computed(() => { + const bounds = [ + indexBounds(visionDetectionPrimaryIndex.value), + indexBounds(trackerPrimaryIndex.value), + ].filter((b) => b !== undefined) + if (bounds.length === 0) return BigInt(0) + return bounds.reduce((a, b) => (a.min < b.min ? a : b)).min + }) + + const globalMaxTimestamp = computed(() => { + const bounds = [ + indexBounds(visionDetectionPrimaryIndex.value), + indexBounds(trackerPrimaryIndex.value), + ].filter((b) => b !== undefined) + if (bounds.length === 0) return BigInt(0) + return bounds.reduce((a, b) => (a.max > b.max ? a : b)).max + }) + + return { + visionDetectionIndices, + primaryIndex, + globalMinTimestamp, + globalMaxTimestamp, + } +} diff --git a/frontend/src/composables/logPlayerMessages.ts b/frontend/src/composables/logPlayerMessages.ts new file mode 100644 index 0000000..b86a9be --- /dev/null +++ b/frontend/src/composables/logPlayerMessages.ts @@ -0,0 +1,110 @@ +import { computed, ref, toValue, watch } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import { + useLogFileIndex, + useLogFileMessageAtTimestamp, + useLogFileMessagesAtTimestamp, + parseProtobuf, +} from '@/composables/logfile.ts' +import type { IndexEntry } from '@/composables/logfile.ts' +import type { useLogManifest } from '@/composables/logManifest.ts' +import { SSL_WrapperPacketSchema } from '@/proto/vision/ssl_vision_wrapper_pb.ts' +import { RefereeSchema } from '@/proto/gc/ssl_gc_referee_message_pb.ts' +import { TrackerWrapperPacketSchema } from '@/proto/tracked/ssl_vision_wrapper_tracked_pb.ts' +import { applyFieldDefaults, defaultField, SOURCE_VISION } from '@/composables/vision.ts' +import type { SSL_GeometryFieldSize } from '@/proto/vision/ssl_vision_geometry_pb.ts' + +export const useLogPlayerMessages = ( + logUrl: MaybeRefOrGetter, + manifest: ReturnType, + visionDetectionIndices: MaybeRefOrGetter>, + currentTimestamp: MaybeRefOrGetter, + activeSource: MaybeRefOrGetter, +) => { + const isVisionActive = computed(() => toValue(activeSource) === SOURCE_VISION) + + const { index: visionGeometryIndex } = useLogFileIndex( + computed(() => manifest.manifestIndexUrl('vision_geometry')), + ) + const { index: refboxIndex } = useLogFileIndex( + computed(() => manifest.manifestIndexUrl('refbox')), + ) + const { index: trackerIndex } = useLogFileIndex( + computed(() => { + const source = toValue(activeSource) + if (source === SOURCE_VISION) return '' + return manifest.manifestIndexUrl('tracker', source) + }), + ) + + // Only fetch vision detection messages when vision is active + const { messages: visionDetectionMessages } = useLogFileMessagesAtTimestamp( + logUrl, + computed(() => (isVisionActive.value ? toValue(visionDetectionIndices) : new Map())), + currentTimestamp, + ) + + const { message: visionGeometryMessage } = useLogFileMessageAtTimestamp( + logUrl, + visionGeometryIndex, + currentTimestamp, + ) + const { message: refboxMessage } = useLogFileMessageAtTimestamp( + logUrl, + refboxIndex, + currentTimestamp, + ) + + // Only fetch tracker message when a tracker source is active + const { message: trackerMessage } = useLogFileMessageAtTimestamp( + logUrl, + computed(() => (isVisionActive.value ? undefined : trackerIndex.value)), + currentTimestamp, + ) + + const visionDetectionWrappers = computed(() => + visionDetectionMessages.value + .map((msg) => parseProtobuf(msg, SSL_WrapperPacketSchema, 'vision detection wrapper')) + .filter((w) => w !== undefined), + ) + + const visionGeometryWrapper = computed(() => + parseProtobuf(visionGeometryMessage.value, SSL_WrapperPacketSchema, 'vision geometry wrapper'), + ) + + const latestField = ref(defaultField) + + watch(visionGeometryWrapper, (wrapper) => { + if (wrapper?.geometry?.field) { + latestField.value = applyFieldDefaults(wrapper.geometry.field) + } + }) + + const detectionFrames = computed(() => { + if (!isVisionActive.value) return [] + return visionDetectionWrappers.value.map((w) => w.detection).filter((d) => d !== undefined) + }) + + const field = computed(() => latestField.value) + + const referee = computed(() => + parseProtobuf(refboxMessage.value, RefereeSchema, 'referee message'), + ) + + const trackedFrame = computed(() => { + if (isVisionActive.value) return undefined + const wrapper = parseProtobuf( + trackerMessage.value, + TrackerWrapperPacketSchema, + 'tracker wrapper', + ) + return wrapper?.trackedFrame + }) + + return { + detectionFrames, + field, + referee, + trackedFrame, + } +} diff --git a/frontend/src/composables/logPlayerPosition.ts b/frontend/src/composables/logPlayerPosition.ts new file mode 100644 index 0000000..2b0e23e --- /dev/null +++ b/frontend/src/composables/logPlayerPosition.ts @@ -0,0 +1,163 @@ +import { computed, onUnmounted, ref, toValue, watch } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import { useRoute } from 'vue-router' +import { findClosestIndex } from '@/composables/logfile.ts' +import type { IndexEntry } from '@/composables/logfile.ts' + +const STORAGE_TIMESTAMP_KEY = 'ssl-vision-timestamp' + +const safeParseBigInt = (value: string): bigint | null => { + try { + return BigInt(value) + } catch { + return null + } +} + +export const useLogPlayerPosition = ( + globalMinTimestamp: MaybeRefOrGetter, + globalMaxTimestamp: MaybeRefOrGetter, + primaryIndex: MaybeRefOrGetter, + logUrl: MaybeRefOrGetter, +) => { + const route = useRoute() + + const currentTimestamp = ref(BigInt(0)) + const initialPositionSet = ref(false) + + // Reset when log file changes + watch( + () => toValue(logUrl), + () => { + currentTimestamp.value = BigInt(0) + initialPositionSet.value = false + }, + ) + + // Restore initial timestamp from query param or localStorage + const initialTimestampNs = computed(() => { + const ts = typeof route.query.ts === 'string' ? route.query.ts : '' + if (ts) return safeParseBigInt(ts) + const saved = localStorage.getItem(STORAGE_TIMESTAMP_KEY) + if (saved) return safeParseBigInt(saved) + return null + }) + + watch([() => toValue(globalMaxTimestamp), initialTimestampNs], ([maxTs, targetNs]) => { + if (initialPositionSet.value || maxTs === BigInt(0)) return + initialPositionSet.value = true + if (targetNs !== null) { + currentTimestamp.value = targetNs + } + }) + + // Slider mapping: linear over global timestamp range + const sliderMax = computed(() => { + const min = toValue(globalMinTimestamp) + const max = toValue(globalMaxTimestamp) + if (max <= min) return 0 + return Number(max - min) + }) + + const sliderValue = computed({ + get: () => { + const min = toValue(globalMinTimestamp) + if (sliderMax.value === 0) return 0 + const val = currentTimestamp.value - min + return Number( + val < BigInt(0) ? BigInt(0) : val > BigInt(sliderMax.value) ? BigInt(sliderMax.value) : val, + ) + }, + set: (val: number) => { + currentTimestamp.value = toValue(globalMinTimestamp) + BigInt(val) + }, + }) + + // Step through frames of the active source's index + const stepForward = () => { + const index = toValue(primaryIndex) + if (!index || index.length === 0) return + const currentIdx = findClosestIndex(index, currentTimestamp.value) + const nextIdx = Math.min(currentIdx + 1, index.length - 1) + currentTimestamp.value = index[nextIdx]!.timestamp + } + + const stepBackward = () => { + const index = toValue(primaryIndex) + if (!index || index.length === 0) return + const currentIdx = findClosestIndex(index, currentTimestamp.value) + const prevIdx = Math.max(currentIdx - 1, 0) + currentTimestamp.value = index[prevIdx]!.timestamp + } + + const skipTime = (seconds: number) => { + const deltaNs = BigInt(seconds) * BigInt(1_000_000_000) + const min = toValue(globalMinTimestamp) + const max = toValue(globalMaxTimestamp) + let newTs = currentTimestamp.value + deltaNs + if (newTs < min) newTs = min + if (newTs > max) newTs = max + currentTimestamp.value = newTs + } + + // Play mode: advance timestamp in real time + const playing = ref(false) + let playTimer: ReturnType | null = null + const PLAY_INTERVAL_MS = 25 + + const startPlaying = () => { + if (playTimer) return + playTimer = setInterval(() => { + const deltaNs = BigInt(PLAY_INTERVAL_MS) * BigInt(1_000_000) + const max = toValue(globalMaxTimestamp) + const newTs = currentTimestamp.value + deltaNs + if (newTs >= max) { + currentTimestamp.value = max + stopPlaying() + } else { + currentTimestamp.value = newTs + } + }, PLAY_INTERVAL_MS) + } + + const stopPlaying = () => { + if (playTimer) { + clearInterval(playTimer) + playTimer = null + } + playing.value = false + } + + const togglePlay = () => { + playing.value = !playing.value + if (playing.value) { + startPlaying() + } else { + stopPlaying() + } + } + + onUnmounted(() => { + if (playTimer) clearInterval(playTimer) + }) + + // Persist current timestamp to localStorage for session restore + watch(currentTimestamp, (timestamp) => { + if (timestamp === BigInt(0)) { + localStorage.removeItem(STORAGE_TIMESTAMP_KEY) + } else { + localStorage.setItem(STORAGE_TIMESTAMP_KEY, timestamp.toString()) + } + }) + + return { + currentTimestamp, + sliderValue, + sliderMax, + stepForward, + stepBackward, + skipTime, + playing, + togglePlay, + } +} diff --git a/frontend/src/composables/logPlayerSources.ts b/frontend/src/composables/logPlayerSources.ts new file mode 100644 index 0000000..77b36c5 --- /dev/null +++ b/frontend/src/composables/logPlayerSources.ts @@ -0,0 +1,32 @@ +import { computed, ref, toValue, watch } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import type { ManifestEntry } from '@/composables/logfile.ts' +import { SOURCE_VISION } from '@/composables/vision.ts' + +export const useLogPlayerSources = (trackerManifestEntries: MaybeRefOrGetter) => { + const activeSource = ref(SOURCE_VISION) + + watch( + () => toValue(trackerManifestEntries), + (entries) => { + if (entries.length > 0 && activeSource.value === SOURCE_VISION) { + activeSource.value = entries[0]!.source ?? SOURCE_VISION + } + }, + ) + + const sources = computed(() => { + const s: Record = { [SOURCE_VISION]: SOURCE_VISION } + for (const entry of toValue(trackerManifestEntries)) { + if (entry.source) { + s[entry.source] = entry.source + } + } + return s + }) + + return { + activeSource, + sources, + } +} diff --git a/frontend/src/composables/logUrl.ts b/frontend/src/composables/logUrl.ts new file mode 100644 index 0000000..57c7039 --- /dev/null +++ b/frontend/src/composables/logUrl.ts @@ -0,0 +1,65 @@ +import { computed, ref, watch } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +const STORAGE_URL_KEY = 'ssl-vision-log-url' + +export const getLogBase = (url: string): string => { + if (!url) return '' + return url.slice(0, -'.log'.length) +} + +export const getLogsBaseUrl = (url: string): string => { + if (!url) return '' + const lastSlash = url.lastIndexOf('/') + return lastSlash >= 0 ? url.substring(0, lastSlash + 1) : '' +} + +export const useLogUrl = () => { + const route = useRoute() + const router = useRouter() + + const logUrlInput = ref('') + + const urlError = computed(() => { + const url = logUrlInput.value.trim() + if (url && !url.endsWith('.log')) return 'URL must end with .log' + return '' + }) + + watch( + () => route.query.url, + (urlParam) => { + if (typeof urlParam === 'string' && urlParam && !logUrlInput.value) { + logUrlInput.value = urlParam + } else if (!urlParam && !logUrlInput.value) { + const savedUrl = localStorage.getItem(STORAGE_URL_KEY) + if (savedUrl) { + logUrlInput.value = savedUrl + router.replace({ query: { ...route.query, url: savedUrl } }) + } + } + }, + { immediate: true }, + ) + + watch(logUrlInput, (url) => { + router.replace({ query: { ...route.query, url: url || undefined } }) + if (url && url.trim()) { + localStorage.setItem(STORAGE_URL_KEY, url) + } else { + localStorage.removeItem(STORAGE_URL_KEY) + } + }) + + const validLogUrl = computed(() => { + const url = logUrlInput.value.trim() + if (!url || !url.endsWith('.log')) return '' + return url + }) + + return { + logUrlInput, + urlError, + validLogUrl, + } +} diff --git a/frontend/src/composables/logfile.ts b/frontend/src/composables/logfile.ts new file mode 100644 index 0000000..77cd82e --- /dev/null +++ b/frontend/src/composables/logfile.ts @@ -0,0 +1,290 @@ +import axios from 'axios' +import { computed, toValue } from 'vue' +import type { MaybeRefOrGetter } from 'vue' +import { computedAsync } from '@vueuse/core' +import { fromBinary } from '@bufbuild/protobuf' +import type { DescMessage, MessageShape } from '@bufbuild/protobuf' + +export type LogFileMessage = { + timestamp: bigint + messageType: number + size: number + data: ArrayBuffer +} + +export type IndexEntry = { + timestamp: bigint + offset: bigint +} + +export type ManifestEntry = { + type: 'refbox' | 'vision_detection' | 'vision_geometry' | 'tracker' + path: string + source?: string +} + +export type LogManifest = { + indices: ManifestEntry[] +} + +export const parseProtobuf = ( + message: LogFileMessage | undefined, + schema: Desc, + label: string, +): MessageShape | undefined => { + if (!message) return undefined + try { + return fromBinary(schema, new Uint8Array(message.data)) + } catch (e) { + console.error(`Failed to parse ${label}:`, e) + return undefined + } +} + +const queryLogFileData = async ( + url: string, + startByte: bigint, + endByte: bigint, + signal?: AbortSignal, +): Promise => { + const rangeHeader = `bytes=${startByte}-${endByte}` + const response = await axios.get(url, { + responseType: 'arraybuffer', + headers: { range: rangeHeader }, + signal, + }) + return response.data +} + +const loadBinaryIndex = async (url: string): Promise => { + if (!url) { + return [] + } + + const response = await axios.get(url, { + responseType: 'arraybuffer', + }) + const buffer = response.data + + // Validate buffer size + if (!buffer || buffer.byteLength === 0) { + return [] + } + + // Each entry is 16 bytes (8 bytes timestamp + 8 bytes offset) + if (buffer.byteLength % 16 !== 0) { + console.error(`Invalid index file size: ${buffer.byteLength} bytes (not divisible by 16)`) + return [] + } + + const dataView = new DataView(buffer) + const numEntries = Math.floor(buffer.byteLength / 16) + + // Validate numEntries is reasonable (prevent creating huge arrays) + if (numEntries < 0 || numEntries > 10_000_000) { + console.error(`Invalid number of entries: ${numEntries}`) + return [] + } + + const entries: IndexEntry[] = new Array(numEntries) + + for (let i = 0; i < numEntries; i++) { + const offset = i * 16 + const timestamp = dataView.getBigInt64(offset, false) // big endian + const fileOffset = dataView.getBigInt64(offset + 8, false) // big endian + entries[i] = { timestamp, offset: fileOffset } + } + + return entries +} + +const queryLogfileMessage = async ( + url: string, + offset: bigint, + signal?: AbortSignal, +): Promise => { + // Read header first to get the size + const headerData = await queryLogFileData(url, offset, offset + BigInt(15), signal) + const headerView = new DataView(headerData) + const timestamp = headerView.getBigInt64(0) + const messageType = headerView.getInt32(8) + const size = headerView.getInt32(12) + + // Now read the full message including payload + const fullData = await queryLogFileData(url, offset, offset + BigInt(16 + size - 1), signal) + const payload = fullData.slice(16, 16 + size) + + return { + timestamp, + messageType, + size, + data: payload, + } +} + +export const useLogFileIndex = (indexUrl: MaybeRefOrGetter) => { + const index = computedAsync(async () => { + try { + const url = toValue(indexUrl) + if (!url) { + return [] + } + return await loadBinaryIndex(url) + } catch (error) { + console.error('Failed to load index file:', error) + return [] + } + }, []) + + return { index } +} + +const loadManifest = async (url: string): Promise => { + if (!url) return { indices: [] } + const response = await axios.get(url) + return response.data +} + +export const useLogFileManifest = (manifestUrl: MaybeRefOrGetter) => { + const manifest = computedAsync( + async () => { + try { + const url = toValue(manifestUrl) + if (!url) return { indices: [] } as LogManifest + return await loadManifest(url) + } catch (error) { + console.error('Failed to load manifest:', error) + return { indices: [] } as LogManifest + } + }, + { indices: [] } as LogManifest, + ) + + return { manifest } +} + +export const findClosestIndex = (index: IndexEntry[], timestamp: bigint): number => { + if (index.length === 0) return 0 + if (timestamp < index[0]!.timestamp) return 0 + + let lo = 0 + let hi = index.length - 1 + while (lo < hi) { + const mid = lo + Math.ceil((hi - lo) / 2) + if (index[mid]!.timestamp <= timestamp) { + lo = mid + } else { + hi = mid - 1 + } + } + return lo +} + +export const findClosestEntry = ( + index: IndexEntry[], + timestamp: bigint, +): IndexEntry | undefined => { + if (index.length === 0 || timestamp < index[0]!.timestamp) return undefined + return index[findClosestIndex(index, timestamp)] +} + +export const useLogFileMessageAtTimestamp = ( + logUrl: MaybeRefOrGetter, + index: MaybeRefOrGetter, + targetTimestamp: MaybeRefOrGetter, +) => { + const message = computedAsync(async (onCancel) => { + const indexValue = toValue(index) + const timestamp = toValue(targetTimestamp) + const url = toValue(logUrl) + + if (!indexValue || indexValue.length === 0) { + return undefined + } + + const closestEntry = findClosestEntry(indexValue, timestamp) + if (!closestEntry) { + return undefined + } + + const abort = new AbortController() + onCancel(() => abort.abort()) + + try { + return await queryLogfileMessage(url, closestEntry.offset, abort.signal) + } catch (e) { + if (!abort.signal.aborted) { + console.error('Failed to fetch message:', e) + } + return undefined + } + }, undefined) + + return { message } +} + +export const useLogFileMultiIndex = ( + baseUrl: MaybeRefOrGetter, + manifestEntries: MaybeRefOrGetter, +) => { + const indices = computedAsync(async () => { + const entries = toValue(manifestEntries) + const base = toValue(baseUrl) + if (!base || entries.length === 0) return new Map() + const result = new Map() + for (const entry of entries) { + const url = `${base}${entry.path}` + try { + result.set(entry.source ?? entry.path, await loadBinaryIndex(url)) + } catch (e) { + console.error('Failed to load index:', entry.source, e) + } + } + return result + }, new Map()) + + const primaryIndex = computed(() => { + const map = indices.value + if (map.size === 0) return undefined + const arrays = Array.from(map.values()) + const merged: IndexEntry[] = arrays.flat() + merged.sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0)) + return merged + }) + + return { indices, primaryIndex } +} + +export const useLogFileMessagesAtTimestamp = ( + logUrl: MaybeRefOrGetter, + indices: MaybeRefOrGetter>, + targetTimestamp: MaybeRefOrGetter, +) => { + const messages = computedAsync(async (onCancel) => { + const map = toValue(indices) + const timestamp = toValue(targetTimestamp) + const url = toValue(logUrl) + + if (!url || map.size === 0) return [] + + const abort = new AbortController() + onCancel(() => abort.abort()) + + const results: LogFileMessage[] = [] + for (const [, index] of map) { + if (!index || index.length === 0) continue + const entry = findClosestEntry(index, timestamp) + if (!entry) continue + try { + results.push(await queryLogfileMessage(url, entry.offset, abort.signal)) + } catch (e) { + if (!abort.signal.aborted) { + console.error('Failed to fetch message for source:', e) + } + } + } + return results + }, []) + + return { messages } +} diff --git a/frontend/src/composables/vision.ts b/frontend/src/composables/vision.ts index 840043e..38de5eb 100644 --- a/frontend/src/composables/vision.ts +++ b/frontend/src/composables/vision.ts @@ -16,7 +16,14 @@ import { import { useWebSocket } from '@vueuse/core' import { determineWebSocketAddress } from '@/helpers/websocket.ts' -const defaultField: SSL_GeometryFieldSize = { +export const applyFieldDefaults = (field: SSL_GeometryFieldSize): SSL_GeometryFieldSize => { + if (!field.boundaryWidthGoalLine) { + field.boundaryWidthGoalLine = field.boundaryWidth + } + return field +} + +export const defaultField: SSL_GeometryFieldSize = { $typeName: 'SSL_GeometryFieldSize', fieldLength: 12000, fieldWidth: 9000, @@ -59,7 +66,7 @@ export const useVisionGeometry = () => { const field = computed(() => { if (message.value && message.value.field) { - return message.value.field + return applyFieldDefaults(message.value.field) } return defaultField }) diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 90e6400..887c12a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,5 +2,6 @@ import { createApp } from 'vue' import App from './App.vue' import './assets/main.css' +import { router } from '@/router.ts' -createApp(App).mount('#app') +createApp(App).use(router).mount('#app') diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..ab7d5ac --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,14 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +import FieldVisualizerVision from './views/FieldVisualizerVision.vue' +import VisualizerPlayer from './views/VisualizerPlayer.vue' + +const routes = [ + { path: '/', component: FieldVisualizerVision }, + { path: '/player', component: VisualizerPlayer }, +] + +export const router = createRouter({ + history: createWebHashHistory(), + routes, +}) diff --git a/frontend/src/components/FieldVisualizerVision.vue b/frontend/src/views/FieldVisualizerVision.vue similarity index 100% rename from frontend/src/components/FieldVisualizerVision.vue rename to frontend/src/views/FieldVisualizerVision.vue diff --git a/frontend/src/views/VisualizerPlayer.vue b/frontend/src/views/VisualizerPlayer.vue new file mode 100644 index 0000000..46f6438 --- /dev/null +++ b/frontend/src/views/VisualizerPlayer.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f50bb46..2a09808 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,9 @@ export default defineConfig({ '/api': { target: 'ws://localhost:8082', ws: true, + }, + '/logs': { + target: 'ws://localhost:8082', } } },