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 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.13] - 2026-01-17
5
+ ## [0.3.14] - 2026-01-17
6
6
 
7
7
  ### Release Summary
8
8
 
9
- #### PR #56: docs: Add multipartUrl guidance for small camera grids
9
+ #### PR #58: feat: Add lightbox and bounding box support to vue-events example
10
10
  ## Summary
11
11
 
12
- - Add documentation clarifying that `multipartUrl` is suitable for camera grids with fewer than 20 cameras
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
- ## Commits
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
- - `8bbc558` docs: Add multipartUrl guidance for small camera grids
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 Results
24
+ ## Test Plan
21
25
 
22
- - **Linting**: Passed (1 warning)
23
- - **Unit Tests**: 188 passed
24
- - **Build**: Successful
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
- `0.3.11`
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: Add Events API support with vue-events example app
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
- - chore: Add Events to AI-CONTEXT generator, screenshot, and PR check step
41
- - docs: Add error checking to listEventTypes example
42
- - docs: Add README for vue-events example
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.11...v0.3.13)
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 07:36:25 CST*
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
@@ -1,6 +1,6 @@
1
1
  # EEN API Toolkit - AI Reference
2
2
 
3
- > **Version:** 0.3.13
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)) return
171
-
172
- const result = await getRecordedImage({
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
- if (!result.error && result.data) {
179
- eventImages.value.set(event.id, result.data.imageData)
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
- <h2>Events: {{ camera.name }}</h2>
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')">&times;</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 class="event-thumbnail">
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
+ >&times;</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">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "een-api-toolkit",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "EEN Video platform API v3.0 library for Vue 3",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",