@trackany-device/components 1.0.0

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 (289) hide show
  1. package/package.json +185 -0
  2. package/src/assets/logo.png +0 -0
  3. package/src/assets/map/arrows/map-arrow-blue.png +0 -0
  4. package/src/assets/map/arrows/map-arrow-green.png +0 -0
  5. package/src/assets/map/arrows/map-arrow-purple.png +0 -0
  6. package/src/assets/map/arrows/map-arrow-red.png +0 -0
  7. package/src/assets/map/flags/flag-blue.png +0 -0
  8. package/src/assets/map/flags/flag-green.png +0 -0
  9. package/src/assets/map/flags/flag-red.png +0 -0
  10. package/src/assets/map/flags/flag-yellow.png +0 -0
  11. package/src/assets/map/pins/map-pin-blue.png +0 -0
  12. package/src/assets/map/pins/map-pin-green.png +0 -0
  13. package/src/assets/map/pins/map-pin-purple.png +0 -0
  14. package/src/assets/map/pins/map-pin-red.png +0 -0
  15. package/src/components/Card.tsx +9 -0
  16. package/src/components/alert-error.tsx +24 -0
  17. package/src/components/app-content.tsx +22 -0
  18. package/src/components/app-header.tsx +153 -0
  19. package/src/components/app-logo-icon.tsx +13 -0
  20. package/src/components/app-logo.tsx +21 -0
  21. package/src/components/app-shell.tsx +19 -0
  22. package/src/components/app-sidebar-header.tsx +68 -0
  23. package/src/components/app-sidebar.tsx +106 -0
  24. package/src/components/appearance-tabs.tsx +46 -0
  25. package/src/components/breadcrumbs.tsx +50 -0
  26. package/src/components/cms/blurred-image.tsx +111 -0
  27. package/src/components/cms/section-bg.tsx +473 -0
  28. package/src/components/cms/section-button.tsx +127 -0
  29. package/src/components/cms/sections/banner-5050-section.tsx +135 -0
  30. package/src/components/cms/sections/blogs-listing-section.tsx +270 -0
  31. package/src/components/cms/sections/cards-grid-section.tsx +185 -0
  32. package/src/components/cms/sections/contact-form-section.tsx +157 -0
  33. package/src/components/cms/sections/cta-section.tsx +101 -0
  34. package/src/components/cms/sections/featured-blog-slider-section.tsx +256 -0
  35. package/src/components/cms/sections/featured-products-grid-section.tsx +173 -0
  36. package/src/components/cms/sections/featured-solutions-grid-section.tsx +183 -0
  37. package/src/components/cms/sections/hero-section.tsx +180 -0
  38. package/src/components/cms/sections/solutions-with-filter-section.tsx +234 -0
  39. package/src/components/cms/sections/text-section.tsx +77 -0
  40. package/src/components/cutout-image.tsx +228 -0
  41. package/src/components/devices/devices-mini-map.tsx +275 -0
  42. package/src/components/docs/docs-shell.tsx +280 -0
  43. package/src/components/fleet-hero-animated.tsx +383 -0
  44. package/src/components/input-error.tsx +17 -0
  45. package/src/components/keenicons/assets/duotone/Read Me.txt +7 -0
  46. package/src/components/keenicons/assets/duotone/demo-files/demo.css +160 -0
  47. package/src/components/keenicons/assets/duotone/demo-files/demo.js +32 -0
  48. package/src/components/keenicons/assets/duotone/demo.html +12424 -0
  49. package/src/components/keenicons/assets/duotone/fonts/keenicons-duotone.svg +1109 -0
  50. package/src/components/keenicons/assets/duotone/fonts/keenicons-duotone.ttf +0 -0
  51. package/src/components/keenicons/assets/duotone/fonts/keenicons-duotone.woff +0 -0
  52. package/src/components/keenicons/assets/duotone/selection.json +17313 -0
  53. package/src/components/keenicons/assets/duotone/style.css +4931 -0
  54. package/src/components/keenicons/assets/filled/Read Me.txt +7 -0
  55. package/src/components/keenicons/assets/filled/demo-files/demo.css +160 -0
  56. package/src/components/keenicons/assets/filled/demo-files/demo.js +32 -0
  57. package/src/components/keenicons/assets/filled/demo.html +12370 -0
  58. package/src/components/keenicons/assets/filled/fonts/keenicons-filled.svg +1082 -0
  59. package/src/components/keenicons/assets/filled/fonts/keenicons-filled.ttf +0 -0
  60. package/src/components/keenicons/assets/filled/fonts/keenicons-filled.woff +0 -0
  61. package/src/components/keenicons/assets/filled/selection.json +17096 -0
  62. package/src/components/keenicons/assets/filled/style.css +4769 -0
  63. package/src/components/keenicons/assets/outline/Read Me.txt +7 -0
  64. package/src/components/keenicons/assets/outline/demo-files/demo.css +160 -0
  65. package/src/components/keenicons/assets/outline/demo-files/demo.js +32 -0
  66. package/src/components/keenicons/assets/outline/demo.html +11356 -0
  67. package/src/components/keenicons/assets/outline/fonts/keenicons-outline.svg +575 -0
  68. package/src/components/keenicons/assets/outline/fonts/keenicons-outline.ttf +0 -0
  69. package/src/components/keenicons/assets/outline/fonts/keenicons-outline.woff +0 -0
  70. package/src/components/keenicons/assets/outline/selection.json +13054 -0
  71. package/src/components/keenicons/assets/outline/style.css +1721 -0
  72. package/src/components/keenicons/assets/solid/Read Me.txt +7 -0
  73. package/src/components/keenicons/assets/solid/demo-files/demo.css +160 -0
  74. package/src/components/keenicons/assets/solid/demo-files/demo.js +32 -0
  75. package/src/components/keenicons/assets/solid/demo.html +11356 -0
  76. package/src/components/keenicons/assets/solid/fonts/keenicons-solid.svg +575 -0
  77. package/src/components/keenicons/assets/solid/fonts/keenicons-solid.ttf +0 -0
  78. package/src/components/keenicons/assets/solid/fonts/keenicons-solid.woff +0 -0
  79. package/src/components/keenicons/assets/solid/selection.json +13048 -0
  80. package/src/components/keenicons/assets/solid/style.css +1721 -0
  81. package/src/components/keenicons/assets/styles.css +4 -0
  82. package/src/components/keenicons/index.ts +2 -0
  83. package/src/components/keenicons/keenicons.tsx +16 -0
  84. package/src/components/keenicons/types.ts +7 -0
  85. package/src/components/nav-footer.tsx +49 -0
  86. package/src/components/nav-main.tsx +53 -0
  87. package/src/components/nav-user.tsx +59 -0
  88. package/src/components/notification-bell.tsx +190 -0
  89. package/src/components/products/product-card.tsx +159 -0
  90. package/src/components/text-link.tsx +23 -0
  91. package/src/components/ui/accordion-menu.tsx +322 -0
  92. package/src/components/ui/accordion.tsx +133 -0
  93. package/src/components/ui/alert-dialog.tsx +82 -0
  94. package/src/components/ui/alert.tsx +63 -0
  95. package/src/components/ui/avatar-group.tsx +129 -0
  96. package/src/components/ui/avatar.tsx +67 -0
  97. package/src/components/ui/badge.tsx +230 -0
  98. package/src/components/ui/breadcrumb.tsx +88 -0
  99. package/src/components/ui/button.tsx +412 -0
  100. package/src/components/ui/calendar.tsx +56 -0
  101. package/src/components/ui/card.tsx +147 -0
  102. package/src/components/ui/chart.tsx +290 -0
  103. package/src/components/ui/checkbox.tsx +47 -0
  104. package/src/components/ui/code.tsx +45 -0
  105. package/src/components/ui/collapsible.tsx +31 -0
  106. package/src/components/ui/command-palette.tsx +189 -0
  107. package/src/components/ui/command.tsx +138 -0
  108. package/src/components/ui/cookie-banner.tsx +220 -0
  109. package/src/components/ui/copy-button.tsx +60 -0
  110. package/src/components/ui/data-grid-column-filter.tsx +124 -0
  111. package/src/components/ui/data-grid-column-header.tsx +284 -0
  112. package/src/components/ui/data-grid-column-visibility.tsx +38 -0
  113. package/src/components/ui/data-grid-pagination.tsx +206 -0
  114. package/src/components/ui/data-grid-table-dnd-rows.tsx +147 -0
  115. package/src/components/ui/data-grid-table-dnd.tsx +175 -0
  116. package/src/components/ui/data-grid-table.tsx +500 -0
  117. package/src/components/ui/data-grid.tsx +193 -0
  118. package/src/components/ui/data-list.tsx +76 -0
  119. package/src/components/ui/datefield.tsx +91 -0
  120. package/src/components/ui/dialog.tsx +139 -0
  121. package/src/components/ui/divider.tsx +41 -0
  122. package/src/components/ui/drawer.tsx +59 -0
  123. package/src/components/ui/dropdown-menu.tsx +224 -0
  124. package/src/components/ui/empty-state.tsx +54 -0
  125. package/src/components/ui/file-upload.tsx +152 -0
  126. package/src/components/ui/form.tsx +88 -0
  127. package/src/components/ui/icon.tsx +14 -0
  128. package/src/components/ui/input-otp.tsx +71 -0
  129. package/src/components/ui/input.tsx +155 -0
  130. package/src/components/ui/kbd.tsx +26 -0
  131. package/src/components/ui/label.tsx +31 -0
  132. package/src/components/ui/navigation-menu.tsx +168 -0
  133. package/src/components/ui/pagination.tsx +37 -0
  134. package/src/components/ui/placeholder-pattern.tsx +21 -0
  135. package/src/components/ui/popover.tsx +50 -0
  136. package/src/components/ui/progress.tsx +65 -0
  137. package/src/components/ui/radio-group.tsx +73 -0
  138. package/src/components/ui/resizable.tsx +39 -0
  139. package/src/components/ui/scroll-area.tsx +50 -0
  140. package/src/components/ui/select.tsx +234 -0
  141. package/src/components/ui/separator.tsx +24 -0
  142. package/src/components/ui/sheet.tsx +147 -0
  143. package/src/components/ui/sidebar.tsx +721 -0
  144. package/src/components/ui/skeleton.tsx +15 -0
  145. package/src/components/ui/slider.tsx +35 -0
  146. package/src/components/ui/sonner.tsx +28 -0
  147. package/src/components/ui/sortable.tsx +724 -0
  148. package/src/components/ui/spinner.tsx +17 -0
  149. package/src/components/ui/stat-card.tsx +82 -0
  150. package/src/components/ui/stepper.tsx +410 -0
  151. package/src/components/ui/switch.tsx +68 -0
  152. package/src/components/ui/table.tsx +42 -0
  153. package/src/components/ui/tabs.tsx +196 -0
  154. package/src/components/ui/timeline.tsx +90 -0
  155. package/src/components/ui/toggle-group.tsx +73 -0
  156. package/src/components/ui/toggle.tsx +45 -0
  157. package/src/components/ui/tooltip.tsx +55 -0
  158. package/src/components/user-info.tsx +33 -0
  159. package/src/components/user-menu-content.tsx +53 -0
  160. package/src/components/web/SiteFooter.tsx +154 -0
  161. package/src/components/web/SiteHeader.tsx +159 -0
  162. package/src/components/workflows/workflow-canvas.tsx +321 -0
  163. package/src/controls/Blockquote.tsx +25 -0
  164. package/src/controls/Button.tsx +101 -0
  165. package/src/controls/Checkbox.tsx +29 -0
  166. package/src/controls/DateField.tsx +37 -0
  167. package/src/controls/FormField.tsx +20 -0
  168. package/src/controls/Heading.tsx +28 -0
  169. package/src/controls/Input.tsx +21 -0
  170. package/src/controls/Label.tsx +18 -0
  171. package/src/controls/Paragraph.tsx +39 -0
  172. package/src/controls/PasswordInput.tsx +40 -0
  173. package/src/controls/RadioGroup.tsx +70 -0
  174. package/src/controls/Select.tsx +24 -0
  175. package/src/controls/Slider.tsx +33 -0
  176. package/src/controls/Switch.tsx +31 -0
  177. package/src/controls/Textarea.tsx +22 -0
  178. package/src/elements/ConfirmPasswordForm.tsx +43 -0
  179. package/src/elements/DeviceStatusBadge.tsx +38 -0
  180. package/src/elements/DriverCard.tsx +67 -0
  181. package/src/elements/ForgotPasswordForm.tsx +64 -0
  182. package/src/elements/IncidentCard.tsx +67 -0
  183. package/src/elements/LoginForm.tsx +100 -0
  184. package/src/elements/OtpForm.tsx +71 -0
  185. package/src/elements/RegisterForm.tsx +150 -0
  186. package/src/elements/ResetPasswordForm.tsx +72 -0
  187. package/src/elements/SmsChallengeForm.tsx +104 -0
  188. package/src/elements/VehicleCard.tsx +73 -0
  189. package/src/elements/VerifyEmailForm.tsx +39 -0
  190. package/src/hooks/use-appearance.tsx +117 -0
  191. package/src/hooks/use-applied-theme.ts +98 -0
  192. package/src/hooks/use-clipboard.ts +34 -0
  193. package/src/hooks/use-current-url.ts +83 -0
  194. package/src/hooks/use-dark-mode.ts +48 -0
  195. package/src/hooks/use-flash-toast.ts +29 -0
  196. package/src/hooks/use-initials.tsx +24 -0
  197. package/src/hooks/use-mobile-navigation.ts +12 -0
  198. package/src/hooks/use-mobile.tsx +38 -0
  199. package/src/index.ts +408 -0
  200. package/src/layouts/AppLayout.tsx +60 -0
  201. package/src/layouts/AuthLayout.tsx +32 -0
  202. package/src/layouts/SettingsLayout.tsx +21 -0
  203. package/src/layouts/app/AIChatLayout.tsx +73 -0
  204. package/src/layouts/app/AsideSidebarLayout.tsx +3 -0
  205. package/src/layouts/app/CalendarSidebarLayout.tsx +69 -0
  206. package/src/layouts/app/CommunitiesNavbarLayout.tsx +3 -0
  207. package/src/layouts/app/DualNavbarSidebarLayout.tsx +3 -0
  208. package/src/layouts/app/FocusSidebarLayout.tsx +75 -0
  209. package/src/layouts/app/MailLayout.tsx +69 -0
  210. package/src/layouts/app/MegaMenuHeaderLayout.tsx +3 -0
  211. package/src/layouts/app/MegaMenuLayout.tsx +81 -0
  212. package/src/layouts/app/MegaMenuNavbarLayout.tsx +88 -0
  213. package/src/layouts/app/MegaMenuSearchNavbarLayout.tsx +3 -0
  214. package/src/layouts/app/NavbarCollapsibleLayout.tsx +88 -0
  215. package/src/layouts/app/NavbarCollapsibleLinksLayout.tsx +3 -0
  216. package/src/layouts/app/NavbarMinimalLayout.tsx +3 -0
  217. package/src/layouts/app/NavbarMinimalSidebarLayout.tsx +3 -0
  218. package/src/layouts/app/NavbarSidebarDashboardLayout.tsx +3 -0
  219. package/src/layouts/app/NavbarSidebarLayout.tsx +92 -0
  220. package/src/layouts/app/NavbarSimpleSidebarLayout.tsx +3 -0
  221. package/src/layouts/app/NavbarTitledSidebarLayout.tsx +3 -0
  222. package/src/layouts/app/PanelSidebarLayout.tsx +3 -0
  223. package/src/layouts/app/SearchNavbarSidebarLayout.tsx +3 -0
  224. package/src/layouts/app/SidebarBreadcrumbLayout.tsx +3 -0
  225. package/src/layouts/app/SidebarCleanLayout.tsx +3 -0
  226. package/src/layouts/app/SidebarCommunitiesLayout.tsx +3 -0
  227. package/src/layouts/app/SidebarContentLayout.tsx +3 -0
  228. package/src/layouts/app/SidebarDualMenuLayout.tsx +104 -0
  229. package/src/layouts/app/SidebarFixedLayout.tsx +166 -0
  230. package/src/layouts/app/SidebarFooterNavbarLayout.tsx +3 -0
  231. package/src/layouts/app/SidebarHeaderMenuLayout.tsx +3 -0
  232. package/src/layouts/app/SidebarMegaMenuLayout.tsx +4 -0
  233. package/src/layouts/app/SidebarMinimalLayout.tsx +70 -0
  234. package/src/layouts/app/SidebarMobileSearchLayout.tsx +3 -0
  235. package/src/layouts/app/SidebarMultiPanelLayout.tsx +3 -0
  236. package/src/layouts/app/SidebarPrimarySecondaryLayout.tsx +3 -0
  237. package/src/layouts/app/SidebarSearchHeaderLayout.tsx +103 -0
  238. package/src/layouts/app/SidebarSearchToolbarLayout.tsx +3 -0
  239. package/src/layouts/app/SidebarTabsDualLayout.tsx +3 -0
  240. package/src/layouts/app/SidebarTabsLayout.tsx +98 -0
  241. package/src/layouts/app/SidebarTreeLayout.tsx +3 -0
  242. package/src/layouts/app/SplitNavbarLayout.tsx +3 -0
  243. package/src/layouts/app/SplitSidebarDashboardLayout.tsx +3 -0
  244. package/src/layouts/app/SplitSidebarLayout.tsx +99 -0
  245. package/src/layouts/app/TopNavLayout.tsx +105 -0
  246. package/src/layouts/app/TopNavLinksLayout.tsx +3 -0
  247. package/src/layouts/app/WorkspaceBreadcrumbLayout.tsx +3 -0
  248. package/src/layouts/app/WorkspaceCommunitiesLayout.tsx +3 -0
  249. package/src/layouts/app/WorkspaceNavbarLayout.tsx +3 -0
  250. package/src/layouts/app/WorkspaceSidebarLayout.tsx +98 -0
  251. package/src/layouts/app/WorkspaceSidebarTitleLayout.tsx +3 -0
  252. package/src/layouts/app/app-header-layout.tsx +45 -0
  253. package/src/layouts/app/app-sidebar-layout.tsx +56 -0
  254. package/src/layouts/app/layout-context.tsx +44 -0
  255. package/src/layouts/app/layout-types.ts +47 -0
  256. package/src/layouts/app/partials/Footer.tsx +35 -0
  257. package/src/layouts/app/partials/HeaderTopbar.tsx +96 -0
  258. package/src/layouts/app/partials/Navbar.tsx +85 -0
  259. package/src/layouts/app/partials/Toolbar.tsx +47 -0
  260. package/src/layouts/app-layout.tsx +29 -0
  261. package/src/layouts/auth/AuthBrandedLayout.tsx +58 -0
  262. package/src/layouts/auth/AuthCardLayout.tsx +31 -0
  263. package/src/layouts/auth/AuthCenteredLayout.tsx +41 -0
  264. package/src/layouts/auth/AuthClassicLayout.tsx +41 -0
  265. package/src/layouts/auth/AuthSimpleLayout.tsx +33 -0
  266. package/src/layouts/auth/AuthSplitLayout.tsx +89 -0
  267. package/src/layouts/web-app-layout.tsx +162 -0
  268. package/src/layouts/web-layout.tsx +23 -0
  269. package/src/lib/datetime.ts +188 -0
  270. package/src/lib/google-maps-loader.ts +99 -0
  271. package/src/lib/location.ts +127 -0
  272. package/src/lib/lucide-icon-map.ts +132 -0
  273. package/src/lib/map-markers.ts +124 -0
  274. package/src/lib/map-styles.ts +351 -0
  275. package/src/lib/utils.ts +11 -0
  276. package/src/platform/adapters/default.tsx +156 -0
  277. package/src/platform/adapters/inertia.tsx +88 -0
  278. package/src/platform/adapters/nextjs.ts +86 -0
  279. package/src/platform/context.tsx +106 -0
  280. package/src/platform/index.ts +27 -0
  281. package/src/platform/types.ts +105 -0
  282. package/src/styles/layouts/sidebar-fixed.css +161 -0
  283. package/src/styles/themes.css +583 -0
  284. package/src/types/assets.d.ts +5 -0
  285. package/src/types/auth.ts +25 -0
  286. package/src/types/global.d.ts +13 -0
  287. package/src/types/index.ts +9 -0
  288. package/src/types/navigation.ts +15 -0
  289. package/src/types/ui.ts +32 -0
@@ -0,0 +1,228 @@
1
+ 'use client';
2
+
3
+ import React, { useEffect, useMemo, useState } from 'react';
4
+
5
+ type EdgeCutoutOptions = {
6
+ threshold?: number; // how white a pixel must be to count as bg
7
+ saturation?: number; // how colorless it must be
8
+ feather?: number; // soften cutout edges
9
+ };
10
+
11
+ type CutoutImageProps = Omit<
12
+ React.ImgHTMLAttributes<HTMLImageElement>,
13
+ 'src'
14
+ > & {
15
+ src: string;
16
+ enabled?: boolean;
17
+ options?: EdgeCutoutOptions;
18
+ };
19
+
20
+ const cache = new Map<string, Promise<string>>();
21
+
22
+ function loadImage(src: string): Promise<HTMLImageElement> {
23
+ return new Promise((resolve, reject) => {
24
+ const img = new Image();
25
+ img.crossOrigin = 'anonymous';
26
+ img.onload = () => resolve(img);
27
+ img.onerror = reject;
28
+ img.src = src;
29
+ });
30
+ }
31
+
32
+ function isNearWhite(
33
+ r: number,
34
+ g: number,
35
+ b: number,
36
+ a: number,
37
+ threshold: number,
38
+ saturation: number,
39
+ ) {
40
+ if (a === 0) {
41
+ return true;
42
+ }
43
+
44
+ const brightness = (r + g + b) / 3;
45
+ const chroma = Math.max(r, g, b) - Math.min(r, g, b);
46
+
47
+ return brightness >= threshold && chroma <= saturation;
48
+ }
49
+
50
+ async function createEdgeCutout(
51
+ src: string,
52
+ options: EdgeCutoutOptions = {},
53
+ ): Promise<string> {
54
+ const {
55
+ threshold = 235, // important: lower than pure white so light-gray bg/shadows also get removed
56
+ saturation = 32,
57
+ feather = 24,
58
+ } = options;
59
+
60
+ const img = await loadImage(src);
61
+
62
+ const canvas = document.createElement('canvas');
63
+ canvas.width = img.naturalWidth || img.width;
64
+ canvas.height = img.naturalHeight || img.height;
65
+
66
+ const ctx = canvas.getContext('2d');
67
+
68
+ if (!ctx) {
69
+ return src;
70
+ }
71
+
72
+ ctx.drawImage(img, 0, 0);
73
+
74
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
75
+ const { data } = imageData;
76
+ const w = canvas.width;
77
+ const h = canvas.height;
78
+
79
+ // Mark only background that is connected to the outer border
80
+ const visited = new Uint8Array(w * h);
81
+ const queueX = new Int32Array(w * h);
82
+ const queueY = new Int32Array(w * h);
83
+ let head = 0;
84
+ let tail = 0;
85
+
86
+ const tryPush = (x: number, y: number) => {
87
+ if (x < 0 || y < 0 || x >= w || y >= h) {
88
+ return;
89
+ }
90
+
91
+ const p = y * w + x;
92
+
93
+ if (visited[p]) {
94
+ return;
95
+ }
96
+
97
+ const i = p * 4;
98
+ const r = data[i];
99
+ const g = data[i + 1];
100
+ const b = data[i + 2];
101
+ const a = data[i + 3];
102
+
103
+ if (!isNearWhite(r, g, b, a, threshold, saturation)) {
104
+ return;
105
+ }
106
+
107
+ visited[p] = 1;
108
+ queueX[tail] = x;
109
+ queueY[tail] = y;
110
+ tail++;
111
+ };
112
+
113
+ // Seed flood fill from all outer borders
114
+ for (let x = 0; x < w; x++) {
115
+ tryPush(x, 0);
116
+ tryPush(x, h - 1);
117
+ }
118
+
119
+ for (let y = 1; y < h - 1; y++) {
120
+ tryPush(0, y);
121
+ tryPush(w - 1, y);
122
+ }
123
+
124
+ while (head < tail) {
125
+ const x = queueX[head];
126
+ const y = queueY[head];
127
+ head++;
128
+
129
+ tryPush(x + 1, y);
130
+ tryPush(x - 1, y);
131
+ tryPush(x, y + 1);
132
+ tryPush(x, y - 1);
133
+ }
134
+
135
+ // Remove visited background pixels, feathering near edges
136
+ const fadeStart = threshold - feather;
137
+
138
+ for (let p = 0; p < visited.length; p++) {
139
+ if (!visited[p]) {
140
+ continue;
141
+ }
142
+
143
+ const i = p * 4;
144
+ const r = data[i];
145
+ const g = data[i + 1];
146
+ const b = data[i + 2];
147
+ const a = data[i + 3];
148
+
149
+ const brightness = (r + g + b) / 3;
150
+
151
+ if (brightness >= threshold) {
152
+ data[i + 3] = 0;
153
+ continue;
154
+ }
155
+
156
+ if (brightness > fadeStart) {
157
+ const keep = (threshold - brightness) / feather; // 1 -> keep more, 0 -> remove
158
+ data[i + 3] = Math.round(a * Math.max(0, Math.min(1, keep)));
159
+ continue;
160
+ }
161
+
162
+ data[i + 3] = 0;
163
+ }
164
+
165
+ ctx.putImageData(imageData, 0, 0);
166
+
167
+ return canvas.toDataURL('image/png');
168
+ }
169
+
170
+ function getCacheKey(src: string, options?: EdgeCutoutOptions) {
171
+ return JSON.stringify([
172
+ src,
173
+ options?.threshold ?? 235,
174
+ options?.saturation ?? 32,
175
+ options?.feather ?? 24,
176
+ ]);
177
+ }
178
+
179
+ function getProcessedSrc(src: string, options?: EdgeCutoutOptions) {
180
+ const key = getCacheKey(src, options);
181
+
182
+ if (!cache.has(key)) {
183
+ cache.set(key, createEdgeCutout(src, options));
184
+ }
185
+
186
+ return cache.get(key)!;
187
+ }
188
+
189
+ export default function CutoutImage({
190
+ src,
191
+ enabled = true,
192
+ options,
193
+ alt = '',
194
+ ...props
195
+ }: CutoutImageProps) {
196
+ const [finalSrc, setFinalSrc] = useState(src);
197
+
198
+ const depsKey = useMemo(() => getCacheKey(src, options), [src, options]);
199
+
200
+ useEffect(() => {
201
+ let cancelled = false;
202
+
203
+ if (!enabled) {
204
+ // eslint-disable-next-line react-hooks/set-state-in-effect
205
+ setFinalSrc(src);
206
+
207
+ return;
208
+ }
209
+
210
+ getProcessedSrc(src, options)
211
+ .then((result) => {
212
+ if (!cancelled) {
213
+ setFinalSrc(result);
214
+ }
215
+ })
216
+ .catch(() => {
217
+ if (!cancelled) {
218
+ setFinalSrc(src);
219
+ }
220
+ });
221
+
222
+ return () => {
223
+ cancelled = true;
224
+ };
225
+ }, [src, enabled, depsKey]);
226
+
227
+ return <img src={finalSrc} alt={alt} {...props} />;
228
+ }
@@ -0,0 +1,275 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Compact device map — "where are my devices" at a glance.
5
+ *
6
+ * Marker logic:
7
+ * • Device has a heading → rotated arrow image (CSS rotate)
8
+ * • Device has no heading → static coloured pin circle
9
+ *
10
+ * Marker colour = network signal strength (0–100 %):
11
+ * red (0–25) · purple (26–50) · blue (51–75) · green (76–100)
12
+ */
13
+ import { MapPin } from 'lucide-react';
14
+ import { useEffect, useMemo, useRef } from 'react';
15
+
16
+ import { hasGoogleMapsKey, loadGoogleMaps } from '../../lib/google-maps-loader';
17
+ import {
18
+ arrowRotation,
19
+ deviceArrowUrl,
20
+ devicePinColor,
21
+ useArrow,
22
+ } from '../../lib/map-markers';
23
+ import {
24
+ isDarkMode,
25
+ mapStyleForAppearance,
26
+ watchDarkMode,
27
+ } from '../../lib/map-styles';
28
+ import { cn } from '../../lib/utils';
29
+
30
+ export type MiniMapDevice = {
31
+ id: number | string;
32
+ name: string | null;
33
+ imei: string | null;
34
+ last_lat: number | null;
35
+ last_lon: number | null;
36
+ /** 0–100 network signal percentage. Drives marker colour. */
37
+ signal?: number | null;
38
+ /** Heading in degrees clockwise from north (0–360). Present → arrow marker. */
39
+ heading?: number | null;
40
+ };
41
+
42
+ /** Incident flag placed at the location where the incident was last opened. */
43
+ export type MiniMapIncident = {
44
+ id: number | string;
45
+ lat: number;
46
+ lon: number;
47
+ flagUrl: string;
48
+ title?: string;
49
+ };
50
+
51
+ type Props = {
52
+ devices: MiniMapDevice[];
53
+ incidents?: MiniMapIncident[];
54
+ height?: string;
55
+ className?: string;
56
+ title?: string;
57
+ fallbackCenter?: { lat: number; lng: number };
58
+ };
59
+
60
+ /** Size of the arrow image rendered inside AdvancedMarkerElement. */
61
+ const ARROW_SIZE_PX = 32;
62
+ /** Diameter of the static pin circle. */
63
+ const PIN_SIZE_PX = 18;
64
+ /** Size of incident flag images. */
65
+ const FLAG_SIZE_PX = 28;
66
+
67
+ function makeArrowElement(device: MiniMapDevice): HTMLElement {
68
+ const img = document.createElement('img');
69
+ img.src = deviceArrowUrl(device.signal);
70
+ img.width = ARROW_SIZE_PX;
71
+ img.height = ARROW_SIZE_PX;
72
+ img.title = device.name ?? device.imei ?? `Device ${device.id}`;
73
+ img.style.display = 'block';
74
+ img.style.transformOrigin = 'center center';
75
+ img.style.transform = arrowRotation(device.heading);
76
+ img.style.filter = 'drop-shadow(0 1px 2px rgba(0,0,0,.4))';
77
+ return img;
78
+ }
79
+
80
+ function makePinElement(device: MiniMapDevice): HTMLElement {
81
+ const div = document.createElement('div');
82
+ const color = devicePinColor(device.signal);
83
+ div.title = device.name ?? device.imei ?? `Device ${device.id}`;
84
+ div.style.cssText = [
85
+ `width:${PIN_SIZE_PX}px`,
86
+ `height:${PIN_SIZE_PX}px`,
87
+ `border-radius:50%`,
88
+ `background:${color}`,
89
+ `border:2.5px solid #fff`,
90
+ `box-shadow:0 1px 4px rgba(0,0,0,.35)`,
91
+ `cursor:pointer`,
92
+ ].join(';');
93
+ return div;
94
+ }
95
+
96
+ function makeFlagElement(incident: MiniMapIncident): HTMLElement {
97
+ const img = document.createElement('img');
98
+ img.src = incident.flagUrl;
99
+ img.width = FLAG_SIZE_PX;
100
+ img.height = FLAG_SIZE_PX;
101
+ if (incident.title) img.title = incident.title;
102
+ img.style.display = 'block';
103
+ img.style.filter = 'drop-shadow(0 1px 3px rgba(0,0,0,.4))';
104
+ return img;
105
+ }
106
+
107
+ export function DevicesMiniMap({
108
+ devices,
109
+ incidents = [],
110
+ height = '320px',
111
+ className,
112
+ title,
113
+ fallbackCenter = { lat: 31.5204, lng: 74.3587 },
114
+ }: Props) {
115
+ const containerRef = useRef<HTMLDivElement>(null);
116
+ const mapRef = useRef<google.maps.Map | null>(null);
117
+ // AdvancedMarkerElement has no common base type in older @types/google.maps;
118
+ // store as unknown and cast when calling setMap.
119
+ const markersRef = useRef<unknown[]>([]);
120
+ const flagsRef = useRef<unknown[]>([]);
121
+
122
+ const positioned = useMemo(
123
+ () => devices.filter(
124
+ (d): d is MiniMapDevice & { last_lat: number; last_lon: number } =>
125
+ d.last_lat !== null && d.last_lon !== null,
126
+ ),
127
+ [devices],
128
+ );
129
+
130
+ useEffect(() => {
131
+ if (!containerRef.current || !hasGoogleMapsKey()) return;
132
+
133
+ let cancelled = false;
134
+
135
+ loadGoogleMaps().then((maps) => {
136
+ if (cancelled || !containerRef.current) return;
137
+
138
+ // Initialise map once.
139
+ if (!mapRef.current) {
140
+ mapRef.current = new maps.Map(containerRef.current, {
141
+ center: fallbackCenter,
142
+ zoom: 6,
143
+ disableDefaultUI: true,
144
+ zoomControl: true,
145
+ clickableIcons: false,
146
+ backgroundColor: isDarkMode() ? '#212121' : '#f5f5f5',
147
+ styles: mapStyleForAppearance(isDarkMode()),
148
+ mapId: 'devices-mini-map',
149
+ });
150
+ }
151
+
152
+ // ── Device markers ──────────────────────────────────────────────
153
+ (markersRef.current as Array<{ setMap: (m: null) => void }>)
154
+ .forEach((m) => m.setMap(null));
155
+ markersRef.current = [];
156
+
157
+ const bounds = new maps.LatLngBounds();
158
+ const AdvancedMarkerElement =
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ (maps as any).marker?.AdvancedMarkerElement as
161
+ | (new (o: object) => { setMap: (m: null) => void })
162
+ | undefined;
163
+
164
+ positioned.forEach((d) => {
165
+ const position = { lat: d.last_lat, lng: d.last_lon };
166
+ const content = useArrow(d.heading)
167
+ ? makeArrowElement(d)
168
+ : makePinElement(d);
169
+
170
+ if (AdvancedMarkerElement) {
171
+ const marker = new AdvancedMarkerElement({
172
+ map: mapRef.current,
173
+ position,
174
+ content,
175
+ });
176
+ markersRef.current.push(marker);
177
+ } else {
178
+ // Fallback: legacy Marker with SVG circle icon.
179
+ const color = devicePinColor(d.signal);
180
+ const marker = new maps.Marker({
181
+ position,
182
+ map: mapRef.current!,
183
+ title: d.name ?? d.imei ?? `Device ${d.id}`,
184
+ icon: {
185
+ path: maps.SymbolPath.CIRCLE,
186
+ scale: 7,
187
+ fillColor: color,
188
+ fillOpacity: 0.95,
189
+ strokeColor: '#ffffff',
190
+ strokeWeight: 2,
191
+ },
192
+ });
193
+ markersRef.current.push(marker as unknown);
194
+ }
195
+
196
+ bounds.extend(position);
197
+ });
198
+
199
+ // ── Incident flags ──────────────────────────────────────────────
200
+ (flagsRef.current as Array<{ setMap: (m: null) => void }>)
201
+ .forEach((f) => f.setMap(null));
202
+ flagsRef.current = [];
203
+
204
+ incidents.forEach((inc) => {
205
+ const content = makeFlagElement(inc);
206
+ if (AdvancedMarkerElement) {
207
+ const flag = new AdvancedMarkerElement({
208
+ map: mapRef.current,
209
+ position: { lat: inc.lat, lng: inc.lon },
210
+ content,
211
+ });
212
+ flagsRef.current.push(flag);
213
+ }
214
+ });
215
+
216
+ // ── Fit bounds ──────────────────────────────────────────────────
217
+ if (positioned.length === 1) {
218
+ mapRef.current!.setCenter({ lat: positioned[0].last_lat, lng: positioned[0].last_lon });
219
+ mapRef.current!.setZoom(11);
220
+ } else if (positioned.length > 1) {
221
+ mapRef.current!.fitBounds(bounds, 40);
222
+ }
223
+ }).catch((e) => console.warn('Map load failed', e));
224
+
225
+ return () => { cancelled = true; };
226
+ }, [positioned, incidents, fallbackCenter]);
227
+
228
+ useEffect(() => {
229
+ return watchDarkMode((isDark) => {
230
+ mapRef.current?.setOptions({
231
+ styles: mapStyleForAppearance(isDark),
232
+ backgroundColor: isDark ? '#212121' : '#f5f5f5',
233
+ });
234
+ });
235
+ }, []);
236
+
237
+ if (!hasGoogleMapsKey()) {
238
+ return (
239
+ <div
240
+ className={cn(
241
+ 'relative flex items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/40 text-muted-foreground',
242
+ className,
243
+ )}
244
+ style={{ height }}
245
+ >
246
+ <div className="text-center">
247
+ <MapPin className="mx-auto mb-2 h-6 w-6" />
248
+ <p className="text-sm font-medium">Map preview unavailable</p>
249
+ <p className="mt-1 text-xs">
250
+ Configure VITE_GOOGLE_MAPS_API_KEY to render device positions here.
251
+ </p>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ return (
258
+ <div
259
+ className={cn('relative overflow-hidden rounded-xl border border-border', className)}
260
+ style={{ height }}
261
+ >
262
+ {title && (
263
+ <div className="pointer-events-none absolute top-3 left-3 z-10 rounded-md bg-background/95 px-2.5 py-1 text-xs font-semibold text-foreground shadow-sm">
264
+ {title}
265
+ </div>
266
+ )}
267
+ <div ref={containerRef} className="absolute inset-0" />
268
+ {positioned.length === 0 && (
269
+ <div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/40 text-sm text-muted-foreground">
270
+ No device positions yet.
271
+ </div>
272
+ )}
273
+ </div>
274
+ );
275
+ }