een-api-toolkit 0.3.13 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -23
- package/README.md +1 -0
- package/docs/AI-CONTEXT.md +14 -1
- package/examples/vue-events/README.md +68 -0
- package/examples/vue-events/e2e/auth.spec.ts +105 -0
- package/examples/vue-events/src/components/EventsModal.vue +452 -14
- package/examples/vue-events/src/views/Home.vue +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,53 +2,59 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
## [0.3.
|
|
5
|
+
## [0.3.14] - 2026-01-17
|
|
6
6
|
|
|
7
7
|
### Release Summary
|
|
8
8
|
|
|
9
|
-
#### PR #
|
|
9
|
+
#### PR #58: feat: Add lightbox and bounding box support to vue-events example
|
|
10
10
|
## Summary
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
- Note that multipartUrl provides automatic image updates with low frequency polling recommended (every 3 seconds)
|
|
14
|
-
- Updated "Choosing the Right Preview Method" table and quick reference in AI-CONTEXT.md
|
|
12
|
+
This PR adds several UI enhancements to the vue-events example application:
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
- **Image lightbox**: Click on event thumbnails to view enlarged images in a full-screen lightbox overlay
|
|
15
|
+
- **Bounding box support**: Display object detection bounding boxes with labels (Person, Vehicle, etc.) overlaid on lightbox images
|
|
16
|
+
- **Detection count**: Show the number of detections for each event in the modal list
|
|
17
|
+
- **Camera ID display**: Show Camera ID in modal header and Camera Name/ID in lightbox info
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
### Commits
|
|
20
|
+
- `c3c486b` feat: Show Camera ID in modal and lightbox
|
|
21
|
+
- `c6cfda3` feat: Add bounding box support to vue-events example
|
|
22
|
+
- `c58450a` feat: Add image lightbox to vue-events example
|
|
19
23
|
|
|
20
|
-
## Test
|
|
24
|
+
## Test Plan
|
|
21
25
|
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
26
|
+
- [x] Lint passes (0 errors)
|
|
27
|
+
- [x] Unit tests pass (236 tests)
|
|
28
|
+
- [x] Build succeeds
|
|
29
|
+
- [x] E2E tests for vue-events pass (16 tests)
|
|
25
30
|
|
|
26
31
|
## Version
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
v0.3.13
|
|
34
|
+
|
|
35
|
+
🤖 Generated with [Claude Code](https://claude.ai/code)
|
|
29
36
|
|
|
30
37
|
|
|
31
38
|
### Detailed Changes
|
|
32
39
|
|
|
33
40
|
#### Features
|
|
34
|
-
- feat:
|
|
41
|
+
- feat: Show Camera ID in modal and lightbox
|
|
42
|
+
- feat: Add bounding box support to vue-events example
|
|
43
|
+
- feat: Add image lightbox to vue-events example
|
|
35
44
|
|
|
36
45
|
#### Bug Fixes
|
|
46
|
+
- fix: Correct vue-media key files in AI-CONTEXT
|
|
47
|
+
- fix: Correct relative paths in AI-CONTEXT example links
|
|
37
48
|
- fix: Address code review recommendations
|
|
38
49
|
|
|
39
50
|
#### Other Changes
|
|
40
|
-
-
|
|
41
|
-
- docs: Add
|
|
42
|
-
- docs: Add
|
|
43
|
-
- docs: Add Events API documentation
|
|
44
|
-
- chore: Add security review step to PR-and-check skill
|
|
45
|
-
- chore: Add E2E tests for example apps to PR-and-check skill
|
|
46
|
-
- docs: Fix multipartUrl description per Gemini review
|
|
47
|
-
- docs: Add multipartUrl guidance for small camera grids
|
|
51
|
+
- docs: Add example apps links to AI-CONTEXT Quick Reference
|
|
52
|
+
- docs: Add vue-events example to main README
|
|
53
|
+
- docs: Add bounding box feature to vue-events README
|
|
48
54
|
|
|
49
55
|
### Links
|
|
50
56
|
- [npm package](https://www.npmjs.com/package/een-api-toolkit)
|
|
51
|
-
- [Full Changelog](https://github.com/klaushofrichter/een-api-toolkit/compare/v0.3.
|
|
57
|
+
- [Full Changelog](https://github.com/klaushofrichter/een-api-toolkit/compare/v0.3.13...v0.3.14)
|
|
52
58
|
|
|
53
59
|
---
|
|
54
|
-
*Released: 2026-01-17
|
|
60
|
+
*Released: 2026-01-17 11:24:04 CST*
|
package/README.md
CHANGED
|
@@ -187,6 +187,7 @@ The `examples/` directory contains complete Vue 3 applications demonstrating too
|
|
|
187
187
|
| **[vue-bridges](./examples/vue-bridges/)** | Bridge listing with device info | `getBridges()` |
|
|
188
188
|
| **[vue-media](./examples/vue-media/)** | Live and recorded image viewing | `getCameras()`, `getLiveImage()`, `getRecordedImage()` |
|
|
189
189
|
| **[vue-feeds](./examples/vue-feeds/)** | Live video streaming with preview and main streams | `getCameras()`, `listFeeds()`, `initMediaSession()` |
|
|
190
|
+
| **[vue-events](./examples/vue-events/)** | Event listing with bounding box overlays | `listEvents()`, `listEventTypes()`, `listEventFieldValues()`, `getRecordedImage()` |
|
|
190
191
|
|
|
191
192
|
Each example includes:
|
|
192
193
|
- Complete OAuth authentication flow
|
package/docs/AI-CONTEXT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# EEN API Toolkit - AI Reference
|
|
2
2
|
|
|
3
|
-
> **Version:** 0.3.
|
|
3
|
+
> **Version:** 0.3.14
|
|
4
4
|
>
|
|
5
5
|
> This file is optimized for AI assistants. It contains all API signatures,
|
|
6
6
|
> types, and usage patterns in a single, parseable document.
|
|
@@ -200,6 +200,19 @@ player.start({ videoElement, cameraId, baseUrl, jwt })
|
|
|
200
200
|
|
|
201
201
|
## Quick Reference
|
|
202
202
|
|
|
203
|
+
### Example Applications
|
|
204
|
+
|
|
205
|
+
Complete Vue 3 applications demonstrating toolkit features:
|
|
206
|
+
|
|
207
|
+
| Example | Description | Key Files |
|
|
208
|
+
|---------|-------------|-----------|
|
|
209
|
+
| [vue-users](../examples/vue-users/) | User management with pagination | `src/views/Users.vue` |
|
|
210
|
+
| [vue-cameras](../examples/vue-cameras/) | Camera listing with status filters | `src/views/Cameras.vue` |
|
|
211
|
+
| [vue-bridges](../examples/vue-bridges/) | Bridge listing with device info | `src/views/Bridges.vue` |
|
|
212
|
+
| [vue-media](../examples/vue-media/) | Live and recorded image viewing | `src/views/LiveCamera.vue`, `RecordedImage.vue`, `HLS.vue` |
|
|
213
|
+
| [vue-feeds](../examples/vue-feeds/) | Live video streaming (preview and main) | `src/views/Feeds.vue` |
|
|
214
|
+
| [vue-events](../examples/vue-events/) | Events with bounding box overlays | `src/components/EventsModal.vue` |
|
|
215
|
+
|
|
203
216
|
### Configuration
|
|
204
217
|
|
|
205
218
|
| Function | Purpose |
|
|
@@ -16,6 +16,9 @@ A Vue 3 example demonstrating how to query and display events from EEN cameras u
|
|
|
16
16
|
- Event type filtering with checkboxes
|
|
17
17
|
- Pagination with "Load More" button
|
|
18
18
|
- Camera grid with click-to-open events modal
|
|
19
|
+
- Image lightbox with enlarged view on thumbnail click
|
|
20
|
+
- Bounding box overlay for object detection events (Person, Vehicle, etc.)
|
|
21
|
+
- Detection count display per event
|
|
19
22
|
|
|
20
23
|
## APIs Used
|
|
21
24
|
|
|
@@ -173,3 +176,68 @@ function getEventTypeName(type: string): string {
|
|
|
173
176
|
return eventTypeNames.get(type) || formatEventType(type)
|
|
174
177
|
}
|
|
175
178
|
```
|
|
179
|
+
|
|
180
|
+
### Extracting Bounding Boxes from Events
|
|
181
|
+
|
|
182
|
+
Events with object detection data include bounding box coordinates for detected objects. The coordinates are normalized (0-1) relative to image dimensions.
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
import { listEvents, type Event } from 'een-api-toolkit'
|
|
186
|
+
|
|
187
|
+
interface BoundingBox {
|
|
188
|
+
x: number
|
|
189
|
+
y: number
|
|
190
|
+
width: number
|
|
191
|
+
height: number
|
|
192
|
+
label?: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Request object detection and classification data in listEvents
|
|
196
|
+
const result = await listEvents({
|
|
197
|
+
actor: `camera:${cameraId}`,
|
|
198
|
+
type__in: ['een.personDetectionEvent.v1', 'een.vehicleDetectionEvent.v1'],
|
|
199
|
+
startTimestamp__gte: startTime,
|
|
200
|
+
include: [
|
|
201
|
+
'data.een.objectDetection.v1',
|
|
202
|
+
'data.een.objectClassification.v1'
|
|
203
|
+
]
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Extract bounding boxes from event data
|
|
207
|
+
function getBoundingBoxes(event: Event): BoundingBox[] {
|
|
208
|
+
const boxes: BoundingBox[] = []
|
|
209
|
+
|
|
210
|
+
// Build label map from classification data
|
|
211
|
+
const labelMap = new Map<string, string>()
|
|
212
|
+
for (const item of event.data) {
|
|
213
|
+
if (item.type === 'een.objectClassification.v1') {
|
|
214
|
+
const objectId = item.objectId as string
|
|
215
|
+
const label = item.label as string
|
|
216
|
+
if (objectId && label) {
|
|
217
|
+
labelMap.set(objectId, label)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Extract bounding boxes from detection data
|
|
223
|
+
for (const item of event.data) {
|
|
224
|
+
if (item.type === 'een.objectDetection.v1') {
|
|
225
|
+
const bbox = item.boundingBox as number[]
|
|
226
|
+
const objectId = item.objectId as string
|
|
227
|
+
|
|
228
|
+
if (Array.isArray(bbox) && bbox.length === 4) {
|
|
229
|
+
const [x1, y1, x2, y2] = bbox
|
|
230
|
+
boxes.push({
|
|
231
|
+
x: x1,
|
|
232
|
+
y: y1,
|
|
233
|
+
width: x2 - x1,
|
|
234
|
+
height: y2 - y1,
|
|
235
|
+
label: labelMap.get(objectId) || 'Object'
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return boxes
|
|
242
|
+
}
|
|
243
|
+
```
|
|
@@ -329,4 +329,109 @@ test.describe('Vue Events Example - Auth', () => {
|
|
|
329
329
|
await page.locator('.close-button').click()
|
|
330
330
|
await expect(page.locator('.modal')).not.toBeVisible()
|
|
331
331
|
})
|
|
332
|
+
|
|
333
|
+
test('can click thumbnail to open enlarged image lightbox', async ({ page }) => {
|
|
334
|
+
skipIfNoProxy()
|
|
335
|
+
skipIfNoCredentials()
|
|
336
|
+
|
|
337
|
+
await performLogin(page, TEST_USER!, TEST_PASSWORD!)
|
|
338
|
+
|
|
339
|
+
// Wait for cameras to load
|
|
340
|
+
await expect(page.locator('.camera-grid, .no-cameras')).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
341
|
+
|
|
342
|
+
// Find an online camera (has 'status-online' class)
|
|
343
|
+
const onlineCameras = page.locator('.camera-card:has(.status-online)')
|
|
344
|
+
const onlineCount = await onlineCameras.count()
|
|
345
|
+
|
|
346
|
+
if (onlineCount === 0) {
|
|
347
|
+
// Fall back to any camera if no online cameras
|
|
348
|
+
const allCameras = page.locator('.camera-card')
|
|
349
|
+
const anyCount = await allCameras.count()
|
|
350
|
+
if (anyCount === 0) {
|
|
351
|
+
console.log('No cameras available to test lightbox')
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
await allCameras.first().click()
|
|
355
|
+
} else {
|
|
356
|
+
await onlineCameras.first().click()
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Wait for modal to appear
|
|
360
|
+
await expect(page.locator('.modal')).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
361
|
+
|
|
362
|
+
// Select 24h time range to ensure we have events
|
|
363
|
+
const timeRangeSelect = page.locator('[data-testid="time-range-select"]')
|
|
364
|
+
await expect(timeRangeSelect).toBeVisible()
|
|
365
|
+
await timeRangeSelect.selectOption('24h')
|
|
366
|
+
|
|
367
|
+
// Wait for events to load
|
|
368
|
+
await page.waitForTimeout(3000)
|
|
369
|
+
|
|
370
|
+
// Check if we have events with images
|
|
371
|
+
const eventsList = page.locator('[data-testid="events-list"]')
|
|
372
|
+
const eventsVisible = await eventsList.isVisible()
|
|
373
|
+
|
|
374
|
+
if (!eventsVisible) {
|
|
375
|
+
console.log('No events found, skipping lightbox test')
|
|
376
|
+
await page.locator('.close-button').click()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Wait for images to load
|
|
381
|
+
await page.waitForTimeout(3000)
|
|
382
|
+
|
|
383
|
+
// Find clickable thumbnails (those with loaded images)
|
|
384
|
+
const clickableThumbnails = page.locator('.event-thumbnail.clickable')
|
|
385
|
+
const thumbnailCount = await clickableThumbnails.count()
|
|
386
|
+
|
|
387
|
+
if (thumbnailCount === 0) {
|
|
388
|
+
console.log('No thumbnails with images loaded, skipping lightbox test')
|
|
389
|
+
await page.locator('.close-button').click()
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
console.log(`Found ${thumbnailCount} clickable thumbnails`)
|
|
394
|
+
|
|
395
|
+
// Click on the first thumbnail to open lightbox
|
|
396
|
+
await clickableThumbnails.first().click()
|
|
397
|
+
|
|
398
|
+
// Verify lightbox overlay appears
|
|
399
|
+
await expect(page.locator('[data-testid="lightbox-overlay"]')).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
400
|
+
|
|
401
|
+
// Verify lightbox image is displayed
|
|
402
|
+
await expect(page.locator('.lightbox-image')).toBeVisible()
|
|
403
|
+
|
|
404
|
+
// Verify lightbox shows event info
|
|
405
|
+
await expect(page.locator('.lightbox-event-type')).toBeVisible()
|
|
406
|
+
await expect(page.locator('.lightbox-event-time')).toBeVisible()
|
|
407
|
+
|
|
408
|
+
// Verify close button is visible
|
|
409
|
+
await expect(page.locator('[data-testid="lightbox-close"]')).toBeVisible()
|
|
410
|
+
|
|
411
|
+
// Close lightbox by clicking the close button
|
|
412
|
+
await page.locator('[data-testid="lightbox-close"]').click()
|
|
413
|
+
|
|
414
|
+
// Verify lightbox is closed
|
|
415
|
+
await expect(page.locator('[data-testid="lightbox-overlay"]')).not.toBeVisible()
|
|
416
|
+
|
|
417
|
+
// Modal should still be open
|
|
418
|
+
await expect(page.locator('.modal')).toBeVisible()
|
|
419
|
+
|
|
420
|
+
// Click another thumbnail to test clicking outside to close
|
|
421
|
+
if (thumbnailCount > 0) {
|
|
422
|
+
await clickableThumbnails.first().click()
|
|
423
|
+
await expect(page.locator('[data-testid="lightbox-overlay"]')).toBeVisible({ timeout: TIMEOUTS.ELEMENT_VISIBLE })
|
|
424
|
+
|
|
425
|
+
// Click outside the image (on the overlay) to close
|
|
426
|
+
// Get the overlay and click at a corner away from the content
|
|
427
|
+
await page.locator('[data-testid="lightbox-overlay"]').click({ position: { x: 10, y: 10 } })
|
|
428
|
+
|
|
429
|
+
// Verify lightbox is closed
|
|
430
|
+
await expect(page.locator('[data-testid="lightbox-overlay"]')).not.toBeVisible()
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Close the events modal
|
|
434
|
+
await page.locator('.close-button').click()
|
|
435
|
+
await expect(page.locator('.modal')).not.toBeVisible()
|
|
436
|
+
})
|
|
332
437
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, watch, computed } from 'vue'
|
|
2
|
+
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
|
3
3
|
import {
|
|
4
4
|
listEvents,
|
|
5
5
|
listEventFieldValues,
|
|
@@ -11,6 +11,44 @@ import {
|
|
|
11
11
|
type EenError
|
|
12
12
|
} from 'een-api-toolkit'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Bounding box from object detection data.
|
|
16
|
+
* Coordinates are normalized (0-1) relative to image dimensions.
|
|
17
|
+
*/
|
|
18
|
+
interface BoundingBox {
|
|
19
|
+
x: number
|
|
20
|
+
y: number
|
|
21
|
+
width: number
|
|
22
|
+
height: number
|
|
23
|
+
label?: string
|
|
24
|
+
confidence?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Constant for converting normalized coordinates (0-1) to SVG viewBox percentage */
|
|
28
|
+
const NORMALIZED_TO_PERCENT = 100
|
|
29
|
+
|
|
30
|
+
/** Maximum number of images to cache to prevent memory issues */
|
|
31
|
+
const MAX_IMAGE_CACHE_SIZE = 50
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Type guard to check if a value is a non-empty string.
|
|
35
|
+
*/
|
|
36
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
37
|
+
return typeof value === 'string' && value.length > 0
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Type guard to validate a bounding box array.
|
|
42
|
+
* Must be an array of exactly 4 numbers.
|
|
43
|
+
*/
|
|
44
|
+
function isValidBoundingBoxArray(value: unknown): value is [number, number, number, number] {
|
|
45
|
+
return (
|
|
46
|
+
Array.isArray(value) &&
|
|
47
|
+
value.length === 4 &&
|
|
48
|
+
value.every(v => typeof v === 'number' && !Number.isNaN(v))
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
14
52
|
const props = defineProps<{
|
|
15
53
|
camera: Camera
|
|
16
54
|
isOpen: boolean
|
|
@@ -40,10 +78,25 @@ const selectedEventTypes = ref<string[]>([])
|
|
|
40
78
|
const timeRange = ref<TimeRange>('1h')
|
|
41
79
|
const eventTypeNames = ref<Map<string, string>>(new Map())
|
|
42
80
|
const eventImages = ref<Map<string, string>>(new Map())
|
|
81
|
+
const imageLoadingIds = ref<Set<string>>(new Set()) // Track which images are currently loading
|
|
82
|
+
const boundingBoxCache = ref<Map<string, BoundingBox[]>>(new Map()) // Cache bounding boxes per event
|
|
83
|
+
const enlargedEventId = ref<string | null>(null)
|
|
43
84
|
|
|
44
85
|
// Computed
|
|
45
86
|
const hasNextPage = computed(() => !!nextPageToken.value)
|
|
46
87
|
const hasNoEvents = computed(() => !loading.value && events.value.length === 0 && !error.value)
|
|
88
|
+
const enlargedEvent = computed(() => {
|
|
89
|
+
if (!enlargedEventId.value) return null
|
|
90
|
+
return events.value.find(e => e.id === enlargedEventId.value) || null
|
|
91
|
+
})
|
|
92
|
+
const enlargedImage = computed(() => {
|
|
93
|
+
if (!enlargedEventId.value) return null
|
|
94
|
+
return eventImages.value.get(enlargedEventId.value) || null
|
|
95
|
+
})
|
|
96
|
+
const enlargedBoundingBoxes = computed(() => {
|
|
97
|
+
if (!enlargedEvent.value) return []
|
|
98
|
+
return getBoundingBoxes(enlargedEvent.value)
|
|
99
|
+
})
|
|
47
100
|
|
|
48
101
|
// Get start timestamp based on time range
|
|
49
102
|
function getStartTimestamp(range: TimeRange): string {
|
|
@@ -56,6 +109,82 @@ function getStartTimestamp(range: TimeRange): string {
|
|
|
56
109
|
return new Date(now - hoursMap[range] * 60 * 60 * 1000).toISOString()
|
|
57
110
|
}
|
|
58
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Get fallback label for detected object based on event type.
|
|
114
|
+
*/
|
|
115
|
+
function getFallbackLabel(eventType: string): string {
|
|
116
|
+
if (eventType.includes('person')) return 'Person'
|
|
117
|
+
if (eventType.includes('vehicle')) return 'Vehicle'
|
|
118
|
+
if (eventType.includes('licensePlate') || eventType.includes('lpr')) return 'License Plate'
|
|
119
|
+
return 'Object'
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Extract bounding boxes from event data.
|
|
124
|
+
* Looks for object detection data schemas and extracts bounding box info.
|
|
125
|
+
* The EEN API returns boundingBox as [x1, y1, x2, y2] normalized coordinates (0-1).
|
|
126
|
+
* Labels are obtained from een.objectClassification.v1 data when available.
|
|
127
|
+
* Results are cached per event ID to avoid redundant calculations.
|
|
128
|
+
*/
|
|
129
|
+
function getBoundingBoxes(event: Event): BoundingBox[] {
|
|
130
|
+
// Check cache first
|
|
131
|
+
const cached = boundingBoxCache.value.get(event.id)
|
|
132
|
+
if (cached) {
|
|
133
|
+
return cached
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const boxes: BoundingBox[] = []
|
|
137
|
+
const fallbackLabel = getFallbackLabel(event.type)
|
|
138
|
+
|
|
139
|
+
// Build a map of objectId -> classification label from objectClassification data
|
|
140
|
+
const classificationMap = new Map<string, string>()
|
|
141
|
+
for (const dataItem of event.data) {
|
|
142
|
+
if (dataItem.type === 'een.objectClassification.v1') {
|
|
143
|
+
// Use type guards for proper runtime validation
|
|
144
|
+
const objectId = dataItem.objectId
|
|
145
|
+
const label = dataItem.label
|
|
146
|
+
if (isNonEmptyString(objectId) && isNonEmptyString(label)) {
|
|
147
|
+
// Capitalize first letter of label
|
|
148
|
+
const formattedLabel = label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
|
|
149
|
+
classificationMap.set(objectId, formattedLabel)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Extract bounding boxes from objectDetection data
|
|
155
|
+
for (const dataItem of event.data) {
|
|
156
|
+
if (dataItem.type === 'een.objectDetection.v1') {
|
|
157
|
+
const boundingBox = dataItem.boundingBox
|
|
158
|
+
const objectId = dataItem.objectId
|
|
159
|
+
|
|
160
|
+
// Use type guard for proper runtime validation of bounding box array
|
|
161
|
+
if (isValidBoundingBoxArray(boundingBox)) {
|
|
162
|
+
const [x1, y1, x2, y2] = boundingBox
|
|
163
|
+
// Use classification label if available, otherwise use fallback
|
|
164
|
+
const label = (isNonEmptyString(objectId) && classificationMap.get(objectId)) || fallbackLabel
|
|
165
|
+
boxes.push({
|
|
166
|
+
x: x1,
|
|
167
|
+
y: y1,
|
|
168
|
+
width: x2 - x1,
|
|
169
|
+
height: y2 - y1,
|
|
170
|
+
label
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Cache the result
|
|
177
|
+
boundingBoxCache.value.set(event.id, boxes)
|
|
178
|
+
return boxes
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get the count of bounding boxes for an event.
|
|
183
|
+
*/
|
|
184
|
+
function getBoundingBoxCount(event: Event): number {
|
|
185
|
+
return getBoundingBoxes(event).length
|
|
186
|
+
}
|
|
187
|
+
|
|
59
188
|
// Fetch available event types for this camera
|
|
60
189
|
async function fetchAvailableEventTypes() {
|
|
61
190
|
loadingFieldValues.value = true
|
|
@@ -128,7 +257,7 @@ async function fetchEvents(append = false) {
|
|
|
128
257
|
pageSize: 20,
|
|
129
258
|
pageToken: append ? nextPageToken.value : undefined,
|
|
130
259
|
sort: '-startTimestamp',
|
|
131
|
-
include: ['data.een.fullFrameImageUrl.v1', 'data.een.croppedFrameImageUrl.v1']
|
|
260
|
+
include: ['data.een.fullFrameImageUrl.v1', 'data.een.croppedFrameImageUrl.v1', 'data.een.objectDetection.v1', 'data.een.objectClassification.v1']
|
|
132
261
|
})
|
|
133
262
|
|
|
134
263
|
if (result.error) {
|
|
@@ -160,23 +289,60 @@ async function loadMore() {
|
|
|
160
289
|
await fetchEvents(true)
|
|
161
290
|
}
|
|
162
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Evict oldest images from cache if it exceeds the maximum size.
|
|
294
|
+
* Removes images that are not currently being displayed.
|
|
295
|
+
*/
|
|
296
|
+
function evictOldestImages() {
|
|
297
|
+
if (eventImages.value.size <= MAX_IMAGE_CACHE_SIZE) return
|
|
298
|
+
|
|
299
|
+
// Get IDs of images to keep (currently visible events)
|
|
300
|
+
const visibleEventIds = new Set(events.value.map(e => e.id))
|
|
301
|
+
|
|
302
|
+
// Find images to evict (not currently visible)
|
|
303
|
+
const idsToEvict: string[] = []
|
|
304
|
+
for (const id of eventImages.value.keys()) {
|
|
305
|
+
if (!visibleEventIds.has(id)) {
|
|
306
|
+
idsToEvict.push(id)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Evict oldest first (Map maintains insertion order)
|
|
311
|
+
const numToEvict = eventImages.value.size - MAX_IMAGE_CACHE_SIZE
|
|
312
|
+
for (let i = 0; i < Math.min(numToEvict, idsToEvict.length); i++) {
|
|
313
|
+
eventImages.value.delete(idsToEvict[i])
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
163
317
|
// Load images for events using getRecordedImage API
|
|
164
318
|
async function loadEventImages(eventsToLoad: Event[]) {
|
|
165
319
|
// Load images in parallel for all camera events
|
|
166
320
|
const loadPromises = eventsToLoad
|
|
167
321
|
.filter(event => event.actorType === 'camera')
|
|
168
322
|
.map(async (event) => {
|
|
169
|
-
// Skip if already loaded
|
|
170
|
-
if (eventImages.value.has(event.id))
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
deviceId: event.actorId,
|
|
174
|
-
type: 'preview',
|
|
175
|
-
timestamp__gte: event.startTimestamp
|
|
176
|
-
})
|
|
323
|
+
// Skip if already loaded or currently loading (prevents race condition)
|
|
324
|
+
if (eventImages.value.has(event.id) || imageLoadingIds.value.has(event.id)) {
|
|
325
|
+
return
|
|
326
|
+
}
|
|
177
327
|
|
|
178
|
-
|
|
179
|
-
|
|
328
|
+
// Mark as loading to prevent duplicate requests
|
|
329
|
+
imageLoadingIds.value.add(event.id)
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
const result = await getRecordedImage({
|
|
333
|
+
deviceId: event.actorId,
|
|
334
|
+
type: 'preview',
|
|
335
|
+
timestamp__gte: event.startTimestamp
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
if (!result.error && result.data) {
|
|
339
|
+
eventImages.value.set(event.id, result.data.imageData)
|
|
340
|
+
// Evict old images if cache is too large
|
|
341
|
+
evictOldestImages()
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
// Remove from loading set regardless of success/failure
|
|
345
|
+
imageLoadingIds.value.delete(event.id)
|
|
180
346
|
}
|
|
181
347
|
})
|
|
182
348
|
|
|
@@ -213,6 +379,32 @@ function toggleAllEventTypes() {
|
|
|
213
379
|
}
|
|
214
380
|
}
|
|
215
381
|
|
|
382
|
+
// Open enlarged image view
|
|
383
|
+
function openEnlargedImage(eventId: string) {
|
|
384
|
+
enlargedEventId.value = eventId
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Close enlarged image view
|
|
388
|
+
function closeEnlargedImage() {
|
|
389
|
+
enlargedEventId.value = null
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Handle keyboard events for accessibility
|
|
393
|
+
function handleKeydown(event: KeyboardEvent) {
|
|
394
|
+
if (event.key === 'Escape' && enlargedEventId.value) {
|
|
395
|
+
closeEnlargedImage()
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Set up keyboard event listener for ESC key
|
|
400
|
+
onMounted(() => {
|
|
401
|
+
window.addEventListener('keydown', handleKeydown)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
onUnmounted(() => {
|
|
405
|
+
window.removeEventListener('keydown', handleKeydown)
|
|
406
|
+
})
|
|
407
|
+
|
|
216
408
|
// Watch for modal open/close
|
|
217
409
|
watch(() => props.isOpen, async (isOpen) => {
|
|
218
410
|
if (isOpen) {
|
|
@@ -220,6 +412,8 @@ watch(() => props.isOpen, async (isOpen) => {
|
|
|
220
412
|
nextPageToken.value = undefined
|
|
221
413
|
error.value = null
|
|
222
414
|
eventImages.value.clear()
|
|
415
|
+
imageLoadingIds.value.clear()
|
|
416
|
+
boundingBoxCache.value.clear()
|
|
223
417
|
|
|
224
418
|
await fetchEventTypeNames()
|
|
225
419
|
await fetchAvailableEventTypes()
|
|
@@ -230,7 +424,10 @@ watch(() => props.isOpen, async (isOpen) => {
|
|
|
230
424
|
} else {
|
|
231
425
|
// Clean up on modal close to free memory (base64 images can be large)
|
|
232
426
|
eventImages.value.clear()
|
|
427
|
+
imageLoadingIds.value.clear()
|
|
428
|
+
boundingBoxCache.value.clear()
|
|
233
429
|
events.value = []
|
|
430
|
+
enlargedEventId.value = null
|
|
234
431
|
}
|
|
235
432
|
}, { immediate: true })
|
|
236
433
|
|
|
@@ -246,7 +443,10 @@ watch([timeRange, selectedEventTypes], () => {
|
|
|
246
443
|
<div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
|
|
247
444
|
<div class="modal">
|
|
248
445
|
<div class="modal-header">
|
|
249
|
-
<
|
|
446
|
+
<div class="header-info">
|
|
447
|
+
<h2>Events: {{ camera.name }}</h2>
|
|
448
|
+
<div class="camera-id">Camera ID: {{ camera.id }}</div>
|
|
449
|
+
</div>
|
|
250
450
|
<button class="close-button" @click="emit('close')">×</button>
|
|
251
451
|
</div>
|
|
252
452
|
|
|
@@ -312,7 +512,11 @@ watch([timeRange, selectedEventTypes], () => {
|
|
|
312
512
|
|
|
313
513
|
<div v-else class="events-list" data-testid="events-list">
|
|
314
514
|
<div v-for="event in events" :key="event.id" class="event-item" data-testid="event-item">
|
|
315
|
-
<div
|
|
515
|
+
<div
|
|
516
|
+
class="event-thumbnail"
|
|
517
|
+
:class="{ clickable: getEventImage(event) }"
|
|
518
|
+
@click="getEventImage(event) && openEnlargedImage(event.id)"
|
|
519
|
+
>
|
|
316
520
|
<img
|
|
317
521
|
v-if="getEventImage(event)"
|
|
318
522
|
:src="getEventImage(event) || ''"
|
|
@@ -325,6 +529,9 @@ watch([timeRange, selectedEventTypes], () => {
|
|
|
325
529
|
<div class="event-info">
|
|
326
530
|
<div class="event-type">{{ getEventTypeName(event.type) }}</div>
|
|
327
531
|
<div class="event-time">{{ formatTimestamp(event.startTimestamp) }}</div>
|
|
532
|
+
<div v-if="getBoundingBoxCount(event) > 0" class="event-detections" data-testid="event-detections">
|
|
533
|
+
{{ getBoundingBoxCount(event) }} detection{{ getBoundingBoxCount(event) !== 1 ? 's' : '' }}
|
|
534
|
+
</div>
|
|
328
535
|
<div class="event-id">ID: {{ event.id }}</div>
|
|
329
536
|
</div>
|
|
330
537
|
</div>
|
|
@@ -336,6 +543,72 @@ watch([timeRange, selectedEventTypes], () => {
|
|
|
336
543
|
</button>
|
|
337
544
|
</div>
|
|
338
545
|
</div>
|
|
546
|
+
|
|
547
|
+
<!-- Enlarged image lightbox -->
|
|
548
|
+
<div
|
|
549
|
+
v-if="enlargedEventId && enlargedImage"
|
|
550
|
+
class="lightbox-overlay"
|
|
551
|
+
@click.self="closeEnlargedImage"
|
|
552
|
+
data-testid="lightbox-overlay"
|
|
553
|
+
>
|
|
554
|
+
<div class="lightbox-content">
|
|
555
|
+
<button
|
|
556
|
+
class="lightbox-close"
|
|
557
|
+
@click="closeEnlargedImage"
|
|
558
|
+
aria-label="Close enlarged image"
|
|
559
|
+
data-testid="lightbox-close"
|
|
560
|
+
>×</button>
|
|
561
|
+
<div class="lightbox-image-container">
|
|
562
|
+
<img :src="enlargedImage" :alt="enlargedEvent?.type || 'Event image'" class="lightbox-image" />
|
|
563
|
+
<!-- Bounding box overlay -->
|
|
564
|
+
<svg
|
|
565
|
+
v-if="enlargedBoundingBoxes.length > 0"
|
|
566
|
+
class="bounding-box-overlay"
|
|
567
|
+
viewBox="0 0 100 100"
|
|
568
|
+
preserveAspectRatio="none"
|
|
569
|
+
data-testid="bounding-box-overlay"
|
|
570
|
+
>
|
|
571
|
+
<rect
|
|
572
|
+
v-for="(box, index) in enlargedBoundingBoxes"
|
|
573
|
+
:key="index"
|
|
574
|
+
:x="box.x * NORMALIZED_TO_PERCENT"
|
|
575
|
+
:y="box.y * NORMALIZED_TO_PERCENT"
|
|
576
|
+
:width="box.width * NORMALIZED_TO_PERCENT"
|
|
577
|
+
:height="box.height * NORMALIZED_TO_PERCENT"
|
|
578
|
+
class="bounding-box"
|
|
579
|
+
data-testid="bounding-box"
|
|
580
|
+
/>
|
|
581
|
+
</svg>
|
|
582
|
+
<!-- Bounding box labels -->
|
|
583
|
+
<div
|
|
584
|
+
v-for="(box, index) in enlargedBoundingBoxes"
|
|
585
|
+
:key="'label-' + index"
|
|
586
|
+
class="bounding-box-label"
|
|
587
|
+
:style="{
|
|
588
|
+
left: (box.x * NORMALIZED_TO_PERCENT) + '%',
|
|
589
|
+
top: (box.y * NORMALIZED_TO_PERCENT) + '%'
|
|
590
|
+
}"
|
|
591
|
+
data-testid="bounding-box-label"
|
|
592
|
+
>
|
|
593
|
+
{{ box.label || 'Object' }}
|
|
594
|
+
<span v-if="box.confidence" class="confidence">
|
|
595
|
+
{{ Math.round(box.confidence * 100) }}%
|
|
596
|
+
</span>
|
|
597
|
+
</div>
|
|
598
|
+
</div>
|
|
599
|
+
<div v-if="enlargedEvent" class="lightbox-info">
|
|
600
|
+
<div class="lightbox-event-line">
|
|
601
|
+
<span class="lightbox-camera-info">{{ camera.name }} ({{ camera.id }})</span>
|
|
602
|
+
<span class="lightbox-separator">|</span>
|
|
603
|
+
<span class="lightbox-event-type">{{ getEventTypeName(enlargedEvent.type) }}</span>
|
|
604
|
+
</div>
|
|
605
|
+
<div class="lightbox-event-time">{{ formatTimestamp(enlargedEvent.startTimestamp) }}</div>
|
|
606
|
+
<div v-if="enlargedBoundingBoxes.length > 0" class="lightbox-detections" data-testid="lightbox-detections">
|
|
607
|
+
{{ enlargedBoundingBoxes.length }} detection{{ enlargedBoundingBoxes.length !== 1 ? 's' : '' }}
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
339
612
|
</div>
|
|
340
613
|
</div>
|
|
341
614
|
</template>
|
|
@@ -378,6 +651,18 @@ watch([timeRange, selectedEventTypes], () => {
|
|
|
378
651
|
font-size: 1.25rem;
|
|
379
652
|
}
|
|
380
653
|
|
|
654
|
+
.header-info {
|
|
655
|
+
display: flex;
|
|
656
|
+
flex-direction: column;
|
|
657
|
+
gap: 2px;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.camera-id {
|
|
661
|
+
font-size: 0.8rem;
|
|
662
|
+
color: #666;
|
|
663
|
+
font-family: monospace;
|
|
664
|
+
}
|
|
665
|
+
|
|
381
666
|
.close-button {
|
|
382
667
|
background: none;
|
|
383
668
|
border: none;
|
|
@@ -567,4 +852,157 @@ watch([timeRange, selectedEventTypes], () => {
|
|
|
567
852
|
.load-more button {
|
|
568
853
|
min-width: 150px;
|
|
569
854
|
}
|
|
855
|
+
|
|
856
|
+
/* Clickable thumbnail */
|
|
857
|
+
.event-thumbnail.clickable {
|
|
858
|
+
cursor: pointer;
|
|
859
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
.event-thumbnail.clickable:hover {
|
|
863
|
+
transform: scale(1.05);
|
|
864
|
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/* Lightbox styles */
|
|
868
|
+
.lightbox-overlay {
|
|
869
|
+
position: fixed;
|
|
870
|
+
top: 0;
|
|
871
|
+
left: 0;
|
|
872
|
+
right: 0;
|
|
873
|
+
bottom: 0;
|
|
874
|
+
background: rgba(0, 0, 0, 0.9);
|
|
875
|
+
display: flex;
|
|
876
|
+
align-items: center;
|
|
877
|
+
justify-content: center;
|
|
878
|
+
z-index: 2000;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
.lightbox-content {
|
|
882
|
+
position: relative;
|
|
883
|
+
max-width: 90vw;
|
|
884
|
+
max-height: 90vh;
|
|
885
|
+
display: flex;
|
|
886
|
+
flex-direction: column;
|
|
887
|
+
align-items: center;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
.lightbox-close {
|
|
891
|
+
position: absolute;
|
|
892
|
+
top: -40px;
|
|
893
|
+
right: -10px;
|
|
894
|
+
background: none;
|
|
895
|
+
border: none;
|
|
896
|
+
color: white;
|
|
897
|
+
font-size: 2rem;
|
|
898
|
+
cursor: pointer;
|
|
899
|
+
padding: 5px 10px;
|
|
900
|
+
line-height: 1;
|
|
901
|
+
z-index: 2001;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
.lightbox-close:hover {
|
|
905
|
+
color: #ccc;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
.lightbox-info {
|
|
909
|
+
margin-top: 15px;
|
|
910
|
+
text-align: center;
|
|
911
|
+
color: white;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.lightbox-event-line {
|
|
915
|
+
display: flex;
|
|
916
|
+
align-items: center;
|
|
917
|
+
justify-content: center;
|
|
918
|
+
gap: 10px;
|
|
919
|
+
margin-bottom: 5px;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
.lightbox-camera-info {
|
|
923
|
+
color: #aaa;
|
|
924
|
+
font-size: 0.95rem;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
.lightbox-separator {
|
|
928
|
+
color: #666;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
.lightbox-event-type {
|
|
932
|
+
font-weight: 600;
|
|
933
|
+
font-size: 1.1rem;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
.lightbox-event-time {
|
|
937
|
+
color: #ccc;
|
|
938
|
+
font-size: 0.9rem;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
.lightbox-detections {
|
|
942
|
+
color: #4CAF50;
|
|
943
|
+
font-size: 0.85rem;
|
|
944
|
+
margin-top: 5px;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/* Detection count in event list */
|
|
948
|
+
.event-detections {
|
|
949
|
+
color: #4CAF50;
|
|
950
|
+
font-size: 0.8rem;
|
|
951
|
+
font-weight: 500;
|
|
952
|
+
margin-bottom: 3px;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/* Lightbox image container for bounding box overlay */
|
|
956
|
+
.lightbox-image-container {
|
|
957
|
+
position: relative;
|
|
958
|
+
display: inline-block;
|
|
959
|
+
max-width: 90vw;
|
|
960
|
+
max-height: 80vh;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
.lightbox-image-container .lightbox-image {
|
|
964
|
+
display: block;
|
|
965
|
+
max-width: 90vw;
|
|
966
|
+
max-height: 80vh;
|
|
967
|
+
object-fit: contain;
|
|
968
|
+
border-radius: 4px;
|
|
969
|
+
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/* SVG overlay for bounding boxes */
|
|
973
|
+
.bounding-box-overlay {
|
|
974
|
+
position: absolute;
|
|
975
|
+
top: 0;
|
|
976
|
+
left: 0;
|
|
977
|
+
width: 100%;
|
|
978
|
+
height: 100%;
|
|
979
|
+
pointer-events: none;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
.bounding-box {
|
|
983
|
+
fill: none;
|
|
984
|
+
stroke: #00FF00;
|
|
985
|
+
stroke-width: 0.5;
|
|
986
|
+
vector-effect: non-scaling-stroke;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/* Bounding box labels */
|
|
990
|
+
.bounding-box-label {
|
|
991
|
+
position: absolute;
|
|
992
|
+
background: rgba(0, 255, 0, 0.85);
|
|
993
|
+
color: #000;
|
|
994
|
+
font-size: 0.7rem;
|
|
995
|
+
font-weight: 600;
|
|
996
|
+
padding: 2px 6px;
|
|
997
|
+
border-radius: 2px;
|
|
998
|
+
white-space: nowrap;
|
|
999
|
+
transform: translateY(-100%);
|
|
1000
|
+
pointer-events: none;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
.bounding-box-label .confidence {
|
|
1004
|
+
font-weight: normal;
|
|
1005
|
+
opacity: 0.8;
|
|
1006
|
+
margin-left: 4px;
|
|
1007
|
+
}
|
|
570
1008
|
</style>
|
|
@@ -77,6 +77,7 @@ onMounted(() => {
|
|
|
77
77
|
<li>Filter events by type using checkboxes</li>
|
|
78
78
|
<li>Filter events by time range (1h, 6h, 24h)</li>
|
|
79
79
|
<li>Display event thumbnails when available</li>
|
|
80
|
+
<li>Click thumbnails to view enlarged images with bounding box overlays</li>
|
|
80
81
|
<li>Pagination with "Load More" button</li>
|
|
81
82
|
</ul>
|
|
82
83
|
<p class="storage-note" data-testid="storage-strategy">
|