een-api-toolkit 0.3.13 → 0.3.15

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +5 -35
  2. package/README.md +2 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.ts +801 -0
  6. package/dist/index.js +486 -252
  7. package/dist/index.js.map +1 -1
  8. package/docs/AI-CONTEXT.md +195 -2
  9. package/examples/vue-alerts-metrics/README.md +136 -0
  10. package/examples/vue-alerts-metrics/e2e/app.spec.ts +74 -0
  11. package/examples/vue-alerts-metrics/e2e/auth.spec.ts +554 -0
  12. package/examples/vue-alerts-metrics/index.html +13 -0
  13. package/examples/vue-alerts-metrics/package-lock.json +1749 -0
  14. package/examples/vue-alerts-metrics/package.json +30 -0
  15. package/examples/vue-alerts-metrics/playwright.config.ts +46 -0
  16. package/examples/vue-alerts-metrics/src/App.vue +108 -0
  17. package/examples/vue-alerts-metrics/src/components/AlertsList.vue +330 -0
  18. package/examples/vue-alerts-metrics/src/components/CameraSelector.vue +96 -0
  19. package/examples/vue-alerts-metrics/src/components/MetricsChart.vue +322 -0
  20. package/examples/vue-alerts-metrics/src/components/NotificationsList.vue +263 -0
  21. package/examples/vue-alerts-metrics/src/components/TimeRangeSelector.vue +74 -0
  22. package/examples/vue-alerts-metrics/src/main.ts +23 -0
  23. package/examples/vue-alerts-metrics/src/router/index.ts +61 -0
  24. package/examples/vue-alerts-metrics/src/views/Callback.vue +76 -0
  25. package/examples/vue-alerts-metrics/src/views/Dashboard.vue +152 -0
  26. package/examples/vue-alerts-metrics/src/views/Home.vue +167 -0
  27. package/examples/vue-alerts-metrics/src/views/Login.vue +33 -0
  28. package/examples/vue-alerts-metrics/src/views/Logout.vue +66 -0
  29. package/examples/vue-alerts-metrics/src/vite-env.d.ts +12 -0
  30. package/examples/vue-alerts-metrics/tsconfig.json +21 -0
  31. package/examples/vue-alerts-metrics/tsconfig.node.json +10 -0
  32. package/examples/vue-alerts-metrics/vite.config.ts +12 -0
  33. package/examples/vue-events/README.md +68 -0
  34. package/examples/vue-events/e2e/auth.spec.ts +105 -0
  35. package/examples/vue-events/src/components/EventsModal.vue +452 -14
  36. package/examples/vue-events/src/views/Home.vue +1 -0
  37. package/package.json +1 -1
@@ -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.15",
4
4
  "description": "EEN Video platform API v3.0 library for Vue 3",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",