1. <script lang="ts">
  2. import { fly, fade } from 'svelte/transition';
  3. import { flip } from 'svelte/animate';
  4. import { onMount } from 'svelte';
  5.  
  6. export interface Alert {
  7. id: string;
  8. vehicleId: string;
  9. routeNum: string;
  10. message: string;
  11. eta: string;
  12. severity: "early" | "delay" | "info";
  13. }
  14.  
  15. let alerts = $state<Alert[]>([]);
  16.  
  17. const routePool = ["5", "12", "82", "101", "44", "9", "27"];
  18. const vehiclePrefix = ["BUS-", "VAN-", "EXP-"];
  19. const messagePool = ["Approaching Station", "Boarding active", "Final approach", "Inbound: Express", "On-site"];
  20. const severities: ("early" | "delay" | "info")[] = ["early", "early", "early", "delay", "info"];
  21.  
  22. function addRandomAlert() {
  23. const id = Math.random().toString(36).substring(2, 9);
  24. const newAlert: Alert = {
  25. id,
  26. vehicleId: vehiclePrefix[Math.floor(Math.random() * vehiclePrefix.length)] + Math.floor(Math.random() * 999),
  27. routeNum: routePool[Math.floor(Math.random() * routePool.length)],
  28. message: messagePool[Math.floor(Math.random() * messagePool.length)],
  29. eta: (Math.floor(Math.random() * 8) + 1) + " min",
  30. severity: severities[Math.floor(Math.random() * severities.length)]
  31. };
  32. alerts = [newAlert, ...alerts].slice(0, 6);
  33. }
  34.  
  35. function tickTime() {
  36. if (Math.random() > 0.6) addRandomAlert();
  37. alerts = alerts.map(a => {
  38. const mins = parseFloat(a.eta);
  39. if (mins <= 0.5) return null;
  40. return { ...a, eta: (mins - 0.5).toFixed(1) + " min" };
  41. }).filter((a): a is Alert => a !== null);
  42. }
  43.  
  44. const getMins = (eta: string) => {
  45. const val = parseFloat(eta.replace(/[^\d.]/g, ''));
  46. return isNaN(val) ? 0 : val;
  47. };
  48.  
  49. let enrichedAlerts = $derived(alerts.map((a) => {
  50. const mins = getMins(a.eta);
  51. const criticality = Math.max(0, Math.min(1, 1 - mins / 12));
  52.  
  53. const meta = {
  54. delay: { label: "DELAYED", cls: "delay", icon: "⚠", color: "#f26522", bg: "rgba(242, 101, 34, 0.08)" },
  55. early: { label: "INBOUND", cls: "early", icon: "◈", color: "#00ff9d", bg: "rgba(0, 255, 157, 0.12)" },
  56. info: { label: "EN ROUTE", cls: "info", icon: "○", color: "#00d1ff", bg: "rgba(0, 209, 255, 0.08)" }
  57. }[a.severity];
  58.  
  59. return {
  60. ...a,
  61. ...meta,
  62. mins,
  63. progress: Math.max(2, Math.min(98, criticality * 100)),
  64. // Card-level glow triggers when vehicle is close (<= 3 mins)
  65. cardGlow: mins <= 3 ? `0 0 25px ${meta.color}33` : '0 4px 12px rgba(0,0,0,0.5)'
  66. };
  67. }));
  68.  
  69. onMount(() => {
  70. addRandomAlert();
  71. addRandomAlert();
  72. const timer = setInterval(tickTime, 3500);
  73. return () => clearInterval(timer);
  74. });
  75. </script>
  76.  
  77. <div class="alerts-wrap">
  78. <div class="alerts-header">
  79. <div class="alerts-title">
  80. <div class="status-dot"></div>
  81. Active Fleet Telemetry
  82. </div>
  83. <div class="alerts-count">
  84. <span class="count-val">{alerts.length}</span> UNITS DETECTED
  85. </div>
  86. </div>
  87.  
  88. <div class="alerts-list">
  89. {#if enrichedAlerts.length === 0}
  90. <div class="empty" in:fade> Establishing Uplink... </div>
  91. {:else}
  92. {#each enrichedAlerts as alert (alert.id)}
  93. <div
  94. animate:flip={{ duration: 600 }}
  95. class="alert-card alert-{alert.cls}"
  96. style:box-shadow={alert.cardGlow}
  97. style:background={alert.bg}
  98. transition:fly={{ y: 20, duration: 400 }}
  99. >
  100. <div class="accent-line" style:background={alert.color}></div>
  101. <div class="alert-body">
  102. <div class="alert-top">
  103. <div class="vehicle-info">
  104. <svg class="bus-icon" viewBox="0 0 24 24" fill="none" stroke={alert.color} stroke-width="2.5">
  105. <path stroke-linecap="round" stroke-linejoin="round" d="M8 7h8m-8 4h8m-9 8h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM8 21v-2m8 2v-2" />
  106. </svg>
  107. <div class="naming">
  108. <span class="route-badge">ROUTE {alert.routeNum}</span>
  109. <span class="vehicle-id">{alert.vehicleId}</span>
  110. </div>
  111. </div>
  112. <span class="tag" style:color={alert.color} style:border-color="{alert.color}66">
  113. <span class="tag-icon">{alert.icon}</span> {alert.label}
  114. </span>
  115. </div>
  116.  
  117. <div class="alert-msg">{alert.message}</div>
  118.  
  119. <div class="eta-container">
  120. <div class="track-info">
  121. <span class="track-label">DOCKING SEQUENCE</span>
  122. <div class="eta-display" class:urgent={alert.mins <= 2}>
  123. <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5">
  124. <circle cx="12" cy="12" r="10" />
  125. <polyline points="12 6 12 12 16 14" />
  126. </svg>
  127. <span class="eta-text">{alert.eta}</span>
  128. </div>
  129. </div>
  130. <div class="progress-outer">
  131. <div class="progress-inner" style:width="{alert.progress}%" style:background="linear-gradient(90deg, transparent, {alert.color})"></div>
  132. <div class="glow-head" style:left="{alert.progress}%" style:background={alert.color} style:box-shadow="0 0 15px {alert.color}"></div>
  133. </div>
  134. </div>
  135. </div>
  136. </div>
  137. {/each}
  138. {/if}
  139. </div>
  140. </div>
  141.  
  142. <style>
  143. .alerts-wrap {
  144. width: 100%;
  145. height: 100vh;
  146. background: radial-gradient(circle at top right, #121212, #050505);
  147. display: flex;
  148. flex-direction: column;
  149. padding: 30px;
  150. box-sizing: border-box;
  151. font-family: 'JetBrains Mono', monospace;
  152. color: #fff;
  153. overflow: hidden;
  154. }
  155.  
  156. .alerts-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 20px; }
  157. .alerts-title { font-size: 0.75rem; font-weight: 700; letter-spacing: 0.15em; color: #555; display: flex; align-items: center; gap: 12px; }
  158. .status-dot { width: 8px; height: 8px; background: #00ff9d; border-radius: 50%; box-shadow: 0 0 12px #00ff9d; animation: pulse 2s infinite; }
  159. @keyframes pulse { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.2); opacity: 0.5; } 100% { transform: scale(1); opacity: 1; } }
  160.  
  161. .alerts-list { flex: 1; display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
  162.  
  163. .alert-card {
  164. position: relative;
  165. border-radius: 12px;
  166. border: 1px solid rgba(255, 255, 255, 0.05);
  167. backdrop-filter: blur(20px);
  168. transition: all 0.6s cubic-bezier(0.23, 1, 0.32, 1);
  169. }
  170.  
  171. .accent-line { position: absolute; left: 0; top: 20%; height: 60%; width: 3px; border-radius: 0 4px 4px 0; }
  172. .alert-body { padding: 20px 24px; }
  173. .alert-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
  174. .vehicle-info { display: flex; align-items: center; gap: 14px; }
  175. .bus-icon { width: 22px; height: 22px; }
  176. .naming { display: flex; flex-direction: column; }
  177. .route-badge { font-size: 0.55rem; color: #ffffff44; letter-spacing: 0.1em; }
  178. .vehicle-id { font-size: 1.1rem; font-weight: 700; color: #fff; }
  179. .tag { font-size: 0.6rem; font-weight: 800; padding: 4px 12px; border-radius: 20px; border: 1px solid; display: flex; align-items: center; gap: 6px; background: rgba(0,0,0,0.3); }
  180. .alert-msg { font-size: 0.8rem; color: #999; margin-bottom: 24px; }
  181.  
  182. .eta-container { display: flex; flex-direction: column; gap: 10px; }
  183. .track-info { display: flex; justify-content: space-between; align-items: center; }
  184. .track-label { font-size: 0.55rem; font-weight: 700; color: #666; letter-spacing: 0.15em; }
  185.  
  186. /* --- OLED TIMER STYLES --- */
  187. .eta-display {
  188. display: flex;
  189. align-items: center;
  190. gap: 8px;
  191. padding: 6px 14px;
  192. background: #000;
  193. border-radius: 6px;
  194. color: #fff;
  195. border: 1px solid #222;
  196. position: relative;
  197. will-change: opacity, box-shadow;
  198. box-shadow: 0 0 10px rgba(255, 255, 255, 0.05);
  199. animation: oled-flicker 0.15s infinite, timer-breathe 4s infinite ease-in-out;
  200. }
  201.  
  202. .eta-text { font-size: 0.95rem; font-weight: 800; font-variant-numeric: tabular-nums; }
  203.  
  204. .urgent {
  205. color: #00ff9d;
  206. border-color: rgba(0, 255, 157, 0.4);
  207. text-shadow: 0 0 8px rgba(0, 255, 157, 0.6);
  208. animation:
  209. urgent-pulse 0.8s infinite alternate,
  210. oled-flicker 0.1s infinite,
  211. urgent-glow 1.5s infinite ease-in-out !important;
  212. }
  213.  
  214. @keyframes oled-flicker {
  215. 0%, 100% { opacity: 1; }
  216. 50% { opacity: 0.96; }
  217. }
  218.  
  219. @keyframes timer-breathe {
  220. 0%, 100% { box-shadow: 0 0 8px rgba(255, 255, 255, 0.03); }
  221. 50% { box-shadow: 0 0 15px rgba(255, 255, 255, 0.08); }
  222. }
  223.  
  224. @keyframes urgent-glow {
  225. 0%, 100% { box-shadow: 0 0 10px rgba(0, 255, 157, 0.1); }
  226. 50% { box-shadow: 0 0 30px rgba(0, 255, 157, 0.4); }
  227. }
  228.  
  229. @keyframes urgent-pulse {
  230. from { transform: scale(1); }
  231. to { transform: scale(1.05); }
  232. }
  233.  
  234. /* --- PROGRESS BAR --- */
  235. .progress-outer { height: 4px; background: rgba(255, 255, 255, 0.03); border-radius: 10px; position: relative; margin-top: 4px; }
  236. .progress-inner { height: 100%; border-radius: 10px; transition: width 3.5s linear; }
  237. .glow-head {
  238. position: absolute;
  239. top: 50%;
  240. width: 6px;
  241. height: 6px;
  242. border-radius: 50%;
  243. transform: translate(-50%, -50%);
  244. transition: left 3.5s linear;
  245. z-index: 2;
  246. }
  247.  
  248. .empty { height: 100%; display: flex; align-items: center; justify-content: center; color: #444; font-size: 0.7rem; letter-spacing: 0.2em; }
  249. </style>