Ndg07 commited on
Commit
da24979
·
unverified ·
2 Parent(s): 4b0bb65fb43bc8

Merge pull request #14 from Dhiman07-cyber/main

Browse files

Major UI/UX Overhaul: Migration to Vite + Tailwind CSS with Full Responsiveness on all device screen

backend/massive_data_ingestion.py CHANGED
@@ -216,7 +216,7 @@ async def high_volume_processing_loop():
216
  await asyncio.sleep(30) # Wait 30 seconds on error
217
 
218
  def cleanup_old_events():
219
- """Delete events older than 24 hours to prevent unbounded database growth"""
220
  try:
221
  import os
222
  from db_adapter import get_db_connection
@@ -227,7 +227,7 @@ def cleanup_old_events():
227
  conn = get_db_connection(db_path)
228
  cursor = conn.cursor()
229
 
230
- cursor.execute("DELETE FROM events WHERE timestamp < datetime('now', '-24 hours')")
231
  # Handle SQLite cursor vs Postgres wrapper differences in reporting deleted rows
232
  deleted = getattr(cursor, 'rowcount', 0)
233
  conn.commit()
 
216
  await asyncio.sleep(30) # Wait 30 seconds on error
217
 
218
  def cleanup_old_events():
219
+ """Delete events older than 7 days to prevent unbounded database growth"""
220
  try:
221
  import os
222
  from db_adapter import get_db_connection
 
227
  conn = get_db_connection(db_path)
228
  cursor = conn.cursor()
229
 
230
+ cursor.execute("DELETE FROM events WHERE timestamp < datetime('now', '-7 days')")
231
  # Handle SQLite cursor vs Postgres wrapper differences in reporting deleted rows
232
  deleted = getattr(cursor, 'rowcount', 0)
233
  conn.commit()
check_dates.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ from datetime import datetime
3
+
4
+ conn = sqlite3.connect('data/enhanced_fake_news.db')
5
+ c = conn.cursor()
6
+ c.execute("SELECT MIN(timestamp), MAX(timestamp), COUNT(*) FROM events")
7
+ min_ts, max_ts, cnt = c.fetchone()
8
+ print(f"Total Events: {cnt}")
9
+ print(f"Min Timestamp: {min_ts}")
10
+ print(f"Max Timestamp: {max_ts}")
11
+
12
+ c.execute("SELECT COUNT(*) FROM events WHERE timestamp > datetime('now', '-24 hours')")
13
+ last_24h_cnt = c.fetchone()[0]
14
+ print(f"Events in last 24h: {last_24h_cnt}")
15
+
16
+ # Check breakdown of classifications
17
+ c.execute("SELECT fake_news_verdict, COUNT(*) FROM events GROUP BY fake_news_verdict")
18
+ print("Verdict breakdown:", c.fetchall())
19
+
20
+ conn.close()
check_db.py CHANGED
@@ -1,11 +1,25 @@
1
  import sqlite3
2
- conn = sqlite3.connect('data/enhanced_fake_news.db')
3
- c = conn.cursor()
4
- c.execute("SELECT name FROM sqlite_master WHERE type='table'")
5
- print('Tables:', c.fetchall())
6
- c.execute('SELECT COUNT(*) FROM events')
7
- print('Events count:', c.fetchone()[0])
8
- c.execute("PRAGMA table_info(events)")
9
- cols = c.fetchall()
10
- print('Event columns:', [col[1] for col in cols])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  conn.close()
 
1
  import sqlite3
2
+ from datetime import datetime
3
+
4
+ conn = sqlite3.connect("data/enhanced_fake_news.db")
5
+ conn.row_factory = sqlite3.Row
6
+
7
+ print("Current UTC according to SQLite:")
8
+ row = conn.execute("SELECT datetime('now') as ut, datetime('now', '-24 hours') as ut24, datetime('now', '-7 days') as ut7d").fetchone()
9
+ print(f" datetime('now'): {row['ut']}")
10
+ print(f" datetime('now', '-24 hours'): {row['ut24']}")
11
+ print(f" datetime('now', '-7 days'): {row['ut7d']}")
12
+
13
+ print("\nLast 5 events in database:")
14
+ rows = conn.execute("SELECT timestamp FROM events ORDER BY timestamp DESC LIMIT 5").fetchall()
15
+ for r in rows:
16
+ print(f" timestamp: {r['timestamp']}")
17
+
18
+ print("\nLet's test the 24 hour filter:")
19
+ row = conn.execute("SELECT COUNT(*) as cnt FROM events WHERE timestamp > datetime('now', '-24 hours')").fetchone()
20
+ print(f" Count with > -24 hours: {row['cnt']}")
21
+
22
+ row = conn.execute("SELECT COUNT(*) as cnt FROM events").fetchone()
23
+ print(f" Total count: {row['cnt']}")
24
+
25
  conn.close()
{public → frontend/assets}/Misinfo.ico RENAMED
File without changes
frontend/assets/css/styles.css ADDED
@@ -0,0 +1 @@
 
 
1
+ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--saffron:#f93;--india-green:#138808;--ashoka-blue:navy}html{scroll-behavior:smooth}body{min-height:100vh;overflow-x:hidden;--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}button,input{font:inherit}::-moz-selection{--tw-bg-opacity:1;background-color:rgb(254 215 170/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}::selection{--tw-bg-opacity:1;background-color:rgb(254 215 170/var(--tw-bg-opacity,1));--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.menu-button.is-open .menu-line:first-child{transform:translateY(8px) rotate(45deg)}.menu-button.is-open .menu-line:nth-child(2){opacity:0}.menu-button.is-open .menu-line:nth-child(3){transform:translateY(-8px) rotate(-45deg)}.mobile-menu{position:fixed;inset:0;z-index:50;display:none;overflow-y:auto;background-color:hsla(0,0%,100%,.95);--tw-backdrop-blur:blur(24px);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.mobile-menu.is-open{display:flex}.heatmap-path{cursor:pointer;fill:#94a3b8;stroke:#fff;outline:2px solid transparent;outline-offset:2px;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1)}.heatmap-path:focus-visible{stroke:#f93}.heatmap-path{stroke-width:.8;filter:drop-shadow(0 1px 2px rgba(15,23,42,.18))}.heatmap-path:hover{stroke:#f93;stroke-width:2}.heatmap-path.selected,.heatmap-path:hover{--tw-brightness:brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.heatmap-path.selected{stroke:#f93;stroke-width:3;filter:drop-shadow(0 0 12px rgba(232,119,34,.42))}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-left-\[10\%\]{left:-10%}.-right-\[10\%\]{right:-10%}.-top-10{top:-2.5rem}.-top-\[15\%\]{top:-15%}.bottom-4{bottom:1rem}.bottom-6{bottom:1.5rem}.left-1\/2{left:50%}.left-1\/4{left:25%}.left-4{left:1rem}.right-0{right:0}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-4{top:1rem}.top-6{top:1.5rem}.top-\[25\%\]{top:25%}.top-\[30\%\]{top:30%}.top-full{top:100%}.isolate{isolation:isolate}.-z-10{z-index:-10}.z-20{z-index:20}.z-40{z-index:40}.z-50{z-index:50}.z-\[100\]{z-index:100}.z-\[200\]{z-index:200}.z-\[60\]{z-index:60}.z-\[70\]{z-index:70}.m-\[-1px\]{margin:-1px}.mx-auto{margin-left:auto;margin-right:auto}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-7{margin-bottom:1.75rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.grid{display:grid}.hidden{display:none}.h-0\.5{height:.125rem}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[0\.9em\]{height:.9em}.h-\[1px\]{height:1px}.h-\[400px\]{height:400px}.h-\[450px\]{height:450px}.h-\[550px\]{height:550px}.h-\[600px\]{height:600px}.h-\[650px\]{height:650px}.h-\[80vh\]{height:80vh}.h-full{height:100%}.max-h-\[20rem\]{max-height:20rem}.max-h-\[40vh\]{max-height:40vh}.max-h-\[500px\]{max-height:500px}.max-h-\[700px\]{max-height:700px}.max-h-full{max-height:100%}.min-h-11{min-height:2.75rem}.min-h-\[1\.2em\]{min-height:1.2em}.min-h-\[16rem\]{min-height:16rem}.min-h-\[22rem\]{min-height:22rem}.min-h-\[350px\]{min-height:350px}.min-h-\[calc\(100svh-4rem\)\]{min-height:calc(100svh - 4rem)}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-0{width:0}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-2\/3{width:66.666667%}.w-20{width:5rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-4{width:1rem}.w-40{width:10rem}.w-48{width:12rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[0\.1em\]{width:.1em}.w-\[1px\]{width:1px}.w-\[400px\]{width:400px}.w-\[450px\]{width:450px}.w-\[480px\]{width:480px}.w-\[550px\]{width:550px}.w-\[600px\]{width:600px}.w-\[650px\]{width:650px}.w-\[90vw\]{width:90vw}.w-fit{width:-moz-fit-content;width:fit-content}.w-full{width:100%}.w-max{width:-moz-max-content;width:max-content}.w-px{width:1px}.min-w-0{min-width:0}.min-w-11{min-width:2.75rem}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.max-w-\[1550px\]{max-width:1550px}.max-w-\[20ch\]{max-width:20ch}.max-w-\[950px\]{max-width:950px}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-none{max-width:none}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-none{flex:none}.shrink-0{flex-shrink:0}.origin-center{transform-origin:center}.origin-top-right{transform-origin:top right}.-translate-x-1\/2{--tw-translate-x:-50%}.-rotate-90,.-translate-x-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate:-90deg}.scale-0{--tw-scale-x:0;--tw-scale-y:0}.scale-0,.scale-100{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x:1;--tw-scale-y:1}.scale-95{--tw-scale-x:.95;--tw-scale-y:.95}.scale-95,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform-gpu{transform:translate3d(var(--tw-translate-x),var(--tw-translate-y),0) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes blink{0%,to{opacity:1}50%{opacity:0}}.animate-blink{animation:blink 1s step-end infinite}@keyframes bounceScroll{0%,15%,to{transform:translateY(0)}7.5%{transform:translateY(-12px)}}.animate-bounce-scroll{animation:bounceScroll 4s ease-in-out infinite}.animate-fade-in{animation:fadeIn .5s ease-out forwards}.animate-fade-in-up{animation:fadeInUp .6s ease-out forwards}@keyframes float{0%,to{transform:translateY(0) scale(1) rotate(0deg)}50%{transform:translateY(-20px) scale(1.05) rotate(1deg)}}.animate-float{animation:float 3s ease-in-out infinite}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}@keyframes premiumFade{0%{opacity:.6}to{opacity:1}}.animate-premium-fade{animation:premiumFade 8s ease-in-out infinite alternate}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.animate-pulse-soft{animation:pulseSoft 2s ease-in-out infinite}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-grabbing{cursor:grabbing}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0{gap:0}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-12{gap:3rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.divide-x>:not([hidden])~:not([hidden]){--tw-divide-x-reverse:0;border-right-width:calc(1px*var(--tw-divide-x-reverse));border-left-width:calc(1px*(1 - var(--tw-divide-x-reverse)))}.divide-slate-100>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(241 245 249/var(--tw-divide-opacity,1))}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.overflow-x-hidden{overflow-x:hidden}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.5rem}.rounded-\[2\.5rem\]{border-radius:2.5rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.rounded-b-2xl{border-bottom-right-radius:1rem;border-bottom-left-radius:1rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-t-4{border-top-width:4px}.border-dashed{border-style:dashed}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-500\/20{border-color:rgba(245,158,11,.2)}.border-ashoka-blue-200,.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-emerald-200{--tw-border-opacity:1;border-color:rgb(167 243 208/var(--tw-border-opacity,1))}.border-emerald-500\/20{border-color:rgba(16,185,129,.2)}.border-orange-200{--tw-border-opacity:1;border-color:rgb(254 215 170/var(--tw-border-opacity,1))}.border-purple-200{--tw-border-opacity:1;border-color:rgb(233 213 255/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-500\/20{border-color:rgba(239,68,68,.2)}.border-saffron-100{--tw-border-opacity:1;border-color:rgb(255 237 213/var(--tw-border-opacity,1))}.border-saffron-200{--tw-border-opacity:1;border-color:rgb(254 215 170/var(--tw-border-opacity,1))}.border-saffron-300{--tw-border-opacity:1;border-color:rgb(253 186 116/var(--tw-border-opacity,1))}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-200\/60{border-color:rgba(226,232,240,.6)}.border-slate-200\/80{border-color:rgba(226,232,240,.8)}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-white\/10{border-color:hsla(0,0%,100%,.1)}.border-white\/20{border-color:hsla(0,0%,100%,.2)}.border-white\/5{border-color:hsla(0,0%,100%,.05)}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.border-t-amber-500{--tw-border-opacity:1;border-top-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-t-ashoka-blue-400{--tw-border-opacity:1;border-top-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-t-ashoka-blue-500{--tw-border-opacity:1;border-top-color:rgb(0 0 128/var(--tw-border-opacity,1))}.border-t-blue-600{--tw-border-opacity:1;border-top-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-t-emerald-600{--tw-border-opacity:1;border-top-color:rgb(5 150 105/var(--tw-border-opacity,1))}.border-t-india-green-500{--tw-border-opacity:1;border-top-color:rgb(19 136 8/var(--tw-border-opacity,1))}.border-t-red-500{--tw-border-opacity:1;border-top-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-t-saffron-500{--tw-border-opacity:1;border-top-color:rgb(255 153 51/var(--tw-border-opacity,1))}.border-t-slate-500{--tw-border-opacity:1;border-top-color:rgb(100 116 139/var(--tw-border-opacity,1))}.bg-\[\#fdfaf0\]{--tw-bg-opacity:1;background-color:rgb(253 250 240/var(--tw-bg-opacity,1))}.bg-\[\#fefcf8\]{--tw-bg-opacity:1;background-color:rgb(254 252 248/var(--tw-bg-opacity,1))}.bg-\[\#fffdf4\]{--tw-bg-opacity:1;background-color:rgb(255 253 244/var(--tw-bg-opacity,1))}.bg-\[\#fffdf4\]\/95{background-color:rgba(255,253,244,.95)}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-ashoka-blue-500{--tw-bg-opacity:1;background-color:rgb(0 0 128/var(--tw-bg-opacity,1))}.bg-ashoka-blue-500\/15{background-color:rgba(0,0,128,.15)}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-current{background-color:currentColor}.bg-emerald-50{--tw-bg-opacity:1;background-color:rgb(236 253 245/var(--tw-bg-opacity,1))}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:rgba(16,185,129,.1)}.bg-emerald-600{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.bg-india-green-500{--tw-bg-opacity:1;background-color:rgb(19 136 8/var(--tw-bg-opacity,1))}.bg-india-green-500\/25{background-color:rgba(19,136,8,.25)}.bg-orange-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-saffron-50{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.bg-saffron-500{--tw-bg-opacity:1;background-color:rgb(255 153 51/var(--tw-bg-opacity,1))}.bg-saffron-500\/30{background-color:rgba(255,153,51,.3)}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-50\/50{background-color:rgba(248,250,252,.5)}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-slate-950{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.bg-slate-950\/40{background-color:rgba(2,6,23,.4)}.bg-slate-950\/90{background-color:rgba(2,6,23,.9)}.bg-slate-950\/95{background-color:rgba(2,6,23,.95)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-white\/10{background-color:hsla(0,0%,100%,.1)}.bg-white\/5{background-color:hsla(0,0%,100%,.05)}.bg-white\/80{background-color:hsla(0,0%,100%,.8)}.bg-white\/90{background-color:hsla(0,0%,100%,.9)}.bg-white\/95{background-color:hsla(0,0%,100%,.95)}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-\[linear-gradient\(135deg\2c rgba\(255\2c 153\2c 51\2c 0\.08\)_0\%\2c rgba\(255\2c 255\2c 255\2c 1\)_50\%\2c rgba\(19\2c 136\2c 8\2c 0\.08\)_100\%\)\]{background-image:linear-gradient(135deg,rgba(255,153,51,.08),#fff 50%,rgba(19,136,8,.08))}.bg-\[radial-gradient\(circle\2c rgba\(0\2c 0\2c 128\2c 0\.12\)_0\%\2c transparent_70\%\)\]{background-image:radial-gradient(circle,rgba(0,0,128,.12) 0,transparent 70%)}.bg-\[radial-gradient\(circle\2c rgba\(19\2c 136\2c 8\2c 0\.22\)_0\%\2c transparent_70\%\)\]{background-image:radial-gradient(circle,rgba(19,136,8,.22) 0,transparent 70%)}.bg-\[radial-gradient\(circle\2c rgba\(255\2c 153\2c 51\2c 0\.28\)_0\%\2c transparent_70\%\)\]{background-image:radial-gradient(circle,rgba(255,153,51,.28) 0,transparent 70%)}.bg-\[radial-gradient\(circle_at_20\%_20\%\2c rgba\(255\2c 153\2c 51\2c 0\.15\)\2c transparent_50\%\)\2c radial-gradient\(circle_at_80\%_80\%\2c rgba\(19\2c 136\2c 8\2c 0\.15\)\2c transparent_50\%\)\]{background-image:radial-gradient(circle at 20% 20%,rgba(255,153,51,.15),transparent 50%),radial-gradient(circle at 80% 80%,rgba(19,136,8,.15),transparent 50%)}.bg-\[radial-gradient\(circle_at_20\%_20\%\2c rgba\(255\2c 153\2c 51\2c 0\.2\)\2c transparent_50\%\)\2c radial-gradient\(circle_at_80\%_80\%\2c rgba\(19\2c 136\2c 8\2c 0\.2\)\2c transparent_50\%\)\]{background-image:radial-gradient(circle at 20% 20%,rgba(255,153,51,.2),transparent 50%),radial-gradient(circle at 80% 80%,rgba(19,136,8,.2),transparent 50%)}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-tr{background-image:linear-gradient(to top right,var(--tw-gradient-stops))}.from-ashoka-blue-200{--tw-gradient-from:#bfdbfe var(--tw-gradient-from-position);--tw-gradient-to:rgba(191,219,254,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-saffron-50\/80{--tw-gradient-from:rgba(255,247,237,.8) var(--tw-gradient-from-position);--tw-gradient-to:rgba(255,247,237,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-saffron-500{--tw-gradient-from:#f93 var(--tw-gradient-from-position);--tw-gradient-to:rgba(255,153,51,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-saffron-500\/10{--tw-gradient-from:rgba(255,153,51,.1) var(--tw-gradient-from-position);--tw-gradient-to:rgba(255,153,51,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-saffron-500\/15{--tw-gradient-from:rgba(255,153,51,.15) var(--tw-gradient-from-position);--tw-gradient-to:rgba(255,153,51,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-ashoka-blue-500{--tw-gradient-to:rgba(0,0,128,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),navy var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),transparent var(--tw-gradient-via-position),var(--tw-gradient-to)}.via-white{--tw-gradient-to:hsla(0,0%,100%,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),#fff var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-ashoka-blue-500{--tw-gradient-to:navy var(--tw-gradient-to-position)}.to-india-green-50\/80{--tw-gradient-to:rgba(240,253,244,.8) var(--tw-gradient-to-position)}.to-india-green-500{--tw-gradient-to:#138808 var(--tw-gradient-to-position)}.to-india-green-500\/10{--tw-gradient-to:rgba(19,136,8,.1) var(--tw-gradient-to-position)}.to-india-green-500\/15{--tw-gradient-to:rgba(19,136,8,.15) var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.stroke-white{stroke:#fff}.object-cover{-o-object-fit:cover;object-fit:cover}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-5{padding-top:1.25rem;padding-bottom:1.25rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-12{padding-bottom:3rem}.pb-2{padding-bottom:.5rem}.pb-32{padding-bottom:8rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pr-2{padding-right:.5rem}.pt-1\.5{padding-top:.375rem}.pt-12{padding-top:3rem}.pt-16{padding-top:4rem}.pt-24{padding-top:6rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-5{line-height:1.25rem}.leading-6{line-height:1.5rem}.leading-7{line-height:1.75rem}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-\[0\.14em\]{letter-spacing:.14em}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-normal{letter-spacing:0}.tracking-tight{letter-spacing:-.025em}.tracking-tighter{letter-spacing:-.05em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-ashoka-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-ashoka-blue-500{--tw-text-opacity:1;color:rgb(0 0 128/var(--tw-text-opacity,1))}.text-ashoka-blue-600,.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-emerald-600{--tw-text-opacity:1;color:rgb(5 150 105/var(--tw-text-opacity,1))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity,1))}.text-india-green-500{--tw-text-opacity:1;color:rgb(19 136 8/var(--tw-text-opacity,1))}.text-india-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-orange-500{--tw-text-opacity:1;color:rgb(249 115 22/var(--tw-text-opacity,1))}.text-orange-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-saffron-500{--tw-text-opacity:1;color:rgb(255 153 51/var(--tw-text-opacity,1))}.text-saffron-600{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.text-saffron-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-slate-950{--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}.text-transparent{color:transparent}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.no-underline{text-decoration-line:none}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-20{opacity:.2}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.opacity-\[0\.04\]{opacity:.04}.opacity-\[0\.05\]{opacity:.05}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-2xl,.shadow-\[0_0_15px_rgba\(255\2c 255\2c 255\2c 0\.15\)\]{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_15px_rgba\(255\2c 255\2c 255\2c 0\.15\)\]{--tw-shadow:0 0 15px hsla(0,0%,100%,.15);--tw-shadow-colored:0 0 15px var(--tw-shadow-color)}.shadow-\[0_0_8px_rgba\(16\2c 185\2c 129\2c 0\.5\)\]{--tw-shadow:0 0 8px rgba(16,185,129,.5);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(19\2c 136\2c 8\2c 0\.4\)\]{--tw-shadow:0 0 8px rgba(19,136,8,.4);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(220\2c 38\2c 38\2c 0\.4\)\]{--tw-shadow:0 0 8px rgba(220,38,38,.4);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(245\2c 158\2c 11\2c 0\.5\)\]{--tw-shadow:0 0 8px rgba(245,158,11,.5);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(249\2c 115\2c 22\2c 0\.4\)\]{--tw-shadow:0 0 8px rgba(249,115,22,.4);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(250\2c 204\2c 21\2c 0\.4\)\]{--tw-shadow:0 0 8px rgba(250,204,21,.4);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_10px_40px_rgba\(0\2c 0\2c 0\2c 0\.1\)\]{--tw-shadow:0 10px 40px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 40px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_10px_40px_rgba\(0\2c 0\2c 0\2c 0\.15\)\]{--tw-shadow:0 10px 40px rgba(0,0,0,.15);--tw-shadow-colored:0 10px 40px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_15px_40px_rgba\(0\2c 0\2c 0\2c 0\.1\)\]{--tw-shadow:0 15px 40px rgba(0,0,0,.1);--tw-shadow-colored:0 15px 40px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_20px_50px_rgba\(0\2c 0\2c 0\2c 0\.5\)\]{--tw-shadow:0 20px 50px rgba(0,0,0,.5);--tw-shadow-colored:0 20px 50px var(--tw-shadow-color)}.shadow-\[0_20px_50px_rgba\(0\2c 0\2c 0\2c 0\.5\)\],.shadow-card{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-card{--tw-shadow:0 1px 2px rgba(15,23,42,.06),0 12px 32px rgba(15,23,42,.06);--tw-shadow-colored:0 1px 2px var(--tw-shadow-color),0 12px 32px var(--tw-shadow-color)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-md,.shadow-premium{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-premium{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05),0 0 0 1px rgba(0,0,0,.05);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -2px var(--tw-shadow-color),0 0 0 1px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.outline-offset-2{outline-offset:2px}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.blur{--tw-blur:blur(8px)}.blur,.blur-\[100px\]{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-\[100px\]{--tw-blur:blur(100px)}.blur-\[110px\]{--tw-blur:blur(110px)}.blur-\[110px\],.blur-\[120px\]{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-\[120px\]{--tw-blur:blur(120px)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.drop-shadow,.drop-shadow-md{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-md{--tw-drop-shadow:drop-shadow(0 4px 3px rgba(0,0,0,.07)) drop-shadow(0 2px 2px rgba(0,0,0,.06))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur-2xl{--tw-backdrop-blur:blur(40px)}.backdrop-blur-2xl,.backdrop-blur-md{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.backdrop-blur-sm,.backdrop-blur-xl{backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur:blur(24px)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-\[fill\2c stroke\2c stroke-width\]{transition-property:fill,stroke,stroke-width;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-\[transform\2c border-color\2c box-shadow\]{transition-property:transform,border-color,box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-\[width\]{transition-property:width;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.duration-1000{transition-duration:1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.ease-\[cubic-bezier\(0\.16\2c 1\2c 0\.3\2c 1\)\]{transition-timing-function:cubic-bezier(.16,1,.3,1)}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.will-change-transform{will-change:transform}@keyframes fadeInUp{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes pulseSoft{0%,to{opacity:1}50%{opacity:.75}}.animate-fade-in-up{animation:fadeInUp .6s ease-out forwards}.animate-fade-in{animation:fadeIn .5s ease-out forwards}.animate-pulse-soft{animation:pulseSoft 2s ease-in-out infinite}.\[a-z0-9_\:\/\\-\\\[\\\]\.\%\]{a-z0-9_:/\-\[\].%}.\[mask-image\:linear-gradient\(to_bottom\2c white_90\%\2c transparent\)\]{-webkit-mask-image:linear-gradient(180deg,#fff 90%,transparent);mask-image:linear-gradient(180deg,#fff 90%,transparent)}#event-detail-card{border:1.5px solid transparent!important;background:linear-gradient(#fffdf4,#fffdf4) padding-box,linear-gradient(135deg,rgba(255,153,51,.4),rgba(0,0,128,.15),rgba(19,136,8,.4)) border-box!important}.hover\:-translate-y-0\.5:hover{--tw-translate-y:-0.125rem}.hover\:-translate-y-0\.5:hover,.hover\:-translate-y-1:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:-translate-y-1:hover{--tw-translate-y:-0.25rem}.hover\:scale-110:hover{--tw-scale-x:1.1;--tw-scale-y:1.1}.hover\:scale-110:hover,.hover\:scale-\[1\.02\]:hover{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x:1.02;--tw-scale-y:1.02}.hover\:scale-\[1\.05\]:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-saffron-200:hover{--tw-border-opacity:1;border-color:rgb(254 215 170/var(--tw-border-opacity,1))}.hover\:border-saffron-300:hover{--tw-border-opacity:1;border-color:rgb(253 186 116/var(--tw-border-opacity,1))}.hover\:border-slate-200:hover{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.hover\:border-white\/10:hover{border-color:hsla(0,0%,100%,.1)}.hover\:bg-ashoka-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-saffron-50:hover{--tw-bg-opacity:1;background-color:rgb(255 247 237/var(--tw-bg-opacity,1))}.hover\:bg-slate-100:hover{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.hover\:bg-slate-200:hover{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.hover\:bg-slate-50:hover{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.hover\:bg-slate-800:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:bg-white:hover{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.hover\:bg-white\/10:hover{background-color:hsla(0,0%,100%,.1)}.hover\:bg-white\/5:hover{background-color:hsla(0,0%,100%,.05)}.hover\:text-ashoka-blue-600:hover{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.hover\:text-saffron-600:hover{--tw-text-opacity:1;color:rgb(234 88 12/var(--tw-text-opacity,1))}.hover\:text-saffron-700:hover{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.hover\:text-saffron-800:hover{--tw-text-opacity:1;color:rgb(154 52 18/var(--tw-text-opacity,1))}.hover\:text-slate-700:hover{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.hover\:text-slate-950:hover{--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-\[0_0_20px_rgba\(0\2c 0\2c 128\2c 0\.3\)\]:hover{--tw-shadow:0 0 20px rgba(0,0,128,.3);--tw-shadow-colored:0 0 20px var(--tw-shadow-color)}.hover\:shadow-\[0_0_20px_rgba\(0\2c 0\2c 128\2c 0\.3\)\]:hover,.hover\:shadow-lg:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.hover\:shadow-md:hover{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.hover\:shadow-md:hover,.hover\:shadow-soft:hover{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.hover\:shadow-soft:hover{--tw-shadow:0 1px 2px rgba(15,23,42,.06),0 12px 32px rgba(15,23,42,.06);--tw-shadow-colored:0 1px 2px var(--tw-shadow-color),0 12px 32px var(--tw-shadow-color)}.focus\:static:focus{position:static}.focus\:fixed:focus{position:fixed}.focus\:left-4:focus{left:1rem}.focus\:top-4:focus{top:1rem}.focus\:z-\[100\]:focus{z-index:100}.focus\:m-0:focus{margin:0}.focus\:h-auto:focus{height:auto}.focus\:w-auto:focus{width:auto}.focus\:overflow-visible:focus{overflow:visible}.focus\:whitespace-normal:focus{white-space:normal}.focus\:rounded-lg:focus{border-radius:.5rem}.focus\:bg-slate-950:focus{--tw-bg-opacity:1;background-color:rgb(2 6 23/var(--tw-bg-opacity,1))}.focus\:p-0:focus{padding:0}.focus\:px-4:focus{padding-left:1rem;padding-right:1rem}.focus\:py-2:focus{padding-top:.5rem;padding-bottom:.5rem}.focus\:text-sm:focus{font-size:.875rem;line-height:1.25rem}.focus\:font-bold:focus{font-weight:700}.focus\:text-white:focus{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.focus\:shadow-lg:focus{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-orange-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(249 115 22/var(--tw-ring-opacity,1))}.focus-visible\:ring-saffron-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(255 153 51/var(--tw-ring-opacity,1))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:focus-within .group-focus-within\:block{display:block}.group:hover .group-hover\:block{display:block}.group:hover .group-hover\:scale-100{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.group:hover .group-hover\:text-saffron-700{--tw-text-opacity:1;color:rgb(194 65 12/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-slate-950{--tw-text-opacity:1;color:rgb(2 6 23/var(--tw-text-opacity,1))}@media (min-width:640px){.sm\:mt-4{margin-top:1rem}.sm\:flex{display:flex}.sm\:h-11{height:2.75rem}.sm\:h-2{height:.5rem}.sm\:h-20{height:5rem}.sm\:min-h-\[450px\]{min-height:450px}.sm\:w-11{width:2.75rem}.sm\:max-w-\[25ch\]{max-width:25ch}.sm\:gap-6{gap:1.5rem}.sm\:rounded-2xl{border-radius:1rem}.sm\:border{border-width:1px}.sm\:border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.sm\:p-5{padding:1.25rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-12{padding-top:3rem;padding-bottom:3rem}.sm\:py-14{padding-top:3.5rem;padding-bottom:3.5rem}.sm\:pb-32{padding-bottom:8rem}.sm\:pt-28{padding-top:7rem}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-4xl{font-size:2.25rem;line-height:2.5rem}.sm\:text-5xl{font-size:3rem;line-height:1}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:text-xs{font-size:.75rem;line-height:1rem}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:px-8{padding-left:2rem;padding-right:2rem}}@media (min-width:1024px){.lg\:block{display:block}.lg\:flex{display:flex}.lg\:hidden{display:none}.lg\:h-16{height:4rem}.lg\:h-9{height:2.25rem}.lg\:h-\[calc\(100vh-1rem\)\]{height:calc(100vh - 1rem)}.lg\:h-full{height:100%}.lg\:min-h-0{min-height:0}.lg\:w-9{width:2.25rem}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-\[1fr_1\.4fr\]{grid-template-columns:1fr 1.4fr}.lg\:grid-cols-\[1fr_480px\]{grid-template-columns:1fr 480px}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:gap-8{gap:2rem}.lg\:overflow-hidden{overflow:hidden}.lg\:overflow-y-auto{overflow-y:auto}.lg\:p-6{padding:1.5rem}.lg\:p-8{padding:2rem}.lg\:px-4{padding-left:1rem;padding-right:1rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-16{padding-top:4rem;padding-bottom:4rem}.lg\:pt-20{padding-top:5rem}.lg\:text-3xl{font-size:1.875rem;line-height:2.25rem}.lg\:text-4xl{font-size:2.25rem;line-height:2.5rem}.lg\:text-6xl{font-size:3.75rem;line-height:1}.lg\:text-base{font-size:1rem;line-height:1.5rem}.lg\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:1280px){.xl\:grid-cols-\[0\.9fr_1\.1fr\]{grid-template-columns:.9fr 1.1fr}.xl\:grid-cols-\[1fr_560px\]{grid-template-columns:1fr 560px}.xl\:text-4xl{font-size:2.25rem;line-height:2.5rem}}
frontend/assets/css/tailwind.css ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ :root {
7
+ --saffron: #FF9933;
8
+ --india-green: #138808;
9
+ --ashoka-blue: #000080;
10
+ }
11
+
12
+ html {
13
+ scroll-behavior: smooth;
14
+ }
15
+
16
+ body {
17
+ @apply min-h-screen overflow-x-hidden text-slate-950 antialiased;
18
+ }
19
+
20
+ button,
21
+ input {
22
+ font: inherit;
23
+ }
24
+
25
+ ::selection {
26
+ @apply bg-saffron-200 text-slate-950;
27
+ }
28
+ }
29
+
30
+ @layer components {
31
+ .skip-link {
32
+ @apply sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-slate-950 focus:px-4 focus:py-2 focus:text-sm focus:font-bold focus:text-white focus:shadow-soft;
33
+ }
34
+
35
+ .menu-button {
36
+ @apply inline-flex h-11 w-11 flex-col items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white text-slate-900 shadow-card outline-none transition hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2;
37
+ }
38
+
39
+ .menu-line {
40
+ @apply h-0.5 w-5 rounded-full bg-current transition;
41
+ }
42
+
43
+ .menu-button.is-open .menu-line:nth-child(1) {
44
+ transform: translateY(8px) rotate(45deg);
45
+ }
46
+
47
+ .menu-button.is-open .menu-line:nth-child(2) {
48
+ opacity: 0;
49
+ }
50
+
51
+ .menu-button.is-open .menu-line:nth-child(3) {
52
+ transform: translateY(-8px) rotate(-45deg);
53
+ }
54
+
55
+ .mobile-menu {
56
+ @apply fixed inset-0 z-50 hidden overflow-y-auto bg-white/95 backdrop-blur-xl;
57
+ }
58
+
59
+ .mobile-menu.is-open {
60
+ @apply flex;
61
+ }
62
+
63
+ .heatmap-control-button {
64
+ @apply inline-flex min-h-10 min-w-10 items-center justify-center rounded-lg border border-slate-200 bg-white/95 text-xs font-black text-slate-800 shadow-card outline-none transition hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-700 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2;
65
+ }
66
+
67
+ .heatmap-svg {
68
+ @apply h-full max-h-full w-full max-w-full transition-transform duration-100 ease-out;
69
+ transform-origin: center center;
70
+ filter: drop-shadow(0 16px 22px rgba(15, 23, 42, 0.22));
71
+ will-change: transform;
72
+ }
73
+
74
+ .heatmap-path {
75
+ @apply cursor-pointer fill-slate-400 stroke-white outline-none transition duration-200 ease-out focus-visible:stroke-saffron-500;
76
+ stroke-width: 0.8;
77
+ filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.18));
78
+ }
79
+
80
+ .heatmap-path:hover {
81
+ @apply stroke-saffron-500 brightness-110;
82
+ stroke-width: 2;
83
+ }
84
+
85
+ .heatmap-path.selected {
86
+ @apply stroke-saffron-500 brightness-110;
87
+ stroke-width: 3;
88
+ filter: drop-shadow(0 0 12px rgba(232, 119, 34, 0.42));
89
+ }
90
+
91
+
92
+ }
93
+
94
+ @layer utilities {
95
+ .text-balance {
96
+ text-wrap: balance;
97
+ }
98
+
99
+ @keyframes fadeInUp {
100
+ from {
101
+ opacity: 0;
102
+ transform: translateY(20px);
103
+ }
104
+ to {
105
+ opacity: 1;
106
+ transform: translateY(0);
107
+ }
108
+ }
109
+
110
+ @keyframes fadeIn {
111
+ from { opacity: 0; }
112
+ to { opacity: 1; }
113
+ }
114
+
115
+ @keyframes pulseSoft {
116
+ 0%, 100% { opacity: 1; }
117
+ 50% { opacity: 0.75; }
118
+ }
119
+
120
+ .animate-fade-in-up {
121
+ animation: fadeInUp 0.6s ease-out forwards;
122
+ }
123
+
124
+ .animate-fade-in {
125
+ animation: fadeIn 0.5s ease-out forwards;
126
+ }
127
+
128
+ .animate-pulse-soft {
129
+ animation: pulseSoft 2s ease-in-out infinite;
130
+ }
131
+ }
132
+
133
+ #event-detail-card {
134
+ border: 1.5px solid transparent !important;
135
+ background: linear-gradient(#fffdf4, #fffdf4) padding-box,
136
+ linear-gradient(135deg, rgba(255, 153, 51, 0.4), rgba(0, 0, 128, 0.15), rgba(19, 136, 8, 0.4)) border-box !important;
137
+ }
138
+
frontend/assets/{indian-flag.png → ind.png} RENAMED
File without changes
frontend/assets/js/api.js ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { sanitizeApiPath, sanitizeStateParam } from './utils/security.js';
2
+
3
+ const REMOTE_API_BASE = 'https://ndg07-heatmap.hf.space';
4
+ const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
5
+ const isViteDev = LOCAL_DEV_HOSTS.has(window.location.hostname) && ['5173', '4173'].includes(window.location.port);
6
+ const API_BASE = window.location.hostname.endsWith('.netlify.app') || isViteDev
7
+ ? REMOTE_API_BASE
8
+ : '';
9
+
10
+ const REQUEST_TIMEOUT_MS = 45000;
11
+ const RETRY_DELAY_MS = 250;
12
+ const STORAGE_PREFIX = 'misinfo:api:';
13
+ const STORAGE_TTL_MS = 10 * 60 * 1000;
14
+ const MAX_STORAGE_BYTES = 512_000;
15
+
16
+ const cache = new Map();
17
+ const inflight = new Map();
18
+ const NON_PERSISTED_PATHS = new Set(['/api/v1/stats']);
19
+
20
+ function cacheKey(path) {
21
+ return `${API_BASE}${path}`;
22
+ }
23
+
24
+ function canPersist(path) {
25
+ return !NON_PERSISTED_PATHS.has(path);
26
+ }
27
+
28
+ function readStorage(key, { ignoreTtl = false } = {}) {
29
+ try {
30
+ const raw = sessionStorage.getItem(STORAGE_PREFIX + key);
31
+ if (!raw) return null;
32
+ const parsed = JSON.parse(raw);
33
+ if (!parsed) return null;
34
+ if (!ignoreTtl && Date.now() - parsed.time > STORAGE_TTL_MS) return null;
35
+ return parsed.data;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function writeStorage(key, data) {
42
+ try {
43
+ const payload = JSON.stringify({ time: Date.now(), data });
44
+ if (payload.length > MAX_STORAGE_BYTES) return;
45
+ sessionStorage.setItem(STORAGE_PREFIX + key, payload);
46
+ } catch {
47
+ // Storage full or unavailable
48
+ }
49
+ }
50
+
51
+ function shouldRetryResponse(status) {
52
+ return status === 429 || status === 502 || status === 503 || status === 504;
53
+ }
54
+
55
+ function delay(ms) {
56
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
57
+ }
58
+
59
+ async function fetchWithRetry(url, signal, attempt = 0) {
60
+ try {
61
+ const response = await fetch(url, { signal, headers: { Accept: 'application/json' } });
62
+ if (!response.ok) {
63
+ if (attempt === 0 && shouldRetryResponse(response.status)) {
64
+ await delay(RETRY_DELAY_MS);
65
+ return fetchWithRetry(url, signal, 1);
66
+ }
67
+ throw new Error(`Request failed with status ${response.status}`);
68
+ }
69
+ return await response.json();
70
+ } catch (error) {
71
+ if (signal.aborted) throw error;
72
+ if (attempt === 0 && error instanceof TypeError) {
73
+ await delay(RETRY_DELAY_MS);
74
+ return fetchWithRetry(url, signal, 1);
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ /** Return last-known API payload synchronously (memory or sessionStorage). */
81
+ export function peekCached(path) {
82
+ const safePath = sanitizeApiPath(path);
83
+ if (!safePath) return null;
84
+ const key = cacheKey(safePath);
85
+ const cached = cache.get(key);
86
+ if (cached) return cached.data;
87
+ if (!canPersist(safePath)) return null;
88
+ return readStorage(key);
89
+ }
90
+
91
+ function resolveFallback(key, skipStorage, cachedEntry, primary, secondary) {
92
+ if (cachedEntry?.data != null) return cachedEntry.data;
93
+ if (primary != null) return primary;
94
+ if (!skipStorage && secondary != null) return secondary;
95
+ return null;
96
+ }
97
+
98
+ function runBackgroundFetch(path, key, { skipStorage, timeoutMs, cachedEntry, stored, storedStale }) {
99
+ const existing = inflight.get(key);
100
+ if (existing) return existing;
101
+
102
+ const controller = new AbortController();
103
+ const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
104
+ const fallback = resolveFallback(key, skipStorage, cachedEntry, stored, storedStale);
105
+
106
+ const request = fetchWithRetry(API_BASE + path, controller.signal)
107
+ .then((data) => {
108
+ cache.set(key, { time: Date.now(), data });
109
+ if (!skipStorage) writeStorage(key, data);
110
+ return data;
111
+ })
112
+ .catch((error) => {
113
+ if (fallback !== null) return fallback;
114
+ if (error?.name === 'AbortError') {
115
+ throw new Error('Request timed out. Showing last known data.');
116
+ }
117
+ throw error;
118
+ })
119
+ .finally(() => {
120
+ window.clearTimeout(timeout);
121
+ inflight.delete(key);
122
+ });
123
+
124
+ inflight.set(key, request);
125
+ return request;
126
+ }
127
+
128
+ export async function fetchJson(path, options = {}) {
129
+ const safePath = sanitizeApiPath(path);
130
+ if (!safePath) {
131
+ throw new Error('Invalid API path');
132
+ }
133
+
134
+ const { cacheMs = 0, skipStorage = false, timeoutMs = REQUEST_TIMEOUT_MS, force = false } = options;
135
+ const key = cacheKey(safePath);
136
+ const cached = cache.get(key);
137
+ const now = Date.now();
138
+ const useStorage = !skipStorage && canPersist(safePath);
139
+
140
+ if (!force && cached && cacheMs > 0 && now - cached.time < cacheMs) {
141
+ return cached.data;
142
+ }
143
+
144
+ const stored = useStorage ? readStorage(key) : null;
145
+ const storedStale = useStorage ? readStorage(key, { ignoreTtl: true }) : null;
146
+ const stale = resolveFallback(key, !useStorage, cached, stored, storedStale);
147
+
148
+ if (!force && stale !== null && cacheMs > 0) {
149
+ runBackgroundFetch(safePath, key, { skipStorage: !useStorage, timeoutMs, cachedEntry: cached, stored, storedStale }).catch(() => {});
150
+ return stale;
151
+ }
152
+
153
+ const pending = inflight.get(key);
154
+ if (pending) return pending;
155
+
156
+ return runBackgroundFetch(safePath, key, { skipStorage: !useStorage, timeoutMs, cachedEntry: cached, stored, storedStale });
157
+ }
158
+
159
+ export function clearApiCache() {
160
+ cache.clear();
161
+ try {
162
+ Object.keys(sessionStorage).forEach((k) => {
163
+ if (k.startsWith(STORAGE_PREFIX)) sessionStorage.removeItem(k);
164
+ });
165
+ } catch {
166
+ // ignore
167
+ }
168
+ }
169
+
170
+ export function clearStateEventsCache(state) {
171
+ const safeState = sanitizeStateParam(state);
172
+ if (!safeState) return;
173
+ const encoded = encodeURIComponent(safeState);
174
+ for (const key of cache.keys()) {
175
+ if (key.includes(`/api/v1/events/state/${encoded}`)) {
176
+ cache.delete(key);
177
+ }
178
+ }
179
+ try {
180
+ Object.keys(sessionStorage).forEach((k) => {
181
+ if (k.startsWith(STORAGE_PREFIX) && k.includes(`/api/v1/events/state/${encoded}`)) {
182
+ sessionStorage.removeItem(k);
183
+ }
184
+ });
185
+ } catch {
186
+ // ignore
187
+ }
188
+ }
189
+
190
+ function extractArray(payload, ...keys) {
191
+ if (Array.isArray(payload)) return payload;
192
+ if (payload && typeof payload === 'object') {
193
+ for (const key of keys) {
194
+ if (Array.isArray(payload[key])) return payload[key];
195
+ }
196
+ }
197
+ return [];
198
+ }
199
+
200
+ export function getStats(options = {}) {
201
+ return fetchJson('/api/v1/stats', { ...options, cacheMs: 0, skipStorage: true });
202
+ }
203
+
204
+ export function getHealth(options = {}) {
205
+ return fetchJson('/health', { cacheMs: 10000, ...options });
206
+ }
207
+
208
+ export async function getHeatmapData(options = {}) {
209
+ const payload = await fetchJson('/api/v1/heatmap/data', { cacheMs: 30000, ...options });
210
+ return extractArray(payload, 'heatmap', 'heatmap_data', 'data');
211
+ }
212
+
213
+ export async function getLiveEvents(limit = 20, options = {}) {
214
+ const safeLimit = Math.max(1, Math.min(500, Math.floor(Number(limit) || 20)));
215
+ const path = `/api/v1/events/live?limit=${safeLimit}`;
216
+ const payload = await fetchJson(path, { cacheMs: 8000, ...options });
217
+ return extractArray(payload, 'events', 'data');
218
+ }
219
+
220
+ export async function getStateEvents(state, options = {}) {
221
+ const safeState = sanitizeStateParam(state);
222
+ if (!safeState) return [];
223
+ const encoded = encodeURIComponent(safeState);
224
+ const { limit = 100, ...fetchOptions } = options;
225
+ const safeLimit = Math.max(1, Math.min(100, Math.floor(Number(limit) || 100)));
226
+ const payload = await fetchJson(`/api/v1/events/state/${encoded}?limit=${safeLimit}`, { cacheMs: 8000, ...fetchOptions });
227
+ return extractArray(payload, 'events', 'data');
228
+ }
frontend/assets/js/components/bottomNavbar.js ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createElement } from '../utils/dom.js';
2
+
3
+ function createSvgIcon(pathD) {
4
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
5
+ svg.setAttribute('class', 'h-5 w-5');
6
+ svg.setAttribute('fill', 'none');
7
+ svg.setAttribute('stroke', 'currentColor');
8
+ svg.setAttribute('viewBox', '0 0 24 24');
9
+ svg.setAttribute('aria-hidden', 'true');
10
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
11
+ path.setAttribute('stroke-linecap', 'round');
12
+ path.setAttribute('stroke-linejoin', 'round');
13
+ path.setAttribute('stroke-width', '1.8');
14
+ path.setAttribute('d', pathD);
15
+ svg.appendChild(path);
16
+ return svg;
17
+ }
18
+
19
+ const ICON_PATHS = {
20
+ home: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6',
21
+ dashboard: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
22
+ heatmap: 'M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7',
23
+ health: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z'
24
+ };
25
+
26
+ function createTooltip(label) {
27
+ const span = document.createElement('span');
28
+ span.className = 'absolute -top-10 scale-0 rounded bg-slate-900 px-2 py-1 text-[10px] font-bold text-white transition-all group-hover:scale-100 whitespace-nowrap';
29
+ span.textContent = label;
30
+ return span;
31
+ }
32
+
33
+ export function mountBottomNavbar(options = {}) {
34
+ const { activeRoute } = options;
35
+
36
+ const container = createElement('div', {
37
+ className: 'fixed bottom-6 left-1/2 z-[60] -translate-x-1/2 transform-gpu lg:hidden flex items-center gap-2 rounded-full border border-white/10 bg-slate-950/90 p-2 shadow-[0_20px_50px_rgba(0,0,0,0.5)] backdrop-blur-2xl transition-all duration-500 hover:scale-[1.02]',
38
+ attrs: { id: 'bottom-premium-nav' }
39
+ });
40
+
41
+ const routes = [
42
+ { key: 'home', href: '/', label: 'Home' },
43
+ { key: 'dashboard', href: '/dashboard.html', label: 'Dashboard' },
44
+ { key: 'heatmap', href: '/heatmap.html', label: 'Heatmap' }
45
+ ];
46
+
47
+ if (activeRoute === 'dashboard') {
48
+ routes.push({ key: 'health', id: 'bottom-nav-health-trigger', label: 'System Health' });
49
+ }
50
+
51
+ routes.forEach(route => {
52
+ const isActive = route.key === activeRoute;
53
+ const btnBaseClass = 'group relative flex h-12 items-center justify-center rounded-full transition-all duration-500 active:scale-95';
54
+ const activeClass = isActive ? 'w-20 bg-white/10 text-white border border-white/20 shadow-[0_0_15px_rgba(255,255,255,0.15)]' : 'w-14 text-slate-400 hover:bg-white/5 hover:text-white';
55
+
56
+ let element;
57
+ if (route.href) {
58
+ element = createElement('a', {
59
+ className: `${btnBaseClass} ${activeClass}`,
60
+ attrs: { href: route.href, 'aria-label': route.label }
61
+ });
62
+ } else {
63
+ element = createElement('button', {
64
+ className: `${btnBaseClass} ${activeClass}`,
65
+ attrs: { type: 'button', 'aria-label': route.label, id: route.id }
66
+ });
67
+ }
68
+
69
+ element.appendChild(createSvgIcon(ICON_PATHS[route.key]));
70
+ element.appendChild(createTooltip(route.label));
71
+ container.appendChild(element);
72
+ });
73
+
74
+ document.body.appendChild(container);
75
+ }
frontend/assets/js/components/hamburger.js ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createElement, qs, qsa } from '../utils/dom.js';
2
+ import { createStatPanel } from './ui.js';
3
+
4
+ export const ROUTES = [
5
+ { key: 'home', label: 'Home', href: '/' },
6
+ { key: 'dashboard', label: 'Dashboard', href: '/dashboard.html' },
7
+ { key: 'heatmap', label: 'Heatmap', href: '/heatmap.html' }
8
+ ];
9
+
10
+ function createMobileLink(route, activeRoute) {
11
+ const active = route.key === activeRoute;
12
+ return createElement('a', {
13
+ className: `rounded-lg border px-4 py-4 text-base font-bold shadow-premium outline-none transition-all duration-200 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-600 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2 ${active ? 'border-saffron-300 bg-saffron-50 text-saffron-600' : 'border-slate-200 bg-white text-slate-800'}`,
14
+ text: route.label,
15
+ attrs: {
16
+ href: route.href,
17
+ ...(active ? { 'aria-current': 'page' } : {})
18
+ }
19
+ });
20
+ }
21
+
22
+ export function createMobileMenu(options = {}) {
23
+ const { activeRoute, showStats } = options;
24
+ const closeButton = createElement('button', {
25
+ className: 'rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-bold text-slate-700 shadow-premium outline-none transition-all duration-200 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2',
26
+ text: 'Close',
27
+ attrs: { type: 'button', id: 'mobile-menu-close', 'aria-label': 'Close navigation menu' }
28
+ });
29
+
30
+ const menu = createElement('div', {
31
+ className: 'fixed inset-0 z-50 hidden overflow-y-auto bg-white/95 backdrop-blur-xl',
32
+ attrs: { id: 'mobile-menu', 'aria-hidden': 'true' }
33
+ }, [
34
+ createElement('div', {
35
+ className: 'mx-auto flex min-h-full w-full max-w-md flex-col justify-between px-5 py-6'
36
+ }, [
37
+ createElement('div', {}, [
38
+ createElement('div', { className: 'mb-7 flex items-center justify-between gap-4' }, [
39
+ createElement('a', {
40
+ className: 'inline-flex min-w-0 items-center gap-3 rounded-lg text-slate-950 no-underline outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2',
41
+ attrs: { href: '/', 'aria-label': 'Misinformation Heatmap home' }
42
+ }, [
43
+ createElement('img', {
44
+ className: 'h-10 w-10 shrink-0 rounded-lg border border-slate-200 object-cover shadow-card',
45
+ attrs: { src: '/assets/ind.png', alt: 'India logo' }
46
+ }),
47
+ createElement('span', { className: 'min-w-0 text-sm font-black leading-tight tracking-tight text-slate-950 whitespace-nowrap' }, [
48
+ createElement('span', { className: 'text-slate-950', text: 'Misinformation' }),
49
+ createElement('span', { className: 'text-india-green-600 ml-1', text: 'Heatmap' })
50
+ ])
51
+ ]),
52
+ closeButton
53
+ ]),
54
+ createElement('div', { className: 'grid gap-3' }, ROUTES.map((route) => createMobileLink(route, activeRoute)))
55
+ ]),
56
+ createElement('div', { className: 'mt-8 grid gap-4' }, [
57
+ showStats ? createElement('div', { attrs: { id: 'mobile-stats-panel' } }, [
58
+ createStatPanel([
59
+ { label: 'Events', value: '--', className: 'text-blue-700' },
60
+ { label: 'Active', value: '--', className: 'text-saffron-700' },
61
+ { label: 'Accuracy', value: '--', className: 'text-emerald-700' },
62
+ { label: 'Selected', value: '--', className: 'text-slate-950' }
63
+ ], { title: 'Map metrics' })
64
+ ]) : null,
65
+ createElement('div', {
66
+ className: 'rounded-lg border border-slate-200 bg-white p-4 shadow-card'
67
+ }, [
68
+ createElement('p', { className: 'text-xs font-black text-slate-500', text: 'Alpha Version 1.1' }),
69
+ createElement('p', { className: 'mt-2 text-sm font-semibold text-slate-900', attrs: { id: 'mobile-nav-status' }, text: 'Checking live pipeline' })
70
+ ])
71
+ ])
72
+ ])
73
+ ]);
74
+
75
+ return menu;
76
+ }
77
+
78
+ export function bindHamburger(toggle, menu) {
79
+ if (!toggle || !menu) return;
80
+ const close = qs('#mobile-menu-close', menu);
81
+
82
+ function setOpen(isOpen) {
83
+ const lines = toggle.querySelectorAll('span');
84
+ if (isOpen) {
85
+ lines[0].style.transform = 'translateY(8px) rotate(45deg)';
86
+ lines[1].style.opacity = '0';
87
+ lines[2].style.transform = 'translateY(-8px) rotate(-45deg)';
88
+ } else {
89
+ lines[0].style.transform = '';
90
+ lines[1].style.opacity = '';
91
+ lines[2].style.transform = '';
92
+ }
93
+ toggle.classList.toggle('is-open', isOpen);
94
+ toggle.setAttribute('aria-expanded', String(isOpen));
95
+ toggle.setAttribute('aria-label', isOpen ? 'Close navigation menu' : 'Open navigation menu');
96
+ menu.classList.toggle('is-open', isOpen);
97
+ menu.classList.toggle('flex', isOpen);
98
+ menu.classList.toggle('hidden', !isOpen);
99
+ menu.setAttribute('aria-hidden', String(!isOpen));
100
+ document.body.classList.toggle('overflow-hidden', isOpen);
101
+
102
+ // Hide bottom navbar when mobile menu is open
103
+ const bottomNav = qs('#bottom-premium-nav');
104
+ if (bottomNav) {
105
+ if (isOpen) bottomNav.classList.add('hidden');
106
+ else bottomNav.classList.remove('hidden');
107
+ }
108
+
109
+ if (isOpen) {
110
+ const firstFocusable = qs('a, button', menu);
111
+ if (firstFocusable) firstFocusable.focus({ preventScroll: true });
112
+ }
113
+ }
114
+
115
+ toggle.addEventListener('click', () => setOpen(!menu.classList.contains('is-open')));
116
+ if (close) close.addEventListener('click', () => setOpen(false));
117
+
118
+ qsa('a', menu).forEach((link) => {
119
+ link.addEventListener('click', () => setOpen(false));
120
+ });
121
+
122
+ menu.addEventListener('click', (event) => {
123
+ if (event.target === menu) setOpen(false);
124
+ });
125
+
126
+ document.addEventListener('keydown', (event) => {
127
+ if (event.key === 'Escape' && menu.classList.contains('is-open')) {
128
+ setOpen(false);
129
+ toggle.focus({ preventScroll: true });
130
+ }
131
+ });
132
+ }
frontend/assets/js/components/navbar.js ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createElement, qs, replaceChildren, setText } from '../utils/dom.js';
2
+ import { createMobileMenu, bindHamburger, ROUTES } from './hamburger.js';
3
+ import { createStatPanel, STATUS_CLASSES } from './ui.js';
4
+ import { mountBottomNavbar } from './bottomNavbar.js';
5
+
6
+ function createDesktopLink(route, activeRoute) {
7
+ const active = route.key === activeRoute;
8
+ return createElement('a', {
9
+ className: `rounded-lg px-3 py-2 text-sm font-bold outline-none transition-all duration-200 hover:bg-slate-100 hover:text-ashoka-blue-600 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2 ${active ? 'border border-saffron-200 bg-saffron-50 text-saffron-600 shadow-sm' : 'text-slate-600'}`,
10
+ text: route.label,
11
+ attrs: {
12
+ href: route.href,
13
+ ...(active ? { 'aria-current': 'page' } : {})
14
+ }
15
+ });
16
+ }
17
+
18
+ function createStatusPill(id, initialText) {
19
+ return createElement('div', {
20
+ className: `inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-bold ${STATUS_CLASSES.neutral}`,
21
+ attrs: { role: 'status', 'aria-live': 'polite', 'data-status-pill': '' }
22
+ }, [
23
+ createElement('span', { className: 'h-2 w-2 rounded-full bg-current opacity-80', attrs: { 'aria-hidden': 'true' } }),
24
+ createElement('span', { attrs: { id }, text: initialText })
25
+ ]);
26
+ }
27
+
28
+ function createStatsPopover() {
29
+ const popover = createElement('div', {
30
+ className: 'absolute right-0 top-full z-50 mt-3 hidden w-[480px] origin-top-right rounded-2xl border border-saffron-100 bg-white p-1.5 shadow-premium group-hover:block group-focus-within:block animate-fade-in',
31
+ attrs: { id: 'navbar-stats-popover', role: 'dialog', 'aria-label': 'Heatmap metrics' }
32
+ }, [
33
+ createElement('div', { className: 'flex divide-x divide-slate-100' }, [
34
+ { label: 'Total Events', id: 'nav-stat-events', color: 'text-orange-500' },
35
+ { label: 'Active States', id: 'nav-stat-active', color: 'text-india-green-600' },
36
+ { label: 'Last Update', id: 'nav-stat-update', color: 'text-orange-500' },
37
+ { label: 'Accuracy', id: 'nav-stat-accuracy', color: 'text-india-green-600' }
38
+ ].map(stat => createElement('div', { className: 'flex-1 px-4 py-3 text-center' }, [
39
+ createElement('p', { className: `text-lg font-bold leading-tight ${stat.color}`, text: '--', attrs: { id: stat.id } }),
40
+ createElement('p', { className: 'mt-0.5 text-[10px] font-bold text-slate-400', text: stat.label })
41
+ ])))
42
+ ]);
43
+
44
+ return createElement('div', { className: 'group relative hidden lg:block' }, [
45
+ createElement('button', {
46
+ className: 'inline-flex h-10 w-10 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-800 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-600 focus-visible:ring-2 focus-visible:ring-saffron-500',
47
+ attrs: {
48
+ type: 'button',
49
+ 'aria-label': 'Show heatmap metrics'
50
+ }
51
+ }, [
52
+ createElement('svg', {
53
+ className: 'h-5 w-5',
54
+ attrs: { fill: 'none', stroke: 'currentColor', viewBox: '0 0 24 24' }
55
+ }, [
56
+ createElement('path', {
57
+ attrs: { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'stroke-width': '2.5', d: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' }
58
+ })
59
+ ])
60
+ ]),
61
+ popover
62
+ ]);
63
+ }
64
+
65
+ export function mountNavbar(options = {}) {
66
+ const mount = qs('[data-navbar]');
67
+ if (!mount) return;
68
+ const page = options.page || document.body.dataset.page || 'home';
69
+ const showStats = true;
70
+ const statusId = options.statusId || `${page}-nav-status`;
71
+
72
+ const header = createElement('header', {
73
+ className: 'fixed top-0 z-40 w-full border-b border-slate-200/80 bg-white/90 backdrop-blur-xl'
74
+ }, [
75
+ createElement('nav', {
76
+ className: 'mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:h-20 lg:h-16 sm:px-6 lg:px-8',
77
+ attrs: { 'aria-label': 'Primary navigation' }
78
+ }, [
79
+ createElement('a', {
80
+ className: 'inline-flex min-w-0 items-center gap-3 rounded-lg text-slate-950 no-underline outline-none transition hover:opacity-90 focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2',
81
+ attrs: { href: '/', 'aria-label': 'Misinformation Heatmap home' }
82
+ }, [
83
+ createElement('img', {
84
+ className: 'h-9 w-9 shrink-0 rounded-lg border border-slate-200 object-cover shadow-card sm:h-11 sm:w-11 lg:h-9 lg:w-9',
85
+ attrs: { src: '/assets/ind.png', alt: 'India logo' }
86
+ }),
87
+ createElement('span', { className: 'min-w-0 text-sm font-extrabold leading-tight tracking-tight text-slate-950 sm:text-lg lg:text-base whitespace-nowrap' }, [
88
+ createElement('span', { className: 'text-slate-950', text: 'Misinformation' }),
89
+ createElement('span', { className: 'text-india-green-600 ml-1', text: 'Heatmap' })
90
+ ])
91
+ ]),
92
+ createElement('div', { className: 'hidden items-center gap-1 lg:flex', attrs: { 'data-desktop-nav': '' } }, ROUTES.map((route) => createDesktopLink(route, page))),
93
+ createElement('div', { className: 'flex items-center gap-3' }, [
94
+ createElement('div', { className: 'hidden sm:flex' }, [
95
+ createStatusPill(statusId, options.initialStatus || 'Checking')
96
+ ]),
97
+ page === 'heatmap' ? createStatsPopover() : null,
98
+ createElement('button', {
99
+ className: 'inline-flex h-11 w-11 flex-col items-center justify-center gap-1.5 rounded-lg border border-slate-200 bg-white text-slate-950 shadow-md outline-none outline-offset-2 transition-all duration-150 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2 lg:hidden',
100
+ attrs: {
101
+ type: 'button',
102
+ id: 'menu-toggle',
103
+ 'aria-controls': 'mobile-menu',
104
+ 'aria-expanded': 'false',
105
+ 'aria-label': 'Open navigation menu'
106
+ }
107
+ }, [
108
+ createElement('span', { className: 'h-0.5 w-5 rounded-full bg-current transition-all duration-150' }),
109
+ createElement('span', { className: 'h-0.5 w-5 rounded-full bg-current transition-all duration-150' }),
110
+ createElement('span', { className: 'h-0.5 w-5 rounded-full bg-current transition-all duration-150' })
111
+ ])
112
+ ])
113
+ ])
114
+ ]);
115
+
116
+ const mobileMenu = createMobileMenu({ activeRoute: page, showStats });
117
+ replaceChildren(mount, [header, mobileMenu]);
118
+ bindHamburger(qs('#menu-toggle', header), mobileMenu);
119
+
120
+ mountBottomNavbar({ activeRoute: page });
121
+ }
122
+
123
+ export function updateMobileNavStatus(text) {
124
+ setText('#mobile-nav-status', text);
125
+ }
126
+
127
+ export function updateStatsPanels(items) {
128
+ // Desktop specific IDs
129
+ const idMap = {
130
+ 'Total Events': '#nav-stat-events',
131
+ 'Events': '#nav-stat-events',
132
+ 'Active States': '#nav-stat-active',
133
+ 'Coverage': '#nav-stat-active',
134
+ 'States': '#nav-stat-active',
135
+ 'Last Update': '#nav-stat-update',
136
+ 'Accuracy': '#nav-stat-accuracy'
137
+ };
138
+
139
+ items.forEach(item => {
140
+ const selector = idMap[item.label];
141
+ if (selector) {
142
+ setText(selector, item.value);
143
+ }
144
+ });
145
+
146
+ const mobile = qs('#mobile-stats-panel');
147
+ if (mobile) {
148
+ replaceChildren(mobile, createStatPanel(items, { title: 'Map metrics' }));
149
+ }
150
+ }
frontend/assets/js/components/ui.js ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createElement, replaceChildren, setText, setWidth } from '../utils/dom.js';
2
+ import { classificationLabel, formatDateTime, getEventClassification, getEventSource, getEventState, getEventTitle } from '../utils/formatters.js';
3
+ import { badgeClassForClassification, safeUrl, sanitizeClassList, toPlainText, toSafeText } from '../utils/security.js';
4
+
5
+ export const STATUS_CLASSES = {
6
+ good: 'border-emerald-200 bg-emerald-50 text-emerald-700',
7
+ warn: 'border-amber-200 bg-amber-50 text-amber-700',
8
+ bad: 'border-red-200 bg-red-50 text-red-700',
9
+ neutral: 'border-slate-200 bg-slate-50 text-slate-600'
10
+ };
11
+
12
+ export function updateStatusText(id, text, state = 'neutral') {
13
+ const element = document.getElementById(id);
14
+ if (!element) return;
15
+ const pill = element.closest('[data-status-pill]');
16
+ if (pill) {
17
+ Object.values(STATUS_CLASSES).forEach((className) => {
18
+ className.split(' ').forEach((item) => {
19
+ if (item) pill.classList.remove(item);
20
+ });
21
+ });
22
+ STATUS_CLASSES[state in STATUS_CLASSES ? state : 'neutral'].split(' ').forEach((item) => {
23
+ if (item) pill.classList.add(item);
24
+ });
25
+ }
26
+ setText(element, text);
27
+ }
28
+
29
+ export function setProgress(id, value) {
30
+ setWidth(`#${id}`, value);
31
+ }
32
+
33
+ export function renderEmptyState(container, title, detail) {
34
+ replaceChildren(container, createElement('article', {
35
+ className: 'rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4'
36
+ }, [
37
+ createElement('p', { className: 'text-sm font-bold text-slate-900', text: title }),
38
+ createElement('p', { className: 'mt-1 text-sm leading-6 text-slate-600', text: detail })
39
+ ]));
40
+ }
41
+
42
+ export function createEventItem(event, options = {}) {
43
+ const classification = getEventClassification(event);
44
+ const type = options.type || classification;
45
+ const tokenText = type === 'real' ? 'OK' : type === 'fake' ? 'FA' : (classificationLabel(classification) || '??').slice(0, 2).toUpperCase();
46
+ const tokenClass = {
47
+ fake: 'border-red-200 bg-red-50 text-red-700',
48
+ real: 'border-emerald-200 bg-emerald-50 text-emerald-700',
49
+ uncertain: 'border-amber-200 bg-amber-50 text-amber-700',
50
+ unknown: 'border-slate-200 bg-slate-100 text-slate-600'
51
+ }[type] || 'border-slate-200 bg-slate-100 text-slate-600';
52
+
53
+ const titleText = getEventTitle(event);
54
+ const href = safeUrl(event && (event.url || event.link || event.article_url));
55
+ const sourceRaw = event && (event.source || event.source_name || event.publisher);
56
+ const stateRaw = event && (event.state || event.region || event.location);
57
+ const sourceText = getEventSource(event);
58
+ const stateText = getEventState(event);
59
+ const timeText = formatDateTime(event && event.timestamp);
60
+ // Only fields intended as summaries belong in the modal. The API's `content`
61
+ // field is a truncated article excerpt, so showing it here overstates what we know.
62
+ const bodyText = toPlainText(event && (event.description || event.summary || event.content), '');
63
+ const hasSource = Boolean(toSafeText(sourceRaw, ''));
64
+ const hasState = Boolean(toSafeText(stateRaw, ''));
65
+ const hasTimestamp = timeText !== '--';
66
+
67
+ const badgeClass = badgeClassForClassification(classification);
68
+
69
+ const metaBadges = [
70
+ createElement('span', {
71
+ className: `inline-flex w-fit rounded-full border px-2 py-0.5 text-[10px] font-bold ${badgeClass}`,
72
+ text: classificationLabel(classification)
73
+ })
74
+ ];
75
+ if (hasSource) {
76
+ metaBadges.push(createElement('span', {
77
+ className: 'inline-flex w-fit rounded-full border border-blue-200 bg-blue-50 px-2 py-0.5 text-[10px] font-bold text-blue-700',
78
+ text: sourceText
79
+ }));
80
+ }
81
+ if (hasState) {
82
+ metaBadges.push(createElement('span', {
83
+ className: 'inline-flex w-fit rounded-full border border-purple-200 bg-purple-50 px-2 py-0.5 text-[10px] font-bold text-purple-700',
84
+ text: stateText
85
+ }));
86
+ }
87
+ if (hasTimestamp) {
88
+ metaBadges.push(createElement('span', {
89
+ className: 'inline-flex w-fit rounded-full border border-slate-200 bg-slate-50 px-2 py-0.5 text-[10px] font-bold text-slate-700',
90
+ text: timeText
91
+ }));
92
+ }
93
+
94
+ const meta = createElement('div', {
95
+ className: 'mt-2 flex flex-wrap items-center gap-1.5'
96
+ }, metaBadges);
97
+
98
+ const article = createElement('button', {
99
+ className: 'group flex w-full min-w-0 gap-3 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm transition-[transform,border-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-saffron-200 hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2',
100
+ attrs: {
101
+ type: 'button',
102
+ 'aria-label': `Open details for ${titleText}`
103
+ }
104
+ }, [
105
+ createElement('div', {
106
+ className: `flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border text-xs font-bold ${tokenClass}`,
107
+ text: tokenText
108
+ }),
109
+ createElement('div', { className: 'min-w-0 flex-1' }, [
110
+ createElement('h3', {
111
+ className: 'line-clamp-2 break-words text-sm font-bold leading-6 text-slate-950 group-hover:text-saffron-700 transition-colors',
112
+ text: titleText
113
+ }),
114
+ meta
115
+ ])
116
+ ]);
117
+
118
+ article.addEventListener('click', () => {
119
+ document.dispatchEvent(new CustomEvent('openEventModal', {
120
+ detail: {
121
+ titleText,
122
+ href,
123
+ classification,
124
+ sourceText,
125
+ stateText,
126
+ timeText,
127
+ hasSource,
128
+ hasState,
129
+ hasTimestamp,
130
+ bodyText
131
+ }
132
+ }));
133
+ });
134
+
135
+ return article;
136
+ }
137
+
138
+ export function renderEventList(container, events, options = {}) {
139
+ const safeEvents = Array.isArray(events) ? events.filter(Boolean).slice(0, options.limit || 12) : [];
140
+ if (!safeEvents.length) {
141
+ renderEmptyState(
142
+ container,
143
+ options.emptyTitle || 'No data yet',
144
+ options.emptyDetail || 'The layout is ready for incoming live events.'
145
+ );
146
+ return safeEvents;
147
+ }
148
+
149
+ replaceChildren(container, safeEvents.map((event) => createEventItem(event, options)));
150
+ return safeEvents;
151
+ }
152
+
153
+ export function createHealthRow(indicator, options = {}) {
154
+ const state = indicator.state || 'neutral';
155
+ const isDark = options.theme === 'dark';
156
+
157
+ const badgeClasses = {
158
+ good: isDark ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400' : 'bg-emerald-50 border-emerald-200 text-emerald-700',
159
+ warn: isDark ? 'bg-amber-500/10 border-amber-500/20 text-amber-400' : 'bg-amber-50 border-amber-200 text-amber-700',
160
+ bad: isDark ? 'bg-red-500/10 border-red-500/20 text-red-400' : 'bg-red-50 border-red-200 text-red-700',
161
+ neutral: isDark ? 'bg-white/5 border-white/10 text-slate-400' : 'bg-slate-50 border-slate-200 text-slate-600'
162
+ };
163
+
164
+ const badgeClass = badgeClasses[state] || badgeClasses.neutral;
165
+ const containerClass = isDark
166
+ ? 'rounded-2xl border border-white/5 bg-white/5 p-4 transition-all hover:bg-white/10 hover:border-white/10'
167
+ : 'rounded-lg border border-slate-200 bg-slate-50 p-3';
168
+
169
+ return createElement('div', {
170
+ className: containerClass
171
+ }, [
172
+ createElement('div', { className: 'flex items-start justify-between gap-4' }, [
173
+ createElement('div', { className: 'min-w-0 flex-1' }, [
174
+ createElement('p', { className: `text-sm font-bold ${isDark ? 'text-white' : 'text-slate-950'}`, text: indicator.label }),
175
+ createElement('p', { className: `mt-1 text-xs leading-relaxed ${isDark ? 'text-slate-400' : 'text-slate-600'}`, text: indicator.detail || 'No additional detail.' })
176
+ ]),
177
+ createElement('span', {
178
+ className: `shrink-0 rounded-full border px-3 py-1 text-[10px] font-bold ${badgeClass}`,
179
+ text: indicator.value || 'Connecting...'
180
+ })
181
+ ])
182
+ ]);
183
+ }
184
+
185
+ export function createStatPanel(items, options = {}) {
186
+ const title = toSafeText(options.title, 'Live metrics');
187
+ const panel = createElement('div', {
188
+ className: 'rounded-xl border border-slate-200 bg-white p-5 shadow-premium backdrop-blur-md'
189
+ }, [
190
+ createElement('p', { className: 'text-xs font-bold text-slate-500', text: title }),
191
+ createElement('dl', {
192
+ className: 'mt-4 grid grid-cols-2 gap-4'
193
+ }, items.map((item) => createElement('div', {
194
+ className: 'rounded-xl border border-slate-200 bg-slate-50 p-3.5 transition-colors hover:bg-white'
195
+ }, [
196
+ createElement('dt', { className: 'text-xs font-bold text-slate-500', text: item.label }),
197
+ createElement('dd', {
198
+ className: `mt-1 text-xl font-bold ${item.className || 'text-slate-950'}`,
199
+ text: item.value
200
+ })
201
+ ])))
202
+ ]);
203
+ return panel;
204
+ }
205
+
206
+ export function createFeedLoadingSpinner() {
207
+ return createElement('div', {
208
+ className: 'flex items-center justify-center py-12 animate-pulse',
209
+ attrs: { role: 'status', 'aria-live': 'polite', 'aria-label': 'Loading feed' }
210
+ }, [
211
+ createElement('div', { className: 'h-6 w-6 rounded-full border-2 border-slate-200 border-t-slate-500 animate-spin', attrs: { 'aria-hidden': 'true' } })
212
+ ]);
213
+ }
214
+
215
+ function createDetailBadge(text, colorClass) {
216
+ const base = 'inline-flex rounded-full border px-2.5 py-1 text-[10px] font-bold';
217
+ return createElement('span', {
218
+ className: `${base} ${sanitizeClassList(colorClass)}`,
219
+ text
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Populate event detail modal using DOM APIs only (no innerHTML).
225
+ * @param {object} detail
226
+ */
227
+ export function populateEventDetailModal(detail = {}) {
228
+ const classification = detail.classification || 'unknown';
229
+ const badgeClass = badgeClassForClassification(classification);
230
+
231
+ const badgesContainer = document.getElementById('event-detail-badges');
232
+ if (badgesContainer) {
233
+ const badges = [createDetailBadge(classificationLabel(classification), badgeClass)];
234
+ if (detail.hasSource) {
235
+ badges.push(createDetailBadge(detail.sourceText, 'border-blue-200 bg-blue-50 text-blue-700'));
236
+ }
237
+ if (detail.hasState) {
238
+ badges.push(createDetailBadge(detail.stateText, 'border-purple-200 bg-purple-50 text-purple-700'));
239
+ }
240
+ if (detail.hasTimestamp) {
241
+ badges.push(createDetailBadge(detail.timeText, 'border-slate-200 bg-slate-50 text-slate-700'));
242
+ }
243
+ replaceChildren(badgesContainer, badges);
244
+ }
245
+
246
+ setText('#event-detail-title', detail.titleText || 'Untitled event');
247
+
248
+ const contentContainer = document.getElementById('event-detail-content');
249
+ if (contentContainer) {
250
+ const bodyText = toPlainText(detail.bodyText, '');
251
+ contentContainer.classList.toggle('hidden', !bodyText);
252
+ replaceChildren(contentContainer, bodyText ? [
253
+ createElement('p', {
254
+ className: 'whitespace-pre-wrap',
255
+ text: bodyText
256
+ })
257
+ ] : []);
258
+ }
259
+
260
+ const linkBtn = document.getElementById('event-detail-link');
261
+ if (linkBtn instanceof HTMLAnchorElement) {
262
+ const href = safeUrl(detail.href);
263
+ if (href) {
264
+ linkBtn.href = href;
265
+ linkBtn.rel = 'noopener noreferrer';
266
+ linkBtn.classList.remove('hidden');
267
+ } else {
268
+ linkBtn.removeAttribute('href');
269
+ linkBtn.classList.add('hidden');
270
+ }
271
+ }
272
+ }
273
+
274
+ export function showToast(message) {
275
+ const existing = document.getElementById('global-toast');
276
+ if (existing) existing.remove();
277
+
278
+ const toast = createElement('div', {
279
+ id: 'global-toast',
280
+ className: 'fixed top-6 right-6 z-[100] flex items-center gap-3 rounded-xl border border-slate-200/60 bg-gradient-to-r from-saffron-50/80 via-white to-india-green-50/80 px-5 py-3 shadow-[0_15px_40px_rgba(0,0,0,0.1)] backdrop-blur-xl',
281
+ }, [
282
+ createElement('div', { className: 'flex items-center gap-1.5' }, [
283
+ createElement('div', { className: 'h-1.5 w-1.5 rounded-full bg-saffron-500 animate-pulse' }),
284
+ createElement('div', { className: 'h-1.5 w-1.5 rounded-full bg-slate-200' }),
285
+ createElement('div', { className: 'h-1.5 w-1.5 rounded-full bg-india-green-500 animate-pulse', style: 'animation-delay: 0.5s' }),
286
+ ]),
287
+ createElement('span', { className: 'text-[11px] font-bold tracking-tight text-slate-800', text: message })
288
+ ]);
289
+
290
+ toast.style.animation = 'toastInRight 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards';
291
+
292
+ document.body.appendChild(toast);
293
+
294
+ setTimeout(() => {
295
+ toast.style.animation = 'toastOutRight 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards';
296
+ setTimeout(() => toast.remove(), 500);
297
+ }, 4500);
298
+ }
frontend/assets/js/main.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mountNavbar } from './components/navbar.js';
2
+ import { initDashboard } from './pages/dashboard.js';
3
+ import { initHeatmap } from './pages/heatmap.js';
4
+ import { initHome } from './pages/home.js';
5
+
6
+ const PAGE_INIT = {
7
+ home: initHome,
8
+ dashboard: initDashboard,
9
+ heatmap: initHeatmap,
10
+ contact: () => { console.log('[contact] initialized'); }
11
+ };
12
+
13
+ document.addEventListener('DOMContentLoaded', () => {
14
+ const page = document.body?.dataset?.page || 'home';
15
+ mountNavbar({
16
+ page,
17
+ statusId: `${page}-nav-status`,
18
+ initialStatus: 'Checking'
19
+ });
20
+
21
+ const init = PAGE_INIT[page];
22
+ if (!init) return;
23
+
24
+ Promise.resolve()
25
+ .then(() => init())
26
+ .catch((error) => {
27
+ console.error(`[${page}] initialization failed:`, error);
28
+ });
29
+ });
frontend/assets/js/pages/dashboard.js ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { clearApiCache, getHealth, getHeatmapData, getLiveEvents, getStats, peekCached } from '../api.js';
2
+ import { createHealthRow, renderEventList, setProgress, updateStatusText } from '../components/ui.js';
3
+ import { updateMobileNavStatus, updateStatsPanels } from '../components/navbar.js';
4
+ import { replaceChildren, setText } from '../utils/dom.js';
5
+ import { formatClock, formatCount, formatPercent, numberOrNull } from '../utils/formatters.js';
6
+ import { toSafeText } from '../utils/security.js';
7
+
8
+ const RING_CIRCUMFERENCE = 465;
9
+ let refreshInFlight = false;
10
+
11
+ function percentOf(value, total) {
12
+ const numerator = numberOrNull(value);
13
+ const denominator = numberOrNull(total);
14
+ if (numerator === null || denominator === null || denominator <= 0) return null;
15
+ return (numerator / denominator) * 100;
16
+ }
17
+
18
+ function updateStats(stats) {
19
+ const total = numberOrNull(stats.total_events);
20
+ const fake = numberOrNull(stats.fake_events);
21
+ const real = numberOrNull(stats.real_events);
22
+ const review = numberOrNull(stats.uncertain_events);
23
+ const totalStates = numberOrNull(stats.total_states);
24
+ const divisor = total ?? [fake, real, review].filter((v) => v !== null).reduce((s, v) => s + v, 0);
25
+
26
+ setText('#dashboard-total-events', formatCount(total));
27
+ setText('#dashboard-fake-events', formatCount(fake));
28
+ setText('#dashboard-real-events', formatCount(real));
29
+
30
+ const accuracy = numberOrNull(stats.classification_accuracy);
31
+ setText('#dashboard-under-review-events', formatCount(review));
32
+
33
+ setProgress('dashboard-total-bar', total === null ? 0 : 100);
34
+ setProgress('dashboard-fake-bar', percentOf(fake, divisor) || 0);
35
+ setProgress('dashboard-real-bar', percentOf(real, divisor) || 0);
36
+ setProgress('dashboard-under-review-bar', percentOf(review, divisor) || 0);
37
+ const normalizedAccuracy = accuracy === null ? 0 : Math.max(0, Math.min(100, Math.abs(accuracy) <= 1 ? accuracy * 100 : accuracy));
38
+
39
+ setText('#dashboard-accuracy-pct', formatPercent(accuracy));
40
+ setText('#dashboard-accuracy-badge', accuracy === null ? 'Unavailable' : 'High Confidence');
41
+ const ring = document.getElementById('dashboard-accuracy-ring');
42
+ if (ring) ring.style.strokeDashoffset = String(RING_CIRCUMFERENCE * (1 - normalizedAccuracy / 100));
43
+
44
+ setText('#dashboard-precision', formatPercent(stats.precision, '--', 1));
45
+ setText('#dashboard-recall', formatPercent(stats.recall, '--', 1));
46
+ setText('#dashboard-f1', formatPercent(stats.f1_score ?? stats.f1, '--', 1));
47
+
48
+ const processing = Boolean(stats.processing_active);
49
+ const mlReady = Boolean(stats.ml_ready);
50
+ const statusText = processing ? 'Live processing' : mlReady ? 'System ready' : 'Stats loaded';
51
+ const statusState = processing || mlReady ? 'good' : 'neutral';
52
+ updateStatusText('dashboard-nav-status', statusText, statusState);
53
+ updateMobileNavStatus(statusText);
54
+
55
+ // Update desktop floating pill status indicator
56
+ const desktopHealthDot = document.getElementById('desktop-health-dot');
57
+ const desktopHealthPing = document.getElementById('desktop-health-ping');
58
+ if (desktopHealthDot && desktopHealthPing) {
59
+ const isGood = statusState === 'good';
60
+ desktopHealthDot.className = `h-full w-full rounded-full transition-colors duration-500 ${isGood ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]'}`;
61
+ desktopHealthPing.className = `absolute h-full w-full animate-ping rounded-full opacity-40 transition-colors duration-500 ${isGood ? 'bg-emerald-500' : 'bg-amber-500'}`;
62
+ }
63
+
64
+ const lastUpdate = new Date().toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
65
+
66
+ updateStatsPanels([
67
+ { label: 'Events', value: formatCount(total), className: 'text-blue-700' },
68
+ { label: 'Risk', value: formatCount(fake), className: 'text-saffron-700' },
69
+ { label: 'Accuracy', value: formatPercent(accuracy), className: 'text-emerald-700' },
70
+ { label: 'Last Update', value: lastUpdate },
71
+ { label: 'Coverage', value: totalStates === null ? '--' : formatCount(totalStates), className: 'text-slate-950' }
72
+ ]);
73
+ }
74
+
75
+ function updateStatsUnavailable() {
76
+ [
77
+ '#dashboard-total-events',
78
+ '#dashboard-fake-events',
79
+ '#dashboard-real-events',
80
+ '#dashboard-under-review-events',
81
+ '#dashboard-accuracy-pct',
82
+ '#dashboard-precision',
83
+ '#dashboard-recall',
84
+ '#dashboard-f1'
85
+ ].forEach((selector) => setText(selector, '--'));
86
+ ['dashboard-total-bar', 'dashboard-fake-bar', 'dashboard-real-bar', 'dashboard-under-review-bar'].forEach((id) => setProgress(id, 0));
87
+ setText('#dashboard-accuracy-badge', 'Connecting...');
88
+ }
89
+
90
+ function updateCoverage(heatmapData) {
91
+ const records = Array.isArray(heatmapData) ? heatmapData : [];
92
+ const regions = records.length;
93
+ const active = records.filter((record) => numberOrNull(record.event_count ?? record.total_events) > 0).length;
94
+ const events = records.reduce((sum, record) => sum + (numberOrNull(record.event_count ?? record.total_events) || 0), 0);
95
+ const confidences = records
96
+ .map((record) => numberOrNull(record.avg_confidence ?? record.ai_confidence ?? record.confidence ?? record.classification_confidence))
97
+ .filter((value) => value !== null);
98
+ const avgConf = confidences.length
99
+ ? confidences.reduce((sum, value) => sum + value, 0) / confidences.length
100
+ : null;
101
+
102
+ setText('#dashboard-coverage-regions', formatCount(regions));
103
+ setText('#dashboard-coverage-active', formatCount(active));
104
+ setText('#dashboard-coverage-events', formatCount(events));
105
+ setText('#dashboard-coverage-confidence', formatPercent(avgConf));
106
+ setText(
107
+ '#dashboard-coverage-note',
108
+ regions ? 'Coverage is derived from the current heatmap API payload.' : 'The heatmap API returned no geographic records yet.'
109
+ );
110
+ }
111
+
112
+ function updateCoverageUnavailable() {
113
+ ['#dashboard-coverage-regions', '#dashboard-coverage-active', '#dashboard-coverage-events', '#dashboard-coverage-confidence'].forEach(id => setText(id, '--'));
114
+ setText('#dashboard-coverage-note', 'Connecting to real-time spatial index...');
115
+ }
116
+
117
+ function renderHealthIndicators(results) {
118
+ const stats = results.stats.status === 'fulfilled' ? results.stats.value : null;
119
+ const health = results.health.status === 'fulfilled' ? results.health.value : null;
120
+ const heatmap = results.heatmap.status === 'fulfilled' ? results.heatmap.value : null;
121
+ const events = results.events.status === 'fulfilled' ? results.events.value : null;
122
+
123
+ const indicators = [
124
+ {
125
+ id: 'gateway',
126
+ label: 'Gateway Diagnostics',
127
+ value: health ? toSafeText(health.status || health.message || 'Operational', 'Operational') : 'Connecting...',
128
+ state: health ? 'good' : 'neutral',
129
+ detail: health ? 'Primary API gateway is responding to health probes.' : 'Waiting for connection to gateway...'
130
+ },
131
+ {
132
+ id: 'analytics',
133
+ label: 'Analytics Engine',
134
+ value: stats ? (stats.processing_active ? 'Active' : 'Idle') : 'Connecting...',
135
+ state: stats ? (stats.processing_active ? 'good' : 'warn') : 'neutral',
136
+ detail: stats ? 'Derived from live processing throughput flags.' : 'Analytics engine metrics are syncing...'
137
+ },
138
+ {
139
+ id: 'neural',
140
+ label: 'Neural Core Status',
141
+ value: stats ? (stats.ml_ready ? 'Stable' : 'Initializing') : 'Connecting...',
142
+ state: stats ? (stats.ml_ready ? 'good' : 'warn') : 'neutral',
143
+ detail: stats ? 'Classification models are loaded and performing inference.' : 'Model core is warming up...'
144
+ },
145
+ {
146
+ id: 'geospatial',
147
+ label: 'Geospatial Index',
148
+ value: heatmap ? `${heatmap.length} nodes` : 'Connecting...',
149
+ state: heatmap ? (heatmap.length ? 'good' : 'warn') : 'neutral',
150
+ detail: heatmap ? 'Geographic data clusters are synchronized for mapping.' : 'Spatial indexing service is synchronizing...'
151
+ },
152
+ {
153
+ id: 'signal',
154
+ label: 'Signal Integrity',
155
+ value: events ? (events.length ? `${events.length} active` : 'Zero signals') : 'Connecting...',
156
+ state: events ? (events.length ? 'good' : 'warn') : 'neutral',
157
+ detail: events ? 'Signal feed is ingesting and classifying recent events.' : 'Integrity check: synchronizing signals...'
158
+ }
159
+ ];
160
+
161
+ replaceChildren('#dashboard-health-indicators', indicators.map((ind) => createHealthRow(ind, { theme: 'light' })));
162
+
163
+ const hasBad = indicators.some((indicator) => indicator.state === 'bad');
164
+ const hasWarn = indicators.some((indicator) => indicator.state === 'warn');
165
+ const status = hasBad ? 'Critical' : hasWarn ? 'Degraded' : (stats && health ? 'Operational' : 'Connecting');
166
+
167
+ setText('#dashboard-health-badge', status);
168
+ }
169
+
170
+ function renderActivity(events) {
171
+ const rendered = renderEventList('#dashboard-activity-feed', events, {
172
+ limit: 15,
173
+ emptyTitle: 'No recent events',
174
+ emptyDetail: 'The pipeline is reachable, but no classifications were returned.'
175
+ });
176
+ setText('#dashboard-events-count', `${rendered.length} events`);
177
+ }
178
+
179
+ function renderActivityUnavailable() {
180
+ renderEventList('#dashboard-activity-feed', [], {
181
+ emptyTitle: 'Connecting to feed',
182
+ emptyDetail: 'Synchronizing real-time events...'
183
+ });
184
+ setText('#dashboard-events-count', 'Connecting');
185
+ }
186
+
187
+ function hydrateDashboardFromCache() {
188
+ const stats = peekCached('/api/v1/stats');
189
+ const heatmapPayload = peekCached('/api/v1/heatmap/data');
190
+ const eventsPayload = peekCached('/api/v1/events/live?limit=15');
191
+ const health = peekCached('/health');
192
+
193
+ if (stats) updateStats(stats);
194
+ if (heatmapPayload) {
195
+ const rows = Array.isArray(heatmapPayload)
196
+ ? heatmapPayload
197
+ : (heatmapPayload.heatmap || heatmapPayload.heatmap_data || heatmapPayload.data || []);
198
+ if (rows.length) updateCoverage(rows);
199
+ }
200
+ if (eventsPayload) {
201
+ const events = Array.isArray(eventsPayload)
202
+ ? eventsPayload
203
+ : (eventsPayload.events || eventsPayload.data || []);
204
+ if (events.length) renderActivity(events);
205
+ }
206
+ if (stats || heatmapPayload || eventsPayload || health) {
207
+ renderHealthIndicators({
208
+ stats: { status: stats ? 'fulfilled' : 'rejected', value: stats },
209
+ heatmap: {
210
+ status: heatmapPayload ? 'fulfilled' : 'rejected',
211
+ value: Array.isArray(heatmapPayload)
212
+ ? heatmapPayload
213
+ : (heatmapPayload?.heatmap || heatmapPayload?.heatmap_data || heatmapPayload?.data || null)
214
+ },
215
+ events: {
216
+ status: eventsPayload ? 'fulfilled' : 'rejected',
217
+ value: Array.isArray(eventsPayload)
218
+ ? eventsPayload
219
+ : (eventsPayload?.events || eventsPayload?.data || null)
220
+ },
221
+ health: { status: health ? 'fulfilled' : 'rejected', value: health }
222
+ });
223
+ setText('#dashboard-last-updated', `Last updated: ${formatClock()}`);
224
+ }
225
+ return Boolean(stats || heatmapPayload || eventsPayload);
226
+ }
227
+
228
+ export async function refreshDashboard(force = false) {
229
+ if (refreshInFlight) return;
230
+ refreshInFlight = true;
231
+ const refreshButton = document.getElementById('dashboard-refresh');
232
+ if (refreshButton) refreshButton.disabled = true;
233
+ if (force) clearApiCache();
234
+ else hydrateDashboardFromCache();
235
+
236
+ const fetchOptions = force ? { cacheMs: 0 } : {};
237
+ const results = {
238
+ stats: { status: 'pending', value: null },
239
+ heatmap: { status: 'pending', value: null },
240
+ events: { status: 'pending', value: null },
241
+ health: { status: 'pending', value: null }
242
+ };
243
+
244
+ const updateUI = () => renderHealthIndicators(results);
245
+
246
+ await Promise.allSettled([
247
+ getStats(fetchOptions).then((value) => {
248
+ results.stats = { status: 'fulfilled', value };
249
+ updateStats(value);
250
+ updateUI();
251
+ }).catch((reason) => {
252
+ results.stats = { status: 'rejected', reason };
253
+ updateUI();
254
+ }),
255
+ getHeatmapData(fetchOptions).then((value) => {
256
+ const rows = Array.isArray(value) ? value : (value?.heatmap || value?.heatmap_data || value?.data || []);
257
+ results.heatmap = { status: 'fulfilled', value: rows };
258
+ updateCoverage(rows);
259
+ updateUI();
260
+ }).catch((reason) => {
261
+ results.heatmap = { status: 'rejected', reason };
262
+ updateUI();
263
+ }),
264
+ getLiveEvents(15, fetchOptions).then((value) => {
265
+ const list = Array.isArray(value) ? value : (value?.events || value?.data || []);
266
+ results.events = { status: 'fulfilled', value: list };
267
+ renderActivity(list);
268
+ updateUI();
269
+ }).catch((reason) => {
270
+ results.events = { status: 'rejected', reason };
271
+ updateUI();
272
+ }),
273
+ getHealth(fetchOptions).then((value) => {
274
+ results.health = { status: 'fulfilled', value };
275
+ updateUI();
276
+ }).catch((reason) => {
277
+ results.health = { status: 'rejected', reason };
278
+ updateUI();
279
+ })
280
+ ]);
281
+
282
+ renderHealthIndicators(results);
283
+ setText('#dashboard-last-updated', `Last updated: ${formatClock()}`);
284
+
285
+ refreshInFlight = false;
286
+ if (refreshButton) refreshButton.disabled = false;
287
+ }
288
+
289
+ export function initDashboard() {
290
+ const refreshButton = document.getElementById('dashboard-refresh');
291
+ const mobileRefreshButton = document.getElementById('dashboard-refresh-mobile');
292
+
293
+ if (refreshButton) {
294
+ refreshButton.addEventListener('click', () => refreshDashboard(true));
295
+ }
296
+ if (mobileRefreshButton) {
297
+ mobileRefreshButton.addEventListener('click', () => refreshDashboard(true));
298
+ }
299
+
300
+ const healthOverlay = document.getElementById('dashboard-health-overlay');
301
+ const healthTrigger = document.getElementById('bottom-nav-health-trigger');
302
+ const desktopHealthTrigger = document.getElementById('desktop-health-trigger');
303
+ const closeOverlay = document.getElementById('close-health-overlay');
304
+
305
+ const toggleOverlay = (show) => {
306
+ if (show) {
307
+ healthOverlay.classList.remove('hidden', 'closing');
308
+ // Force reflow
309
+ healthOverlay.offsetHeight;
310
+ healthOverlay.classList.add('active');
311
+ } else {
312
+ healthOverlay.classList.remove('active');
313
+ healthOverlay.classList.add('closing');
314
+ setTimeout(() => {
315
+ if (healthOverlay.classList.contains('closing')) {
316
+ healthOverlay.classList.add('hidden');
317
+ healthOverlay.classList.remove('closing');
318
+ }
319
+ }, 300); // Match CSS animation duration (0.3s)
320
+ }
321
+ };
322
+
323
+ const triggers = [healthTrigger, desktopHealthTrigger].filter(Boolean);
324
+ if (triggers.length > 0 && healthOverlay) {
325
+ triggers.forEach(trigger => {
326
+ trigger.addEventListener('click', () => {
327
+ const isActive = healthOverlay.classList.contains('active');
328
+ toggleOverlay(!isActive);
329
+ });
330
+ });
331
+ }
332
+
333
+ if (closeOverlay && healthOverlay) {
334
+ closeOverlay.addEventListener('click', () => toggleOverlay(false));
335
+ }
336
+
337
+ if (healthOverlay) {
338
+ document.addEventListener('keydown', (event) => {
339
+ if (event.key === 'Escape' && healthOverlay.classList.contains('active')) {
340
+ toggleOverlay(false);
341
+ }
342
+ });
343
+
344
+ healthOverlay.addEventListener('click', (event) => {
345
+ if (event.target === healthOverlay) toggleOverlay(false);
346
+ });
347
+ }
348
+
349
+ refreshDashboard().catch(() => {
350
+ // Retry once after 5s — covers cold-start when backend ML models are still loading
351
+ setTimeout(() => refreshDashboard().catch(() => {}), 5000);
352
+ });
353
+
354
+ window.setInterval(() => {
355
+ if (document.visibilityState === 'visible') {
356
+ refreshDashboard().catch(() => {});
357
+ }
358
+ }, 30000);
359
+ }
frontend/assets/js/pages/heatmap.js ADDED
@@ -0,0 +1,1036 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { clearApiCache, clearStateEventsCache, getHeatmapData, getLiveEvents, getStateEvents, getStats, peekCached } from '../api.js';
2
+ import {
3
+ createEventItem,
4
+ createFeedLoadingSpinner,
5
+ populateEventDetailModal,
6
+ renderEventList,
7
+ setProgress,
8
+ showToast,
9
+ updateStatusText
10
+ } from '../components/ui.js';
11
+ import { updateMobileNavStatus, updateStatsPanels } from '../components/navbar.js';
12
+ import { createElement, qs, qsa, replaceChildren, setText } from '../utils/dom.js';
13
+ import {
14
+ formatCount,
15
+ formatPercent,
16
+ formatRatioAsPercent,
17
+ getEventClassification,
18
+ getFakeRatio,
19
+ getEventState,
20
+ getEventTitle,
21
+ normalizeName,
22
+ numberOrNull,
23
+ riskFromRecord
24
+ } from '../utils/formatters.js';
25
+ import { isInteractiveInput, toSafeText } from '../utils/security.js';
26
+
27
+ const SVG_PATH = '/assets/map/in.svg';
28
+ const MIN_ZOOM = 0.72;
29
+ const MAX_ZOOM = 2.8;
30
+ const BUTTON_ZOOM_STEP = 0.12;
31
+ const MAP_BOUNDS_PADDING = 100;
32
+
33
+ const STATE_ALIASES = {
34
+ 'andaman and nicobar': 'Andaman and Nicobar Islands',
35
+ 'andaman and nicobar islands': 'Andaman and Nicobar Islands',
36
+ 'dadra and nagar haveli and daman and diu': 'Dadra and Nagar Haveli and Daman and Diu',
37
+ 'daman and diu': 'Dadra and Nagar Haveli and Daman and Diu',
38
+ orissa: 'Odisha',
39
+ uttaranchal: 'Uttarakhand',
40
+ delhi: 'Delhi'
41
+ };
42
+
43
+ let heatmapData = [];
44
+ let stateLookup = new Map();
45
+ let latestStats = null;
46
+ let svgLoadPromise = null;
47
+ let selectedState = null;
48
+ let selectedStateRecord = null;
49
+ let svgMap = null;
50
+ let currentZoom = window.innerWidth <= 1024 ? 1.0 : 1.15;
51
+ let currentX = 0;
52
+ let currentY = 0;
53
+ let isDragging = false;
54
+ let lastPointerX = 0;
55
+ let lastPointerY = 0;
56
+ let pinchStartDistance = 0;
57
+ let pinchStartZoom = 1;
58
+ let mapInteractionActive = false;
59
+ let refreshInFlight = false;
60
+ let feedsGeneration = 0;
61
+ let globalEventsPool = null;
62
+
63
+ const FEED_DISPLAY_LIMIT = 10;
64
+ const LIVE_EVENTS_FETCH_LIMIT = 80;
65
+ const STATE_EVENTS_FETCH_LIMIT = 100;
66
+
67
+ function feedDelay(ms) {
68
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
69
+ }
70
+
71
+ function mapStatus(text, state = 'neutral') {
72
+ updateStatusText('heatmap-nav-status', text, state);
73
+ updateMobileNavStatus(text);
74
+ }
75
+
76
+ function heatmapRecords() {
77
+ return Array.isArray(heatmapData) ? heatmapData.filter(Boolean) : [];
78
+ }
79
+
80
+ function rebuildStateLookup(records = heatmapRecords()) {
81
+ const map = new Map();
82
+ for (const record of records) {
83
+ const keys = [
84
+ normalizeName(record.state),
85
+ normalizeName(record.name),
86
+ normalizeName(record.region)
87
+ ].filter(Boolean);
88
+ for (const key of keys) {
89
+ if (!map.has(key)) map.set(key, record);
90
+ }
91
+ }
92
+ for (const [alias, canonical] of Object.entries(STATE_ALIASES)) {
93
+ const target = map.get(normalizeName(canonical));
94
+ if (target) map.set(normalizeName(alias), target);
95
+ }
96
+ stateLookup = map;
97
+ }
98
+
99
+ function applyHeatmapPayload(records) {
100
+ if (!Array.isArray(records)) return false;
101
+ if (!records.length) return heatmapRecords().length > 0;
102
+ heatmapData = records;
103
+ rebuildStateLookup(records);
104
+ return true;
105
+ }
106
+
107
+ function syncHeatmapView() {
108
+ if (!svgMap) return;
109
+ svgMap.removeAttribute('fill');
110
+ if (heatmapRecords().length) {
111
+ applyHeatmapColors();
112
+ updateStatsPopover();
113
+ mapStatus('Map live', 'good');
114
+ } else {
115
+ mapStatus('No heatmap data', 'warn');
116
+ }
117
+ hideLoading();
118
+ }
119
+
120
+ function hydrateHeatmapFromCache() {
121
+ const stats = peekCached('/api/v1/stats');
122
+ if (stats) {
123
+ latestStats = stats;
124
+ updateStatsPopover();
125
+ }
126
+ const payload = peekCached('/api/v1/heatmap/data');
127
+ if (!payload) return false;
128
+ const rows = Array.isArray(payload) ? payload : (payload.heatmap || payload.heatmap_data || payload.data || []);
129
+ if (!rows.length) return false;
130
+ applyHeatmapPayload(rows);
131
+ return true;
132
+ }
133
+
134
+ function getRecordCount(record) {
135
+ return numberOrNull(record && (record.event_count ?? record.total_events ?? record.events));
136
+ }
137
+
138
+ function totalMappedEvents() {
139
+ return heatmapRecords().reduce((sum, record) => sum + (getRecordCount(record) || 0), 0);
140
+ }
141
+
142
+ function activeMappedStates() {
143
+ return heatmapRecords().filter((record) => (getRecordCount(record) || 0) > 0).length;
144
+ }
145
+
146
+ function updateStatsPopover() {
147
+ const total = numberOrNull(latestStats && latestStats.total_events) ?? totalMappedEvents();
148
+ const active = activeMappedStates();
149
+ const accuracy = latestStats ? formatPercent(latestStats.classification_accuracy) : '--';
150
+ const rawTs = latestStats && (latestStats.last_update_time || latestStats.last_update || latestStats.timestamp);
151
+ const updateDate = rawTs ? new Date(rawTs) : null;
152
+ const timeString = updateDate && !Number.isNaN(updateDate.getTime())
153
+ ? updateDate.toLocaleTimeString('en-IN', {
154
+ hour: '2-digit',
155
+ minute: '2-digit',
156
+ hour12: true
157
+ })
158
+ : new Date().toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
159
+
160
+ updateStatsPanels([
161
+ { label: 'Total Events', value: formatCount(total) },
162
+ { label: 'Active States', value: heatmapRecords().length ? `${active} States` : '--' },
163
+ { label: 'Last Update', value: timeString },
164
+ { label: 'Accuracy', value: accuracy }
165
+ ]);
166
+ }
167
+
168
+ function updateProgressBar(percent) {
169
+ setProgress('heatmap-progress-bar', percent);
170
+ }
171
+
172
+ function hideLoading() {
173
+ const loading = qs('#heatmap-loading');
174
+ if (loading) loading.remove();
175
+ }
176
+
177
+ function showMapError(message) {
178
+ const root = qs('#heatmap-svg-root');
179
+ if (!root) return;
180
+ replaceChildren(root, createElement('div', { className: 'max-w-sm px-6 text-center' }, [
181
+ createElement('p', { className: 'text-lg font-bold text-slate-950', text: 'Map could not load' }),
182
+ createElement('p', { className: 'mt-2 text-sm leading-6 text-slate-600', text: message })
183
+ ]));
184
+ }
185
+
186
+ async function loadSvgMap() {
187
+ updateProgressBar(20);
188
+ const response = await fetch(SVG_PATH, { headers: { Accept: 'image/svg+xml,text/xml' } });
189
+ if (!response.ok) throw new Error('SVG asset is unavailable.');
190
+ const svgText = await response.text();
191
+ const parsed = new DOMParser().parseFromString(svgText, 'image/svg+xml');
192
+ if (parsed.querySelector('parsererror')) {
193
+ console.error('SVG Parsing Error:', parsed.querySelector('parsererror').textContent);
194
+ throw new Error('SVG asset could not be parsed safely.');
195
+ }
196
+ const sourceSvg = parsed.querySelector('svg');
197
+ if (!sourceSvg) {
198
+ console.error('SVG Source Error: No <svg> tag found in fetched content.');
199
+ throw new Error('SVG root is missing.');
200
+ }
201
+ sanitizeInlineSvg(sourceSvg);
202
+
203
+ const root = qs('#heatmap-svg-root');
204
+ if (!root) throw new Error('Map container is missing.');
205
+ replaceChildren(root, document.importNode(sourceSvg, true));
206
+ svgMap = qs('svg', root);
207
+ if (!svgMap) throw new Error('Map SVG was not inserted.');
208
+
209
+ configureSvg();
210
+ bindMapPaths();
211
+ setupZoomAndPan();
212
+ resetZoom();
213
+ updateProgressBar(55);
214
+ }
215
+
216
+ function sanitizeInlineSvg(sourceSvg) {
217
+ sourceSvg
218
+ .querySelectorAll('script, foreignObject, iframe, object, embed, image, use, a, style, link')
219
+ .forEach((node) => node.remove());
220
+
221
+ [sourceSvg, ...sourceSvg.querySelectorAll('*')].forEach((element) => {
222
+ Array.from(element.attributes).forEach((attribute) => {
223
+ const name = attribute.name.toLowerCase();
224
+ if (name.startsWith('on') || name === 'style' || name === 'href' || name === 'xlink:href') {
225
+ element.removeAttribute(attribute.name);
226
+ }
227
+ });
228
+ });
229
+ }
230
+
231
+ function configureSvg() {
232
+ const sourceViewBox = svgMap.getAttribute('viewBox') || svgMap.getAttribute('viewbox') || '0 0 1000 1000';
233
+ svgMap.setAttribute('viewBox', sourceViewBox);
234
+ svgMap.removeAttribute('viewbox');
235
+ svgMap.removeAttribute('width');
236
+ svgMap.removeAttribute('height');
237
+ svgMap.setAttribute('preserveAspectRatio', 'xMidYMid meet');
238
+ const classes = 'h-full max-h-full w-full max-w-full transition-transform duration-100 ease-out origin-center drop-shadow-md will-change-transform';
239
+ svgMap.classList.add(...classes.split(' '));
240
+ svgMap.setAttribute('aria-label', 'India state risk map');
241
+ }
242
+
243
+ function bindMapPaths() {
244
+ qsa('path', svgMap).forEach((path) => {
245
+ const fallbackName = path.getAttribute('name') || path.getAttribute('title') || path.id || 'Map region';
246
+ const pathClasses = 'heatmap-path cursor-pointer stroke-white outline-none transition-[fill,stroke,stroke-width] duration-200 ease-out';
247
+ path.classList.add(...pathClasses.split(' '));
248
+ path.style.strokeWidth = '0.75';
249
+ path.setAttribute('tabindex', '0');
250
+ path.setAttribute('role', 'button');
251
+ path.setAttribute('aria-label', fallbackName);
252
+ path.addEventListener('click', handleStateClick);
253
+ path.addEventListener('keydown', (event) => {
254
+ if (event.key === 'Enter' || event.key === ' ') {
255
+ event.preventDefault();
256
+ handleStateClick(event);
257
+ }
258
+ });
259
+ path.addEventListener('mouseenter', (event) => {
260
+ if (!path.classList.contains('selected')) {
261
+ path.style.stroke = '#FF9933';
262
+ path.style.strokeWidth = '2.5';
263
+ }
264
+ handleStateHover(event);
265
+ });
266
+ path.addEventListener('mousemove', handleStateHover);
267
+ path.addEventListener('mouseleave', () => {
268
+ if (!path.classList.contains('selected')) {
269
+ path.style.stroke = '#ffffff';
270
+ path.style.strokeWidth = '0.75';
271
+ }
272
+ hideTooltip();
273
+ });
274
+ path.addEventListener('focus', (event) => {
275
+ if (!path.classList.contains('selected')) {
276
+ path.style.stroke = '#E87722';
277
+ }
278
+ handleStateHover(event);
279
+ });
280
+ path.addEventListener('blur', (event) => {
281
+ if (!path.classList.contains('selected')) {
282
+ path.style.stroke = '#ffffff';
283
+ }
284
+ hideTooltip();
285
+ });
286
+ });
287
+ }
288
+
289
+ async function loadHeatmapData(force = false) {
290
+ if (force) clearApiCache();
291
+ else hydrateHeatmapFromCache();
292
+
293
+ const fetchOptions = force ? { cacheMs: 0 } : {};
294
+ updateProgressBar(70);
295
+ const newData = await getHeatmapData(fetchOptions);
296
+ applyHeatmapPayload(newData);
297
+ updateProgressBar(100);
298
+ return newData;
299
+ }
300
+
301
+ async function ensureSvgMap() {
302
+ if (svgMap) return;
303
+ svgLoadPromise = svgLoadPromise || loadSvgMap().catch((error) => {
304
+ svgLoadPromise = null;
305
+ throw error;
306
+ });
307
+ await svgLoadPromise;
308
+ }
309
+
310
+ async function refreshHeatmap(force = false) {
311
+ if (refreshInFlight) return;
312
+ refreshInFlight = true;
313
+ const refreshButton = qs('#heatmap-refresh') || qs('#heatmap-header-refresh') || qs('#heatmap-refresh-mobile');
314
+ if (refreshButton) refreshButton.disabled = true;
315
+
316
+ try {
317
+ await Promise.all([ensureSvgMap(), loadHeatmapData(force)]);
318
+ syncHeatmapView();
319
+ await Promise.allSettled([
320
+ updateStats(),
321
+ updateFeeds(Boolean(selectedState), { forceRefresh: force })
322
+ ]);
323
+ } catch (error) {
324
+ console.error('Heatmap refresh failed:', error);
325
+ hideLoading();
326
+ if (!heatmapRecords().length) mapStatus('Connecting to spatial feed...', 'neutral');
327
+ else syncHeatmapView();
328
+ } finally {
329
+ refreshInFlight = false;
330
+ if (refreshButton) refreshButton.disabled = false;
331
+ }
332
+ }
333
+
334
+ async function updateStats() {
335
+ try {
336
+ latestStats = await getStats();
337
+ const processing = Boolean(latestStats.processing_active);
338
+ const mlReady = Boolean(latestStats.ml_ready);
339
+ mapStatus(processing ? 'Live processing' : mlReady ? 'System ready' : 'Map data loaded', processing || mlReady ? 'good' : 'neutral');
340
+ } catch {
341
+ latestStats = null;
342
+ }
343
+ updateStatsPopover();
344
+ }
345
+
346
+ function findStateData(pathElement) {
347
+ if (!pathElement) return null;
348
+ const rawName = pathElement.getAttribute('name') || pathElement.getAttribute('title') || pathElement.id || '';
349
+ const normalizedSvgName = normalizeName(rawName);
350
+ if (!normalizedSvgName) return null;
351
+
352
+ const direct = stateLookup.get(normalizedSvgName);
353
+ if (direct) return direct;
354
+
355
+ const alias = STATE_ALIASES[normalizedSvgName];
356
+ if (alias) {
357
+ const aliasHit = stateLookup.get(normalizeName(alias));
358
+ if (aliasHit) return aliasHit;
359
+ }
360
+
361
+ for (const [key, record] of stateLookup) {
362
+ if (key.includes(normalizedSvgName) || normalizedSvgName.includes(key)) return record;
363
+ }
364
+ return null;
365
+ }
366
+
367
+ function applyHeatmapColors() {
368
+ if (!svgMap) return;
369
+ qsa('path', svgMap).forEach((path) => {
370
+ const record = findStateData(path);
371
+ const risk = riskFromRecord(record || { risk_level: 'insufficient_data' });
372
+ path.setAttribute('fill', risk.color);
373
+ path.style.fill = risk.color;
374
+ path.style.stroke = '#ffffff';
375
+ path.style.strokeWidth = '0.75';
376
+ path.dataset.risk = risk.key;
377
+ path.dataset.state = record ? toSafeText(record.state || record.name || path.getAttribute('name')) : toSafeText(path.getAttribute('name') || path.id);
378
+ });
379
+ }
380
+
381
+ function handleStateClick(event) {
382
+ event.stopPropagation();
383
+ const path = event.currentTarget || event.target;
384
+ if (!path || !svgMap) return;
385
+
386
+ const candidateState = toSafeText(path.getAttribute('name') || path.id, 'Unknown state');
387
+ const record = findStateData(path);
388
+ const stateName = record ? toSafeText(record.state || record.name) : candidateState;
389
+
390
+ if (selectedState === stateName) {
391
+ clearStateSelection();
392
+ return;
393
+ }
394
+
395
+ if (path.parentNode) {
396
+ path.parentNode.appendChild(path);
397
+ }
398
+ qsa('.selected', svgMap).forEach((element) => {
399
+ element.classList.remove('selected');
400
+ element.style.strokeWidth = '0.75';
401
+ element.style.stroke = '#ffffff';
402
+ });
403
+ path.classList.add('selected');
404
+ path.style.strokeWidth = '3.5';
405
+ path.style.stroke = '#FF9933';
406
+ selectedStateRecord = record;
407
+ selectedState = stateName;
408
+ mapInteractionActive = true;
409
+
410
+ updateStatsPopover();
411
+ updateFeeds(true);
412
+ }
413
+
414
+
415
+ function clearStateSelection() {
416
+ selectedState = null;
417
+ selectedStateRecord = null;
418
+ if (svgMap) {
419
+ qsa('.selected', svgMap).forEach((element) => {
420
+ element.classList.remove('selected');
421
+ element.style.strokeWidth = '0.75';
422
+ element.style.stroke = '#ffffff';
423
+ });
424
+ }
425
+
426
+ updateStatsPopover();
427
+ updateFeeds(true);
428
+ }
429
+
430
+ function handleStateHover(event) {
431
+ const path = event.currentTarget || event.target;
432
+ if (!path) return;
433
+ const record = findStateData(path);
434
+ const fallbackName = path.getAttribute('name') || path.id || 'Unknown state';
435
+ showTooltip(event, record, fallbackName);
436
+ }
437
+
438
+ function showTooltip(event, record, fallbackName) {
439
+ const tooltip = qs('#heatmap-tooltip');
440
+ if (!tooltip) return;
441
+ const risk = riskFromRecord(record || { risk_level: 'insufficient_data' });
442
+ const children = [
443
+ createElement('p', { className: 'font-bold text-white', text: record ? (record.state || record.name) : fallbackName })
444
+ ];
445
+ if (record) {
446
+ const fakeCount = numberOrNull(record.fake_count ?? record.fake_events);
447
+ const realCount = numberOrNull(record.real_count ?? record.real_events ?? record.verified_count ?? record.verified_events);
448
+ children.push(
449
+ createElement('p', { text: `Events: ${formatCount(record.event_count ?? record.total_events ?? record.events)}` }),
450
+ createElement('p', { text: `Fake News: ${formatCount(fakeCount)}` }),
451
+ createElement('p', { text: `Verified News: ${formatCount(realCount)}` }),
452
+ createElement('p', { text: `Fake ratio: ${formatRatioAsPercent(getFakeRatio(record))}` }),
453
+ createElement('p', { text: `Risk: ${risk.label}` })
454
+ );
455
+ } else {
456
+ children.push(createElement('p', { className: 'text-slate-300', text: 'No live metrics available for this region.' }));
457
+ }
458
+
459
+ replaceChildren(tooltip, children);
460
+ tooltip.style.display = 'block';
461
+ tooltip.setAttribute('aria-hidden', 'false');
462
+
463
+ // Dynamic positioning to prevent clipping
464
+ const tooltipRect = tooltip.getBoundingClientRect();
465
+ const gap = 15;
466
+ let left = event.clientX + gap;
467
+ let top = event.clientY + gap;
468
+
469
+ // Horizontal clamping
470
+ if (left + tooltipRect.width > window.innerWidth - gap) {
471
+ left = event.clientX - tooltipRect.width - gap;
472
+ }
473
+
474
+ // Vertical clamping - if near bottom, show above cursor
475
+ if (top + tooltipRect.height > window.innerHeight - gap) {
476
+ top = event.clientY - tooltipRect.height - gap;
477
+ }
478
+
479
+ // Ensure it doesn't go off the top/left
480
+ left = Math.max(gap, left);
481
+ top = Math.max(gap, top);
482
+
483
+ tooltip.style.left = `${left}px`;
484
+ tooltip.style.top = `${top}px`;
485
+ }
486
+
487
+ function hideTooltip() {
488
+ const tooltip = qs('#heatmap-tooltip');
489
+ if (!tooltip) return;
490
+ tooltip.style.display = 'none';
491
+ tooltip.setAttribute('aria-hidden', 'true');
492
+ }
493
+
494
+ function activeMapStates() {
495
+ return heatmapRecords()
496
+ .filter((record) => (getRecordCount(record) || 0) > 0)
497
+ .sort((a, b) => (getRecordCount(b) || 0) - (getRecordCount(a) || 0));
498
+ }
499
+
500
+ function matchesNormalizedState(candidate, expected) {
501
+ if (!candidate || !expected) return false;
502
+ return candidate === expected || candidate.includes(expected) || expected.includes(candidate);
503
+ }
504
+
505
+ function getCanonicalState(stateName) {
506
+ const norm = normalizeName(stateName);
507
+ if (!norm) return null;
508
+ const alias = STATE_ALIASES[norm];
509
+ return alias ? normalizeName(alias) : norm;
510
+ }
511
+
512
+ function eventMatchesState(event, stateName) {
513
+ const expected = getCanonicalState(stateName);
514
+ if (!expected) return false;
515
+
516
+ // Primary check: state field
517
+ const eventState = getEventState(event);
518
+ if (eventState && getCanonicalState(eventState) === expected) return true;
519
+
520
+ // Secondary checks: other fields
521
+ const candidates = [
522
+ event.location,
523
+ event.region,
524
+ getEventTitle(event)
525
+ ].map(getCanonicalState).filter(Boolean);
526
+
527
+ return candidates.some((candidate) => candidate === expected || candidate.includes(expected) || expected.includes(candidate));
528
+ }
529
+
530
+ function dedupeEvents(events) {
531
+ const seen = new Set();
532
+ return (Array.isArray(events) ? events : []).filter((event) => {
533
+ if (!event) return false;
534
+ const key = `${normalizeName(getEventTitle(event))}|${normalizeName(getEventState(event))}|${event.timestamp || ''}`;
535
+ if (seen.has(key)) return false;
536
+ seen.add(key);
537
+ return true;
538
+ });
539
+ }
540
+
541
+ function feedEmptyOptions(type, stateName = selectedState) {
542
+ const isFake = type === 'fake';
543
+ return {
544
+ type,
545
+ emptyTitle: stateName
546
+ ? (isFake ? `No alerts for ${stateName}` : `No verified news for ${stateName}`)
547
+ : (isFake ? 'Clear signal' : 'Awaiting updates'),
548
+ emptyDetail: isFake
549
+ ? 'Monitoring for new misinformation signals across active map regions.'
550
+ : 'Verified reports appear here as they are authenticated by the pipeline.'
551
+ };
552
+ }
553
+
554
+ async function renderFeedsProgressively(fakeEvents, realEvents, generation, stateName) {
555
+ if (generation !== feedsGeneration) return;
556
+ const alertsEl = qs('#heatmap-misinformation-feed');
557
+ const newsEl = qs('#heatmap-real-news-feed');
558
+ const displayLimit = stateName ? 20 : FEED_DISPLAY_LIMIT;
559
+ const fakeList = fakeEvents.slice(0, displayLimit);
560
+ const realList = realEvents.slice(0, displayLimit);
561
+
562
+ if (alertsEl) {
563
+ if (fakeList.length) {
564
+ replaceChildren(alertsEl, fakeList.map(event => createEventItem(event, { type: 'fake' })));
565
+ } else {
566
+ renderEventList('#heatmap-misinformation-feed', [], feedEmptyOptions('fake', stateName));
567
+ }
568
+ setText('#heatmap-misinformation-count', `${fakeList.length} item${fakeList.length === 1 ? '' : 's'}`);
569
+ }
570
+ if (newsEl) {
571
+ if (realList.length) {
572
+ replaceChildren(newsEl, realList.map(event => createEventItem(event, { type: 'real' })));
573
+ } else {
574
+ renderEventList('#heatmap-real-news-feed', [], feedEmptyOptions('real', stateName));
575
+ }
576
+ setText('#heatmap-real-news-count', `${realList.length} item${realList.length === 1 ? '' : 's'}`);
577
+ }
578
+ }
579
+
580
+ async function updateFeeds(forceState = false, options = {}) {
581
+ const feedAlerts = qs('#heatmap-misinformation-feed');
582
+ const feedNews = qs('#heatmap-real-news-feed');
583
+ const stateAtRequest = selectedState;
584
+ const generation = ++feedsGeneration;
585
+
586
+ // Immediately show loading placeholder in Alerts/Verified when state is selected/clicked
587
+ if (stateAtRequest) {
588
+ if (feedAlerts) replaceChildren(feedAlerts, [createFeedLoadingSpinner()]);
589
+ if (feedNews) replaceChildren(feedNews, [createFeedLoadingSpinner()]);
590
+ }
591
+
592
+ const forceRefresh = Boolean(options.forceRefresh);
593
+
594
+ if (forceRefresh && stateAtRequest) {
595
+ clearStateEventsCache(stateAtRequest);
596
+ }
597
+
598
+ try {
599
+ if (stateAtRequest) {
600
+ // 1. Fetch latest 10 quickly
601
+ const fetchOptions = forceRefresh ? { cacheMs: 0, force: true } : {};
602
+ const events10 = await getStateEvents(stateAtRequest, { ...fetchOptions, limit: 10 });
603
+ if (generation !== feedsGeneration) return;
604
+
605
+ const deduped10 = dedupeEvents(events10);
606
+ const filtered10 = deduped10.filter((event) => eventMatchesState(event, stateAtRequest));
607
+ const fake10 = filtered10.filter((event) => getEventClassification(event) === 'fake');
608
+ const real10 = filtered10.filter((event) => getEventClassification(event) === 'real');
609
+
610
+ await renderFeedsProgressively(fake10, real10, generation, stateAtRequest);
611
+
612
+ // 2. Fetch up to 20 in the background
613
+ getStateEvents(stateAtRequest, { ...fetchOptions, limit: 20 })
614
+ .then(async (events20) => {
615
+ if (generation !== feedsGeneration) return;
616
+ const deduped20 = dedupeEvents(events20);
617
+ const filtered20 = deduped20.filter((event) => eventMatchesState(event, stateAtRequest));
618
+ const fake20 = filtered20.filter((event) => getEventClassification(event) === 'fake');
619
+ const real20 = filtered20.filter((event) => getEventClassification(event) === 'real');
620
+ await renderFeedsProgressively(fake20, real20, generation, stateAtRequest);
621
+ })
622
+ .catch((err) => {
623
+ console.warn('Background feed fetch failed:', err);
624
+ });
625
+ } else {
626
+ // No state selected: fetch only live events (global pool)
627
+ const fetchOptions = forceRefresh ? { cacheMs: 0, force: true } : {};
628
+ const events = await getLiveEvents(LIVE_EVENTS_FETCH_LIMIT, fetchOptions);
629
+ if (generation !== feedsGeneration) return;
630
+
631
+ const deduped = dedupeEvents(events);
632
+ const fakeEvents = deduped.filter((event) => getEventClassification(event) === 'fake');
633
+ const realEvents = deduped.filter((event) => getEventClassification(event) === 'real');
634
+ await renderFeedsProgressively(fakeEvents, realEvents, generation, stateAtRequest);
635
+ }
636
+ } catch (error) {
637
+ if (generation !== feedsGeneration) return;
638
+ console.error('Feed update failed:', error);
639
+ if (feedAlerts) {
640
+ renderEventList('#heatmap-misinformation-feed', [], {
641
+ ...feedEmptyOptions('fake', stateAtRequest),
642
+ emptyTitle: 'Feed unavailable',
643
+ emptyDetail: 'Could not load events. Use Refresh to try again.'
644
+ });
645
+ }
646
+ if (feedNews) {
647
+ renderEventList('#heatmap-real-news-feed', [], {
648
+ ...feedEmptyOptions('real', stateAtRequest),
649
+ emptyTitle: 'Feed unavailable',
650
+ emptyDetail: 'Could not load events. Use Refresh to try again.'
651
+ });
652
+ }
653
+ setText('#heatmap-misinformation-count', '0 items');
654
+ setText('#heatmap-real-news-count', '0 items');
655
+ }
656
+ }
657
+
658
+ function setupControls() {
659
+ const refresh = qs('#heatmap-refresh');
660
+ const refreshHeader = qs('#heatmap-header-refresh');
661
+ const refreshMobile = qs('#heatmap-refresh-mobile');
662
+
663
+ const refreshHandler = () => refreshHeatmap(true);
664
+ if (refresh) refresh.addEventListener('click', refreshHandler);
665
+ if (refreshHeader) refreshHeader.addEventListener('click', refreshHandler);
666
+ if (refreshMobile) refreshMobile.addEventListener('click', refreshHandler);
667
+
668
+ const zoomInButton = qs('#heatmap-zoom-in');
669
+ const zoomOutButton = qs('#heatmap-zoom-out');
670
+ const zoomResetButton = qs('#heatmap-zoom-reset');
671
+ if (zoomInButton) zoomInButton.addEventListener('click', () => zoomBy(BUTTON_ZOOM_STEP));
672
+ if (zoomOutButton) zoomOutButton.addEventListener('click', () => zoomBy(-BUTTON_ZOOM_STEP));
673
+ if (zoomResetButton) zoomResetButton.addEventListener('click', resetZoom);
674
+
675
+ const scrollDownBtn = qs('#scroll-down-btn');
676
+ if (scrollDownBtn) {
677
+ scrollDownBtn.addEventListener('click', () => {
678
+ window.scrollBy({
679
+ top: window.innerHeight * 0.7,
680
+ behavior: 'smooth'
681
+ });
682
+ });
683
+ }
684
+
685
+ const legendToggle = qs('#risk-legend-toggle');
686
+ const legendPanel = qs('#risk-legend-panel');
687
+ const legendIcon = qs('#risk-legend-icon');
688
+ if (legendToggle && legendPanel) {
689
+ legendToggle.addEventListener('click', () => {
690
+ const expanded = legendToggle.getAttribute('aria-expanded') === 'true';
691
+ legendToggle.setAttribute('aria-expanded', String(!expanded));
692
+ if (expanded) {
693
+ legendPanel.style.maxHeight = '0px';
694
+ legendIcon.style.transform = 'rotate(-180deg)';
695
+ } else {
696
+ legendPanel.style.maxHeight = '500px';
697
+ legendIcon.style.transform = 'rotate(0deg)';
698
+ }
699
+ });
700
+
701
+ // Collapse legend by default for mobile phones and Nest Hub (1024x600) to save space
702
+ // EXCLUDE Surface Duo (540x720) as per user request to show legend
703
+ const isNestHub = window.innerWidth === 1024 && window.innerHeight === 600;
704
+ const isSurfaceDuo = window.innerWidth === 540 && window.innerHeight === 720;
705
+ if ((window.innerWidth < 600 && !isSurfaceDuo) || isNestHub) {
706
+ legendToggle.setAttribute('aria-expanded', 'false');
707
+ legendPanel.style.maxHeight = '0px';
708
+ legendIcon.style.transform = 'rotate(-180deg)';
709
+ }
710
+ }
711
+
712
+
713
+ // Tab Logic
714
+ const tabAlerts = qs('#tab-alerts');
715
+ const tabNews = qs('#tab-news');
716
+ const panelAlerts = qs('#panel-alerts');
717
+ const panelNews = qs('#panel-news');
718
+
719
+ if (tabAlerts && tabNews && panelAlerts && panelNews) {
720
+ const setActiveTab = (activeTab) => {
721
+ if (activeTab === 'alerts') {
722
+ tabAlerts.className = 'flex-1 rounded-lg px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-all duration-200 bg-white text-red-600 shadow-sm border border-slate-200';
723
+ tabNews.className = 'flex-1 rounded-lg px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-all duration-200 text-slate-500 hover:text-slate-700';
724
+ panelAlerts.classList.remove('hidden');
725
+ panelNews.classList.add('hidden');
726
+ } else {
727
+ tabNews.className = 'flex-1 rounded-lg px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-all duration-200 bg-white text-emerald-600 shadow-sm border border-slate-200';
728
+ tabAlerts.className = 'flex-1 rounded-lg px-4 py-2.5 text-xs font-bold uppercase tracking-widest transition-all duration-200 text-slate-500 hover:text-slate-700';
729
+ panelNews.classList.remove('hidden');
730
+ panelAlerts.classList.add('hidden');
731
+ }
732
+ };
733
+
734
+ tabAlerts.addEventListener('click', () => setActiveTab('alerts'));
735
+ tabNews.addEventListener('click', () => setActiveTab('news'));
736
+ }
737
+
738
+ const stateClearButton = qs('#heatmap-state-clear');
739
+ if (stateClearButton) {
740
+ stateClearButton.addEventListener('click', clearStateSelection);
741
+ }
742
+
743
+ const svgRoot = qs('#heatmap-svg-root');
744
+ if (svgRoot) {
745
+ svgRoot.addEventListener('click', (event) => {
746
+ if (event.target && event.target.tagName !== 'path') {
747
+ clearStateSelection();
748
+ }
749
+ });
750
+ }
751
+
752
+ setupEventModal();
753
+ document.addEventListener('keydown', handleHeatmapShortcuts);
754
+ }
755
+
756
+ function setupEventModal() {
757
+ const modal = qs('#event-detail-modal');
758
+ const overlay = qs('#event-detail-overlay');
759
+ const closeBtn = qs('#event-detail-close');
760
+
761
+ if (!modal) return;
762
+ let lastFocusedElement = null;
763
+
764
+ const closeModal = () => {
765
+ modal.classList.add('opacity-0', 'pointer-events-none');
766
+ modal.classList.remove('opacity-100', 'pointer-events-auto');
767
+ modal.setAttribute('aria-hidden', 'true');
768
+ document.body.classList.remove('overflow-hidden');
769
+ const card = qs('#event-detail-card');
770
+ if (card) {
771
+ card.classList.add('scale-95');
772
+ card.classList.remove('scale-100');
773
+ }
774
+ if (lastFocusedElement instanceof HTMLElement) {
775
+ lastFocusedElement.focus({ preventScroll: true });
776
+ }
777
+ lastFocusedElement = null;
778
+ };
779
+
780
+ closeBtn?.addEventListener('click', closeModal);
781
+ overlay?.addEventListener('click', closeModal);
782
+ modal.addEventListener('keydown', (event) => {
783
+ if (event.key !== 'Tab' || modal.classList.contains('pointer-events-none')) return;
784
+ const focusable = qsa('button:not([disabled]), a[href]', modal)
785
+ .filter((element) => element instanceof HTMLElement && !element.classList.contains('hidden'));
786
+ if (!focusable.length) return;
787
+ const first = focusable[0];
788
+ const last = focusable[focusable.length - 1];
789
+ if (event.shiftKey && document.activeElement === first) {
790
+ event.preventDefault();
791
+ last.focus();
792
+ } else if (!event.shiftKey && document.activeElement === last) {
793
+ event.preventDefault();
794
+ first.focus();
795
+ }
796
+ });
797
+ document.addEventListener('keydown', (event) => {
798
+ if (event.key === 'Escape' && modal.classList.contains('pointer-events-auto')) {
799
+ closeModal();
800
+ }
801
+ });
802
+
803
+ document.addEventListener('openEventModal', (e) => {
804
+ lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
805
+ populateEventDetailModal(e.detail || {});
806
+
807
+ // Show modal
808
+ modal.classList.remove('opacity-0', 'pointer-events-none');
809
+ modal.classList.add('opacity-100', 'pointer-events-auto');
810
+ modal.setAttribute('aria-hidden', 'false');
811
+ document.body.classList.add('overflow-hidden');
812
+ const card = qs('#event-detail-card');
813
+ if (card) {
814
+ card.classList.remove('scale-95');
815
+ card.classList.add('scale-100');
816
+ }
817
+ closeBtn?.focus({ preventScroll: true });
818
+ });
819
+ }
820
+
821
+ function setupZoomAndPan() {
822
+ const root = qs('#heatmap-svg-root');
823
+ if (!root || !svgMap) return;
824
+
825
+ root.addEventListener('pointerenter', () => {
826
+ mapInteractionActive = true;
827
+ });
828
+ root.addEventListener('focusin', () => {
829
+ mapInteractionActive = true;
830
+ });
831
+ root.addEventListener('mousedown', () => {
832
+ mapInteractionActive = true;
833
+ });
834
+ root.addEventListener('touchstart', () => {
835
+ mapInteractionActive = true;
836
+ }, { passive: true });
837
+
838
+ root.addEventListener('wheel', (event) => {
839
+ if (event.cancelable) event.preventDefault();
840
+ const direction = event.deltaY > 0 ? -1 : 1;
841
+ const magnitude = Math.min(1, Math.abs(event.deltaY) / 600);
842
+ zoomBy(direction * 0.10 * Math.max(0.25, magnitude), event.clientX, event.clientY);
843
+ }, { passive: false });
844
+
845
+ root.addEventListener('mousedown', (event) => {
846
+ if (event.button !== 0) return;
847
+ isDragging = true;
848
+ lastPointerX = event.clientX;
849
+ lastPointerY = event.clientY;
850
+ root.classList.add('cursor-grabbing');
851
+ });
852
+
853
+ document.addEventListener('mousemove', (event) => {
854
+ if (!isDragging) return;
855
+ currentX += event.clientX - lastPointerX;
856
+ currentY += event.clientY - lastPointerY;
857
+ lastPointerX = event.clientX;
858
+ lastPointerY = event.clientY;
859
+ updateTransform();
860
+ });
861
+
862
+ document.addEventListener('mouseup', () => {
863
+ isDragging = false;
864
+ root.classList.remove('cursor-grabbing');
865
+ });
866
+
867
+ root.addEventListener('touchstart', (event) => {
868
+ if (event.touches.length === 2) {
869
+ pinchStartDistance = getTouchDistance(event.touches);
870
+ pinchStartZoom = currentZoom;
871
+ } else if (event.touches.length === 1) {
872
+ isDragging = true;
873
+ lastPointerX = event.touches[0].clientX;
874
+ lastPointerY = event.touches[0].clientY;
875
+ }
876
+ }, { passive: true });
877
+
878
+ root.addEventListener('touchmove', (event) => {
879
+ if (event.cancelable) event.preventDefault();
880
+ if (event.touches.length === 2 && pinchStartDistance > 0) {
881
+ const distance = getTouchDistance(event.touches);
882
+ const ratio = distance / pinchStartDistance;
883
+ const dampened = 1 + (ratio - 1) * 0.45;
884
+ currentZoom = clampZoom(pinchStartZoom * dampened);
885
+ updateTransform();
886
+ } else if (event.touches.length === 1 && isDragging) {
887
+ currentX += event.touches[0].clientX - lastPointerX;
888
+ currentY += event.touches[0].clientY - lastPointerY;
889
+ lastPointerX = event.touches[0].clientX;
890
+ lastPointerY = event.touches[0].clientY;
891
+ updateTransform();
892
+ }
893
+ }, { passive: false });
894
+
895
+ root.addEventListener('touchend', () => {
896
+ isDragging = false;
897
+ pinchStartDistance = 0;
898
+ });
899
+ }
900
+
901
+ function getTouchDistance(touches) {
902
+ const dx = touches[0].clientX - touches[1].clientX;
903
+ const dy = touches[0].clientY - touches[1].clientY;
904
+ return Math.sqrt(dx * dx + dy * dy);
905
+ }
906
+
907
+ function clampZoom(value) {
908
+ return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, value));
909
+ }
910
+
911
+ function zoomBy(delta, clientX, clientY) {
912
+ const nextZoom = clampZoom(currentZoom + delta);
913
+ applyZoom(nextZoom, clientX, clientY);
914
+ }
915
+
916
+ function applyZoom(nextZoom, clientX, clientY) {
917
+ if (!svgMap) return;
918
+ const root = qs('#heatmap-svg-root');
919
+ const previous = currentZoom;
920
+ if (previous === nextZoom) return;
921
+
922
+ if (root && Number.isFinite(clientX) && Number.isFinite(clientY)) {
923
+ const rect = root.getBoundingClientRect();
924
+ const focalX = clientX - rect.left - rect.width / 2;
925
+ const focalY = clientY - rect.top - rect.height / 2;
926
+ const zoomRatio = nextZoom / previous;
927
+ currentX = focalX - (focalX - currentX) * zoomRatio;
928
+ currentY = focalY - (focalY - currentY) * zoomRatio;
929
+ }
930
+
931
+ currentZoom = nextZoom;
932
+ updateTransform();
933
+ }
934
+
935
+ function resetZoom() {
936
+ currentZoom = window.innerWidth < 1024 ? 0.95 : 1.15;
937
+ currentX = 0;
938
+ currentY = 0;
939
+ updateTransform();
940
+ }
941
+
942
+ function updateTransform() {
943
+ if (!svgMap) return;
944
+
945
+ // Enforce bounds
946
+ const root = qs('#heatmap-svg-root');
947
+ if (root) {
948
+ const rect = root.getBoundingClientRect();
949
+ const halfW = rect.width / 2;
950
+ const halfH = rect.height / 2;
951
+
952
+ // Calculate allowed movement based on zoom
953
+ // We want to keep the map somewhat visible at all times
954
+ const maxShiftX = halfW * currentZoom + MAP_BOUNDS_PADDING;
955
+ const maxShiftY = halfH * currentZoom + MAP_BOUNDS_PADDING;
956
+
957
+ currentX = Math.max(-maxShiftX, Math.min(maxShiftX, currentX));
958
+ currentY = Math.max(-maxShiftY, Math.min(maxShiftY, currentY));
959
+ }
960
+
961
+ svgMap.style.transform = `translate(${currentX}px, ${currentY}px) scale(${currentZoom})`;
962
+ // setText('#heatmap-zoom-level', `${Math.round(currentZoom * 100)}%`);
963
+ }
964
+
965
+ function handleHeatmapShortcuts(event) {
966
+ if (!event.ctrlKey || !mapInteractionActive || isInteractiveInput(event.target)) return;
967
+ const key = event.key;
968
+ if (key === '+' || key === '=') {
969
+ event.preventDefault();
970
+ zoomBy(BUTTON_ZOOM_STEP);
971
+ } else if (key === '-') {
972
+ event.preventDefault();
973
+ zoomBy(-BUTTON_ZOOM_STEP);
974
+ } else if (key === ' ') {
975
+ event.preventDefault();
976
+ resetZoom();
977
+ }
978
+ }
979
+
980
+ function hydrateFeedsFromCache() {
981
+ const payload = peekCached(`/api/v1/events/live?limit=${LIVE_EVENTS_FETCH_LIMIT}`);
982
+ if (!payload) return;
983
+ let events = Array.isArray(payload) ? payload : (payload.events || payload.data || []);
984
+ if (!events.length) return;
985
+
986
+ const active = activeMapStates();
987
+ if (active.length) {
988
+ const onMap = events.filter((event) =>
989
+ active.some((record) => eventMatchesState(event, record.state || record.name))
990
+ );
991
+ if (onMap.length >= 3) events = onMap;
992
+ }
993
+
994
+ const fakeEvents = events.filter((event) => getEventClassification(event) === 'fake');
995
+ const realEvents = events.filter((event) => getEventClassification(event) === 'real');
996
+ renderEventList('#heatmap-misinformation-feed', fakeEvents, { type: 'fake', limit: FEED_DISPLAY_LIMIT });
997
+ renderEventList('#heatmap-real-news-feed', realEvents, { type: 'real', limit: FEED_DISPLAY_LIMIT });
998
+ setText('#heatmap-misinformation-count', `${Math.min(fakeEvents.length, FEED_DISPLAY_LIMIT)} items`);
999
+ setText('#heatmap-real-news-count', `${Math.min(realEvents.length, FEED_DISPLAY_LIMIT)} items`);
1000
+ }
1001
+
1002
+ export async function initHeatmap() {
1003
+ if ('scrollRestoration' in history) {
1004
+ history.scrollRestoration = 'manual';
1005
+ }
1006
+ window.scrollTo(0, 0);
1007
+
1008
+ setupControls();
1009
+ mapStatus('Loading map', 'neutral');
1010
+ hydrateHeatmapFromCache();
1011
+ hydrateFeedsFromCache();
1012
+ updateStatsPopover();
1013
+
1014
+ try {
1015
+ await loadSvgMap();
1016
+ syncHeatmapView();
1017
+ } catch (error) {
1018
+ console.error('Heatmap map load failed:', error);
1019
+ hideLoading();
1020
+ showMapError(error && error.message ? error.message : 'The local SVG could not be loaded.');
1021
+ mapStatus('Reconnecting to map frame...', 'neutral');
1022
+ return;
1023
+ }
1024
+
1025
+ // Background refresh — map stays interactive; feeds fill progressively
1026
+ loadHeatmapData(false)
1027
+ .then(() => {
1028
+ syncHeatmapView();
1029
+ return Promise.allSettled([updateStats(), updateFeeds(false)]);
1030
+ })
1031
+ .catch((error) => console.warn('Heatmap data refresh:', error));
1032
+
1033
+ window.setInterval(() => {
1034
+ if (document.visibilityState === 'visible') refreshHeatmap();
1035
+ }, 45000);
1036
+ }
frontend/assets/js/pages/home.js ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getStats, peekCached } from '../api.js';
2
+ import { setProgress, updateStatusText } from '../components/ui.js';
3
+ import { updateMobileNavStatus, updateStatsPanels } from '../components/navbar.js';
4
+ import { setText } from '../utils/dom.js';
5
+ import { formatCount, formatPercent, numberOrNull } from '../utils/formatters.js';
6
+
7
+ function percentOf(value, total) {
8
+ const numerator = numberOrNull(value);
9
+ const denominator = numberOrNull(total);
10
+ if (numerator === null || denominator === null || denominator <= 0) return null;
11
+ return (numerator / denominator) * 100;
12
+ }
13
+
14
+ function setUnavailable() {
15
+ [
16
+ '#home-fake-count',
17
+ '#home-real-count',
18
+ '#home-states-count',
19
+ '#home-total-events',
20
+ '#home-misinformation-events',
21
+ '#home-verified-events',
22
+ '#home-accuracy-events',
23
+ '#home-accuracy-aside',
24
+ '#home-fake-percent',
25
+ '#home-real-percent'
26
+ ].forEach((selector) => setText(selector, '--'));
27
+ setText('#home-total-label', 'Connecting');
28
+ setText('#home-data-state', 'Connecting to real-time intelligence...');
29
+ setProgress('home-fake-bar', 0);
30
+ setProgress('home-real-bar', 0);
31
+ updateStatusText('home-nav-status', 'Connecting...', 'neutral');
32
+ updateMobileNavStatus('Connecting...');
33
+ }
34
+
35
+ function updateHome(data) {
36
+ const total = numberOrNull(data.total_events);
37
+ const fake = numberOrNull(data.fake_events);
38
+ const real = numberOrNull(data.real_events);
39
+ const totalStates = numberOrNull(data.total_states);
40
+ const divisor = total ?? [fake, real].filter((v) => v !== null).reduce((s, v) => s + v, 0);
41
+ const statesText = totalStates === null ? '--' : formatCount(totalStates);
42
+
43
+ setText('#home-fake-count', formatCount(fake));
44
+ setText('#home-real-count', formatCount(real));
45
+ setText('#home-states-count', statesText);
46
+ setText('#home-total-events', formatCount(total));
47
+ setText('#home-misinformation-events', formatCount(fake));
48
+ setText('#home-verified-events', formatCount(real));
49
+ setText('#home-accuracy-events', formatPercent(data.classification_accuracy));
50
+ setText('#home-accuracy-aside', formatPercent(data.classification_accuracy));
51
+ setText('#home-total-label', total === null ? 'No total yet' : `${formatCount(total)} total`);
52
+
53
+ const fakePct = percentOf(fake, divisor);
54
+ const realPct = percentOf(real, divisor);
55
+
56
+ setText('#home-fake-percent', fakePct === null ? '--' : formatPercent(fakePct));
57
+ setText('#home-real-percent', realPct === null ? '--' : formatPercent(realPct));
58
+ setProgress('home-fake-bar', fakePct || 0);
59
+ setProgress('home-real-bar', realPct || 0);
60
+
61
+ const processing = Boolean(data.processing_active);
62
+ const mlReady = Boolean(data.ml_ready);
63
+ const statusText = processing ? 'Live processing' : mlReady ? 'System ready' : 'Data loaded';
64
+ updateStatusText('home-nav-status', statusText, processing || mlReady ? 'good' : 'neutral');
65
+ updateMobileNavStatus(statusText);
66
+
67
+ const lastUpdate = new Date().toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', hour12: true });
68
+
69
+ updateStatsPanels([
70
+ { label: 'Events', value: formatCount(total), className: 'text-blue-700' },
71
+ { label: 'Risk', value: formatCount(fake), className: 'text-saffron-700' },
72
+ { label: 'Accuracy', value: formatPercent(data.classification_accuracy), className: 'text-emerald-700' },
73
+ { label: 'Last Update', value: lastUpdate },
74
+ { label: 'States', value: statesText, className: 'text-slate-950' }
75
+ ]);
76
+
77
+ setText('#home-data-state', 'Live backend values loaded successfully.');
78
+ }
79
+
80
+ export async function initHome() {
81
+ const cached = peekCached('/api/v1/stats');
82
+ if (cached) {
83
+ updateHome(cached);
84
+ } else {
85
+ updateStatusText('home-nav-status', 'Loading data', 'neutral');
86
+ updateMobileNavStatus('Loading data');
87
+ }
88
+
89
+ try {
90
+ const stats = await getStats();
91
+ updateHome(stats);
92
+ } catch {
93
+ if (!cached) setUnavailable();
94
+ // Retry once after 5s — covers cold-start when backend ML models are still loading
95
+ setTimeout(async () => {
96
+ try {
97
+ const stats = await getStats({ cacheMs: 0 });
98
+ updateHome(stats);
99
+ } catch { /* next interval will handle it */ }
100
+ }, 5000);
101
+ }
102
+
103
+ initTypewriter();
104
+
105
+ window.setInterval(async () => {
106
+ if (document.visibilityState !== 'visible') return;
107
+ try {
108
+ const stats = await getStats();
109
+ updateHome(stats);
110
+ } catch {
111
+ // Silently preserve the last known data on background refresh failures.
112
+ // The stale-while-revalidate cache in api.js handles returning cached data,
113
+ // so this catch only fires when there was never any data at all.
114
+ }
115
+ }, 30000);
116
+ }
117
+
118
+ function initTypewriter() {
119
+ const textElement = document.getElementById('typewriter-text');
120
+ if (!textElement) return;
121
+
122
+ const words = [
123
+ 'before it spreads.',
124
+ 'across Indian news.',
125
+ 'in real-time.',
126
+ 'before it goes viral.'
127
+ ];
128
+
129
+ let wordIndex = 0;
130
+ let text = '';
131
+ let isDeleting = false;
132
+ let typeSpeed = 100;
133
+
134
+ // Initial setup so we start by deleting the first hardcoded word
135
+ textElement.textContent = words[0];
136
+ text = words[0];
137
+ isDeleting = true;
138
+
139
+ function type() {
140
+ const current = wordIndex % words.length;
141
+ const fullText = words[current];
142
+
143
+ if (isDeleting) {
144
+ text = fullText.substring(0, text.length - 1);
145
+ typeSpeed = 40;
146
+ } else {
147
+ text = fullText.substring(0, text.length + 1);
148
+ typeSpeed = 80;
149
+ }
150
+
151
+ textElement.textContent = text;
152
+
153
+ if (!isDeleting && text === fullText) {
154
+ // Pause at the end of the word
155
+ typeSpeed = 3000;
156
+ isDeleting = true;
157
+ } else if (isDeleting && text === '') {
158
+ isDeleting = false;
159
+ wordIndex++;
160
+ // Pause before starting next word
161
+ typeSpeed = 500;
162
+ }
163
+
164
+ setTimeout(type, typeSpeed);
165
+ }
166
+
167
+ // Start the effect after a brief delay
168
+ setTimeout(type, 3000);
169
+ }
frontend/assets/js/utils/dom.js ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { sanitizeClassList, setSafeAttribute, toSafeText } from './security.js';
2
+
3
+ export function qs(selector, root = document) {
4
+ if (typeof selector !== 'string' || !selector) return null;
5
+ return root.querySelector(selector);
6
+ }
7
+
8
+ export function qsa(selector, root = document) {
9
+ if (typeof selector !== 'string' || !selector) return [];
10
+ return Array.from(root.querySelectorAll(selector));
11
+ }
12
+
13
+ const SVG_TAGS = new Set([
14
+ 'svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'ellipse',
15
+ 'g', 'defs', 'linearGradient', 'stop', 'filter', 'feGaussianBlur', 'feComposite'
16
+ ]);
17
+
18
+ /**
19
+ * @param {string} tag
20
+ * @param {{ className?: string, text?: string, attrs?: Record<string, unknown>, dataset?: Record<string, unknown>, style?: string }} [options]
21
+ * @param {Array<Node|string|number>} [children]
22
+ */
23
+ export function createElement(tag, options = {}, children = []) {
24
+ const isSvg = SVG_TAGS.has(tag);
25
+ const element = isSvg
26
+ ? document.createElementNS('http://www.w3.org/2000/svg', tag)
27
+ : document.createElement(tag);
28
+
29
+ const { className, text, attrs, dataset, style } = options;
30
+
31
+ if (className) {
32
+ const safeClass = sanitizeClassList(className);
33
+ if (safeClass) {
34
+ if (isSvg) element.setAttribute('class', safeClass);
35
+ else element.className = safeClass;
36
+ }
37
+ }
38
+
39
+ if (text !== undefined) {
40
+ if (isSvg) element.append(document.createTextNode(toSafeText(text, '')));
41
+ else element.textContent = toSafeText(text, '');
42
+ }
43
+
44
+ if (attrs) {
45
+ Object.entries(attrs).forEach(([name, value]) => {
46
+ if (value === false || value === null || value === undefined) return;
47
+ setSafeAttribute(element, name, value);
48
+ });
49
+ }
50
+
51
+ if (dataset) {
52
+ Object.entries(dataset).forEach(([name, value]) => {
53
+ if (value === null || value === undefined) return;
54
+ const safeName = name.replace(/[^a-zA-Z0-9_-]/g, '');
55
+ if (safeName) element.dataset[safeName] = toSafeText(value, '', 200);
56
+ });
57
+ }
58
+
59
+ if (style && element instanceof HTMLElement) {
60
+ element.setAttribute('style', String(style).slice(0, 500));
61
+ }
62
+
63
+ const safeChildren = Array.isArray(children) ? children : [children];
64
+ safeChildren.filter(Boolean).forEach((child) => {
65
+ if (typeof child === 'string' || typeof child === 'number') {
66
+ element.append(document.createTextNode(String(child)));
67
+ } else {
68
+ element.append(child);
69
+ }
70
+ });
71
+
72
+ return element;
73
+ }
74
+
75
+ export function setText(target, value, fallback = '--') {
76
+ const element = typeof target === 'string' ? qs(target) : target;
77
+ if (!element) return;
78
+ element.textContent = toSafeText(value, fallback);
79
+ }
80
+
81
+ export function setWidth(target, value) {
82
+ const element = typeof target === 'string' ? qs(target) : target;
83
+ if (!element) return;
84
+ const number = Number(value);
85
+ const width = Number.isFinite(number) ? Math.max(0, Math.min(100, number)) : 0;
86
+ element.style.width = `${width}%`;
87
+ }
88
+
89
+ export function replaceChildren(target, children = []) {
90
+ const element = typeof target === 'string' ? qs(target) : target;
91
+ if (!element) return;
92
+ const safeChildren = Array.isArray(children) ? children : [children];
93
+ element.replaceChildren(...safeChildren.filter(Boolean));
94
+ }
95
+
96
+ export function clearElement(target) {
97
+ const element = typeof target === 'string' ? qs(target) : target;
98
+ if (!element) return;
99
+ element.replaceChildren();
100
+ }
101
+
102
+ export function toggleHidden(target, hidden) {
103
+ const element = typeof target === 'string' ? qs(target) : target;
104
+ if (!element) return;
105
+ element.hidden = Boolean(hidden);
106
+ }
107
+
108
+ export function updateClassList(element, classNames, enabled) {
109
+ if (!element) return;
110
+ sanitizeClassList(classNames, '').split(/\s+/).filter(Boolean).forEach((className) => {
111
+ element.classList.toggle(className, Boolean(enabled));
112
+ });
113
+ }
frontend/assets/js/utils/formatters.js ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { toSafeText } from './security.js';
2
+
3
+ export function numberOrNull(value) {
4
+ if (value === null || value === undefined || value === '') return null;
5
+ const number = Number(value);
6
+ return Number.isFinite(number) ? number : null;
7
+ }
8
+
9
+ export function formatCount(value, fallback = '--') {
10
+ const number = numberOrNull(value);
11
+ if (number === null) return fallback;
12
+ const rounded = Math.max(0, Math.round(number));
13
+ if (rounded >= 1000000) return `${(rounded / 1000000).toFixed(1)}M`;
14
+ if (rounded >= 1000) return `${(rounded / 1000).toFixed(1)}K`;
15
+ return String(rounded);
16
+ }
17
+
18
+ export function normalizePercent(value) {
19
+ const number = numberOrNull(value);
20
+ if (number === null) return null;
21
+ const percent = Math.abs(number) <= 1 ? number * 100 : number;
22
+ return Math.max(0, Math.min(100, percent));
23
+ }
24
+
25
+ export function formatPercent(value, fallback = '--', decimals = 0) {
26
+ const percent = normalizePercent(value);
27
+ if (percent === null) return fallback;
28
+ if (decimals > 0) {
29
+ return `${percent.toFixed(decimals)}%`;
30
+ }
31
+ return `${Math.round(percent)}%`;
32
+ }
33
+
34
+ export function formatRatioAsPercent(value, fallback = '--') {
35
+ const number = numberOrNull(value);
36
+ if (number === null) return fallback;
37
+ return `${Math.round(Math.max(0, Math.min(100, number * 100)))}%`;
38
+ }
39
+
40
+ export function formatDateTime(value, fallback = '--') {
41
+ if (!value) return fallback;
42
+ const date = new Date(value);
43
+ if (Number.isNaN(date.getTime())) return fallback;
44
+ return date.toLocaleString('en-IN', {
45
+ hour: '2-digit',
46
+ minute: '2-digit',
47
+ hour12: true,
48
+ day: '2-digit',
49
+ month: 'short'
50
+ });
51
+ }
52
+
53
+ export function formatClock(value = new Date()) {
54
+ const date = value instanceof Date ? value : new Date(value);
55
+ if (Number.isNaN(date.getTime())) return '--';
56
+ return date.toLocaleTimeString('en-IN', {
57
+ hour: '2-digit',
58
+ minute: '2-digit',
59
+ second: '2-digit',
60
+ hour12: true
61
+ });
62
+ }
63
+
64
+ export function normalizeClassification(value) {
65
+ const text = toSafeText(value, 'unknown').toLowerCase();
66
+ if (['fake', 'misinformation', 'misleading', 'false'].includes(text)) return 'fake';
67
+ if (['real', 'verified', 'true', 'credible'].includes(text)) return 'real';
68
+ if (['uncertain', 'review', 'under_review', 'needs_review'].includes(text)) return 'uncertain';
69
+ return 'unknown';
70
+ }
71
+
72
+ export function getEventClassification(event) {
73
+ if (!event || typeof event !== 'object') return 'unknown';
74
+ const direct = normalizeClassification(
75
+ event.classification
76
+ ?? event.fake_news_verdict
77
+ ?? event.verdict
78
+ ?? event.status
79
+ ?? event.truth_status
80
+ );
81
+ if (direct !== 'unknown') return direct;
82
+
83
+ if (typeof event.is_fake === 'boolean') return event.is_fake ? 'fake' : 'real';
84
+ const isFake = numberOrNull(event.is_fake);
85
+ if (isFake !== null) return isFake > 0 ? 'fake' : 'real';
86
+ return 'unknown';
87
+ }
88
+
89
+ export function classificationLabel(value) {
90
+ const classification = normalizeClassification(value);
91
+ if (classification === 'fake') return 'Misinformation';
92
+ if (classification === 'real') return 'Verified';
93
+ if (classification === 'uncertain') return 'Under review';
94
+ return 'Unknown';
95
+ }
96
+
97
+ export function getEventTitle(event) {
98
+ return toSafeText(event && (event.title || event.headline || event.name), 'Untitled event');
99
+ }
100
+
101
+ export function getEventSource(event) {
102
+ return toSafeText(event && (event.source || event.source_name || event.publisher), 'Unknown source');
103
+ }
104
+
105
+ export function getEventState(event) {
106
+ return toSafeText(event && (event.state || event.region || event.location), 'India');
107
+ }
108
+
109
+ export function normalizeName(value) {
110
+ return toSafeText(value, '')
111
+ .toLowerCase()
112
+ .normalize('NFKD')
113
+ .replace(/[\u0300-\u036f]/g, '')
114
+ .replace(/[^a-z0-9\s]/g, ' ')
115
+ .replace(/\sand\s/g, ' ')
116
+ .replace(/\s+/g, ' ')
117
+ .trim();
118
+ }
119
+
120
+ export const RISK_LEVELS = {
121
+ least: {
122
+ key: 'least',
123
+ label: 'Least',
124
+ color: '#138808',
125
+ badgeClass: 'border-emerald-200 bg-emerald-50 text-emerald-700'
126
+ },
127
+ leastMedium: {
128
+ key: 'least-medium',
129
+ label: 'Least-Medium',
130
+ color: '#facc15',
131
+ badgeClass: 'border-yellow-200 bg-yellow-50 text-yellow-700'
132
+ },
133
+ mediumHigh: {
134
+ key: 'medium-high',
135
+ label: 'Medium-High',
136
+ color: '#f97316',
137
+ badgeClass: 'border-orange-200 bg-orange-50 text-orange-700'
138
+ },
139
+ high: {
140
+ key: 'high',
141
+ label: 'High',
142
+ color: '#dc2626',
143
+ badgeClass: 'border-red-200 bg-red-50 text-red-700'
144
+ },
145
+ insufficient: {
146
+ key: 'insufficient-data',
147
+ label: 'Least risk',
148
+ color: '#138808',
149
+ badgeClass: 'border-emerald-200 bg-emerald-50 text-emerald-700'
150
+ }
151
+ };
152
+
153
+ export function getFakeRatio(record = {}) {
154
+ const candidates = [
155
+ record.fake_probability,
156
+ record.fake_ratio,
157
+ record.fake_news_ratio,
158
+ record.fakeRate,
159
+ record.fake_rate
160
+ ];
161
+ const ratio = candidates.map(numberOrNull).find((value) => value !== null);
162
+ if (ratio !== undefined) {
163
+ return ratio > 1 ? ratio / 100 : ratio;
164
+ }
165
+
166
+ const fake = numberOrNull(record.fake_count ?? record.fake_events);
167
+ const total = numberOrNull(record.event_count ?? record.total_events);
168
+ if (fake !== null && total !== null && total > 0) return fake / total;
169
+ return null;
170
+ }
171
+
172
+ export function riskFromRecord(record = {}) {
173
+ const level = toSafeText(record.risk_level, '').toLowerCase().replace(/[\s-]+/g, '_');
174
+ if (['low', 'least'].includes(level)) return RISK_LEVELS.least;
175
+ if (['low_medium', 'least_medium', 'least_medium_risk'].includes(level)) return RISK_LEVELS.leastMedium;
176
+ if (['medium', 'medium_high', 'moderate'].includes(level)) return RISK_LEVELS.mediumHigh;
177
+ if (level === 'high') return RISK_LEVELS.high;
178
+ if (level === 'insufficient_data' || level === 'no_data') return RISK_LEVELS.insufficient;
179
+
180
+ const ratio = getFakeRatio(record);
181
+ if (ratio === null) return RISK_LEVELS.insufficient;
182
+ if (ratio < 0.02) return RISK_LEVELS.least;
183
+ if (ratio < 0.05) return RISK_LEVELS.leastMedium;
184
+ if (ratio < 0.10) return RISK_LEVELS.mediumHigh;
185
+ return RISK_LEVELS.high;
186
+ }
frontend/assets/js/utils/security.js ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @typedef {'fake'|'real'|'uncertain'|'unknown'} Classification */
2
+
3
+ const MAX_TEXT_LENGTH = 2000;
4
+ const MAX_PLAIN_TEXT_LENGTH = 600;
5
+ const MAX_STATE_LENGTH = 80;
6
+ const MAX_API_PATH_LENGTH = 256;
7
+
8
+ const FORBIDDEN_ATTR_PREFIX = /^on/i;
9
+ const SAFE_ATTR_NAMES = new Set([
10
+ 'id',
11
+ 'class',
12
+ 'href',
13
+ 'src',
14
+ 'alt',
15
+ 'title',
16
+ 'type',
17
+ 'role',
18
+ 'tabindex',
19
+ 'aria-label',
20
+ 'aria-hidden',
21
+ 'aria-live',
22
+ 'aria-expanded',
23
+ 'aria-controls',
24
+ 'aria-current',
25
+ 'aria-describedby',
26
+ 'viewBox',
27
+ 'preserveAspectRatio',
28
+ 'stroke',
29
+ 'stroke-width',
30
+ 'stroke-linecap',
31
+ 'stroke-linejoin',
32
+ 'fill',
33
+ 'd',
34
+ 'name',
35
+ 'for',
36
+ 'rel',
37
+ 'target',
38
+ 'width',
39
+ 'height',
40
+ 'xmlns',
41
+ 'viewbox'
42
+ ]);
43
+
44
+ /** Tailwind utility tokens allowed in dynamic class strings from API-derived UI. */
45
+ const SAFE_CLASS_TOKEN = /^[a-z0-9_:/\-\[\].%]+$/i;
46
+
47
+ export const CLASSIFICATION_BADGE_CLASSES = {
48
+ fake: 'border-red-200 bg-red-50 text-red-700',
49
+ real: 'border-emerald-200 bg-emerald-50 text-emerald-700',
50
+ uncertain: 'border-amber-200 bg-amber-50 text-amber-700',
51
+ unknown: 'border-slate-200 bg-slate-100 text-slate-600'
52
+ };
53
+
54
+ /**
55
+ * Strip control chars and normalize whitespace for safe text insertion.
56
+ * @param {unknown} value
57
+ * @param {string} [fallback]
58
+ * @param {number} [maxLen]
59
+ */
60
+ export function toSafeText(value, fallback = '--', maxLen = MAX_TEXT_LENGTH) {
61
+ if (value === null || value === undefined) return fallback;
62
+ const text = String(value)
63
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
64
+ .replace(/\s+/g, ' ')
65
+ .trim()
66
+ .slice(0, maxLen);
67
+ return text || fallback;
68
+ }
69
+
70
+ const domParser = new DOMParser();
71
+
72
+ /**
73
+ * Convert untrusted HTML-ish feed text into plain text for display.
74
+ * The result is rendered with textContent by callers, never as HTML.
75
+ * @param {unknown} value
76
+ * @param {string} [fallback]
77
+ * @param {number} [maxLen]
78
+ */
79
+ export function toPlainText(value, fallback = '', maxLen = MAX_PLAIN_TEXT_LENGTH) {
80
+ if (value === null || value === undefined) return fallback;
81
+ const raw = String(value).trim();
82
+ if (!raw) return fallback;
83
+
84
+ // Quick path: if no tags, just sanitize and return
85
+ if (!raw.includes('<')) return toSafeText(raw, fallback, maxLen);
86
+
87
+ const truncated = raw.slice(0, maxLen * 4);
88
+ const parsed = domParser.parseFromString(truncated, 'text/html');
89
+
90
+ // Remove dangerous tags that might have text content we don't want displayed
91
+ const styleScript = parsed.querySelectorAll('script, style, template, noscript');
92
+ for (let i = 0; i < styleScript.length; i++) {
93
+ styleScript[i].remove();
94
+ }
95
+
96
+ return toSafeText(parsed.body?.textContent || truncated, fallback, maxLen);
97
+ }
98
+
99
+ /**
100
+ * Allow only http(s) URLs; reject javascript:, data:, etc.
101
+ * @param {unknown} value
102
+ */
103
+ export function safeUrl(value) {
104
+ if (value === null || value === undefined) return null;
105
+ try {
106
+ const raw = String(value).trim();
107
+ if (!raw || raw.length > 2048) return null;
108
+ const url = new URL(raw, window.location.origin);
109
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
110
+ if (url.username || url.password) return null;
111
+ return url.href;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Sanitize dynamic class lists (blocks attribute injection via className).
119
+ * @param {unknown} value
120
+ * @param {string} [fallback]
121
+ */
122
+ export function sanitizeClassList(value, fallback = '') {
123
+ const raw = toSafeText(value, '', 500);
124
+ if (!raw) return fallback;
125
+ const tokens = raw.split(/\s+/).filter((token) => SAFE_CLASS_TOKEN.test(token));
126
+ return tokens.length ? tokens.join(' ') : fallback;
127
+ }
128
+
129
+ /**
130
+ * @param {unknown} classification
131
+ */
132
+ export function badgeClassForClassification(classification) {
133
+ const key = toSafeText(classification, 'unknown', 32).toLowerCase();
134
+ if (key === 'fake' || key === 'misinformation') return CLASSIFICATION_BADGE_CLASSES.fake;
135
+ if (key === 'real' || key === 'verified') return CLASSIFICATION_BADGE_CLASSES.real;
136
+ if (key === 'uncertain' || key === 'review') return CLASSIFICATION_BADGE_CLASSES.uncertain;
137
+ return CLASSIFICATION_BADGE_CLASSES.unknown;
138
+ }
139
+
140
+ /**
141
+ * Validate relative API paths (no open redirects / path traversal).
142
+ * @param {unknown} path
143
+ */
144
+ export function sanitizeApiPath(path) {
145
+ const raw = String(path || '').trim();
146
+ if (!raw || raw.length > MAX_API_PATH_LENGTH) return null;
147
+ if (!raw.startsWith('/') || raw.startsWith('//') || raw.includes('..')) return null;
148
+ if (/[<>"'`\\\s]/.test(raw)) return null;
149
+ return raw;
150
+ }
151
+
152
+ /**
153
+ * State/region slug for /api/v1/events/state/{state}
154
+ * @param {unknown} state
155
+ */
156
+ export function sanitizeStateParam(state) {
157
+ const text = toSafeText(state, '', MAX_STATE_LENGTH);
158
+ if (!text) return null;
159
+ const cleaned = text.replace(/[^a-zA-Z0-9\s.\-()&']/g, '').trim();
160
+ return cleaned || null;
161
+ }
162
+
163
+ /**
164
+ * @param {string} name
165
+ * @param {unknown} value
166
+ */
167
+ export function isSafeAttribute(name, value) {
168
+ if (!name || FORBIDDEN_ATTR_PREFIX.test(name)) return false;
169
+ const lower = name.toLowerCase();
170
+ if (!SAFE_ATTR_NAMES.has(lower) && !lower.startsWith('aria-') && !lower.startsWith('data-')) {
171
+ return false;
172
+ }
173
+ if (lower === 'href' || lower === 'src') {
174
+ return safeUrl(value) !== null || String(value).startsWith('/');
175
+ }
176
+ if (lower === 'style') return false;
177
+ return true;
178
+ }
179
+
180
+ /**
181
+ * @param {Element} element
182
+ * @param {string} name
183
+ * @param {unknown} value
184
+ */
185
+ export function setSafeAttribute(element, name, value) {
186
+ if (!element || !isSafeAttribute(name, value)) return;
187
+ const lower = name.toLowerCase();
188
+ if (lower === 'href' || lower === 'src') {
189
+ const url = safeUrl(value);
190
+ if (url) element.setAttribute(name, url);
191
+ else if (lower === 'src' && String(value).startsWith('/')) {
192
+ element.setAttribute(name, String(value).slice(0, 512));
193
+ }
194
+ return;
195
+ }
196
+ element.setAttribute(name, value === true ? '' : String(value));
197
+ }
198
+
199
+ export function isInteractiveInput(target) {
200
+ if (!(target instanceof Element)) return false;
201
+ const tag = target.tagName.toLowerCase();
202
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable;
203
+ }
frontend/{map → assets/map}/in.svg RENAMED
File without changes
frontend/{map → assets/map}/mapdata.js RENAMED
File without changes
frontend/contact.html ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, viewport-fit=cover">
7
+ <meta name="referrer" content="strict-origin-when-cross-origin">
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://ndg07-heatmap.hf.space; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests">
9
+ <style>
10
+ /* Custom spacing since global styles.css has limited utility classes */
11
+ .page-hero { padding-top: 80px; padding-bottom: 40px; }
12
+ .contact-grid { gap: 32px; display: grid; margin-top: 40px; margin-bottom: 60px; }
13
+ .contact-card { padding: 32px; height: 100%; display: flex; flex-direction: column; justify-content: space-between; }
14
+ .faq-section { margin-top: 40px; margin-bottom: 80px; padding: 48px 32px; border-radius: 40px; }
15
+
16
+ @media (min-width: 640px) {
17
+ .contact-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
18
+ }
19
+ @media (min-width: 1024px) {
20
+ .contact-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
21
+ .page-hero { padding-top: 100px; padding-bottom: 60px; }
22
+ }
23
+ </style>
24
+ <title>Contact Us - Misinformation Heatmap</title>
25
+ <meta name="description"
26
+ content="Connect with the team behind Misinformation Heatmap for support, feedback, or collaborations.">
27
+ <link rel="icon" type="image/x-icon" href="/assets/Misinfo.ico">
28
+ <link rel="shortcut icon" type="image/x-icon" href="/assets/Misinfo.ico">
29
+ <link rel="manifest" href="/manifest.json">
30
+ <link rel="stylesheet" href="/assets/css/styles.css">
31
+ <script type="module" src="/assets/js/main.js"></script>
32
+ </head>
33
+
34
+ <body data-page="contact" class="min-h-screen overflow-x-hidden antialiased bg-slate-50">
35
+ <div data-navbar></div>
36
+
37
+ <main id="main-content" class="page-hero">
38
+ <div class="mx-auto max-w-7xl px-6 lg:px-8">
39
+ <!-- Hero Header -->
40
+ <header class="mx-auto max-w-3xl text-center">
41
+ <p class="mb-6 text-xs font-bold uppercase tracking-[0.2em] text-saffron-500">Connect with us</p>
42
+ <h1 class="text-4xl font-black tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
43
+ Get in <span class="text-ashoka-blue-500">touch</span>
44
+ </h1>
45
+ <p class="mt-8 text-lg leading-relaxed text-slate-600 sm:text-xl">
46
+ Need assistance or looking to partner? Reach out through any of our channels below. Our team is dedicated to verifying the digital landscape across India.
47
+ </p>
48
+ </header>
49
+
50
+ <!-- Contact Grid -->
51
+ <section class="contact-grid">
52
+ <!-- Email -->
53
+ <article class="contact-card rounded-2xl border border-slate-200 bg-white shadow-card transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
54
+ <div>
55
+ <div class="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-saffron-50 text-saffron-600">
56
+ <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
58
+ </svg>
59
+ </div>
60
+ <h2 class="text-xl font-black text-slate-950">Email Support</h2>
61
+ <p class="mt-2 text-sm text-slate-500 leading-relaxed">Fast response for all general inquiries.</p>
62
+ </div>
63
+ <div class="mt-8 pt-6 border-t border-slate-50">
64
+ <a href="mailto:nayandg8@gmail.com" class="text-sm font-bold text-saffron-600 hover:text-saffron-700">Send Email →</a>
65
+ </div>
66
+ </article>
67
+
68
+ <!-- LinkedIn -->
69
+ <article class="contact-card rounded-2xl border border-slate-200 bg-white shadow-card transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
70
+ <div>
71
+ <div class="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
72
+ <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
73
+ <path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
74
+ </svg>
75
+ </div>
76
+ <h2 class="text-xl font-black text-slate-950">LinkedIn</h2>
77
+ <p class="mt-2 text-sm text-slate-500 leading-relaxed">Professional networking and updates.</p>
78
+ </div>
79
+ <div class="mt-8 pt-6 border-t border-slate-50">
80
+ <a href="https://linkedin.com/in/nayandeep-goswami-b92663329" target="_blank" rel="noopener noreferrer" class="text-sm font-bold text-blue-600 hover:text-blue-700">View Profile →</a>
81
+ </div>
82
+ </article>
83
+
84
+ <!-- Website -->
85
+ <article class="contact-card rounded-2xl border border-slate-200 bg-white shadow-card transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
86
+ <div>
87
+ <div class="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 text-emerald-600">
88
+ <svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
89
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
90
+ </svg>
91
+ </div>
92
+ <h2 class="text-xl font-black text-slate-950">Official Site</h2>
93
+ <p class="mt-2 text-sm text-slate-500 leading-relaxed">Explore our research and case studies.</p>
94
+ </div>
95
+ <div class="mt-8 pt-6 border-t border-slate-50">
96
+ <a href="https://nayandeep.dev" target="_blank" rel="noopener noreferrer" class="text-sm font-bold text-emerald-600 hover:text-emerald-700">Visit Website →</a>
97
+ </div>
98
+ </article>
99
+
100
+ <!-- Instagram -->
101
+ <article class="contact-card rounded-2xl border border-slate-200 bg-white shadow-card transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
102
+ <div>
103
+ <div class="mb-6 flex h-12 w-12 items-center justify-center rounded-xl bg-orange-50 text-orange-600">
104
+ <svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
105
+ <path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259 0.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
106
+ </svg>
107
+ </div>
108
+ <h2 class="text-xl font-black text-slate-950">Instagram</h2>
109
+ <p class="mt-2 text-sm text-slate-500 leading-relaxed">Behind the scenes and live signals.</p>
110
+ </div>
111
+ <div class="mt-8 pt-6 border-t border-slate-50">
112
+ <a href="https://instagram.com/nayan_dg" target="_blank" rel="noopener noreferrer" class="text-sm font-bold text-orange-600 hover:text-orange-700">Follow Us →</a>
113
+ </div>
114
+ </article>
115
+ </section>
116
+
117
+ <!-- FAQ Section -->
118
+ <section class="faq-section bg-slate-900 text-white shadow-2xl">
119
+ <div class="grid grid-cols-1 gap-16 lg:grid-cols-[1fr_1.4fr] lg:items-center">
120
+ <div class="text-center">
121
+ <h2 class="text-3xl font-black sm:text-4xl leading-tight">Common <br class="hidden lg:block">inquiries</h2>
122
+ <p class="mt-4 text-slate-400 text-sm mx-auto max-w-xs">Find quick answers to common partnership questions.</p>
123
+ </div>
124
+
125
+ <div class="grid flex-1 gap-8 sm:grid-cols-2">
126
+ <article>
127
+ <h3 class="text-xs font-black uppercase tracking-widest text-saffron-500">Media & Press</h3>
128
+ <p class="mt-3 text-sm leading-relaxed text-slate-300">For press kits or interview requests regarding our signal monitoring, reach out via email.</p>
129
+ </article>
130
+ <article>
131
+ <h3 class="text-xs font-black uppercase tracking-widest text-india-green-500">Collaborations</h3>
132
+ <p class="mt-3 text-sm leading-relaxed text-slate-300">We are open to working with research institutions across India.</p>
133
+ </article>
134
+ <article>
135
+ <h3 class="text-xs font-black uppercase tracking-widest text-ashoka-blue-400">API Access</h3>
136
+ <p class="mt-3 text-sm leading-relaxed text-slate-300">Enterprise API access is available for verified government and educational entities.</p>
137
+ </article>
138
+ <article>
139
+ <h3 class="text-xs font-black uppercase tracking-widest text-slate-400">Support Hours</h3>
140
+ <p class="mt-3 text-sm leading-relaxed text-slate-300">Our core team monitors signals 24/7. General responses typically arrive within 24 hours.</p>
141
+ </article>
142
+ </div>
143
+ </div>
144
+ </section>
145
+ </div>
146
+ </main>
147
+
148
+ <footer class="border-t border-slate-200 bg-slate-950 pt-16 pb-32 text-white">
149
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
150
+ <div class="grid grid-cols-1 gap-12 md:grid-cols-3">
151
+ <!-- Brand Section -->
152
+ <div class="max-w-xs">
153
+ <div class="flex items-center gap-2">
154
+ <div class="h-8 w-8 rounded-lg bg-gradient-to-tr from-saffron-500 via-white to-india-green-500 p-1">
155
+ <div class="h-full w-full rounded-md bg-slate-950 flex items-center justify-center">
156
+ <span class="text-[10px] font-black">MH</span>
157
+ </div>
158
+ </div>
159
+ <span class="text-lg font-black tracking-tight">Misinfo<span class="text-saffron-500">Heatmap</span></span>
160
+ </div>
161
+ <p class="mt-6 text-sm leading-7 text-slate-400">
162
+ Advanced AI-powered platform for real-time misinformation detection and verification across India's digital
163
+ landscape.
164
+ </p>
165
+ <div class="mt-6 flex items-center gap-4">
166
+ <span class="inline-flex items-center gap-1.5 rounded-full bg-white/5 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-300 border border-white/10">
167
+ <span class="h-1.5 w-1.5 rounded-full bg-saffron-500"></span>
168
+ Monitored in India
169
+ </span>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Platform Links -->
174
+ <div>
175
+ <h3 class="text-sm font-bold uppercase tracking-widest text-saffron-500">Platform</h3>
176
+ <ul class="mt-6 space-y-4 text-sm text-slate-400">
177
+ <li><a href="/heatmap.html" class="transition hover:text-white">Live Heatmap</a></li>
178
+ <li><a href="/dashboard.html" class="transition hover:text-white">Intelligence Dashboard</a></li>
179
+ </ul>
180
+ </div>
181
+
182
+ <!-- Support -->
183
+ <div>
184
+ <h3 class="text-sm font-bold uppercase tracking-widest text-ashoka-blue-400">Support</h3>
185
+ <ul class="mt-6 space-y-4 text-sm text-slate-400">
186
+ <li><a href="/contact.html" class="transition hover:text-white font-bold text-slate-300">Contact Us</a></li>
187
+ </ul>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- Bottom Bar -->
192
+ <div class="mt-16 border-t border-white/5 pt-8">
193
+ <div class="flex flex-col items-center justify-between gap-6 md:flex-row">
194
+ <p class="text-xs text-slate-500">© 2026 Misinformation Heatmap.</p>
195
+ <div class="flex items-center gap-6">
196
+ <a href="/privacy-policy.html" class="text-xs text-slate-500 hover:text-white transition">Privacy Policy</a>
197
+ <div class="h-1 w-24 rounded-full bg-gradient-to-r from-saffron-500 via-white to-india-green-500 opacity-30"></div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </footer>
203
+ </body>
204
+
205
+ </html>
frontend/dashboard.html CHANGED
@@ -1,750 +1,488 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Dashboard — Misinformation Heatmap</title>
7
- <meta name="description" content="Real-time analytics dashboard for misinformation detection across India.">
8
- <link rel="icon" type="image/x-icon" href="/public/Misinfo.ico">
9
- <link rel="preconnect" href="https://fonts.googleapis.com">
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
12
- <style>
13
- :root {
14
- --saffron: #E87722;
15
- --saffron-light: #FF9933;
16
- --saffron-dark: #c4601a;
17
- --green: #138808;
18
- --green-light: #1ea50e;
19
- --navy: #000080;
20
- --red: #dc2626;
21
- --amber: #d97706;
22
- --blue: #2563eb;
23
- --bg: #F8F8F4;
24
- --bg-card: #FFFFFF;
25
- --border: #E8E8E0;
26
- --border-accent: rgba(232,119,34,0.25);
27
- --text-primary: #1A1A1A;
28
- --text-secondary:#555555;
29
- --text-muted: #888888;
30
- --shadow-sm: 0 2px 8px rgba(0,0,0,0.07);
31
- --shadow-md: 0 4px 20px rgba(0,0,0,0.10);
32
- --radius: 16px;
33
- --radius-sm: 10px;
34
- }
35
-
36
- * { margin: 0; padding: 0; box-sizing: border-box; }
37
-
38
- body {
39
- font-family: 'Inter', sans-serif;
40
- background: var(--bg);
41
- color: var(--text-primary);
42
- min-height: 100vh;
43
- overflow-x: hidden;
44
- max-width: 100vw;
45
- }
46
-
47
- /* ─── BACKGROUND ─── */
48
- .bg-blobs { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
49
- .blob { position: absolute; border-radius: 50%; filter: blur(90px); opacity: 0.12; }
50
- .blob-1 { width: 600px; height: 600px; top: -150px; left: -100px; background: var(--saffron-light); }
51
- .blob-2 { width: 500px; height: 500px; bottom: -100px; right: -100px; background: var(--green-light); }
52
-
53
- /* ─── NAVBAR ─── */
54
- .navbar {
55
- position: sticky; top: 0; z-index: 100;
56
- height: 60px; display: flex; align-items: center; padding: 0 2rem;
57
- background: rgba(248,248,244,0.9); backdrop-filter: blur(20px);
58
- border-bottom: 1px solid var(--border); box-shadow: var(--shadow-sm);
59
- }
60
- .nav-inner { display: flex; align-items: center; justify-content: space-between; width: 100%; }
61
- .logo { display: flex; align-items: center; gap: 4px; font-family: 'Outfit', sans-serif; font-size: 1.05rem; font-weight: 800; text-decoration: none; color: var(--text-primary); }
62
- .logo-badge { font-size: 0.6rem; font-weight: 700; background: var(--saffron); color: #fff; padding: 2px 5px; border-radius: 3px; }
63
- .logo .logo-name { color: var(--saffron); }
64
- .nav-links { display: flex; gap: 0.25rem; }
65
- .nav-link { color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-weight: 500; padding: 0.4rem 0.9rem; border-radius: var(--radius-sm); transition: all 0.2s; }
66
- .nav-link:hover { color: var(--saffron); background: rgba(232,119,34,0.07); }
67
- .nav-link.active { color: var(--saffron); background: rgba(232,119,34,0.1); border: 1px solid rgba(232,119,34,0.2); font-weight: 600; }
68
- .nav-right { display: flex; align-items: center; gap: 0.75rem; }
69
- .status-pill { display: flex; align-items: center; gap: 6px; background: rgba(19,136,8,0.08); border: 1px solid rgba(19,136,8,0.25); border-radius: 20px; padding: 4px 12px; font-size: 0.78rem; font-weight: 600; color: var(--green); }
70
- .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
71
- @keyframes pulse { 0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(19,136,8,0.4);}50%{opacity:0.8;box-shadow:0 0 0 5px rgba(19,136,8,0);} }
72
- .refresh-btn { background: var(--bg-card); border: 1px solid var(--border); color: var(--text-secondary); padding: 5px 14px; border-radius: var(--radius-sm); font-size: 0.8rem; cursor: pointer; transition: all 0.2s; font-family: 'Inter', sans-serif; }
73
- .refresh-btn:hover { border-color: var(--saffron); color: var(--saffron); }
74
-
75
- /* Hamburger Menu */
76
- .hamburger-btn {
77
- display: none;
78
- flex-direction: column;
79
- gap: 5px;
80
- background: none;
81
- border: none;
82
- cursor: pointer;
83
- padding: 8px;
84
- z-index: 2000;
85
- }
86
- .hamburger-line {
87
- width: 24px;
88
- height: 2px;
89
- background: var(--text-primary);
90
- transition: all 0.3s ease;
91
- border-radius: 2px;
92
- }
93
- .hamburger-btn.active .hamburger-line:nth-child(1) {
94
- transform: rotate(45deg) translate(5px, 5px);
95
- }
96
- .hamburger-btn.active .hamburger-line:nth-child(2) {
97
- opacity: 0;
98
- }
99
- .hamburger-btn.active .hamburger-line:nth-child(3) {
100
- transform: rotate(-45deg) translate(5px, -5px);
101
- }
102
-
103
- /* Mobile Menu Overlay */
104
- .mobile-menu-overlay {
105
- position: fixed;
106
- top: 0;
107
- right: 0;
108
- width: 100%;
109
- height: 100%;
110
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.98), rgba(232, 119, 34, 0.95));
111
- z-index: 1500;
112
- transform: translateX(100%);
113
- transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
114
- display: flex;
115
- flex-direction: column;
116
- justify-content: space-between;
117
- padding: 80px 2rem 2rem;
118
- }
119
- .mobile-menu-overlay.active {
120
- transform: translateX(0);
121
- }
122
- .mobile-menu-top {
123
- display: flex;
124
- flex-direction: column;
125
- gap: 1rem;
126
- }
127
- .mobile-menu-link {
128
- font-family: 'Outfit', sans-serif;
129
- font-size: 1.5rem;
130
- font-weight: 700;
131
- color: #fff;
132
- text-decoration: none;
133
- padding: 1rem 0;
134
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
135
- transition: all 0.3s ease;
136
- }
137
- .mobile-menu-link:hover {
138
- padding-left: 1rem;
139
- background: rgba(255, 255, 255, 0.1);
140
- }
141
- .mobile-menu-bottom {
142
- display: flex;
143
- flex-direction: column;
144
- gap: 1rem;
145
- padding-top: 2rem;
146
- border-top: 1px solid rgba(255, 255, 255, 0.2);
147
- }
148
- .mobile-menu-brand {
149
- font-family: 'Outfit', sans-serif;
150
- font-size: 1.2rem;
151
- font-weight: 800;
152
- color: #fff;
153
- display: flex;
154
- align-items: center;
155
- gap: 8px;
156
- }
157
- .mobile-menu-brand .logo-badge {
158
- background: #fff;
159
- color: var(--saffron);
160
- }
161
- .mobile-menu-brand .logo-name {
162
- color: #fff;
163
- }
164
- .mobile-menu-tags {
165
- display: flex;
166
- gap: 8px;
167
- flex-wrap: wrap;
168
- }
169
- .menu-tag {
170
- font-size: 0.7rem;
171
- font-weight: 700;
172
- padding: 4px 10px;
173
- border-radius: 12px;
174
- text-transform: uppercase;
175
- }
176
- .menu-tag.alpha {
177
- background: rgba(255, 255, 255, 0.2);
178
- color: #fff;
179
- }
180
- .menu-tag.live {
181
- background: #fff;
182
- color: var(--green);
183
- animation: pulse 2s infinite;
184
- }
185
 
186
- /* ─── MAIN LAYOUT ─── */
187
- .main { position: relative; z-index: 1; padding: 2rem; max-width: 1400px; margin: 0 auto; }
188
- .page-header { margin-bottom: 2rem; }
189
- .page-title { font-family: 'Outfit', sans-serif; font-size: 1.8rem; font-weight: 800; color: var(--text-primary); }
190
- .page-sub { color: var(--text-muted); font-size: 0.88rem; margin-top: 4px; }
191
- .last-updated { color: var(--text-muted); font-size: 0.78rem; margin-top: 2px; }
192
-
193
- /* ─── STAT CARDS ─── */
194
- .stats-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 1.25rem; margin-bottom: 2rem; }
195
- .stat-card {
196
- background: var(--bg-card); border: 1px solid var(--border);
197
- border-radius: var(--radius); padding: 1.5rem; position: relative;
198
- overflow: hidden; transition: all 0.25s; box-shadow: var(--shadow-sm);
199
- }
200
- .stat-card::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; }
201
- .stat-card.total::before { background: var(--blue); }
202
- .stat-card.fake::before { background: var(--red); }
203
- .stat-card.real::before { background: var(--green); }
204
- .stat-card.uncertain::before { background: var(--amber); }
205
- .stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
206
- .stat-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
207
- .stat-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); }
208
- .stat-icon { font-size: 1.4rem; }
209
- .stat-value { font-family: 'Outfit', sans-serif; font-size: 2.4rem; font-weight: 900; line-height: 1; }
210
- .stat-card.total .stat-value { color: var(--blue); }
211
- .stat-card.fake .stat-value { color: var(--red); }
212
- .stat-card.real .stat-value { color: var(--green); }
213
- .stat-card.uncertain .stat-value { color: var(--amber); }
214
- .stat-desc { font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }
215
- .stat-bar { height: 3px; background: var(--border); border-radius: 2px; margin-top: 1rem; overflow: hidden; }
216
- .stat-bar-fill { height: 100%; border-radius: 2px; transition: width 1s ease; width: 0; }
217
- .stat-card.total .stat-bar-fill { background: var(--blue); }
218
- .stat-card.fake .stat-bar-fill { background: var(--red); }
219
- .stat-card.real .stat-bar-fill { background: var(--green); }
220
- .stat-card.uncertain .stat-bar-fill { background: var(--amber); }
221
-
222
- /* ─── GRID ROWS ─── */
223
- .row { display: grid; gap: 1.25rem; margin-bottom: 1.25rem; }
224
- .row-2 { grid-template-columns: 1fr 1.6fr; }
225
- .row-2b { grid-template-columns: 1.6fr 1fr; }
226
- .row-3 { grid-template-columns: 1fr 1fr 1fr; }
227
-
228
- .panel {
229
- background: var(--bg-card); border: 1px solid var(--border);
230
- border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm);
231
- display: flex; flex-direction: column;
232
- }
233
- .panel-header {
234
- display: flex; align-items: center; justify-content: space-between;
235
- padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border);
236
- }
237
- .panel-title { font-size: 0.95rem; font-weight: 700; color: var(--text-primary); }
238
- .panel-sub { font-size: 0.8rem; color: var(--text-muted); margin-top: 2px; }
239
- .panel-badge { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); background: var(--bg); border: 1px solid var(--border); padding: 3px 10px; border-radius: 10px; }
240
- .panel-body { padding: 1.5rem; flex: 1; }
241
-
242
- /* ─── ACCURACY RING ─── */
243
- .accuracy-center { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 1.5rem 0; }
244
- .ring-wrap { position: relative; display: inline-flex; align-items: center; justify-content: center; }
245
- .ring-label { position: absolute; text-align: center; }
246
- .ring-pct { font-family: 'Outfit', sans-serif; font-size: 2.2rem; font-weight: 900; color: var(--green); line-height: 1; }
247
- .ring-sub { font-size: 0.7rem; color: var(--text-muted); margin-top: 2px; }
248
-
249
- .metric-bar-row { display: flex; align-items: center; justify-content: space-around; margin-top: 1.25rem; padding-top: 1.25rem; border-top: 1px solid var(--border); }
250
- .metric-bar-item { text-align: center; }
251
- .mbi-val { font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 800; }
252
- .mbi-lbl { font-size: 0.72rem; color: var(--text-muted); margin-top: 2px; }
253
-
254
- /* ─── ACTIVITY FEED ─── */
255
- .activity-feed { overflow-y: auto; max-height: 320px; display: flex; flex-direction: column; gap: 0.6rem; }
256
- .activity-feed::-webkit-scrollbar { width: 4px; }
257
- .activity-feed::-webkit-scrollbar-track { background: var(--bg); }
258
- .activity-feed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
259
- .activity-item { display: flex; align-items: flex-start; gap: 0.75rem; padding: 0.75rem; border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--border); transition: all 0.2s; }
260
- .activity-item:hover { border-color: var(--border-accent); background: rgba(232,119,34,0.02); }
261
- .activity-dot { font-size: 1.1rem; flex-shrink: 0; }
262
- .activity-info { flex: 1; min-width: 0; }
263
- .activity-title { font-size: 0.83rem; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
264
- .activity-meta { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; margin-top: 3px; font-size: 0.72rem; color: var(--text-muted); }
265
- .activity-tag { font-weight: 700; padding: 1px 6px; border-radius: 4px; font-size: 0.65rem; }
266
- .activity-tag.fake { background: rgba(220,38,38,0.1); color: var(--red); }
267
- .activity-tag.real { background: rgba(19,136,8,0.1); color: var(--green); }
268
- .activity-tag.uncertain { background: rgba(217,119,6,0.1); color: var(--amber); }
269
- .feed-count { font-size: 0.78rem; color: var(--text-muted); }
270
-
271
- /* ─── COVERAGE ─── */
272
- .coverage-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 1rem; }
273
- .cov-item { text-align: center; padding: 1rem; background: var(--bg); border-radius: var(--radius-sm); border: 1px solid var(--border); }
274
- .cov-val { font-family: 'Outfit', sans-serif; font-size: 1.8rem; font-weight: 900; color: var(--saffron); }
275
- .cov-label { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
276
-
277
- /* ─── HEALTH METRICS ─── */
278
- .metric-row { display: flex; align-items: center; justify-content: space-between; padding: 0.7rem 0; border-bottom: 1px solid var(--border); }
279
- .metric-row:last-child { border-bottom: none; }
280
- .metric-name { font-size: 0.85rem; color: var(--text-secondary); }
281
- .metric-badge { font-size: 0.72rem; font-weight: 600; padding: 3px 10px; border-radius: 10px; }
282
- .metric-badge.green { background: rgba(19,136,8,0.1); color: var(--green); }
283
- .metric-badge.amber { background: rgba(217,119,6,0.1); color: var(--amber); }
284
- .metric-badge.red { background: rgba(220,38,38,0.1); color: var(--red); }
285
-
286
- /* ─── MAP LINK ─── */
287
- .map-link { display: inline-flex; align-items: center; gap: 6px; background: var(--saffron); color: #fff; font-size: 0.82rem; font-weight: 600; padding: 6px 14px; border-radius: var(--radius-sm); text-decoration: none; transition: all 0.2s; }
288
- .map-link:hover { background: var(--saffron-dark); }
289
-
290
- .empty-feed { text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.88rem; }
291
- .empty-icon { font-size: 2rem; margin-bottom: 8px; }
292
-
293
- /* Enhanced Mobile Responsiveness */
294
- @media (max-width: 1200px) {
295
- .navbar { padding: 0 1rem; }
296
- .main { padding: 1.5rem; }
297
- .stats-grid { grid-template-columns: repeat(2, 1fr); gap: 1rem; }
298
- .row-2, .row-2b { grid-template-columns: 1fr; gap: 1rem; }
299
- .coverage-grid { grid-template-columns: repeat(2, 1fr); }
300
- }
301
-
302
- @media (max-width: 1024px) {
303
- .nav-links { gap: 0.5rem; }
304
- .nav-link { font-size: 0.8rem; padding: 0.3rem 0.7rem; }
305
- .row-2, .row-2b { grid-template-columns: 1fr; }
306
- .coverage-grid { grid-template-columns: 1fr; }
307
- }
308
-
309
- @media (max-width: 768px) {
310
- .navbar { padding: 0 1rem; height: auto; }
311
- .nav-inner { flex-direction: column; gap: 0.75rem; padding: 0.75rem 0; }
312
- .nav-links { order: 2; }
313
- .nav-right { order: 1; }
314
- .main { padding: 1rem; }
315
- .page-header { margin-bottom: 1.5rem; }
316
- .page-title { font-size: 1.5rem; }
317
- .stats-grid { grid-template-columns: 1fr 1fr; gap: 0.75rem; }
318
- .stat-card { padding: 1.25rem; }
319
- .stat-value { font-size: 2rem; }
320
- .row-3 { grid-template-columns: 1fr; }
321
- .panel-header { padding: 1rem; }
322
- .panel-body { padding: 1rem; }
323
- .activity-feed { max-height: 250px; }
324
- .coverage-grid { grid-template-columns: 1fr; gap: 0.75rem; }
325
- .cov-item { padding: 0.75rem; }
326
- .metric-row { padding: 0.5rem 0; font-size: 0.8rem; }
327
- }
328
-
329
- @media (max-width: 480px) {
330
- .navbar { padding: 0 0.75rem; }
331
- .logo { font-size: 0.9rem; }
332
- .logo-badge { font-size: 0.55rem; }
333
- .nav-inner { padding: 0.5rem 0; }
334
- .nav-link { font-size: 0.75rem; padding: 0.25rem 0.5rem; }
335
- .refresh-btn { font-size: 0.7rem; padding: 4px 10px; }
336
- .status-pill { display: none; }
337
- .hamburger-btn { display: flex; }
338
- .main { padding: 0.75rem; }
339
- .page-title { font-size: 1.3rem; }
340
- .page-sub { font-size: 0.8rem; }
341
- .stats-grid { grid-template-columns: 1fr; gap: 0.5rem; }
342
- .stat-card { padding: 1rem; }
343
- .stat-value { font-size: 1.8rem; }
344
- .stat-label { font-size: 0.65rem; }
345
- .stat-desc { font-size: 0.7rem; }
346
- .panel-header { padding: 0.75rem; }
347
- .panel-title { font-size: 0.85rem; }
348
- .panel-sub { font-size: 0.75rem; }
349
- .panel-body { padding: 0.75rem; }
350
- .activity-feed { max-height: 200px; gap: 0.4rem; }
351
- .activity-item { padding: 0.5rem; }
352
- .activity-title { font-size: 0.75rem; }
353
- .activity-meta { font-size: 0.65rem; }
354
- .coverage-grid { grid-template-columns: 1fr; gap: 0.5rem; }
355
- .cov-item { padding: 0.5rem; }
356
- .cov-val { font-size: 1.5rem; }
357
- .cov-label { font-size: 0.7rem; }
358
- .metric-row { padding: 0.4rem 0; font-size: 0.75rem; }
359
- .metric-name { font-size: 0.75rem; }
360
- .metric-badge { font-size: 0.65rem; padding: 2px 6px; }
361
- }
362
- </style>
363
  </head>
364
- <body>
365
- <div class="bg-blobs">
366
- <div class="blob blob-1"></div>
367
- <div class="blob blob-2"></div>
368
- </div>
369
 
370
- <!-- Navbar -->
371
- <nav class="navbar">
372
- <div class="nav-inner">
373
- <a href="/" class="logo">
374
- <span class="logo-badge">IN</span>
375
- <span>Misinformation</span><span class="logo-name">&nbsp;Heatmap</span>
376
- </a>
377
- <div class="nav-links">
378
- <a href="/" class="nav-link">Home</a>
379
- <a href="/dashboard" class="nav-link active">Dashboard</a>
380
- <a href="/map/enhanced-india-heatmap.html" class="nav-link">Heatmap</a>
381
- </div>
382
- <div class="nav-right">
383
- <div class="status-pill">
384
- <span class="status-dot"></span>
385
- <span id="system-status">Connecting…</span>
386
- </div>
387
- <button class="refresh-btn" onclick="refreshAll()">↻ Refresh</button>
388
- <button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle menu">
389
- <span class="hamburger-line"></span>
390
- <span class="hamburger-line"></span>
391
- <span class="hamburger-line"></span>
392
- </button>
393
- </div>
394
  </div>
395
- </nav>
396
 
397
- <!-- Mobile Menu Overlay -->
398
- <div class="mobile-menu-overlay" id="mobile-menu">
399
- <div class="mobile-menu-top">
400
- <a href="/" class="mobile-menu-link">Home</a>
401
- <a href="/dashboard" class="mobile-menu-link">Dashboard</a>
402
- <a href="/map/enhanced-india-heatmap.html" class="mobile-menu-link">Heatmap</a>
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  </div>
404
- <div class="mobile-menu-bottom">
405
- <div class="mobile-menu-brand">
406
- <span class="logo-badge">IN</span>
407
- <span>Misinformation</span><span class="logo-name">&nbsp;Heatmap</span>
408
- </div>
409
- <div class="mobile-menu-tags">
410
- <span class="menu-tag alpha">Alpha v1.0</span>
411
- <span class="menu-tag live">● Live</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  </div>
 
413
  </div>
414
- </div>
415
-
416
- <div class="main">
417
- <!-- Header -->
418
- <div class="page-header">
419
- <div class="page-title">Real-Time Analytics</div>
420
- <div class="page-sub">Comprehensive misinformation detection metrics across India</div>
421
- <div class="last-updated" id="last-updated">Fetching data…</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
422
  </div>
423
-
424
- <!-- Stat Cards -->
425
- <div class="stats-grid">
426
- <div class="stat-card total">
427
- <div class="stat-header">
428
- <div class="stat-label">Total Events</div>
429
- <div class="stat-icon">📊</div>
430
- </div>
431
- <div class="stat-value" id="total-events">—</div>
432
- <div class="stat-desc">Processed in real-time</div>
433
- <div class="stat-bar"><div class="stat-bar-fill" id="bar-total"></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
434
  </div>
435
- <div class="stat-card fake">
436
- <div class="stat-header">
437
- <div class="stat-label">Misinformation</div>
438
- <div class="stat-icon">🚨</div>
439
- </div>
440
- <div class="stat-value" id="fake-events">—</div>
441
- <div class="stat-desc">High confidence detections</div>
442
- <div class="stat-bar"><div class="stat-bar-fill" id="bar-fake"></div></div>
443
  </div>
444
- <div class="stat-card real">
445
- <div class="stat-header">
446
- <div class="stat-label">Verified News</div>
447
- <div class="stat-icon">✅</div>
448
- </div>
449
- <div class="stat-value" id="real-events">—</div>
450
- <div class="stat-desc">Authenticated sources</div>
451
- <div class="stat-bar"><div class="stat-bar-fill" id="bar-real"></div></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
452
  </div>
453
- <div class="stat-card uncertain">
454
- <div class="stat-header">
455
- <div class="stat-label">Under Review</div>
456
- <div class="stat-icon">⚠️</div>
 
 
 
 
 
 
 
 
457
  </div>
458
- <div class="stat-value" id="uncertain-events">—</div>
459
- <div class="stat-desc">Requires human verification</div>
460
- <div class="stat-bar"><div class="stat-bar-fill" id="bar-uncertain"></div></div>
461
  </div>
 
 
 
 
 
462
  </div>
 
 
463
 
464
- <!-- Row: AI Performance + Live Activity -->
465
- <div class="row row-2">
466
- <!-- AI Performance -->
467
- <div class="panel">
468
- <div class="panel-header">
469
- <div>
470
- <div class="panel-title">AI Detection Performance</div>
471
- <div class="panel-sub">Multi-layered analysis system accuracy</div>
472
- </div>
473
- </div>
474
- <div class="panel-body">
475
- <div class="accuracy-center">
476
- <div class="ring-wrap">
477
- <svg width="160" height="160" style="transform:rotate(-90deg)">
478
- <circle cx="80" cy="80" r="56" fill="none" stroke="#E8E8E0" stroke-width="10"/>
479
- <circle id="accuracy-ring-fill" cx="80" cy="80" r="56" fill="none"
480
- stroke="#138808" stroke-width="10" stroke-linecap="round"
481
- stroke-dasharray="351.86" stroke-dashoffset="351.86"
482
- style="transition: stroke-dashoffset 1.5s ease"/>
483
- </svg>
484
- <div class="ring-label">
485
- <div class="ring-pct" id="accuracy-pct">91%</div>
486
- <div class="ring-sub">Overall Accuracy</div>
487
- </div>
488
- </div>
489
- </div>
490
- <div class="metric-bar-row">
491
- <div class="metric-bar-item">
492
- <div class="mbi-val" style="color:var(--blue);">94.2%</div>
493
- <div class="mbi-lbl">Precision</div>
494
- </div>
495
- <div class="metric-bar-item">
496
- <div class="mbi-val" style="color:var(--green);">91.7%</div>
497
- <div class="mbi-lbl">Recall</div>
498
- </div>
499
- <div class="metric-bar-item">
500
- <div class="mbi-val" style="color:var(--saffron);">92.9%</div>
501
- <div class="mbi-lbl">F1-Score</div>
502
- </div>
503
- </div>
504
- </div>
505
- </div>
506
 
507
- <!-- Live Activity -->
508
- <div class="panel">
509
- <div class="panel-header">
510
- <div>
511
- <div class="panel-title">Live Activity</div>
512
- <div class="panel-sub">Recent classifications</div>
513
- </div>
514
- <span class="feed-count" id="events-count">Loading…</span>
515
- </div>
516
- <div class="panel-body" style="overflow:hidden; padding:1rem;">
517
- <div class="activity-feed" id="activity-feed">
518
- <div class="empty-feed"><div class="empty-icon">⏳</div>Loading recent activity…</div>
519
- </div>
520
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  </div>
523
 
524
- <!-- Row: Coverage + Health -->
525
- <div class="row row-2b">
526
- <!-- Geographic Coverage -->
527
- <div class="panel">
528
- <div class="panel-header">
529
- <div>
530
- <div class="panel-title">Geographic Coverage</div>
531
- <div class="panel-sub">Monitoring across all Indian states and union territories</div>
532
- </div>
533
- <a href="/map/enhanced-india-heatmap.html" class="map-link">🗺️ View Interactive Map</a>
534
- </div>
535
- <div class="panel-body">
536
- <div class="coverage-grid">
537
- <div class="cov-item">
538
- <div class="cov-val" id="total-states">36</div>
539
- <div class="cov-label">States &amp; UTs</div>
540
- </div>
541
- <div class="cov-item">
542
- <div class="cov-val">34+</div>
543
- <div class="cov-label">News Sources</div>
544
- </div>
545
- <div class="cov-item">
546
- <div class="cov-val">100+</div>
547
- <div class="cov-label">Articles/Second</div>
548
- </div>
549
- </div>
550
- <div style="margin-top:1.25rem; padding:1rem; background:rgba(232,119,34,0.05); border:1px solid rgba(232,119,34,0.15); border-radius:12px; font-size:0.85rem; color:var(--text-secondary); line-height:1.6;">
551
- 🌐 Monitoring <strong style="color:var(--saffron)">all 28 states + 8 union territories</strong> with concurrent RSS ingestion, processed through our 5-model ML ensemble, IndicBERT NLP pipeline, and GDELT global news index.
552
- </div>
553
- </div>
554
- </div>
555
 
556
- <!-- System Health -->
557
- <div class="panel">
558
- <div class="panel-header">
559
- <div class="panel-title">System Health</div>
560
- <div class="panel-badge" id="health-badge">Checking…</div>
561
- </div>
562
- <div class="panel-body">
563
- <div class="metric-row">
564
- <span class="metric-name">API Server</span>
565
- <span class="metric-badge green" id="health-api">● Online</span>
566
- </div>
567
- <div class="metric-row">
568
- <span class="metric-name">Data Processing</span>
569
- <span class="metric-badge amber" id="health-proc">● Checking</span>
570
- </div>
571
- <div class="metric-row">
572
- <span class="metric-name">Database</span>
573
- <span class="metric-badge green" id="health-db">● Connected</span>
574
- </div>
575
- <div class="metric-row">
576
- <span class="metric-name">ML Models</span>
577
- <span class="metric-badge green" id="health-ml">● Checking</span>
578
- </div>
579
- <div class="metric-row">
580
- <span class="metric-name">RSS Feeds</span>
581
- <span class="metric-badge green">● 34+ Active</span>
582
- </div>
583
- <div class="metric-row">
584
- <span class="metric-name">GDELT Source</span>
585
- <span class="metric-badge green">● Connected</span>
586
- </div>
587
- </div>
588
  </div>
 
589
  </div>
 
590
  </div>
591
-
592
- <script>
593
- // ── API Configuration ──
594
- const API_BASE = window.location.hostname.includes('netlify.app')
595
- ? 'https://ndg07-heatmap.hf.space'
596
- : '';
597
-
598
- // ── Hamburger menu toggle ──
599
- const hamburgerBtn = document.getElementById('hamburger-btn');
600
- const mobileMenu = document.getElementById('mobile-menu');
601
-
602
- hamburgerBtn.addEventListener('click', () => {
603
- hamburgerBtn.classList.toggle('active');
604
- mobileMenu.classList.toggle('active');
605
- document.body.style.overflow = mobileMenu.classList.contains('active') ? 'hidden' : '';
606
- });
607
-
608
- // Close menu when clicking on a link
609
- document.querySelectorAll('.mobile-menu-link').forEach(link => {
610
- link.addEventListener('click', () => {
611
- hamburgerBtn.classList.remove('active');
612
- mobileMenu.classList.remove('active');
613
- document.body.style.overflow = '';
614
- });
615
- });
616
-
617
- // Close menu when clicking outside
618
- mobileMenu.addEventListener('click', (e) => {
619
- if (e.target === mobileMenu) {
620
- hamburgerBtn.classList.remove('active');
621
- mobileMenu.classList.remove('active');
622
- document.body.style.overflow = '';
623
- }
624
- });
625
-
626
- const fmt = n => n >= 1000000 ? (n/1e6).toFixed(1)+'M' : n >= 1000 ? (n/1000).toFixed(1)+'K' : String(n);
627
-
628
- function animateNum(el, to) {
629
- if (!el) return;
630
- const from = parseInt(el.textContent.replace(/[^0-9]/g,'')) || 0;
631
- if (from === to) return;
632
- const step = Math.max(1, Math.ceil(Math.abs(to - from) / 25));
633
- let cur = from;
634
- const timer = setInterval(() => {
635
- cur += (to > from ? step : -step);
636
- if ((to > from && cur >= to) || (to < from && cur <= to)) { cur = to; clearInterval(timer); }
637
- el.textContent = fmt(cur);
638
- }, 25);
639
- }
640
-
641
- async function updateStats(d) {
642
- try {
643
- if (!d) {
644
- const res = await fetch(API_BASE + '/api/v1/stats');
645
- if (!res.ok) throw new Error();
646
- d = await res.json();
647
- }
648
-
649
- animateNum(document.getElementById('total-events'), d.total_events || 0);
650
- animateNum(document.getElementById('fake-events'), d.fake_events || 0);
651
- animateNum(document.getElementById('real-events'), d.real_events || 0);
652
- animateNum(document.getElementById('uncertain-events'), d.uncertain_events || 0);
653
- document.getElementById('total-states').textContent = d.total_states || 36;
654
-
655
- const total = d.total_events || 1;
656
- setTimeout(() => {
657
- document.getElementById('bar-total').style.width = '100%';
658
- document.getElementById('bar-fake').style.width = ((d.fake_events||0)/total*100) + '%';
659
- document.getElementById('bar-real').style.width = ((d.real_events||0)/total*100) + '%';
660
- document.getElementById('bar-uncertain').style.width = ((d.uncertain_events||0)/total*100) + '%';
661
- }, 200);
662
-
663
- const statusEl = document.getElementById('system-status');
664
- const procEl = document.getElementById('health-proc');
665
- const mlEl = document.getElementById('health-ml');
666
- const badge = document.getElementById('health-badge');
667
-
668
- statusEl.textContent = d.processing_active ? 'Live Processing' : (d.ml_ready ? 'System Ready' : 'Loading…');
669
- procEl.textContent = d.processing_active ? '● Active' : '● Idle';
670
- procEl.className = `metric-badge ${d.processing_active ? 'green' : 'amber'}`;
671
- mlEl.textContent = d.ml_ready ? '● Loaded' : '● Loading…';
672
- mlEl.className = `metric-badge ${d.ml_ready ? 'green' : 'amber'}`;
673
- badge.textContent = d.processing_active ? 'All Systems Go' : 'Standby';
674
- badge.style.color = d.processing_active ? 'var(--green)' : 'var(--text-muted)';
675
-
676
- // Accuracy ring
677
- const acc = d.classification_accuracy || 0.91;
678
- document.getElementById('accuracy-pct').textContent = Math.round(acc * 100) + '%';
679
- setTimeout(() => {
680
- document.getElementById('accuracy-ring-fill').style.strokeDashoffset = 351.86 * (1 - acc);
681
- }, 300);
682
-
683
- document.getElementById('last-updated').textContent =
684
- 'Last updated: ' + new Date().toLocaleTimeString('en-IN', { hour12: true });
685
-
686
- } catch(_) {
687
- document.getElementById('system-status').textContent = 'Connecting…';
688
- }
689
- }
690
-
691
- async function updateActivity(eventsPayload) {
692
- try {
693
- let events;
694
- if (!eventsPayload) {
695
- const res = await fetch(API_BASE + '/api/v1/events/live?limit=15');
696
- if (!res.ok) throw new Error();
697
- const d = await res.json();
698
- events = d.events || [];
699
- } else {
700
- events = eventsPayload.events || [];
701
- }
702
-
703
- document.getElementById('events-count').textContent = events.length + ' events';
704
-
705
- const feed = document.getElementById('activity-feed');
706
- if (events.length === 0) {
707
- feed.innerHTML = `<div class="empty-feed"><div class="empty-icon">📭</div><p>No recent events</p></div>`;
708
- return;
709
- }
710
-
711
- feed.innerHTML = events.map(ev => {
712
- const cls = ev.classification || 'uncertain';
713
- const icon = cls === 'fake' ? '🚨' : cls === 'real' ? '✅' : '⚠️';
714
- const time = ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString('en-IN', {hour:'2-digit', minute:'2-digit', hour12:true}) : '—';
715
- const title = (ev.title || 'Processing event…').substring(0, 90);
716
- const conf = ev.confidence ? Math.round(ev.confidence * 100) + '%' : '';
717
- return `<div class="activity-item">
718
- <div class="activity-dot">${icon}</div>
719
- <div class="activity-info">
720
- <div class="activity-title" title="${(ev.title||'').replace(/"/g,'&quot;')}">${title}</div>
721
- <div class="activity-meta">
722
- <span class="activity-tag ${cls}">${cls.toUpperCase()}</span>
723
- <span>${ev.source || 'Unknown'}</span>
724
- <span>📍 ${ev.state || 'India'}</span>
725
- ${conf ? `<span>${conf}</span>` : ''}
726
- <span>🕐 ${time}</span>
727
- </div>
728
- </div>
729
- </div>`;
730
- }).join('');
731
- } catch(_) {}
732
- }
733
-
734
- function refreshAll() { updateStats(); updateActivity(); }
735
-
736
- // Initial load
737
- updateStats();
738
- updateActivity();
739
-
740
- // SSE for real-time updates
741
- const sse = new EventSource(API_BASE + '/api/v1/stream');
742
- sse.addEventListener('stats', e => updateStats(JSON.parse(e.data)));
743
- sse.addEventListener('live_events', e => updateActivity(JSON.parse(e.data)));
744
- sse.onerror = () => { document.getElementById('system-status').textContent = 'Reconnecting…'; };
745
-
746
- // Fallback polling every 15s in case SSE stalls
747
- setInterval(refreshAll, 15000);
748
- </script>
749
  </body>
750
- </html>
 
 
1
  <!DOCTYPE html>
2
  <html lang="en">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, viewport-fit=cover">
7
+ <meta name="referrer" content="strict-origin-when-cross-origin">
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://ndg07-heatmap.hf.space; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests">
9
+ <style>
10
+ html, body { overflow-x: hidden; width: 100%; position: relative; }
11
+
12
+ /* Surface Pro 7 Specific Enhancements */
13
+ @media screen and (device-width: 912px) and (device-height: 1368px),
14
+ screen and (device-width: 912px) and (device-height: 608px) {
15
+ html {
16
+ font-size: 18px !important;
17
+ }
18
+ }
19
+
20
+ /* Specific scaling for Nest Hub Max (1280x800) - More breathing room */
21
+ @media screen and (device-width: 1280px) and (device-height: 800px) {
22
+ body #main-content {
23
+ padding-top: 6rem !important;
24
+ padding-bottom: 1.5rem !important;
25
+ min-height: calc(100vh - 1rem) !important;
26
+ }
27
+ }
28
+
29
+ @media screen and (device-width: 912px) and (device-height: 1368px),
30
+ screen and (device-width: 912px) and (device-height: 608px) {
31
+ h1 { font-size: 3.5rem !important; line-height: 1.1 !important; }
32
+ .text-2xl { font-size: 2rem !important; }
33
+ .text-sm { font-size: 1.05rem !important; line-height: 1.6 !important; }
34
+ .text-xs { font-size: 0.9rem !important; letter-spacing: 0.25em !important; }
35
+
36
+ #main-content { padding-top: 9rem !important; }
37
+
38
+ /* Card/Island Scaling for Pro 7 */
39
+ #island-toggle {
40
+ width: 85vw !important;
41
+ height: 70vh !important;
42
+ max-width: 1000px !important;
43
+ max-height: 900px !important;
44
+ }
45
+
46
+ /* Animation Overrides for Pro 7 (Less extreme surge) */
47
+ #dashboard-health-overlay.active #island-toggle {
48
+ animation: fadeInUpPro 0.45s cubic-bezier(0.16, 1, 0.3, 1) forwards;
49
+ }
50
+ #dashboard-health-overlay.closing #island-toggle {
51
+ animation: fadeOutDownPro 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
52
+ }
53
+
54
+ @keyframes fadeInUpPro {
55
+ 0% { opacity: 0; transform: translateY(500px) scale(0.4); border-radius: 10rem; }
56
+ 70% { opacity: 1; transform: translateY(-15px) scale(1.01); }
57
+ 100% { opacity: 1; transform: translateY(0) scale(1); border-radius: 2.5rem; }
58
+ }
59
+ @keyframes fadeOutDownPro {
60
+ 0% { opacity: 1; transform: translateY(0) scale(1); border-radius: 2.5rem; }
61
+ 100% { opacity: 0; transform: translateY(500px) scale(0.4); border-radius: 10rem; }
62
+ }
63
+
64
+ /* Pill Scaling for Pro 7 */
65
+ #bottom-premium-nav {
66
+ bottom: 3rem !important;
67
+ gap: 1.25rem !important;
68
+ padding: 0.75rem !important;
69
+ border-width: 2px !important;
70
+ }
71
+ #bottom-premium-nav a, #bottom-premium-nav button {
72
+ height: 3.75rem !important;
73
+ width: 3.75rem !important;
74
+ }
75
+ #bottom-premium-nav a.w-20, #bottom-premium-nav button.w-20 {
76
+ width: 6rem !important;
77
+ }
78
+ #bottom-premium-nav svg {
79
+ width: 1.75rem !important;
80
+ height: 1.75rem !important;
81
+ }
82
+
83
+ [data-desktop-nav] {
84
+ display: flex !important;
85
+ }
86
+
87
+ #menu-toggle {
88
+ display: none !important;
89
+ }
90
+
91
+ #bottom-premium-nav {
92
+ display: none !important;
93
+ }
94
+ }
95
+ </style>
96
+ <title>Dashboard - Misinformation Heatmap</title>
97
+ <meta name="description"
98
+ content="Real-time analytics dashboard for misinformation detection, verification status, system health, and live news classifications across India.">
99
+ <link rel="icon" type="image/x-icon" href="/assets/Misinfo.ico">
100
+ <link rel="shortcut icon" type="image/x-icon" href="/assets/Misinfo.ico">
101
+ <link rel="manifest" href="/manifest.json">
102
+ <link rel="stylesheet" href="/assets/css/styles.css">
103
+ <script type="module" src="/assets/js/main.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  </head>
 
 
 
 
 
105
 
106
+ <body data-page="dashboard"
107
+ class="min-h-screen overflow-x-hidden text-slate-950 antialiased relative">
108
+ <div class="fixed inset-0 -z-10 bg-[linear-gradient(135deg,rgba(255,153,51,0.08)_0%,rgba(255,255,255,1)_50%,rgba(19,136,8,0.08)_100%)] animate-premium-fade"></div>
109
+ <a class="absolute w-[1px] h-[1px] p-0 m-[-1px] overflow-hidden clip-[rect(0,0,0,0)] whitespace-nowrap border-0 focus:static focus:w-auto focus:h-auto focus:p-0 focus:m-0 focus:overflow-visible focus:clip-auto focus:whitespace-normal focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-slate-950 focus:px-4 focus:py-2 focus:text-sm focus:font-bold focus:text-white focus:shadow-lg"
110
+ href="#main-content">Skip to content</a>
111
+ <div data-navbar></div>
112
+
113
+ <main id="main-content" class="mx-auto w-full max-w-7xl px-4 pt-24 pb-32 sm:px-6 sm:pt-28 sm:pb-32 lg:px-8">
114
+ <section class="mb-6 rounded-2xl border border-slate-200 bg-white/80 p-5 shadow-premium backdrop-blur-md sm:p-6 lg:p-8">
115
+ <div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between lg:gap-8">
116
+ <div class="min-w-0 flex-1">
117
+ <p class="text-xs font-black text-saffron-500">Real-Time Analytics</p>
118
+ <h1 class="mt-1 text-2xl font-black tracking-tight text-slate-950 sm:text-3xl lg:text-4xl leading-tight">
119
+ System <span class="text-ashoka-blue-500">Overview</span>
120
+ </h1>
121
+ <p class="mt-4 max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">Comprehensive misinformation detection metrics across India</p>
122
+ <p class="mt-2 text-xs font-medium text-slate-500" id="dashboard-last-updated" aria-live="polite">Waiting for
123
+ the first update</p>
 
 
 
 
 
 
124
  </div>
 
125
 
126
+ <div class="flex shrink-0 flex-col gap-2">
127
+ <!-- Desktop Buttons -->
128
+ <div class="hidden lg:flex flex-col gap-2">
129
+ <button
130
+ class="inline-flex min-h-11 w-48 items-center justify-center rounded-lg bg-slate-950 px-6 py-2.5 text-sm font-bold text-white shadow-card transition hover:-translate-y-0.5 hover:bg-slate-800"
131
+ type="button" id="dashboard-refresh">Refresh now</button>
132
+ <a href="/heatmap.html"
133
+ class="inline-flex min-h-11 w-48 items-center justify-center rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-sm font-bold text-slate-800 shadow-card transition hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50">View
134
+ heatmap</a>
135
+ </div>
136
+ <!-- Mobile Buttons -->
137
+ <div class="flex flex-col gap-2 lg:hidden">
138
+ <button
139
+ class="inline-flex min-h-11 items-center justify-center rounded-lg bg-slate-950 px-4 py-2.5 text-sm font-bold text-white shadow-card transition hover:-translate-y-0.5 hover:bg-slate-800"
140
+ type="button" id="dashboard-refresh-mobile">Refresh</button>
141
+ <a href="/heatmap.html"
142
+ class="inline-flex min-h-11 items-center justify-center rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-bold text-slate-800 shadow-card transition hover:-translate-y-0.5">View
143
+ heatmap</a>
144
+ </div>
145
  </div>
146
+ </div>
147
+ </section>
148
+
149
+ <section class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-4 sm:gap-6 lg:gap-8" aria-label="Dashboard statistics">
150
+ <article
151
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-blue-600 bg-white p-3.5 shadow-card sm:p-5 lg:p-6 transition-all duration-300 hover:shadow-lg">
152
+ <div class="flex flex-wrap items-start justify-between gap-2">
153
+ <p class="text-[10px] font-black uppercase tracking-wider text-slate-500 sm:text-xs lg:text-sm">Total Events</p>
154
+ <span class="rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-black text-blue-700 sm:text-xs lg:text-sm">All</span>
155
+ </div>
156
+ <p class="mt-3 text-xl font-black leading-none text-blue-700 sm:mt-4 sm:text-2xl lg:text-3xl xl:text-4xl" id="dashboard-total-events"
157
+ aria-live="polite">--</p>
158
+ <p class="mt-1.5 text-[10px] font-medium text-slate-500 sm:text-sm">Processed live</p>
159
+ <div class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-200 sm:mt-4 sm:h-2"><span
160
+ class="block h-full w-0 rounded-full bg-blue-600 transition-[width] duration-500 ease-out"
161
+ id="dashboard-total-bar"></span></div>
162
+ </article>
163
+
164
+ <article
165
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-red-500 bg-white p-3.5 shadow-card sm:p-5 lg:p-6 transition-all duration-300 hover:shadow-lg">
166
+ <div class="flex flex-wrap items-start justify-between gap-2">
167
+ <p class="text-[10px] font-black uppercase tracking-wider text-slate-500 sm:text-xs lg:text-sm">Misinformation</p>
168
+ <span class="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-black text-red-700 sm:text-xs lg:text-sm">Risk</span>
169
+ </div>
170
+ <p class="mt-3 text-xl font-black leading-none text-red-700 sm:mt-4 sm:text-2xl lg:text-3xl xl:text-4xl" id="dashboard-fake-events"
171
+ aria-live="polite">--</p>
172
+ <p class="mt-1.5 text-[10px] font-medium text-slate-500 sm:text-sm">High-confidence</p>
173
+ <div class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-200 sm:mt-4 sm:h-2"><span
174
+ class="block h-full w-0 rounded-full bg-red-500 transition-[width] duration-500 ease-out"
175
+ id="dashboard-fake-bar"></span></div>
176
+ </article>
177
+
178
+ <article
179
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-emerald-600 bg-white p-3.5 shadow-card sm:p-5 lg:p-6 transition-all duration-300 hover:shadow-lg">
180
+ <div class="flex flex-wrap items-start justify-between gap-2">
181
+ <p class="text-[10px] font-black uppercase tracking-wider text-slate-500 sm:text-xs lg:text-sm">Verified News</p>
182
+ <span class="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-black text-emerald-700 sm:text-xs lg:text-sm">Clear</span>
183
+ </div>
184
+ <p class="mt-3 text-xl font-black leading-none text-emerald-700 sm:mt-4 sm:text-2xl lg:text-3xl xl:text-4xl" id="dashboard-real-events"
185
+ aria-live="polite">--</p>
186
+ <p class="mt-1.5 text-[10px] font-medium text-slate-500 sm:text-sm">Authenticated</p>
187
+ <div class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-200 sm:mt-4 sm:h-2"><span
188
+ class="block h-full w-0 rounded-full bg-emerald-600 transition-[width] duration-500 ease-out"
189
+ id="dashboard-real-bar"></span></div>
190
+ </article>
191
+
192
+ <article
193
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-amber-500 bg-white p-3.5 shadow-card sm:p-5 lg:p-6 transition-all duration-300 hover:shadow-lg">
194
+ <div class="flex flex-wrap items-start justify-between gap-2">
195
+ <p class="text-[10px] font-black uppercase tracking-wider text-slate-500 sm:text-xs lg:text-sm">Under Review</p>
196
+ <span class="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-black text-amber-700 sm:text-xs lg:text-sm">Pending</span>
197
+ </div>
198
+ <p class="mt-3 text-xl font-black leading-none text-amber-600 sm:mt-4 sm:text-2xl lg:text-3xl xl:text-4xl" id="dashboard-under-review-events"
199
+ aria-live="polite">--</p>
200
+ <p class="mt-1.5 text-[10px] font-medium text-slate-500 sm:text-sm">Requires verification</p>
201
+ <div class="mt-3 h-1.5 overflow-hidden rounded-full bg-slate-200 sm:mt-4 sm:h-2"><span
202
+ class="block h-full w-0 rounded-full bg-amber-500 transition-[width] duration-500 ease-out"
203
+ id="dashboard-under-review-bar"></span></div>
204
+ </article>
205
+ </section>
206
+
207
+ <section class="grid gap-6 lg:grid-cols-[1fr_1.4fr] xl:grid-cols-[0.9fr_1.1fr]">
208
+ <article class="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-card flex flex-col justify-between">
209
+ <div class="flex items-start justify-between gap-4 border-b border-slate-100 px-6 py-4">
210
+ <div class="min-w-0">
211
+ <p class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">Model accuracy</p>
212
+ <h2 class="mt-1 text-lg font-black text-slate-950">AI Precision</h2>
213
+ </div>
214
+ <span class="shrink-0 rounded-full bg-emerald-50 px-3 py-1 text-[10px] font-black text-emerald-700" id="dashboard-accuracy-badge">Healthy</span>
215
+ </div>
216
+
217
+ <div class="p-6 pb-4 flex-1 flex flex-col items-center justify-center">
218
+ <div class="relative flex h-40 w-40 items-center justify-center">
219
+ <!-- Background Ring -->
220
+ <svg class="absolute h-full w-full -rotate-90" viewBox="0 0 180 180">
221
+ <circle cx="90" cy="90" r="74" fill="none" stroke="#f1f5f9" stroke-width="14"></circle>
222
+ </svg>
223
+ <!-- Progress Ring -->
224
+ <svg class="absolute h-full w-full -rotate-90" viewBox="0 0 180 180" role="img">
225
+ <defs>
226
+ <linearGradient id="accuracy-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
227
+ <stop offset="0%" stop-color="#138808" />
228
+ <stop offset="100%" stop-color="#22c55e" />
229
+ </linearGradient>
230
+ <filter id="glow">
231
+ <feGaussianBlur stdDeviation="3" result="blur" />
232
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
233
+ </filter>
234
+ </defs>
235
+ <circle id="dashboard-accuracy-ring" cx="90" cy="90" r="74" fill="none" stroke="url(#accuracy-gradient)"
236
+ stroke-width="14" stroke-linecap="round" stroke-dasharray="465" stroke-dashoffset="465"
237
+ class="transition-all duration-1000 ease-out" filter="url(#glow)">
238
+ </circle>
239
+ </svg>
240
+ <div class="relative text-center animate-fade-in flex flex-col items-center">
241
+ <p class="text-4xl font-black tracking-tighter text-emerald-600" id="dashboard-accuracy-pct">--</p>
242
+ <p class="mt-1 text-[10px] font-medium text-slate-400">Overall Accuracy</p>
243
  </div>
244
+ </div>
245
  </div>
246
+
247
+ <dl class="grid grid-cols-3 gap-0 divide-x divide-slate-100 border-t border-slate-100 bg-slate-50/50 py-4">
248
+ <div class="px-2 text-center flex flex-col items-center">
249
+ <dd class="text-2xl font-black tracking-tight text-blue-600" id="dashboard-precision">--</dd>
250
+ <dt class="mt-1 text-xs font-medium text-slate-500">Precision</dt>
251
+ </div>
252
+ <div class="px-2 text-center flex flex-col items-center">
253
+ <dd class="text-2xl font-black tracking-tight text-emerald-600" id="dashboard-recall">--</dd>
254
+ <dt class="mt-1 text-xs font-medium text-slate-500">Recall</dt>
255
+ </div>
256
+ <div class="px-2 text-center flex flex-col items-center">
257
+ <dd class="text-2xl font-black tracking-tight text-saffron-600" id="dashboard-f1">--</dd>
258
+ <dt class="mt-1 text-xs font-medium text-slate-500">F1-Score</dt>
259
+ </div>
260
+ </dl>
261
+ </article>
262
+
263
+ <article
264
+ class="min-h-[22rem] overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-card">
265
+ <div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
266
+ <div class="min-w-0">
267
+ <p class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">Live activity</p>
268
+ <h2 class="mt-1 text-lg font-black text-slate-950">Recent feed</h2>
269
+ </div>
270
+ <div class="flex items-center gap-3">
271
+ <span class="h-2 w-2 animate-pulse rounded-full bg-red-500"></span>
272
+ <span
273
+ class="text-[10px] font-black uppercase tracking-wider text-slate-500"
274
+ id="dashboard-events-count">Loading</span>
275
+ </div>
276
  </div>
277
+ <div class="p-5">
278
+ <div class="flex max-h-[20rem] min-h-[16rem] flex-col gap-4 overflow-y-auto pt-1.5 pr-2 custom-scrollbar" id="dashboard-activity-feed"
279
+ aria-live="polite">
280
+ <article class="flex min-w-0 gap-4 rounded-xl border border-slate-100 bg-slate-50/50 p-4">
281
+ <div class="h-10 w-10 shrink-0 animate-pulse rounded-lg bg-slate-200"></div>
282
+ <div class="min-w-0 flex-1">
283
+ <div class="h-4 w-full animate-pulse rounded bg-slate-200"></div>
284
+ <div class="mt-3 h-4 w-2/3 animate-pulse rounded bg-slate-200"></div>
285
+ </div>
286
+ </article>
287
+ </div>
288
+ </div>
289
+ </article>
290
+ </section>
291
+
292
+ <!-- Dynamic System Island - Expanded View Only -->
293
+ <div id="dashboard-health-overlay" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-950/40 backdrop-blur-sm">
294
+ <div id="island-toggle" class="group relative flex flex-col overflow-hidden rounded-[2.5rem] bg-white text-slate-950 border border-slate-200 shadow-premium w-[90vw] max-w-[950px] h-[80vh] max-h-[700px]">
295
+ <div id="island-expanded" class="flex w-full h-full flex-col p-6 sm:p-8 text-left">
296
+ <header class="flex items-center justify-between border-b border-slate-100 pb-6">
297
+ <div>
298
+ <p class="text-[10px] font-black uppercase tracking-[0.2em] text-slate-500">Real-time monitoring</p>
299
+ <h3 class="mt-1 text-xl font-black text-slate-950">System health</h3>
300
  </div>
301
+ <div class="flex items-center gap-3">
302
+ <span class="text-[10px] font-bold text-emerald-600" id="dashboard-health-badge">Operational</span>
303
+ <div class="h-4 w-px bg-slate-200"></div>
304
+ <button id="close-health-overlay" class="text-[10px] font-bold text-slate-400 hover:text-slate-950 transition-colors">Close ×</button>
 
 
 
 
305
  </div>
306
+ </header>
307
+
308
+ <div class="mt-8 w-full flex-1 overflow-y-auto pr-2 custom-scrollbar island-scroll-container">
309
+ <!-- Key Metrics Grid -->
310
+ <div class="grid w-full grid-cols-2 gap-3 lg:grid-cols-4">
311
+ <div class="rounded-2xl border border-slate-100 bg-slate-50 p-4 transition-all hover:bg-slate-100 hover:border-slate-200">
312
+ <p class="text-[10px] font-bold text-slate-500">Regional Coverage</p>
313
+ <p class="mt-2 text-2xl font-black text-slate-950" id="dashboard-coverage-regions">--</p>
314
+ </div>
315
+ <div class="rounded-2xl border border-slate-100 bg-slate-50 p-4 transition-all hover:bg-slate-100 hover:border-slate-200">
316
+ <p class="text-[10px] font-bold text-slate-500">Active Signals</p>
317
+ <p class="mt-2 text-2xl font-black text-slate-950" id="dashboard-coverage-active">--</p>
318
+ </div>
319
+ <div class="rounded-2xl border border-slate-100 bg-slate-50 p-4 transition-all hover:bg-slate-100 hover:border-slate-200">
320
+ <p class="text-[10px] font-bold text-slate-500">Event Ingestion</p>
321
+ <p class="mt-2 text-2xl font-black text-slate-950" id="dashboard-coverage-events">--</p>
322
+ </div>
323
+ <div class="rounded-2xl border border-slate-100 bg-slate-50 p-4 transition-all hover:bg-slate-100 hover:border-slate-200">
324
+ <p class="text-[10px] font-bold text-slate-500">Engine Precision</p>
325
+ <p class="mt-2 text-2xl font-black text-slate-950" id="dashboard-coverage-confidence">--</p>
326
+ </div>
327
  </div>
328
+
329
+ <!-- Module Diagnostics -->
330
+ <div class="mt-8">
331
+ <div class="mb-4 flex items-center justify-between">
332
+ <p class="text-[10px] font-black text-slate-500">Module Diagnostics</p>
333
+ <p class="text-[10px] text-slate-500" id="dashboard-coverage-note">Synchronized with live feed</p>
334
+ </div>
335
+ <div class="flex w-full flex-col gap-3" id="dashboard-health-indicators">
336
+ <!-- Indicators populated by JS -->
337
+ <div class="flex h-16 animate-pulse items-center gap-4 rounded-xl bg-slate-50 px-4">
338
+ <div class="h-2 w-2 rounded-full bg-slate-300"></div>
339
+ <div class="h-3 w-32 rounded bg-slate-300"></div>
340
  </div>
341
+ </div>
 
 
342
  </div>
343
+ </div>
344
+
345
+ <footer class="mt-auto flex items-center justify-center pt-6">
346
+ <div class="h-1 w-12 rounded-full bg-slate-200 transition-colors group-hover:bg-slate-300"></div>
347
+ </footer>
348
  </div>
349
+ </div>
350
+ </div>
351
 
352
+ <!-- Desktop System Health Trigger (Floating Pill) -->
353
+ <button id="desktop-health-trigger" class="group fixed bottom-6 right-6 z-[60] hidden h-14 w-max items-center gap-2 rounded-full border border-slate-200 bg-white/95 shadow-premium backdrop-blur-2xl transition-all duration-500 hover:scale-[1.05] hover:border-saffron-300 active:scale-95 lg:flex px-4" aria-label="System health status">
354
+ <div class="relative flex h-2.5 w-2.5 flex-none items-center justify-center">
355
+ <div id="desktop-health-ping" class="absolute h-full w-full animate-ping rounded-full bg-emerald-500 opacity-40"></div>
356
+ <div id="desktop-health-dot" class="h-full w-full rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
357
+ </div>
358
+ <span id="desktop-health-text" class="whitespace-nowrap text-xs font-black text-slate-900">System Health</span>
359
+ <div class="h-4 w-px flex-none bg-slate-200"></div>
360
+ <div class="flex h-5 w-5 flex-none items-center justify-center">
361
+ <svg class="h-full w-full text-slate-400 group-hover:text-slate-950 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
362
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
363
+ </svg>
364
+ </div>
365
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
 
367
+ <style>
368
+ #desktop-health-trigger {
369
+ transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease, border-color 0.3s ease;
370
+ padding-left: 1.25rem !important;
371
+ padding-right: 1.75rem !important;
372
+ width: max-content !important;
373
+ }
374
+ #desktop-health-trigger svg {
375
+ overflow: visible !important;
376
+ }
377
+ #desktop-health-trigger:active {
378
+ transform: scale(0.92) translateY(2px) !important;
379
+ }
380
+ #dashboard-health-overlay.active,
381
+ #dashboard-health-overlay.closing {
382
+ display: flex;
383
+ }
384
+ @keyframes springExpand {
385
+ 0% { opacity: 0; transform: scale(0.9) translateY(20px); }
386
+ 70% { opacity: 1; transform: scale(1.02) translateY(-2px); }
387
+ 100% { opacity: 1; transform: scale(1) translateY(0); }
388
+ }
389
+ @keyframes springShrink {
390
+ 0% { opacity: 1; transform: scale(1) translateY(0); }
391
+ 100% { opacity: 0; transform: scale(0.9) translateY(10px); }
392
+ }
393
+ #dashboard-health-overlay.active {
394
+ animation: fadeIn 0.25s ease-out forwards;
395
+ }
396
+ #dashboard-health-overlay.active #island-toggle {
397
+ animation: springExpand 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
398
+ transform-origin: bottom right;
399
+ }
400
+ #dashboard-health-overlay.closing {
401
+ animation: fadeOut 0.2s ease-in forwards;
402
+ }
403
+ #dashboard-health-overlay.closing #island-toggle {
404
+ animation: springShrink 0.25s cubic-bezier(0.4, 0, 1, 1) forwards;
405
+ transform-origin: bottom right;
406
+ }
407
+ @media (max-width: 880px) {
408
+ #island-toggle {
409
+ width: calc(100vw - 24px) !important;
410
+ height: 80vh !important;
411
+ max-height: 700px !important;
412
+ }
413
+ }
414
+ .custom-scrollbar::-webkit-scrollbar { width: 4px; }
415
+ .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
416
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 10px; }
417
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.2); }
418
+
419
+ .island-scroll-container {
420
+ scrollbar-gutter: stable;
421
+ }
422
+ </style>
423
+ </main>
424
+
425
+ <footer class="border-t border-slate-200 bg-slate-950 pt-16 pb-32 text-white">
426
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
427
+ <div class="grid grid-cols-1 gap-12 md:grid-cols-3">
428
+ <!-- Brand Section -->
429
+ <div class="max-w-xs">
430
+ <div class="flex items-center gap-2">
431
+ <div class="h-8 w-8 rounded-lg bg-gradient-to-tr from-saffron-500 via-white to-india-green-500 p-1">
432
+ <div class="h-full w-full rounded-md bg-slate-950 flex items-center justify-center">
433
+ <span class="text-[10px] font-black">MH</span>
434
+ </div>
435
  </div>
436
+ <span class="text-lg font-black tracking-tight">Misinfo<span class="text-saffron-500">Heatmap</span></span>
437
+ </div>
438
+ <p class="mt-6 text-sm leading-7 text-slate-400">
439
+ Advanced AI-powered platform for real-time misinformation detection and verification across India's digital
440
+ landscape.
441
+ </p>
442
+ <div class="mt-6 flex items-center gap-4">
443
+ <!-- India Badge -->
444
+ <span
445
+ class="inline-flex items-center gap-1.5 rounded-full bg-white/5 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-300 border border-white/10">
446
+ <span class="h-1.5 w-1.5 rounded-full bg-saffron-500"></span>
447
+ Monitored in India
448
+ </span>
449
+ </div>
450
  </div>
451
 
452
+ <!-- Platform Links -->
453
+ <div>
454
+ <h3 class="text-sm font-black uppercase tracking-[0.2em] text-saffron-500">Platform</h3>
455
+ <ul class="mt-6 space-y-4 text-sm text-slate-400">
456
+ <li><a href="/heatmap.html" class="transition hover:text-white font-medium">Live Heatmap</a></li>
457
+ <li><a href="/dashboard.html" class="transition hover:text-white font-medium">Intelligence Dashboard</a></li>
458
+ </ul>
459
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
 
461
+ <!-- Support -->
462
+ <div>
463
+ <h3 class="text-sm font-black uppercase tracking-[0.2em] text-ashoka-blue-400">Support</h3>
464
+ <ul class="mt-6 space-y-4 text-sm text-slate-400">
465
+ <li><a href="/contact.html" class="transition hover:text-white font-black text-slate-300">Contact Us</a></li>
466
+ </ul>
467
+ </div>
468
+ </div>
469
+
470
+ <!-- Bottom Bar -->
471
+ <div class="mt-16 border-t border-white/5 pt-8">
472
+ <div class="flex flex-col items-center justify-between gap-6 md:flex-row">
473
+ <p class="text-xs text-slate-500">
474
+ © 2026 Misinformation Heatmap. Powered by Advanced Neural Networks.
475
+ </p>
476
+ <div class="flex items-center gap-8">
477
+ <a href="/privacy-policy.html" class="text-xs text-slate-400 hover:text-white font-medium transition">Privacy Policy</a>
478
+ <div
479
+ class="h-1 w-32 rounded-full bg-gradient-to-r from-saffron-500 via-white to-india-green-500 opacity-20">
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  </div>
481
+ </div>
482
  </div>
483
+ </div>
484
  </div>
485
+ </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  </body>
487
+
488
+ </html>
frontend/heatmap.html ADDED
@@ -0,0 +1,654 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, viewport-fit=cover">
7
+ <meta name="referrer" content="strict-origin-when-cross-origin">
8
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://ndg07-heatmap.hf.space; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests">
9
+ <style>
10
+ html,
11
+ body {
12
+ overflow-x: hidden;
13
+ width: 100%;
14
+ position: relative;
15
+ }
16
+
17
+ /* Surface Pro 7 & Asus Zenbook Fold Specific Enhancements */
18
+ @media screen and (device-width: 912px) and (device-height: 1368px),
19
+ screen and (device-width: 912px) and (device-height: 608px),
20
+ screen and (device-width: 853px) and (device-height: 1280px) {
21
+ body #main-content {
22
+ padding-top: 5.75rem !important;
23
+ }
24
+
25
+ html {
26
+ font-size: 18px !important;
27
+ }
28
+
29
+ h2 {
30
+ font-size: 1.5rem !important;
31
+ }
32
+
33
+ p {
34
+ font-size: 1.1rem !important;
35
+ }
36
+
37
+ /* Pill and Scroll Button Alignment for Pro 7 */
38
+ #bottom-premium-nav,
39
+ #scroll-down-btn {
40
+ bottom: 3rem !important;
41
+ gap: 1.25rem !important;
42
+ padding: 0.75rem !important;
43
+ border-width: 2px !important;
44
+ }
45
+
46
+ #bottom-premium-nav a,
47
+ #bottom-premium-nav button {
48
+ height: 3.75rem !important;
49
+ width: 3.75rem !important;
50
+ }
51
+
52
+ #bottom-premium-nav a.w-20,
53
+ #bottom-premium-nav button.w-20 {
54
+ width: 6rem !important;
55
+ }
56
+
57
+ #bottom-premium-nav svg {
58
+ width: 1.75rem !important;
59
+ height: 1.75rem !important;
60
+ }
61
+
62
+ [data-desktop-nav] {
63
+ display: flex !important;
64
+ }
65
+
66
+ #menu-toggle {
67
+ display: none !important;
68
+ }
69
+
70
+ #bottom-premium-nav,
71
+ #scroll-down-btn {
72
+ display: none !important;
73
+ }
74
+
75
+ /* Heatmap Specific Text Scaling for Pro 7 */
76
+ #heatmap-header-refresh {
77
+ font-size: 0.85rem !important;
78
+ padding: 0.4rem 0.8rem !important;
79
+ }
80
+
81
+ #heatmap-zoom-controls button {
82
+ font-size: 1.2rem !important;
83
+ min-height: 3.25rem !important;
84
+ min-width: 3.25rem !important;
85
+ }
86
+
87
+ #risk-legend-toggle {
88
+ font-size: 0.9rem !important;
89
+ padding-top: 0.75rem !important;
90
+ padding-bottom: 0.75rem !important;
91
+ }
92
+
93
+ #risk-legend-panel .grid {
94
+ font-size: 0.85rem !important;
95
+ gap: 0.75rem !important;
96
+ padding: 1rem !important;
97
+ }
98
+
99
+ aside h3 {
100
+ font-size: 0.75rem !important;
101
+ letter-spacing: 0.15em !important;
102
+ }
103
+
104
+ #heatmap-misinformation-count,
105
+ #heatmap-real-news-count {
106
+ font-size: 0.75rem !important;
107
+ padding: 0.25rem 0.6rem !important;
108
+ }
109
+
110
+ .feed-filter-control {
111
+ font-size: 0.7rem !important;
112
+ padding-top: 0.4rem !important;
113
+ padding-bottom: 0.4rem !important;
114
+ }
115
+ }
116
+
117
+ /* Short Viewport Adjustments (Nest Hub, Surface Duo) - Only for non-desktop widths */
118
+ @media (max-height: 720px) and (max-width: 1299px) {
119
+ #main-content {
120
+ padding-top: 6.5rem !important;
121
+ padding-bottom: 5rem !important;
122
+ }
123
+
124
+ .sm\:pt-26 {
125
+ padding-top: 6.5rem !important;
126
+ }
127
+ }
128
+
129
+ /* Desktop Only Scaling (Wider than Nest Hub Max) - Reinforced */
130
+ @media screen and (min-width: 1300px) {
131
+ #main-content {
132
+ height: calc(100vh - 1rem) !important;
133
+ padding-top: 5.25rem !important;
134
+ padding-bottom: 1.25rem !important;
135
+ max-width: 1650px !important;
136
+ }
137
+
138
+ #main-content>section,
139
+ #main-content>section>article,
140
+ #main-content>section>aside {
141
+ height: 100% !important;
142
+ }
143
+ }
144
+
145
+ /* Target tall map ONLY for narrow phones (Pixel, iPhone, etc.) */
146
+ @media (max-width: 500px) {
147
+ #india-map-container {
148
+ min-height: 650px !important;
149
+ }
150
+
151
+ /* Shift pill left to avoid overlap with scroll button on narrow screens */
152
+ #bottom-premium-nav {
153
+ left: 40% !important;
154
+ }
155
+ }
156
+
157
+ /* Samsung Galaxy S8+ / S28+ Specific Adjustment (360x740) */
158
+ @media screen and (device-width: 360px) and (device-height: 740px) {
159
+ #india-map-container {
160
+ min-height: 470px !important;
161
+ }
162
+
163
+ h2 {
164
+ font-size: 1rem !important;
165
+ }
166
+ }
167
+
168
+ /* Specific scaling for Nest Hub (1024x600) - Compact Heatmap */
169
+ @media screen and (device-width: 1024px) and (device-height: 600px) {
170
+ html {
171
+ font-size: 17px !important;
172
+ }
173
+
174
+ #main-content {
175
+ height: calc(100vh - 0.5rem) !important;
176
+ padding-top: 5rem !important;
177
+ padding-bottom: 0.75rem !important;
178
+ }
179
+
180
+ #main-content>section,
181
+ #main-content>section>article,
182
+ #main-content>section>aside {
183
+ height: 100% !important;
184
+ }
185
+
186
+ h2 {
187
+ font-size: 1.2rem !important;
188
+ }
189
+
190
+ p.text-\[10px\] {
191
+ font-size: 9px !important;
192
+ }
193
+
194
+ #heatmap-header-refresh {
195
+ padding: 0.25rem 0.5rem !important;
196
+ font-size: 9px !important;
197
+ }
198
+
199
+ #main-content>section {
200
+ gap: 1rem !important;
201
+ }
202
+
203
+ #heatmap-zoom-controls {
204
+ scale: 0.85 !important;
205
+ transform-origin: right top !important;
206
+ }
207
+
208
+ #heatmap-svg-root {
209
+ padding: 3rem !important;
210
+ }
211
+
212
+ [data-desktop-nav] {
213
+ display: flex !important;
214
+ }
215
+
216
+ #menu-toggle {
217
+ display: none !important;
218
+ }
219
+
220
+ #bottom-premium-nav {
221
+ display: none !important;
222
+ }
223
+ }
224
+
225
+ /* Specific scaling for Nest Hub Max (1280x800) - More breathing room */
226
+ @media screen and (device-width: 1280px) and (device-height: 800px) {
227
+ html {
228
+ font-size: 18px !important;
229
+ }
230
+
231
+ body #main-content {
232
+ height: calc(100vh - 1rem) !important;
233
+ padding-top: 6rem !important;
234
+ padding-bottom: 1.5rem !important;
235
+ }
236
+
237
+ #main-content>section,
238
+ #main-content>section>article,
239
+ #main-content>section>aside {
240
+ height: 100% !important;
241
+ }
242
+
243
+ [data-desktop-nav] {
244
+ display: flex !important;
245
+ }
246
+
247
+ #menu-toggle {
248
+ display: none !important;
249
+ }
250
+
251
+ #bottom-premium-nav {
252
+ display: none !important;
253
+ }
254
+ }
255
+
256
+ /* Surface Duo Specific Fix (Custom) */
257
+ @media screen and (max-width: 600px) and (max-height: 720px) {
258
+ #main-content {
259
+ padding-top: 5.5rem !important;
260
+ padding-bottom: 2rem !important;
261
+ }
262
+
263
+ #india-map-container {
264
+ min-height: 600px !important;
265
+ height: 600px !important;
266
+ flex: none !important;
267
+ }
268
+
269
+ #heatmap-svg-root {
270
+ padding: 3rem 5rem 7.5rem 5rem !important;
271
+ display: flex !important;
272
+ align-items: center !important;
273
+ justify-content: center !important;
274
+ }
275
+
276
+ #heatmap-svg-root>svg {
277
+ transform: translateY(-25px) !important;
278
+ }
279
+
280
+ #risk-legend-container {
281
+ bottom: 6rem !important;
282
+ left: 0.75rem !important;
283
+ scale: 0.9;
284
+ transform-origin: left bottom;
285
+ }
286
+
287
+ #risk-legend-toggle {
288
+ padding: 0.25rem 0.5rem !important;
289
+ }
290
+
291
+ #risk-legend-panel {
292
+ max-height: none;
293
+ }
294
+
295
+ #heatmap-zoom-controls {
296
+ scale: 0.8;
297
+ transform-origin: right center;
298
+ }
299
+ }
300
+
301
+ /* Premium Hover & Select Scale Effects for Heatmap Paths */
302
+ .heatmap-path {
303
+ transform-box: fill-box !important;
304
+ transform-origin: center !important;
305
+ transition: fill 0.3s cubic-bezier(0.4, 0, 0.2, 1),
306
+ stroke 0.3s cubic-bezier(0.4, 0, 0.2, 1),
307
+ stroke-width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
308
+ transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1),
309
+ filter 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
310
+ will-change: transform, filter !important;
311
+ }
312
+
313
+ .heatmap-path:hover {
314
+ transform: scale(1.045) translateY(-2px) !important;
315
+ filter: drop-shadow(0 8px 16px rgba(0, 0, 0, 0.12)) brightness(1.04) !important;
316
+ z-index: 50 !important;
317
+ }
318
+
319
+ .heatmap-path.selected {
320
+ transform: scale(1.06) translateY(-3px) !important;
321
+ filter: drop-shadow(0 12px 20px rgba(251, 146, 60, 0.22)) brightness(1.06) !important;
322
+ z-index: 60 !important;
323
+ }
324
+ </style>
325
+ <title>Heatmap - Misinformation Heatmap</title>
326
+ <meta name="description"
327
+ content="Interactive India heatmap for monitoring state-wise misinformation risk, live alerts, verified news, and AI confidence signals.">
328
+ <link rel="icon" type="image/x-icon" href="/assets/Misinfo.ico">
329
+ <link rel="shortcut icon" type="image/x-icon" href="/assets/Misinfo.ico">
330
+ <link rel="manifest" href="/manifest.json">
331
+ <link rel="stylesheet" href="/assets/css/styles.css">
332
+ <script type="module" src="/assets/js/main.js"></script>
333
+ </head>
334
+
335
+ <body data-page="heatmap" class="min-h-screen overflow-x-hidden text-slate-950 antialiased relative">
336
+ <div class="fixed inset-0 -z-10 bg-white"></div>
337
+ <div
338
+ class="fixed inset-0 -z-10 overflow-hidden pointer-events-none [mask-image:linear-gradient(to_bottom,white_90%,transparent)]"
339
+ style="contain: strict;">
340
+ <!-- Main Saffron Glow -->
341
+ <div
342
+ class="absolute -top-[15%] -left-[10%] h-[650px] w-[650px] rounded-full bg-[radial-gradient(circle,rgba(255,153,51,0.28)_0%,transparent_70%)] animate-float will-change-transform transform-gpu">
343
+ </div>
344
+
345
+ <!-- Main Green Glow -->
346
+ <div
347
+ class="absolute top-[25%] -right-[10%] h-[600px] w-[600px] rounded-full bg-[radial-gradient(circle,rgba(19,136,8,0.22)_0%,transparent_70%)] animate-float will-change-transform transform-gpu"
348
+ style="animation-delay: 2s"></div>
349
+
350
+ <!-- Ashoka Blue Glow -->
351
+ <div
352
+ class="absolute top-1/2 left-1/4 h-[450px] w-[450px] rounded-full bg-[radial-gradient(circle,rgba(0,0,128,0.12)_0%,transparent_70%)] animate-pulse will-change-transform transform-gpu"
353
+ style="animation-delay: 4s"></div>
354
+
355
+ <!-- Subtle Saffron/Green Accent Gradients -->
356
+ <div
357
+ class="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(255,153,51,0.2),transparent_50%),radial-gradient(circle_at_80%_80%,rgba(19,136,8,0.2),transparent_50%)]">
358
+ </div>
359
+
360
+ <!-- Refined noise and pattern -->
361
+ <div class="absolute inset-0 opacity-[0.05]"
362
+ style="background-image: radial-gradient(circle at 2px 2px, rgba(0,0,0,0.15) 1px, transparent 0); background-size: 48px 48px;">
363
+ </div>
364
+
365
+ <!-- Diagonal Accent Overlay -->
366
+ <div
367
+ class="absolute inset-0 bg-gradient-to-br from-saffron-500/15 via-transparent to-india-green-500/15 opacity-80">
368
+ </div>
369
+ </div>
370
+ <a class="absolute w-[1px] h-[1px] p-0 m-[-1px] overflow-hidden clip-[rect(0,0,0,0)] whitespace-nowrap border-0 focus:static focus:w-auto focus:h-auto focus:p-0 focus:m-0 focus:overflow-visible focus:clip-auto focus:whitespace-normal focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-slate-950 focus:px-4 focus:py-2 focus:text-sm focus:font-bold focus:text-white focus:shadow-lg"
371
+ href="#main-content">Skip to content</a>
372
+ <!-- Scroll Down Helper (Mobile/Tablet Only) -->
373
+ <button id="scroll-down-btn"
374
+ class="fixed bottom-6 right-4 z-[70] flex h-16 w-16 animate-bounce-scroll items-center justify-center rounded-full bg-[#fffdf4]/95 text-slate-950 shadow-[0_10px_40px_rgba(0,0,0,0.15)] backdrop-blur-md transition-all hover:scale-110 active:scale-95 lg:hidden"
375
+ aria-label="Scroll down to news">
376
+ <svg class="h-7 w-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
377
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 13l-7 7-7-7M12 5v15"></path>
378
+ </svg>
379
+ </button>
380
+
381
+ <div data-navbar></div>
382
+
383
+ <main id="main-content"
384
+ class="mx-auto w-full max-w-[1550px] px-4 pt-24 pb-32 sm:px-6 sm:pt-26 lg:px-4 lg:h-[calc(100vh-1rem)] lg:pt-20 lg:overflow-hidden">
385
+ <section
386
+ class="grid gap-6 lg:grid-cols-[1fr_480px] xl:grid-cols-[1fr_560px] animate-fade-in-up lg:h-full lg:overflow-hidden">
387
+ <article id="india-map-container"
388
+ class="relative overflow-hidden bg-[#fffdf4] shadow-premium sm:rounded-2xl rounded-2xl sm:border sm:border-slate-200 flex flex-col lg:h-full isolate">
389
+ <div class="flex items-center justify-between gap-4 border-b border-slate-200 bg-[#fdfaf0] px-5 py-4 sm:px-6">
390
+ <div class="min-w-0">
391
+ <p class="text-[10px] font-bold text-slate-500">Live risk map</p>
392
+ <h2 class="mt-0.5 text-lg font-extrabold text-slate-950">State intensity surface</h2>
393
+ </div>
394
+ <button
395
+ class="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-[11px] font-bold text-slate-700 shadow-sm transition hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-700"
396
+ type="button" id="heatmap-header-refresh">
397
+ <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
398
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3"
399
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
400
+ </path>
401
+ </svg>
402
+ Refresh
403
+ </button>
404
+ </div>
405
+
406
+ <div
407
+ class="relative flex-1 min-h-[350px] overflow-hidden bg-[#fffdf4] rounded-b-2xl sm:min-h-[450px] lg:min-h-0"
408
+ style="contain: layout paint;" id="india-map-viewport">
409
+
410
+ <div id="heatmap-zoom-controls" class="absolute right-4 top-4 z-20 flex flex-col gap-2"
411
+ aria-label="Map controls">
412
+ <button
413
+ class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-slate-200 bg-white/95 text-base font-extrabold text-slate-800 shadow-card outline-none transition-all duration-150 hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-800 focus-visible:ring-2 focus-visible:ring-saffron-500"
414
+ type="button" id="heatmap-zoom-in" aria-label="Zoom in">+</button>
415
+ <button
416
+ class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-slate-200 bg-white/95 px-2 text-[10px] font-extrabold text-slate-800 shadow-card outline-none transition-all duration-150 hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-800 focus-visible:ring-2 focus-visible:ring-saffron-500"
417
+ type="button" id="heatmap-zoom-reset" aria-label="Reset zoom">Reset</button>
418
+ <button
419
+ class="inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-slate-200 bg-white/95 text-base font-extrabold text-slate-800 shadow-card outline-none transition-all duration-150 hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-800 focus-visible:ring-2 focus-visible:ring-saffron-500"
420
+ type="button" id="heatmap-zoom-out" aria-label="Zoom out">-</button>
421
+ <button
422
+ class="mt-2 inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-slate-200 bg-white/95 px-3 text-[10px] font-bold text-slate-800 shadow-card outline-none transition-all duration-150 hover:-translate-y-0.5 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-800 focus-visible:ring-2 focus-visible:ring-saffron-500"
423
+ type="button" id="heatmap-state-clear" aria-label="Clear state selection">Clear</button>
424
+ </div>
425
+
426
+ <div id="heatmap-svg-root"
427
+ class="flex h-full w-full touch-none select-none items-center justify-center overflow-hidden p-4 sm:p-6 rounded-b-2xl"
428
+ tabindex="0" aria-label="Interactive India heatmap">
429
+ <div id="heatmap-loading"
430
+ class="flex max-w-sm flex-col items-center justify-center px-6 text-center animate-pulse-soft">
431
+ <div class="h-14 w-14 animate-spin rounded-full border-4 border-ashoka-blue-200 border-t-ashoka-blue-500">
432
+ </div>
433
+ <p class="mt-6 text-lg font-extrabold text-slate-950 tracking-tight">Analyzing Signals</p>
434
+ <p class="mt-2 text-sm leading-6 text-slate-600 font-medium">Fetching real-time state risk indicators...
435
+ </p>
436
+ </div>
437
+ </div>
438
+
439
+ <div id="risk-legend-container" class="absolute bottom-4 left-4 z-20 w-40">
440
+ <div
441
+ class="overflow-hidden rounded-2xl border border-slate-200 bg-white/95 shadow-[0_10px_40px_rgba(0,0,0,0.1)] backdrop-blur-xl">
442
+ <button
443
+ class="flex w-full items-center justify-between gap-4 px-4 py-2.5 text-left text-[11px] font-bold text-slate-800 focus-visible:outline-none"
444
+ type="button" id="risk-legend-toggle" aria-expanded="true" aria-controls="risk-legend-panel">
445
+ <span>Risk levels</span>
446
+ <svg id="risk-legend-icon" class="h-3 w-3 transition-transform duration-300" fill="none"
447
+ stroke="currentColor" viewBox="0 0 24 24">
448
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M19 9l-7 7-7-7"></path>
449
+ </svg>
450
+ </button>
451
+ <div class="max-h-[500px] overflow-hidden transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]"
452
+ id="risk-legend-panel">
453
+ <div class="grid gap-2 border-t border-slate-100 px-4 py-3 text-[11px] font-bold text-slate-600">
454
+ <div class="flex items-center gap-3"><span
455
+ class="h-3 w-3 shrink-0 rounded-full shadow-[0_0_8px_rgba(19,136,8,0.4)]" style="background-color: #138808;"></span><span>Least
456
+ risk</span></div>
457
+ <div class="flex items-center gap-3"><span
458
+ class="h-3 w-3 shrink-0 rounded-full bg-yellow-400 shadow-[0_0_8px_rgba(250,204,21,0.4)]"></span><span>Low-Medium</span>
459
+ </div>
460
+ <div class="flex items-center gap-3"><span
461
+ class="h-3 w-3 shrink-0 rounded-full bg-orange-500 shadow-[0_0_8px_rgba(249,115,22,0.4)]"></span><span>Medium-High</span>
462
+ </div>
463
+ <div class="flex items-center gap-3"><span
464
+ class="h-3 w-3 shrink-0 rounded-full bg-red-600 shadow-[0_0_8px_rgba(220,38,38,0.4)]"></span><span>High
465
+ risk</span></div>
466
+ </div>
467
+ </div>
468
+ </div>
469
+ </div>
470
+
471
+
472
+ </div>
473
+ </article>
474
+
475
+ <aside class="flex flex-col gap-6 lg:h-full lg:overflow-hidden" aria-label="Heatmap details">
476
+ <div
477
+ class="flex h-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-[#fffdf4] shadow-premium isolate">
478
+ <!-- Tab Header -->
479
+ <div class="flex border-b border-slate-200 bg-[#fefcf8] p-1.5">
480
+ <button id="tab-alerts"
481
+ class="flex-1 rounded-lg px-4 py-2.5 text-xs font-bold transition-all duration-200 bg-white text-red-600 shadow-sm border border-slate-200">
482
+ Alerts
483
+ </button>
484
+ <button id="tab-news"
485
+ class="flex-1 rounded-lg px-4 py-2.5 text-xs font-bold transition-all duration-200 text-slate-500 hover:text-slate-700">
486
+ Verified
487
+ </button>
488
+ </div>
489
+
490
+ <!-- Tab Content Containers -->
491
+ <div class="flex-1 lg:overflow-hidden flex flex-col lg:h-full">
492
+ <!-- Alerts Panel -->
493
+ <div class="flex-1 lg:overflow-y-auto px-4 py-5 sm:px-6 custom-scrollbar lg:h-full"
494
+ id="panel-alerts">
495
+ <div class="mb-5 flex items-center justify-between">
496
+ <h3 class="text-[10px] font-bold text-red-600">Misinformation alerts</h3>
497
+ <div class="flex items-center gap-2">
498
+ <span class="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-red-600"
499
+ id="heatmap-misinformation-count">--</span>
500
+ </div>
501
+ </div>
502
+ <div class="grid gap-4" id="heatmap-misinformation-feed"></div>
503
+ </div>
504
+
505
+ <!-- Verified News Panel (Hidden by default) -->
506
+ <div class="hidden flex-1 lg:overflow-y-auto px-4 py-5 sm:px-6 custom-scrollbar lg:h-full"
507
+ id="panel-news">
508
+ <div class="mb-5 flex items-center justify-between">
509
+ <h3 class="text-[10px] font-bold text-emerald-600">Verified reporting</h3>
510
+ <div class="flex items-center gap-2">
511
+ <span class="rounded-full bg-emerald-50 px-2 py-0.5 text-[10px] font-bold text-emerald-600"
512
+ id="heatmap-real-news-count">--</span>
513
+ </div>
514
+ </div>
515
+ <div class="grid gap-4" id="heatmap-real-news-feed"></div>
516
+ </div>
517
+ </div>
518
+ </div>
519
+ </aside>
520
+ </section>
521
+ </main>
522
+ <style>
523
+ .custom-scrollbar::-webkit-scrollbar {
524
+ width: 4px;
525
+ }
526
+
527
+ .custom-scrollbar {
528
+ scrollbar-width: thin;
529
+ scrollbar-color: #e2e8f0 transparent;
530
+ scroll-behavior: smooth;
531
+ -webkit-overflow-scrolling: touch;
532
+ overscroll-behavior: contain;
533
+ transform: translate3d(0, 0, 0);
534
+ backface-visibility: hidden;
535
+ contain: content;
536
+ }
537
+
538
+ .custom-scrollbar::-webkit-scrollbar-track {
539
+ background: transparent;
540
+ }
541
+
542
+ .custom-scrollbar::-webkit-scrollbar-thumb {
543
+ background: #e2e8f0;
544
+ border-radius: 10px;
545
+ }
546
+
547
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
548
+ background: #cbd5e1;
549
+ }
550
+
551
+ .feed-filter-control {
552
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
553
+ background: white !important;
554
+ border: 1px solid #e2e8f0 !important;
555
+ border-radius: 8px !important;
556
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02) !important;
557
+ padding-top: 4px !important;
558
+ padding-bottom: 4px !important;
559
+ font-size: 10px !important;
560
+ letter-spacing: 0.05em !important;
561
+ color: #475569 !important;
562
+ cursor: pointer !important;
563
+ }
564
+
565
+ .feed-filter-control:hover {
566
+ border-color: #fb923c !important;
567
+ background: #fffaf5 !important;
568
+ color: #9a3412 !important;
569
+ box-shadow: 0 4px 12px rgba(251, 146, 60, 0.08) !important;
570
+ }
571
+
572
+ .feed-filter-control:focus {
573
+ outline: none !important;
574
+ border-color: #FF9933 !important;
575
+ box-shadow: 0 0 0 3px rgba(255, 153, 51, 0.15) !important;
576
+ }
577
+
578
+ /* Premium Tricolor Border for Scroll Button */
579
+ #scroll-down-btn {
580
+ border: 3.5px solid transparent !important;
581
+ background: linear-gradient(#fffdf4, #fffdf4) padding-box,
582
+ linear-gradient(to right, rgba(255, 153, 51, 0.65), #ffffff, rgba(19, 136, 8, 0.65)) border-box !important;
583
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
584
+ }
585
+
586
+ #scroll-down-btn:hover {
587
+ background: linear-gradient(#fffaf5, #fffaf5) padding-box,
588
+ linear-gradient(to right, rgba(255, 153, 51, 0.9), #ffffff, rgba(19, 136, 8, 0.9)) border-box !important;
589
+ box-shadow: 0 15px 45px rgba(255, 153, 51, 0.15) !important;
590
+ }
591
+
592
+ @media (max-width: 1023px) {
593
+
594
+ #heatmap-misinformation-count,
595
+ #heatmap-real-news-count {
596
+ display: none !important;
597
+ }
598
+ }
599
+
600
+ #event-detail-card {
601
+ border: 1.5px solid transparent !important;
602
+ background: linear-gradient(to bottom, #fffdf4, #fffdf4) padding-box,
603
+ linear-gradient(135deg, rgba(255, 153, 51, 0.4), rgba(0, 0, 128, 0.15), rgba(19, 136, 8, 0.4)) border-box !important;
604
+ }
605
+ </style>
606
+ <div id="heatmap-tooltip"
607
+ class="pointer-events-none fixed z-[60] hidden max-w-xs rounded-xl border border-slate-800 bg-slate-950/95 px-4 py-3 text-[11px] font-semibold leading-relaxed text-white shadow-2xl backdrop-blur-md"
608
+ role="status" aria-hidden="true"></div>
609
+
610
+ <!-- Event Details Modal -->
611
+ <div id="event-detail-modal"
612
+ class="fixed inset-0 z-[200] flex items-center justify-center p-4 opacity-0 pointer-events-none transition-all duration-300"
613
+ role="dialog" aria-modal="true" aria-labelledby="event-detail-title" aria-hidden="true">
614
+ <div class="absolute inset-0 bg-slate-950/40 backdrop-blur-sm" id="event-detail-overlay"></div>
615
+ <div
616
+ class="relative w-full max-w-2xl scale-95 transform rounded-3xl bg-[#fffdf4] shadow-2xl transition-transform duration-300"
617
+ style="border: 1.5px solid transparent !important; background: linear-gradient(to bottom, #fffdf4, #fffdf4) padding-box, linear-gradient(135deg, rgba(255, 153, 51, 0.4), rgba(0, 0, 128, 0.15), rgba(19, 136, 8, 0.4)) border-box !important;"
618
+ id="event-detail-card">
619
+ <div class="p-6 sm:p-8">
620
+ <div class="flex items-start justify-between gap-4 mb-4">
621
+ <div class="flex flex-wrap items-center gap-2" id="event-detail-badges">
622
+ <!-- Badges injected here -->
623
+ </div>
624
+ <button type="button" id="event-detail-close" aria-label="Close event details"
625
+ class="rounded-full bg-slate-100 p-2 text-slate-500 hover:bg-slate-200 hover:text-slate-700 transition">
626
+ <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
627
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"></path>
628
+ </svg>
629
+ </button>
630
+ </div>
631
+
632
+ <h2 class="text-xl sm:text-2xl font-extrabold text-slate-950 leading-tight mb-4" id="event-detail-title"></h2>
633
+
634
+ <div class="prose prose-sm prose-slate max-w-none max-h-[40vh] overflow-y-auto custom-scrollbar pr-2"
635
+ id="event-detail-content">
636
+ <!-- Content injected here -->
637
+ </div>
638
+
639
+ <div class="mt-8 flex justify-end">
640
+ <a id="event-detail-link" target="_blank" rel="noopener noreferrer"
641
+ class="hidden rounded-xl bg-slate-950 px-6 py-2.5 text-sm font-bold text-white shadow-md transition hover:bg-slate-800 hover:-translate-y-0.5">
642
+ Read Full Article
643
+ <svg class="ml-2 inline-block h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
644
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5"
645
+ d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
646
+ </svg>
647
+ </a>
648
+ </div>
649
+ </div>
650
+ </div>
651
+ </div>
652
+ </body>
653
+
654
+ </html>
frontend/index.html CHANGED
@@ -1,580 +1,547 @@
1
  <!DOCTYPE html>
2
  <html lang="en">
 
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Misinformation Heatmap — Real-Time AI Detection Across India</title>
7
- <meta name="description" content="AI-powered platform detecting misinformation across all 36 Indian states in real-time using IndicBERT, satellite verification, and fact-checking networks.">
8
- <link rel="icon" type="image/x-icon" href="/public/Misinfo.ico">
9
- <link rel="shortcut icon" type="image/x-icon" href="/public/Misinfo.ico">
10
- <link rel="icon" type="image/vnd.microsoft.icon" href="/public/Misinfo.ico">
11
- <link rel="preconnect" href="https://fonts.googleapis.com">
12
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
13
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=Outfit:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
14
- <style>
15
- :root {
16
- --saffron: #E87722;
17
- --saffron-light: #FF9933;
18
- --saffron-dark: #c4601a;
19
- --green: #138808;
20
- --green-light: #1ea50e;
21
- --navy: #000080;
22
- --bg: #FAFAF7;
23
- --bg-card: #FFFFFF;
24
- --bg-card-hover: #F5F5F0;
25
- --border: #E8E8E0;
26
- --border-accent: rgba(232,119,34,0.3);
27
- --text-primary: #1A1A1A;
28
- --text-secondary: #555555;
29
- --text-muted: #888888;
30
- --shadow-sm: 0 2px 8px rgba(0,0,0,0.08);
31
- --shadow-md: 0 4px 20px rgba(0,0,0,0.10);
32
- --shadow-lg: 0 8px 40px rgba(0,0,0,0.12);
33
- --radius: 16px;
34
- --radius-sm: 10px;
35
- }
36
-
37
- * { margin: 0; padding: 0; box-sizing: border-box; }
38
- html { scroll-behavior: smooth; }
39
-
40
- body {
41
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
42
- background: var(--bg);
43
- color: var(--text-primary);
44
- min-height: 100vh;
45
- overflow-x: hidden;
46
- max-width: 100vw;
47
- }
48
-
49
- /* ─── NAVBAR ─── */
50
- .navbar {
51
- position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
52
- padding: 0 2rem; height: 62px;
53
- display: flex; align-items: center;
54
- background: rgba(255, 255, 255, 0.95);
55
- backdrop-filter: blur(20px);
56
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
57
- transition: box-shadow 0.3s;
58
- }
59
- .navbar.scrolled { box-shadow: var(--shadow-sm); }
60
- .nav-inner { width: 100%; max-width: 1280px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
61
- .logo { display: flex; align-items: center; gap: 4px; font-family: 'Outfit', sans-serif; font-size: 1.15rem; font-weight: 800; text-decoration: none; color: var(--text-primary); }
62
- .logo-badge { font-size: 0.65rem; font-weight: 700; background: var(--saffron); color: #fff; padding: 2px 6px; border-radius: 4px; letter-spacing: 0.5px; }
63
- .logo .logo-name { color: var(--saffron); }
64
-
65
- .nav-links { display: flex; align-items: center; gap: 0.25rem; }
66
- .nav-link { color: var(--text-secondary); text-decoration: none; font-size: 0.88rem; font-weight: 500; padding: 0.4rem 0.9rem; border-radius: var(--radius-sm); transition: all 0.2s; }
67
- .nav-link:hover { color: var(--saffron); background: rgba(232,119,34,0.07); }
68
- .nav-link.active { color: var(--saffron); background: rgba(232,119,34,0.1); border: 1px solid rgba(232,119,34,0.2); }
69
-
70
- .status-pill { display: flex; align-items: center; gap: 6px; background: rgba(19,136,8,0.08); border: 1px solid rgba(19,136,8,0.25); border-radius: 20px; padding: 5px 14px; font-size: 0.8rem; font-weight: 600; color: var(--green); }
71
- .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
72
- @keyframes pulse { 0%,100%{opacity:1; box-shadow:0 0 0 0 rgba(19,136,8,0.4);} 50%{opacity:0.8; box-shadow:0 0 0 5px rgba(19,136,8,0);} }
73
-
74
- /* Hamburger Menu */
75
- .hamburger-btn {
76
- display: none;
77
- flex-direction: column;
78
- gap: 5px;
79
- background: none;
80
- border: none;
81
- cursor: pointer;
82
- padding: 8px;
83
- z-index: 2001;
84
- position: relative;
85
- }
86
- .hamburger-line {
87
- width: 24px;
88
- height: 2px;
89
- background: var(--text-primary);
90
- transition: all 0.3s;
91
- border-radius: 2px;
92
- }
93
- .hamburger-btn.active .hamburger-line:nth-child(1) {
94
- transform: rotate(45deg) translate(5px, 5px);
95
- }
96
- .hamburger-btn.active .hamburger-line:nth-child(2) {
97
- opacity: 0;
98
- }
99
- .hamburger-btn.active .hamburger-line:nth-child(3) {
100
- transform: rotate(-45deg) translate(5px, -5px);
101
- }
102
-
103
- /* Mobile Menu Overlay */
104
- .mobile-menu-overlay {
105
- position: fixed;
106
- top: 0;
107
- right: 0;
108
- width: 100%;
109
- height: 100%;
110
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.98), rgba(232, 119, 34, 0.95));
111
- z-index: 1500;
112
- transform: translateX(100%);
113
- transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
114
- display: flex;
115
- flex-direction: column;
116
- justify-content: space-between;
117
- padding: 80px 2rem 2rem;
118
- }
119
- .mobile-menu-overlay.active {
120
- transform: translateX(0);
121
- }
122
- .mobile-menu-top {
123
- display: flex;
124
- flex-direction: column;
125
- gap: 1rem;
126
- }
127
- .mobile-menu-link {
128
- font-family: 'Outfit', sans-serif;
129
- font-size: 1.5rem;
130
- font-weight: 700;
131
- color: #fff;
132
- text-decoration: none;
133
- padding: 1rem 0;
134
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
135
- transition: all 0.3s ease;
136
- }
137
- .mobile-menu-link:hover {
138
- padding-left: 1rem;
139
- background: rgba(255, 255, 255, 0.1);
140
- }
141
- .mobile-menu-bottom {
142
- display: flex;
143
- flex-direction: column;
144
- gap: 1rem;
145
- padding-top: 2rem;
146
- border-top: 1px solid rgba(255, 255, 255, 0.2);
147
- }
148
- .mobile-menu-brand {
149
- font-family: 'Outfit', sans-serif;
150
- font-size: 1.2rem;
151
- font-weight: 800;
152
- color: #fff;
153
- display: flex;
154
- align-items: center;
155
- gap: 8px;
156
- }
157
- .mobile-menu-brand .logo-badge {
158
- background: #fff;
159
- color: var(--saffron);
160
- }
161
-
162
- /* ─── HERO ─── */
163
- .hero {
164
- position: relative; z-index: 1; padding: 140px 2rem 100px;
165
- text-align: center;
166
- /* Premium Indian Tricolor inspired gradient with smooth transitions */
167
- background: linear-gradient(135deg,
168
- #FF9933 0%, /* Saffron */
169
- #FFB84D 15%, /* Light saffron transition */
170
- #FFF5E6 30%, /* Warm cream */
171
- #F8F8F8 45%, /* Soft off-white */
172
- #F0F8F0 60%, /* Light green tint */
173
- #E8F5E8 75%, /* Soft green */
174
- #138808 100% /* Green */
175
- );
176
- position: relative;
177
- display: flex;
178
- flex-direction: column;
179
- justify-content: center;
180
- align-items: center;
181
- min-height: 100vh;
182
- }
183
- .hero::after {
184
- content: '';
185
- position: absolute;
186
- top: 0; left: 0; right: 0; bottom: 0;
187
- background: radial-gradient(ellipse at center,
188
- rgba(255,255,255,0.1) 0%,
189
- rgba(255,255,255,0) 70%);
190
- pointer-events: none;
191
- }
192
- .hero::before {
193
- content: '';
194
- position: absolute;
195
- top: 0; left: 0; right: 0; bottom: 0;
196
- background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="20" cy="20" r="1" fill="%23FF9933" opacity="0.1"/><circle cx="80" cy="40" r="1" fill="%23138808" opacity="0.1"/><circle cx="40" cy="80" r="1" fill="%23FF9933" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>') repeat;
197
- opacity: 0.3;
198
- pointer-events: none;
199
- }
200
- .hero-eyebrow { display: inline-flex; align-items: center; gap: 8px; font-size: 0.82rem; font-weight: 600; color: var(--saffron); background: rgba(255, 153, 51, 0.2); border: 1px solid rgba(255, 153, 51, 0.4); border-radius: 20px; padding: 6px 16px; margin-bottom: 2rem; letter-spacing: 0.5px; text-transform: uppercase; }
201
- .hero h1 { font-family: 'Outfit', sans-serif; font-size: clamp(2.4rem,6vw,4rem); font-weight: 900; line-height: 1.1; letter-spacing: -1px; margin-bottom: 1.5rem; background: linear-gradient(135deg, #FF9933 0%, #B45309 50%, #138808 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
202
- .hero p { font-size: 1.1rem; color: var(--text-secondary); max-width: 580px; margin: 0 auto 2.5rem; line-height: 1.7; }
203
- .hero-actions { display: flex; align-items: center; justify-content: center; gap: 1rem; flex-wrap: wrap; }
204
- .btn-primary { display: inline-flex; align-items: center; gap: 8px; background: linear-gradient(135deg, #FF9933 0%, #B45309 100%); color: white; font-weight: 700; font-size: 0.95rem; padding: 0.8rem 2rem; border-radius: var(--radius-sm); text-decoration: none; transition: all 0.2s; box-shadow: 0 4px 20px rgba(255, 153, 51, 0.3); }
205
- .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(255, 153, 51, 0.4); }
206
- .btn-outline { display: inline-flex; align-items: center; gap: 8px; background: transparent; color: var(--text-primary); font-weight: 600; font-size: 0.95rem; padding: 0.75rem 1.75rem; border-radius: var(--radius-sm); border: 1.5px solid var(--border); text-decoration: none; transition: all 0.2s; }
207
- .btn-outline:hover { border-color: var(--saffron); color: var(--saffron); background: rgba(232,119,34,0.04); }
208
-
209
- /* ─── STATS STRIP ─── */
210
- .stats-strip { position: relative; z-index: 1; padding: 5rem 2rem 5rem; background: rgba(255, 255, 255, 0.95); }
211
- .stats-inner { max-width: 900px; margin: 0 auto; display: grid; grid-template-columns: repeat(4,1fr); gap: 1.25rem; }
212
- .stat-pill {
213
- background: rgba(255, 255, 255, 0.8); border: 1px solid var(--border); border-radius: var(--radius);
214
- padding: 1.75rem 1.5rem; text-align: center; box-shadow: var(--shadow-sm);
215
- transition: all 0.25s;
216
- }
217
- .stat-pill:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); border-color: var(--border-accent); }
218
- .stat-pill .val { font-family: 'Outfit', sans-serif; font-size: 2.2rem; font-weight: 900; color: var(--saffron); line-height: 1; }
219
- .stat-pill .lbl { font-size: 0.8rem; color: var(--text-muted); margin-top: 6px; font-weight: 500; }
220
-
221
- /* ─── FEATURES ─── */
222
- .features { position: relative; z-index: 1; padding: 5rem 2rem; background: rgba(255, 255, 255, 0.9); }
223
- .section-header { text-align: center; margin-bottom: 3.5rem; }
224
- .section-tag { display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--green); background: rgba(19,136,8,0.08); border: 1px solid rgba(19,136,8,0.2); border-radius: 20px; padding: 4px 14px; margin-bottom: 1rem; text-transform: uppercase; letter-spacing: 0.5px; }
225
- .section-title { font-family: 'Outfit', sans-serif; font-size: 2rem; font-weight: 800; color: var(--text-primary); margin-bottom: 0.75rem; }
226
- .section-sub { color: var(--text-secondary); font-size: 1rem; }
227
-
228
- .features-grid { max-width: 1100px; margin: 0 auto; display: grid; grid-template-columns: repeat(3,1fr); gap: 1.5rem; }
229
- .feature-card {
230
- background: white; border: 1px solid var(--border); border-radius: var(--radius);
231
- padding: 2rem; transition: all 0.25s; box-shadow: var(--shadow-sm);
232
- }
233
- .feature-card:hover { box-shadow: var(--shadow-md); border-color: var(--border-accent); transform: translateY(-2px); }
234
- .feature-icon { font-size: 2rem; margin-bottom: 1rem; }
235
- .feature-title { font-size: 1rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem; }
236
- .feature-desc { font-size: 0.88rem; color: var(--text-secondary); line-height: 1.6; }
237
-
238
- /* ─── LIVE TICKER ─── */
239
- .ticker-section { position: relative; z-index:1; padding: 3rem 2rem; }
240
- .ticker-inner { max-width: 1100px; margin: 0 auto; }
241
- .ticker-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; }
242
- .ticker-title { font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 800; color: var(--text-primary); }
243
- .ticker-live { display: flex; align-items: center; gap: 6px; font-size: 0.8rem; font-weight: 600; color: #dc2626; }
244
- .ticker-live-dot { width: 8px; height: 8px; border-radius: 50%; background: #dc2626; animation: pulse-red 1.5s infinite; }
245
- @keyframes pulse-red { 0%,100%{opacity:1;}50%{opacity:0.4;} }
246
-
247
- .ticker-cards { display: grid; grid-template-columns: repeat(3,1fr); gap: 1rem; }
248
- .ticker-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 1.2rem; border-left: 3px solid var(--green); box-shadow: var(--shadow-sm); }
249
- .ticker-card.fake { border-left-color: #dc2626; }
250
- .ticker-card.uncertain { border-left-color: #f59e0b; }
251
- .ticker-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 0.7rem; font-weight: 700; padding: 2px 8px; border-radius: 4px; margin-bottom: 0.6rem; }
252
- .ticker-badge.real { background: rgba(19,136,8,0.1); color: var(--green); }
253
- .ticker-badge.fake { background: rgba(220,38,38,0.1); color: #dc2626; }
254
- .ticker-badge.uncertain { background: rgba(245,158,11,0.1); color: #d97706; }
255
- .ticker-text { font-size: 0.85rem; font-weight: 600; color: var(--text-primary); line-height: 1.4; margin-bottom: 0.4rem; }
256
- .ticker-meta { font-size: 0.75rem; color: var(--text-muted); }
257
-
258
- /* ─── CTA ─── */
259
- .cta-section { position: relative; z-index:1; padding: 5rem 2rem; text-align: center; background: linear-gradient(135deg, rgba(232,119,34,0.06), rgba(19,136,8,0.04)); }
260
- .cta-inner { max-width: 600px; margin: 0 auto; }
261
- .cta-title { font-family: 'Outfit', sans-serif; font-size: 2rem; font-weight: 800; color: var(--text-primary); margin-bottom: 1rem; }
262
- .cta-sub { color: var(--text-secondary); margin-bottom: 2rem; font-size: 1rem; line-height: 1.6; }
263
-
264
- /* ─── FOOTER ─── */
265
- .footer { position: relative; z-index:1; border-top: 1px solid var(--border); padding: 2rem; text-align: center; color: var(--text-muted); font-size: 0.82rem; }
266
- .footer-flag { display: inline-flex; gap: 4px; margin-bottom: 8px; }
267
- .flag-stripe { width: 18px; height: 8px; border-radius: 2px; }
268
-
269
- /* ---- RESPONSIVE ---- */
270
- @media (max-width: 1200px) {
271
- .hero { padding: 120px 1.5rem 80px; }
272
- .stats-inner { grid-template-columns: repeat(2, 1fr); gap: 1rem; }
273
- .features-grid { grid-template-columns: repeat(2, 1fr); gap: 1.25rem; }
274
- }
275
-
276
- @media (max-width: 768px) {
277
- .navbar { padding: 0 1rem; }
278
- .nav-links { gap: 0.5rem; }
279
- .nav-link { font-size: 0.8rem; padding: 0.3rem 0.7rem; }
280
- .hero {
281
- padding: 80px 1rem 60px;
282
- height: 100vh;
283
- min-height: 100vh;
284
- display: flex;
285
- flex-direction: column;
286
- justify-content: center;
287
- align-items: center;
288
- }
289
- .hero h1 { font-size: 2.2rem; }
290
- .hero p { font-size: 1rem; max-width: 100%; }
291
- .hero-actions { gap: 0.75rem; }
292
- .btn-primary, .btn-outline { font-size: 0.85rem; padding: 0.7rem 1.5rem; }
293
- .stats-strip { padding: 4rem 1rem 4rem; }
294
- .stats-inner { grid-template-columns: 1fr 1fr; gap: 0.75rem; }
295
- .stat-pill { padding: 1.25rem 1rem; }
296
- .features-grid { grid-template-columns: 1fr; gap: 1rem; }
297
- .feature-card { padding: 1.5rem; }
298
- .ticker-cards { grid-template-columns: 1fr; gap: 0.75rem; }
299
- .cta-section { padding: 3rem 1rem; }
300
- }
301
-
302
- @media (max-width: 480px) {
303
- .navbar { padding: 0 0.75rem; }
304
- .logo { font-size: 1rem; }
305
- .logo-badge { font-size: 0.55rem; }
306
- .nav-links { display: none; }
307
- .status-pill { display: none; }
308
- .hamburger-btn { display: flex; }
309
- .mobile-menu-overlay {
310
- padding: 100px 1rem 1rem;
311
- z-index: 1499;
312
- }
313
- .mobile-menu-link { font-size: 1.3rem; padding: 0.8rem 0; }
314
- .hero {
315
- padding: 60px 0.75rem 40px;
316
- height: 100vh;
317
- min-height: 100vh;
318
- display: flex;
319
- flex-direction: column;
320
- justify-content: center;
321
- align-items: center;
322
- position: relative;
323
- }
324
- .hero h1 { font-size: 1.8rem; line-height: 1.2; }
325
- .hero p { font-size: 0.9rem; }
326
- .hero-actions { flex-direction: column; align-items: stretch; }
327
- .btn-primary, .btn-outline { width: 100%; justify-content: center; font-size: 0.8rem; padding: 0.8rem; }
328
- .stats-strip { padding: 3rem 0.75rem 3rem; }
329
- .stats-inner { grid-template-columns: 1fr; gap: 0.5rem; }
330
- .stat-pill { padding: 1rem; }
331
- .stat-pill .val { font-size: 1.8rem; }
332
- .features { padding: 3rem 0.75rem; }
333
- .feature-card { padding: 1.25rem; }
334
- .ticker-section { padding: 2rem 0.75rem; }
335
- .ticker-card { padding: 1rem; }
336
- .footer { padding: 1.5rem 0.75rem; }
337
- }
338
- </style>
339
  </head>
340
- <body>
341
- <!-- Navbar -->
342
- <nav class="navbar" id="navbar">
343
- <div class="nav-inner">
344
- <a href="/" class="logo">
345
- <span class="logo-badge">IN</span>
346
- <span>Misinformation</span><span class="logo-name">&nbsp;Heatmap</span>
347
- </a>
348
- <div class="nav-links">
349
- <a href="/" class="nav-link active">Home</a>
350
- <a href="/dashboard" class="nav-link">Dashboard</a>
351
- <a href="/map/enhanced-india-heatmap.html" class="nav-link">Heatmap</a>
352
- </div>
353
- <div class="status-pill">
354
- <span class="status-dot"></span>
355
- <span id="nav-status">System Ready</span>
356
- </div>
357
- <button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle menu">
358
- <span class="hamburger-line"></span>
359
- <span class="hamburger-line"></span>
360
- <span class="hamburger-line"></span>
361
- </button>
362
- </div>
363
- </nav>
364
-
365
- <!-- Mobile Menu Overlay -->
366
- <div class="mobile-menu-overlay" id="mobile-menu">
367
- <div class="mobile-menu-top">
368
- <a href="/" class="mobile-menu-link">Home</a>
369
- <a href="/dashboard" class="mobile-menu-link">Dashboard</a>
370
- <a href="/map/enhanced-india-heatmap.html" class="mobile-menu-link">Heatmap</a>
371
- </div>
372
- <div class="mobile-menu-bottom">
373
- <div class="mobile-menu-brand">
374
- <span class="logo-badge">IN</span>
375
- <span>Misinformation</span><span class="logo-name">&nbsp;Heatmap</span>
376
- </div>
377
- <div class="mobile-menu-tags">
378
- <span class="menu-tag alpha">Alpha v1.0</span>
379
- <span class="menu-tag live">● Live</span>
380
- </div>
381
- </div>
382
- </div>
383
 
384
- <!-- Hero -->
385
- <section class="hero">
386
- <div class="hero-eyebrow">⚡ Powered by IndicBERT &amp; 5-Model Ensemble AI</div>
387
- <h1>
388
- Combating Fake News<br>
389
- Across India
390
- </h1>
391
- <p>An AI-powered platform that detects and tracks misinformation across <strong>34+ Indian news sources</strong>, covering all 36 states and union territories in real-time.</p>
392
- <div class="hero-actions">
393
- <a href="/map/enhanced-india-heatmap.html" class="btn-primary">🗺️ Explore Live Heatmap</a>
394
- <a href="/dashboard" class="btn-outline">📊 View Dashboard</a>
 
 
395
  </div>
396
- </section>
397
 
398
- <!-- Stats strip -->
399
- <div class="stats-strip">
400
- <div class="section-header">
401
- <div class="section-tag">Real-Time Metrics</div>
402
- <h2 class="section-title">Platform Performance Overview</h2>
403
- <p class="section-sub">Live statistics showcasing our AI-powered misinformation detection capabilities across India</p>
 
 
 
 
 
 
 
404
  </div>
405
- <div class="stats-inner">
406
- <div class="stat-pill">
407
- <div class="val" id="stat-events">—</div>
408
- <div class="lbl">Events Analyzed</div>
409
- </div>
410
- <div class="stat-pill">
411
- <div class="val" style="color:var(--green);">91%</div>
412
- <div class="lbl">Detection Accuracy</div>
413
- </div>
414
- <div class="stat-pill">
415
- <div class="val" style="color:var(--navy);">36</div>
416
- <div class="lbl">States &amp; UTs</div>
417
- </div>
418
- <div class="stat-pill">
419
- <div class="val">34+</div>
420
- <div class="lbl">News Sources</div>
421
- </div>
422
  </div>
423
- </div>
424
 
425
- <!-- Features -->
426
- <section class="features">
427
- <div class="section-header">
428
- <div class="section-tag">How It Works</div>
429
- <h2 class="section-title">Multi-Layer Detection Engine</h2>
430
- <p class="section-sub">Every article passes through 5 specialized models before a verdict is issued</p>
431
  </div>
432
- <div class="features-grid">
433
- <div class="feature-card">
434
- <div class="feature-icon">🧠</div>
435
- <div class="feature-title">IndicBERT NLP</div>
436
- <div class="feature-desc">State-of-the-art multilingual transformer trained on Indian languages including Hindi, Tamil, Telugu, and 20+ more.</div>
437
- </div>
438
- <div class="feature-card">
439
- <div class="feature-icon">🔍</div>
440
- <div class="feature-title">Fact-Check Engine</div>
441
- <div class="feature-desc">Cross-references against debunked claims from Alt News, Boom Live, The Quint WebQoof, and India Today Fact Check.</div>
442
- </div>
443
- <div class="feature-card">
444
- <div class="feature-icon">📡</div>
445
- <div class="feature-title">Real-Time RSS Ingestion</div>
446
- <div class="feature-desc">Monitors 34+ Indian news sources including TOI, The Hindu, NDTV, Indian Express, and regional outlets every 2 minutes.</div>
447
- </div>
448
- <div class="feature-card">
449
- <div class="feature-icon">🗺️</div>
450
- <div class="feature-title">Geographic Mapping</div>
451
- <div class="feature-desc">200+ city-to-state mappings identify the precise geographic origin of each misinformation event.</div>
452
- </div>
453
- <div class="feature-card">
454
- <div class="feature-icon">📊</div>
455
- <div class="feature-title">Ensemble Classifier</div>
456
- <div class="feature-desc">5-model ensemble combining linguistic analysis, source credibility, ML classification, and cross-reference scoring.</div>
457
- </div>
458
- <div class="feature-card">
459
- <div class="feature-icon">🌐</div>
460
- <div class="feature-title">GDELT Integration</div>
461
- <div class="feature-desc">Pulls India-specific news from the Global Database of Events — the world's largest open-source news index. Zero cost, unlimited volume.</div>
462
- </div>
463
  </div>
 
464
  </section>
465
 
466
- <!-- Live Ticker -->
467
- <section class="ticker-section">
468
- <div class="ticker-inner">
469
- <div class="ticker-header">
470
- <div class="ticker-title">Live Activity Feed</div>
471
- <div class="ticker-live"><span class="ticker-live-dot"></span> LIVE</div>
472
- </div>
473
- <div class="ticker-cards" id="ticker-cards">
474
- <div class="ticker-card"><div class="ticker-text" style="color:var(--text-muted); text-align:center;">⏳ Loading recent events…</div></div>
475
- </div>
476
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  </section>
478
 
479
- <!-- CTA -->
480
- <section class="cta-section">
481
- <div class="cta-inner">
482
- <div class="cta-title">See India's Misinformation Map</div>
483
- <p class="cta-sub">Interact with the live India heatmap to explore state-wise risk levels, browse real-time alerts, and drill into individual events.</p>
484
- <a href="/map/enhanced-india-heatmap.html" class="btn-primary" style="font-size:1rem; padding:1rem 2.5rem;">🗺️ Open Interactive Heatmap →</a>
 
 
485
  </div>
 
 
 
 
486
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
 
488
- <!-- Footer -->
489
- <footer class="footer">
490
- <div class="footer-flag">
491
- <div class="flag-stripe" style="background:#FF9933;"></div>
492
- <div class="flag-stripe" style="background:#FFFFFF; border:1px solid #ddd;"></div>
493
- <div class="flag-stripe" style="background:#138808;"></div>
 
494
  </div>
495
- <div>Made with ❤️ for India &nbsp;·&nbsp; Powered by IndicBERT &amp; GDELT &nbsp;·&nbsp; Local-first, Zero-cost architecture</div>
496
- </footer>
497
-
498
- <script>
499
- // ── API Configuration ──
500
- // Defaults to relative paths (local). Put your HF Space URL here for Netlify deployments!
501
- const API_BASE = window.location.hostname.includes('netlify.app')
502
- ? 'https://ndg07-heatmap.hf.space'
503
- : '';
504
-
505
- // ── Navbar scroll effect ──
506
- window.addEventListener('scroll', () => {
507
- document.getElementById('navbar').classList.toggle('scrolled', window.scrollY > 20);
508
- });
509
-
510
- // ── Hamburger menu toggle ──
511
- const hamburgerBtn = document.getElementById('hamburger-btn');
512
- const mobileMenu = document.getElementById('mobile-menu');
513
-
514
- hamburgerBtn.addEventListener('click', () => {
515
- hamburgerBtn.classList.toggle('active');
516
- mobileMenu.classList.toggle('active');
517
- document.body.style.overflow = mobileMenu.classList.contains('active') ? 'hidden' : '';
518
- });
519
-
520
- // Close menu when clicking on a link
521
- document.querySelectorAll('.mobile-menu-link').forEach(link => {
522
- link.addEventListener('click', () => {
523
- hamburgerBtn.classList.remove('active');
524
- mobileMenu.classList.remove('active');
525
- document.body.style.overflow = '';
526
- });
527
- });
528
-
529
- // Close menu when clicking outside
530
- mobileMenu.addEventListener('click', (e) => {
531
- if (e.target === mobileMenu) {
532
- hamburgerBtn.classList.remove('active');
533
- mobileMenu.classList.remove('active');
534
- document.body.style.overflow = '';
535
- }
536
- });
537
-
538
- // ── Fetch and display stats ──
539
- async function loadStats() {
540
- try {
541
- const r = await fetch(API_BASE + '/api/v1/stats');
542
- if (!r.ok) return;
543
- const d = await r.json();
544
- const el = document.getElementById('stat-events');
545
- const n = d.total_events || 0;
546
- el.textContent = n >= 1000 ? (n/1000).toFixed(1)+'K' : n;
547
- document.getElementById('nav-status').textContent = d.ml_ready ? 'System Ready' : 'Loading…';
548
- } catch(_) {}
549
- }
550
-
551
- // ── Fetch and display live ticker ──
552
- async function loadTicker() {
553
- try {
554
- const r = await fetch(API_BASE + '/api/v1/events/live?limit=6');
555
- if (!r.ok) return;
556
- const d = await r.json();
557
- const events = d.events || [];
558
- if (events.length === 0) return;
559
-
560
- const container = document.getElementById('ticker-cards');
561
- container.innerHTML = events.slice(0,6).map(ev => {
562
- const cls = ev.classification || 'uncertain';
563
- const icon = cls === 'fake' ? '🚨' : cls === 'real' ? '✅' : '⚠️';
564
- const title = (ev.title || '').substring(0, 90);
565
- return `<div class="ticker-card ${cls === 'fake' ? 'fake' : cls === 'uncertain' ? 'uncertain' : ''}">
566
- <div class="ticker-badge ${cls}">${icon} ${cls.toUpperCase()}</div>
567
- <div class="ticker-text">${title}</div>
568
- <div class="ticker-meta">${ev.source || 'Unknown'} · ${ev.state || 'India'}</div>
569
- </div>`;
570
- }).join('');
571
- } catch(_) {}
572
- }
573
-
574
- loadStats();
575
- loadTicker();
576
- setInterval(loadStats, 30000);
577
- setInterval(loadTicker, 15000);
578
- </script>
579
  </body>
 
580
  </html>
 
1
  <!DOCTYPE html>
2
  <html lang="en">
3
+
4
  <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, viewport-fit=cover">
7
+ <meta name="referrer" content="strict-origin-when-cross-origin">
8
+ <meta http-equiv="Content-Security-Policy"
9
+ content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://ndg07-heatmap.hf.space; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests">
10
+ <style>
11
+ html,
12
+ body {
13
+ overflow-x: hidden;
14
+ width: 100%;
15
+ position: relative;
16
+ }
17
+
18
+ /* Surface Duo and Surface Pro 7 specific enhancements */
19
+
20
+ /* Base styles for all targeted 'Pro' and 'Duo' viewports */
21
+ @media screen and (device-width: 540px) and (device-height: 720px),
22
+ screen and (device-width: 1114px) and (device-height: 705px),
23
+ screen and (device-width: 912px) and (device-height: 1368px),
24
+ screen and (device-width: 912px) and (device-height: 608px) {
25
+
26
+ .hero-btn {
27
+ min-width: 210px !important;
28
+ border-radius: 12px !important;
29
+ /* Moderately curvy as requested */
30
+ padding: 0.875rem 2rem !important;
31
+ font-size: 0.95rem !important;
32
+ letter-spacing: 0.025em !important;
33
+ border-width: 2px !important;
34
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06) !important;
35
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
36
+ }
37
+
38
+ .hero-btn:hover {
39
+ transform: translateY(-2px) !important;
40
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1) !important;
41
+ }
42
+ }
43
+
44
+ /* Surface Duo Specific: Vertical Stacking & Horizontal Centering */
45
+ @media screen and (device-width: 540px) and (device-height: 720px),
46
+ screen and (device-width: 1114px) and (device-height: 705px) {
47
+ #hero-buttons {
48
+ display: flex !important;
49
+ flex-direction: column !important;
50
+ align-items: center !important;
51
+ justify-content: center !important;
52
+ gap: 1.25rem !important;
53
+ margin-left: auto !important;
54
+ margin-right: auto !important;
55
+ width: 100% !important;
56
+ }
57
+
58
+ .hero-btn {
59
+ width: 280px !important;
60
+ /* Consistent width for stacked look */
61
+ max-width: 90% !important;
62
+ }
63
+ }
64
+
65
+ /* Surface Pro 7 Specific Typography & UI Enhancements (Portrait & Landscape) */
66
+ @media screen and (device-width: 912px) and (device-height: 1368px),
67
+ screen and (device-width: 912px) and (device-height: 608px) {
68
+ #hero-tagline {
69
+ font-size: 0.95rem !important;
70
+ /* Increased a bit */
71
+ letter-spacing: 0.28em !important;
72
+ }
73
+
74
+ #hero-title {
75
+ font-size: 4rem !important;
76
+ /* Kept at 4rem for impact */
77
+ line-height: 1.1 !important;
78
+ }
79
+
80
+ #hero-description {
81
+ font-size: 1.35rem !important;
82
+ /* Increased just a bit */
83
+ line-height: 1.8 !important;
84
+ max-width: 90% !important;
85
+ margin-left: auto !important;
86
+ margin-right: auto !important;
87
+ }
88
+
89
+ /* Slightly larger buttons for Pro 7 */
90
+ .hero-btn {
91
+ min-width: 230px !important;
92
+ padding: 1rem 2.5rem !important;
93
+ font-size: 1.05rem !important;
94
+ }
95
+
96
+ /* Pill Scaling for Pro 7 */
97
+ #bottom-premium-nav {
98
+ bottom: 3rem !important;
99
+ gap: 1.25rem !important;
100
+ padding: 0.75rem !important;
101
+ border-width: 2px !important;
102
+ }
103
+
104
+ #bottom-premium-nav a,
105
+ #bottom-premium-nav button {
106
+ height: 3.75rem !important;
107
+ width: 3.75rem !important;
108
+ }
109
+
110
+ #bottom-premium-nav a.w-20,
111
+ #bottom-premium-nav button.w-20 {
112
+ width: 6rem !important;
113
+ }
114
+
115
+ #bottom-premium-nav svg {
116
+ width: 1.75rem !important;
117
+ height: 1.75rem !important;
118
+ }
119
+ }
120
+
121
+ /* Large 'Pro' viewports (Duo Dual & Pro 7 Landscape) - Desktop-like layout */
122
+ @media screen and (device-width: 1114px) and (device-height: 705px),
123
+ screen and (device-width: 912px) and (device-height: 608px),
124
+ screen and (device-width: 1024px) and (device-height: 600px),
125
+ screen and (device-width: 1280px) and (device-height: 800px) {
126
+
127
+ #hero-main-layout {
128
+ grid-template-columns: minmax(0, 1.15fr) minmax(360px, 0.85fr) !important;
129
+ gap: 3rem !important;
130
+ padding-top: 5rem !important;
131
+ padding-bottom: 5rem !important;
132
+ }
133
+
134
+ #hero-aside {
135
+ display: block !important;
136
+ opacity: 1 !important;
137
+ visibility: visible !important;
138
+ animation: fadeIn 0.8s ease-out forwards !important;
139
+ }
140
+
141
+ /* Desktop-style nav logic for non-Duo Pro devices or Duo Dual (if preferred side-by-side there, but user requested stacking for Duo) */
142
+ /* Note: Duo logic above overrides this for Duo specific viewports */
143
+ #hero-buttons {
144
+ display: flex !important;
145
+ flex-direction: row !important;
146
+ justify-content: flex-start !important;
147
+ gap: 1.5rem !important;
148
+ margin-top: 3rem !important;
149
+ }
150
+
151
+ /* Enable desktop navigation for these wider 'Pro' screens */
152
+ [data-desktop-nav] {
153
+ display: flex !important;
154
+ }
155
+
156
+ #menu-toggle {
157
+ display: none !important;
158
+ }
159
+
160
+ #bottom-premium-nav {
161
+ display: none !important;
162
+ }
163
+ }
164
+
165
+ /* Specific scaling for Nest Hub (1024x600) - Compact Desktop */
166
+ @media screen and (device-width: 1024px) and (device-height: 600px) {
167
+ #hero-main-layout {
168
+ gap: 2rem !important;
169
+ padding-top: 2.5rem !important;
170
+ padding-bottom: 2.5rem !important;
171
+ }
172
+
173
+ #hero-tagline {
174
+ font-size: 0.8rem !important;
175
+ letter-spacing: 0.2em !important;
176
+ }
177
+
178
+ #hero-title {
179
+ font-size: 2.5rem !important;
180
+ line-height: 1.05 !important;
181
+ margin-top: 0.5rem !important;
182
+ }
183
+
184
+ #hero-description {
185
+ font-size: 1rem !important;
186
+ line-height: 1.5 !important;
187
+ margin-top: 0.75rem !important;
188
+ max-width: 85% !important;
189
+ }
190
+
191
+ #hero-buttons {
192
+ margin-top: 1.5rem !important;
193
+ gap: 1rem !important;
194
+ }
195
+
196
+ .hero-btn {
197
+ min-width: 180px !important;
198
+ padding: 0.75rem 1.5rem !important;
199
+ font-size: 0.85rem !important;
200
+ }
201
+
202
+ #hero-aside {
203
+ padding: 1.125rem !important;
204
+ max-width: 380px !important;
205
+ }
206
+
207
+ #hero-aside h2 {
208
+ font-size: 1.25rem !important;
209
+ }
210
+
211
+ #hero-aside img {
212
+ height: 2.5rem !important;
213
+ width: 2.5rem !important;
214
+ }
215
+
216
+ #hero-aside .grid {
217
+ margin-top: 0.75rem !important;
218
+ gap: 0.5rem !important;
219
+ }
220
+
221
+ #hero-aside .rounded-lg {
222
+ padding: 0.625rem !important;
223
+ }
224
+
225
+ #hero-aside .text-2xl {
226
+ font-size: 1.25rem !important;
227
+ }
228
+
229
+ #hero-aside .text-xs {
230
+ font-size: 0.75rem !important;
231
+ }
232
+
233
+ #hero-aside .mt-6 {
234
+ margin-top: 0.75rem !important;
235
+ }
236
+
237
+ #home-data-state {
238
+ margin-top: 0.75rem !important;
239
+ font-size: 0.8rem !important;
240
+ line-height: 1.4 !important;
241
+ }
242
+ }
243
+
244
+ /* Specific scaling for Nest Hub Max (1280x800) - More breathing room */
245
+ @media screen and (device-width: 1280px) and (device-height: 800px) {
246
+ body #main-content {
247
+ padding-top: 6rem !important;
248
+ padding-bottom: 1.5rem !important;
249
+ min-height: calc(100vh - 1rem) !important;
250
+ }
251
+
252
+ #hero-main-layout {
253
+ gap: 2.75rem !important;
254
+ }
255
+ }
256
+
257
+ /* Mobile-specific fix for typewriter text wrapping (iPhone XR and similar) */
258
+ @media screen and (max-width: 430px) {
259
+ #hero-title {
260
+ max-width: 22ch !important;
261
+ /* Allow more characters per line */
262
+ font-size: 1.7rem !important;
263
+ /* Slightly smaller than text-3xl to prevent wrapping */
264
+ }
265
+
266
+ #hero-description {
267
+ font-size: 0.85rem !important;
268
+ /* Keep description balanced */
269
+ }
270
+ }
271
+
272
+ #hero-section {
273
+ background: linear-gradient(135deg, #FF9933 0%, #FFB84D 15%, #FFF5E6 30%, #F8F8F8 45%, #F0F8F0 60%, #E8F5E8 75%, #138808 100%) !important;
274
+ }
275
+
276
+ #hero-main-layout {
277
+ display: flex !important;
278
+ flex-direction: column !important;
279
+ align-items: center !important;
280
+ justify-content: center !important;
281
+ text-align: center !important;
282
+ padding-top: 5rem !important;
283
+ padding-bottom: 5rem !important;
284
+ }
285
+ </style>
286
+ <title>Misinformation Heatmap - Real-Time AI Detection Across India</title>
287
+ <meta name="description"
288
+ content="AI-powered platform detecting misinformation across Indian states in real time with multilingual analysis, source verification, and live heatmap monitoring.">
289
+ <link rel="icon" type="image/x-icon" href="/assets/Misinfo.ico">
290
+ <link rel="shortcut icon" type="image/x-icon" href="/assets/Misinfo.ico">
291
+ <link rel="manifest" href="/manifest.json">
292
+ <link rel="stylesheet" href="/assets/css/styles.css">
293
+ <script type="module" src="/assets/js/main.js"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  </head>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
+ <body data-page="home" class="min-h-screen overflow-x-hidden text-slate-900 antialiased relative">
297
+ <div class="fixed inset-0 -z-10 bg-white"></div>
298
+ <a class="absolute w-[1px] h-[1px] p-0 m-[-1px] overflow-hidden clip-[rect(0,0,0,0)] whitespace-nowrap border-0 focus:static focus:w-auto focus:h-auto focus:p-0 focus:m-0 focus:overflow-visible focus:clip-auto focus:whitespace-normal focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-lg focus:bg-slate-950 focus:px-4 focus:py-2 focus:text-sm focus:font-bold focus:text-white focus:shadow-lg"
299
+ href="#main-content">Skip to content</a>
300
+ <div data-navbar></div>
301
+
302
+ <main id="main-content" class="pt-12">
303
+ <section id="hero-section" class="relative isolate min-h-[calc(100svh-4rem)] overflow-hidden">
304
+ <!-- Premium Hero Background Layer -->
305
+ <div class="absolute inset-0 -z-10 overflow-hidden [mask-image:linear-gradient(to_bottom,white_90%,transparent)]">
306
+ <!-- Main Saffron Glow -->
307
+ <div
308
+ class="absolute -top-[15%] -left-[10%] h-[600px] w-[600px] rounded-full bg-saffron-500/30 blur-[100px] animate-float opacity-90">
309
  </div>
 
310
 
311
+ <!-- Main Green Glow -->
312
+ <div
313
+ class="absolute top-[30%] -right-[10%] h-[550px] w-[550px] rounded-full bg-india-green-500/25 blur-[110px] animate-float opacity-80"
314
+ style="animation-delay: 2s"></div>
315
+
316
+ <!-- Ashoka Blue Glow -->
317
+ <div
318
+ class="absolute top-1/2 left-1/4 h-[400px] w-[400px] rounded-full bg-ashoka-blue-500/15 blur-[120px] animate-pulse opacity-60"
319
+ style="animation-delay: 4s"></div>
320
+
321
+ <!-- Subtle Saffron/Green Accent Gradients -->
322
+ <div
323
+ class="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(255,153,51,0.15),transparent_50%),radial-gradient(circle_at_80%_80%,rgba(19,136,8,0.15),transparent_50%)]">
324
  </div>
325
+
326
+ <!-- Refined noise and pattern -->
327
+ <div class="absolute inset-0 opacity-[0.04]"
328
+ style="background-image: radial-gradient(circle at 2px 2px, rgba(0,0,0,0.15) 1px, transparent 0); background-size: 48px 48px;">
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  </div>
 
330
 
331
+ <!-- Diagonal Accent Overlay -->
332
+ <div
333
+ class="absolute inset-0 bg-gradient-to-br from-saffron-500/10 via-transparent to-india-green-500/10 opacity-70">
 
 
 
334
  </div>
335
+ </div>
336
+ <div id="hero-main-layout"
337
+ class="mx-auto flex flex-col justify-center items-center text-center min-h-[calc(100svh-4rem)] w-full max-w-7xl px-4 py-8 sm:px-6 sm:py-12 md:px-8 lg:py-16">
338
+ <div class="min-w-0 max-w-4xl flex flex-col justify-center items-center text-center mx-auto animate-fade-in-up">
339
+ <p id="hero-tagline" class="text-xs font-black text-saffron-500 mx-auto">
340
+ Ai monitoring
341
+ for India-wide news signals</p>
342
+ <h1 id="hero-title"
343
+ class="mt-4 max-w-[20ch] text-3xl font-black leading-tight tracking-normal sm:max-w-[25ch] sm:text-5xl lg:text-6xl mx-auto text-center">
344
+ <span class="block text-saffron-600">Track</span>
345
+ <span
346
+ class="block bg-clip-text text-transparent bg-gradient-to-b from-ashoka-blue-200 via-ashoka-blue-500 to-ashoka-blue-500">misinformation</span>
347
+ <span class="flex items-center justify-center min-h-[1.2em]">
348
+ <span id="typewriter-text" class="inline-block pb-2 text-india-green-500">before it spreads.</span>
349
+ <span class="w-[0.1em] h-[0.9em] bg-saffron-500 ml-1 animate-blink"></span>
350
+ </span>
351
+ </h1>
352
+ <p id="hero-description"
353
+ class="mt-4 max-w-2xl text-sm leading-6 text-slate-600 sm:text-lg mx-auto text-center leading-relaxed">
354
+ A real-time intelligence surface for reviewing state-wise misinformation signals, live classifications, and
355
+ source health across Indian news coverage.
356
+ </p>
357
+ <div id="hero-buttons" class="mt-8 flex flex-wrap justify-center gap-4 mt-6">
358
+ <a href="/heatmap.html"
359
+ class="hero-btn inline-flex min-h-11 items-center justify-center rounded-lg bg-ashoka-blue-500 px-5 py-2.5 text-sm font-bold text-white shadow-premium transition hover:-translate-y-1 hover:bg-ashoka-blue-600 hover:shadow-[0_0_20px_rgba(0,0,128,0.3)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2">Explore
360
+ live heatmap</a>
361
+ <a href="/dashboard.html"
362
+ class="hero-btn inline-flex min-h-11 items-center justify-center rounded-lg border border-slate-200 bg-white px-5 py-2.5 text-sm font-bold text-slate-800 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:bg-saffron-50 hover:text-saffron-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2">Open
363
+ dashboard</a>
364
+ </div>
 
365
  </div>
366
+ </div>
367
  </section>
368
 
369
+ <section class="mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 sm:py-14 lg:px-8" aria-labelledby="performance-title">
370
+ <div class="mb-6 max-w-3xl">
371
+ <p class="text-xs font-black uppercase tracking-[0.18em] text-saffron-700">Platform performance overview</p>
372
+ <h2 id="performance-title" class="mt-2 text-2xl font-black tracking-normal text-slate-900 sm:text-3xl">Current
373
+ signal coverage in real time.</h2>
374
+ <p class="mt-3 text-sm leading-7 text-slate-600 sm:text-base">Every value below is populated from API responses.
375
+ Empty states stay visible when data is unavailable.</p>
376
+ </div>
377
+
378
+ <div class="grid grid-cols-2 gap-3 md:grid-cols-4">
379
+ <article
380
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-ashoka-blue-500 bg-white p-4 shadow-premium sm:p-5 transition hover:-translate-y-1">
381
+ <p class="text-xs font-black uppercase tracking-[0.14em] text-slate-500">Events analyzed</p>
382
+ <p class="mt-4 text-3xl font-black leading-none text-ashoka-blue-500 sm:text-4xl" id="home-total-events"
383
+ aria-live="polite">--</p>
384
+ <p class="mt-2 text-xs font-semibold leading-5 text-slate-500">Processed by the live pipeline</p>
385
+ </article>
386
+ <article
387
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-saffron-500 bg-white p-4 shadow-premium sm:p-5 transition hover:-translate-y-1">
388
+ <p class="text-xs font-black uppercase tracking-[0.14em] text-slate-500">Misinformation</p>
389
+ <p class="mt-4 text-3xl font-black leading-none text-red-600 sm:text-4xl" id="home-misinformation-events"
390
+ aria-live="polite">--</p>
391
+ <p class="mt-2 text-xs font-semibold leading-5 text-slate-500">High-confidence risk signals</p>
392
+ </article>
393
+ <article
394
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-india-green-500 bg-white p-4 shadow-premium sm:p-5 transition hover:-translate-y-1">
395
+ <p class="text-xs font-black uppercase tracking-[0.14em] text-slate-500">Verified news</p>
396
+ <p class="mt-4 text-3xl font-black leading-none text-india-green-600 sm:text-4xl" id="home-verified-events"
397
+ aria-live="polite">--</p>
398
+ <p class="mt-2 text-xs font-semibold leading-5 text-slate-500">Authenticated reporting signals</p>
399
+ </article>
400
+ <article
401
+ class="min-w-0 rounded-xl border border-t-4 border-slate-200 border-t-ashoka-blue-400 bg-white p-4 shadow-premium sm:p-5 transition hover:-translate-y-1">
402
+ <p class="text-xs font-black uppercase tracking-[0.14em] text-slate-500">AI Accuracy</p>
403
+ <p class="mt-4 text-3xl font-black leading-none text-ashoka-blue-600 sm:text-4xl" id="home-accuracy-events"
404
+ aria-live="polite">--</p>
405
+ <p class="mt-2 text-xs font-semibold leading-5 text-slate-500">Model classification confidence</p>
406
+ </article>
407
+ </div>
408
+ </section>
409
+
410
+ <section class="mx-auto w-full max-w-7xl mt-10 px-4 pb-12 sm:px-6 lg:px-8" aria-labelledby="capabilities-title">
411
+ <div class="mb-8 max-w-3xl">
412
+ <p class="text-xs font-black uppercase tracking-[0.18em] text-saffron-700">Detection workflow</p>
413
+ <h2 id="capabilities-title" class="mt-2 text-2xl font-black tracking-normal text-slate-900 sm:text-3xl">Built
414
+ for fast triage and careful verification.</h2>
415
+ <p class="mt-3 text-sm leading-7 text-slate-600 sm:text-base">The interface emphasizes scannable signals, clear
416
+ state, and traceable classifications without hiding uncertainty.</p>
417
+ </div>
418
+
419
+ <div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mt-8">
420
+ <article
421
+ class="rounded-2xl border border-slate-200 bg-white p-6 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:shadow-soft flex flex-col justify-start">
422
+ <div class="text-3xl leading-none" aria-hidden="true">🧠</div>
423
+ <h3 class="mt-4 text-lg font-black text-slate-900">IndicBERT NLP</h3>
424
+ <p class="mt-2 text-sm leading-relaxed text-slate-500">State-of-the-art multilingual transformer trained on
425
+ Indian languages including Hindi, Tamil, Telugu, and 20+ more.</p>
426
+ </article>
427
+ <article
428
+ class="rounded-2xl border border-slate-200 bg-white p-6 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:shadow-soft flex flex-col justify-start">
429
+ <div class="text-3xl leading-none" aria-hidden="true">🔍</div>
430
+ <h3 class="mt-4 text-lg font-black text-slate-900">Fact-Check Engine</h3>
431
+ <p class="mt-2 text-sm leading-relaxed text-slate-500">Cross-references against debunked claims from Alt News,
432
+ Boom Live, The Quint WebQoof, and India Today Fact Check.</p>
433
+ </article>
434
+ <article
435
+ class="rounded-2xl border border-slate-200 bg-white p-6 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:shadow-soft flex flex-col justify-start">
436
+ <div class="text-3xl leading-none" aria-hidden="true">📡</div>
437
+ <h3 class="mt-4 text-lg font-black text-slate-900">Real-Time RSS Ingestion</h3>
438
+ <p class="mt-2 text-sm leading-relaxed text-slate-500">Monitors 34+ Indian news sources including TOI, The
439
+ Hindu, NDTV, Indian Express, and regional outlets every 2 minutes.</p>
440
+ </article>
441
+ <article
442
+ class="rounded-2xl border border-slate-200 bg-white p-6 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:shadow-soft flex flex-col justify-start">
443
+ <div class="text-3xl leading-none" aria-hidden="true">🗺️</div>
444
+ <h3 class="mt-4 text-lg font-black text-slate-900">Geographic Mapping</h3>
445
+ <p class="mt-2 text-sm leading-relaxed text-slate-500">200+ city-to-state mappings identify the precise
446
+ geographic origin of each misinformation event.</p>
447
+ </article>
448
+ <article
449
+ class="rounded-2xl border border-slate-200 bg-white p-6 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:shadow-soft flex flex-col justify-start">
450
+ <div class="text-3xl leading-none" aria-hidden="true">📊</div>
451
+ <h3 class="mt-4 text-lg font-black text-slate-900">Ensemble Classifier</h3>
452
+ <p class="mt-2 text-sm leading-relaxed text-slate-500">5-model ensemble combining linguistic analysis, source
453
+ credibility, ML classification, and cross-reference scoring.</p>
454
+ </article>
455
+ <article
456
+ class="rounded-2xl border border-slate-200 bg-white p-6 shadow-premium transition hover:-translate-y-1 hover:border-saffron-300 hover:shadow-soft flex flex-col justify-start">
457
+ <div class="text-3xl leading-none" aria-hidden="true">🌐</div>
458
+ <h3 class="mt-4 text-lg font-black text-slate-900">GDELT Integration</h3>
459
+ <p class="mt-2 text-sm leading-relaxed text-slate-500">Pulls India-specific news from the Global Database of
460
+ Events — the world's largest open-source news index. Zero cost, unlimited volume.</p>
461
+ </article>
462
+ </div>
463
  </section>
464
 
465
+ <section class="mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 sm:py-14 lg:px-8">
466
+ <div
467
+ class="flex flex-col gap-5 rounded-2xl border border-slate-200 bg-slate-50 p-5 text-slate-900 shadow-card sm:p-8 lg:flex-row lg:items-center lg:justify-between">
468
+ <div class="min-w-0">
469
+ <p class="text-xs font-black uppercase tracking-[0.18em] text-saffron-600">Interactive map</p>
470
+ <h2 class="mt-2 text-2xl font-black text-slate-950 sm:text-3xl">Review state-level risk on the heatmap.</h2>
471
+ <p class="mt-2 max-w-2xl text-sm leading-6 text-slate-600 sm:text-base">Move from the national overview into
472
+ geographic detail, then drill into recent activity for individual states.</p>
473
  </div>
474
+ <a href="/heatmap.html"
475
+ class="hero-btn inline-flex min-h-11 shrink-0 items-center justify-center rounded-lg bg-ashoka-blue-500 px-5 py-2.5 text-sm font-bold text-white shadow-premium transition hover:-translate-y-0.5 hover:bg-ashoka-blue-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-saffron-500 focus-visible:ring-offset-2">Open
476
+ heatmap</a>
477
+ </div>
478
  </section>
479
+ </main>
480
+
481
+ <footer class="border-t border-slate-200 bg-slate-950 pt-16 pb-32 text-white">
482
+ <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
483
+ <div class="grid grid-cols-1 gap-12 md:grid-cols-3">
484
+ <!-- Brand Section -->
485
+ <div class="max-w-xs">
486
+ <div class="flex items-center gap-2">
487
+ <div class="h-8 w-8 rounded-lg bg-gradient-to-tr from-saffron-500 via-white to-india-green-500 p-1">
488
+ <div class="h-full w-full rounded-md bg-slate-950 flex items-center justify-center">
489
+ <span class="text-[10px] font-black">MH</span>
490
+ </div>
491
+ </div>
492
+ <span class="text-lg font-black tracking-tight">Misinfo<span class="text-saffron-500">Heatmap</span></span>
493
+ </div>
494
+ <p class="mt-6 text-sm leading-7 text-slate-400">
495
+ Advanced AI-powered platform for real-time misinformation detection and verification across India's digital
496
+ landscape.
497
+ </p>
498
+ <div class="mt-6 flex items-center gap-4">
499
+ <!-- India Badge -->
500
+ <span
501
+ class="inline-flex items-center gap-1.5 rounded-full bg-white/5 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-slate-300 border border-white/10">
502
+ <span class="h-1.5 w-1.5 rounded-full bg-saffron-500"></span>
503
+ Monitored in India
504
+ </span>
505
+ </div>
506
+ </div>
507
+
508
+ <!-- Platform Links -->
509
+ <div>
510
+ <h3 class="text-sm font-black uppercase tracking-[0.2em] text-saffron-500">Platform</h3>
511
+ <ul class="mt-6 space-y-4 text-sm text-slate-400">
512
+ <li><a href="/heatmap.html" class="transition hover:text-white font-medium">Live Heatmap</a></li>
513
+ <li><a href="/dashboard.html" class="transition hover:text-white font-medium">Intelligence Dashboard</a>
514
+ </li>
515
+ </ul>
516
+ </div>
517
 
518
+ <!-- Support -->
519
+ <div>
520
+ <h3 class="text-sm font-black uppercase tracking-[0.2em] text-ashoka-blue-400">Support</h3>
521
+ <ul class="mt-6 space-y-4 text-sm text-slate-400">
522
+ <li><a href="/contact.html" class="transition hover:text-white font-black text-slate-300">Contact Us</a>
523
+ </li>
524
+ </ul>
525
  </div>
526
+ </div>
527
+
528
+ <!-- Bottom Bar -->
529
+ <div class="mt-16 border-t border-white/5 pt-8">
530
+ <div class="flex flex-col items-center justify-between gap-6 md:flex-row">
531
+ <p class="text-xs text-slate-500">
532
+ © 2026 Misinformation Heatmap. Powered by Advanced Neural Networks.
533
+ </p>
534
+ <div class="flex items-center gap-8">
535
+ <a href="/privacy-policy.html"
536
+ class="text-xs text-slate-400 hover:text-white font-medium transition">Privacy Policy</a>
537
+ <div
538
+ class="h-1 w-32 rounded-full bg-gradient-to-r from-saffron-500 via-white to-india-green-500 opacity-20">
539
+ </div>
540
+ </div>
541
+ </div>
542
+ </div>
543
+ </div>
544
+ </footer>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  </body>
546
+
547
  </html>
frontend/manifest.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Misinformation Heatmap - India",
3
+ "short_name": "Misinfo Heatmap",
4
+ "description": "Real-time misinformation detection and analysis across Indian states",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#FAFAF7",
8
+ "theme_color": "#E87722",
9
+ "orientation": "portrait-primary",
10
+ "icons": [
11
+ {
12
+ "src": "/assets/ind.png",
13
+ "sizes": "256x256",
14
+ "type": "image/png",
15
+ "purpose": "any maskable"
16
+ },
17
+ {
18
+ "src": "/assets/Misinfo.ico",
19
+ "sizes": "256x256",
20
+ "type": "image/x-icon"
21
+ }
22
+ ],
23
+ "categories": ["news", "education", "utilities"],
24
+ "lang": "en",
25
+ "dir": "ltr",
26
+ "scope": "/",
27
+ "prefer_related_applications": false
28
+ }
frontend/map/enhanced-india-heatmap.html DELETED
@@ -1,1971 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Misinformation Heatmap - India</title>
7
- <link rel="icon" type="image/x-icon" href="/public/Misinfo.ico">
8
- <style>
9
- :root {
10
- --saffron: #E87722;
11
- --saffron-light: #FF9933;
12
- --green: #138808;
13
- --red: #dc2626;
14
- --amber: #d97706;
15
- --blue: #2563eb;
16
- --bg: #F8F8F4;
17
- --bg-card: rgba(255,255,255,0.98);
18
- --bg-card-hover: rgba(248,248,244,0.98);
19
- --border: rgba(0,0,0,0.10);
20
- --border-accent: rgba(232,119,34,0.30);
21
- --text-primary: #1A1A1A;
22
- --text-secondary:#555555;
23
- --text-muted: #888888;
24
- --radius: 16px;
25
- --radius-sm: 10px;
26
- --accent: #E87722;
27
- --high-risk: #dc2626;
28
- --medium-risk: #d97706;
29
- --low-risk: #138808;
30
- --shadow: 0 4px 20px rgba(0,0,0,0.08);
31
- --shadow-sm: 0 2px 8px rgba(0,0,0,0.08);
32
- --shadow-lg: 0 8px 32px rgba(0,0,0,0.12);
33
- }
34
-
35
- @keyframes pulse {
36
- 0%, 100% { opacity: 1; }
37
- 50% { opacity: 0.5; }
38
- }
39
-
40
- @keyframes glow {
41
- 0%, 100% {
42
- box-shadow: 0 0 5px rgba(255, 153, 51, 0.3);
43
- }
44
- 50% {
45
- box-shadow: 0 0 20px rgba(255, 153, 51, 0.6), 0 0 30px rgba(255, 153, 51, 0.4);
46
- }
47
- }
48
-
49
- @keyframes slideInFromRight {
50
- from {
51
- opacity: 0;
52
- transform: translateX(30px);
53
- }
54
- to {
55
- opacity: 1;
56
- transform: translateX(0);
57
- }
58
- }
59
-
60
- @keyframes fadeInUp {
61
- from {
62
- opacity: 0;
63
- transform: translateY(20px);
64
- }
65
- to {
66
- opacity: 1;
67
- transform: translateY(0);
68
- }
69
- }
70
-
71
- body {
72
- font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
73
- background: #F8F8F4;
74
- color: #1A1A1A;
75
- margin: 0;
76
- padding: 0;
77
- min-height: 100vh;
78
- overflow-x: hidden;
79
- max-width: 100vw;
80
- scroll-behavior: smooth;
81
- }
82
-
83
- /* Saffron and green gradient blobs for Indian flag feel */
84
- body::before {
85
- content: '';
86
- position: fixed;
87
- top: 0; left: 0; width: 100%; height: 100%;
88
- background:
89
- radial-gradient(ellipse 55% 50% at -10% -10%, rgba(232,119,34,0.18) 0%, transparent 60%),
90
- radial-gradient(ellipse 45% 45% at 110% 110%, rgba(19,136,8,0.14) 0%, transparent 60%);
91
- pointer-events: none;
92
- z-index: 0;
93
- }
94
-
95
- /* Main layout structure - FULL SCREEN unified container */
96
- .main-layout {
97
- display: flex;
98
- flex-direction: column;
99
- height: 100vh;
100
- width: 100vw;
101
- background: transparent;
102
- position: relative;
103
- z-index: 1;
104
- }
105
-
106
- /* Tricolor accent for the unified container */
107
- .main-layout::before {
108
- content: '';
109
- position: absolute;
110
- top: 0;
111
- left: 0;
112
- right: 0;
113
- height: 2px;
114
- background: linear-gradient(90deg, #FF9933, rgba(255,255,255,0.5), #138808);
115
- z-index: 1;
116
- }
117
-
118
- /* Navigation bar */
119
- .navbar {
120
- position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
121
- padding: 0 2rem; height: 62px;
122
- display: flex; align-items: center;
123
- background: rgba(255, 255, 255, 0.95);
124
- backdrop-filter: blur(20px);
125
- border-bottom: 1px solid rgba(0, 0, 0, 0.1);
126
- transition: box-shadow 0.3s;
127
- }
128
- .navbar.scrolled { box-shadow: var(--shadow-sm); }
129
- .nav-inner { width: 100%; max-width: 1280px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; }
130
- .logo { display: flex; align-items: center; gap: 4px; font-family: 'Outfit', sans-serif; font-size: 1.15rem; font-weight: 800; text-decoration: none; color: var(--text-primary); }
131
- .logo-badge { font-size: 0.65rem; font-weight: 700; background: var(--saffron); color: #fff; padding: 2px 6px; border-radius: 4px; letter-spacing: 0.5px; }
132
- .logo .logo-name { color: var(--saffron); }
133
-
134
- .nav-links { display: flex; align-items: center; gap: 0.25rem; }
135
- .nav-link { color: var(--text-secondary); text-decoration: none; font-size: 0.88rem; font-weight: 500; padding: 0.4rem 0.9rem; border-radius: var(--radius-sm); transition: all 0.2s; }
136
- .nav-link:hover { color: var(--saffron); background: rgba(232,119,34,0.07); }
137
- .nav-link.active { color: var(--saffron); background: rgba(232,119,34,0.1); border: 1px solid rgba(232,119,34,0.2); }
138
-
139
- .status-pill { display: flex; align-items: center; gap: 6px; background: rgba(19,136,8,0.08); border: 1px solid rgba(19,136,8,0.25); border-radius: 20px; padding: 5px 14px; font-size: 0.8rem; font-weight: 600; color: var(--green); }
140
- .status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: pulse 2s infinite; }
141
- @keyframes pulse { 0%,100%{opacity:1; box-shadow:0 0 0 0 rgba(19,136,8,0.4);} 50%{opacity:0.8; box-shadow:0 0 0 5px rgba(19,136,8,0);} }
142
-
143
- /* Hamburger button */
144
- .hamburger-btn {
145
- display: none;
146
- flex-direction: column;
147
- gap: 5px;
148
- background: none;
149
- border: none;
150
- cursor: pointer;
151
- padding: 8px;
152
- z-index: 10000;
153
- }
154
- .hamburger-line {
155
- width: 24px;
156
- height: 2px;
157
- background: var(--text-primary);
158
- transition: all 0.3s ease;
159
- border-radius: 2px;
160
- }
161
- .hamburger-btn.active .hamburger-line:nth-child(1) {
162
- transform: rotate(45deg) translate(5px, 5px);
163
- }
164
- .hamburger-btn.active .hamburger-line:nth-child(2) {
165
- opacity: 0;
166
- }
167
- .hamburger-btn.active .hamburger-line:nth-child(3) {
168
- transform: rotate(-45deg) translate(5px, -5px);
169
- }
170
-
171
- /* Mobile Menu Overlay */
172
- .mobile-menu-overlay {
173
- position: fixed;
174
- top: 0;
175
- left: 0;
176
- width: 100%;
177
- height: 100%;
178
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.98), rgba(232, 119, 34, 0.95));
179
- z-index: 9999;
180
- transform: translateX(-100%);
181
- transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
182
- display: flex;
183
- flex-direction: column;
184
- justify-content: space-between;
185
- padding: 80px 2rem 2rem;
186
- overflow-x: hidden;
187
- }
188
- .mobile-menu-overlay.active {
189
- transform: translateX(0);
190
- }
191
- .mobile-menu-top {
192
- display: flex;
193
- flex-direction: column;
194
- gap: 1rem;
195
- }
196
- .mobile-menu-link {
197
- font-family: 'Outfit', sans-serif;
198
- font-size: 1.5rem;
199
- font-weight: 700;
200
- color: #fff;
201
- text-decoration: none;
202
- padding: 1rem 0;
203
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
204
- transition: all 0.3s ease;
205
- width: 100%;
206
- box-sizing: border-box;
207
- display: block;
208
- }
209
- .mobile-menu-link:hover {
210
- padding-left: 1rem;
211
- background: rgba(255, 255, 255, 0.1);
212
- }
213
- .mobile-menu-bottom {
214
- display: flex;
215
- flex-direction: column;
216
- gap: 1rem;
217
- padding-top: 2rem;
218
- border-top: 1px solid rgba(255, 255, 255, 0.2);
219
- background: rgba(232, 119, 34, 0.3);
220
- padding: 2rem;
221
- margin: 0 -2rem -2rem -2rem;
222
- }
223
- .mobile-menu-brand {
224
- font-family: 'Outfit', sans-serif;
225
- font-size: 1.2rem;
226
- font-weight: 800;
227
- color: #fff;
228
- display: flex;
229
- align-items: center;
230
- gap: 8px;
231
- }
232
- .mobile-menu-brand .logo-badge {
233
- background: #fff;
234
- color: var(--accent);
235
- }
236
- .mobile-menu-brand .logo-name {
237
- color: #fff;
238
- }
239
- .mobile-menu-tags {
240
- display: flex;
241
- gap: 8px;
242
- flex-wrap: wrap;
243
- }
244
- .menu-tag {
245
- font-size: 0.7rem;
246
- font-weight: 700;
247
- padding: 4px 10px;
248
- border-radius: 12px;
249
- text-transform: uppercase;
250
- }
251
- .menu-tag.alpha {
252
- background: rgba(255, 255, 255, 0.2);
253
- color: #fff;
254
- }
255
- .menu-tag.live {
256
- background: #fff;
257
- color: var(--green);
258
- animation: pulse 2s infinite;
259
- }
260
-
261
- /* Content area - NO separate container styling */
262
- .content-area {
263
- display: flex;
264
- flex: 1;
265
- overflow: hidden;
266
- min-height: 0;
267
- padding-top: 62px;
268
- }
269
-
270
- /* Map section (left half) */
271
- .map-section {
272
- flex: 0 0 700px;
273
- display: flex;
274
- flex-direction: column;
275
- padding: 16px;
276
- padding-right: 8px;
277
- overflow: hidden;
278
- }
279
-
280
- /* Information section (right half) */
281
- .info-section {
282
- flex: 1;
283
- display: flex;
284
- flex-direction: column;
285
- padding: 16px;
286
- padding-left: 8px;
287
- border-left: 1px solid rgba(0, 0, 0, 0.1);
288
- min-width: 400px;
289
- overflow-y: auto;
290
- }
291
-
292
- .map-container {
293
- flex: 1;
294
- overflow: hidden;
295
- position: relative;
296
- margin: 12px;
297
- border-radius: 16px;
298
- background: #FFFFFF;
299
- box-shadow: 0 4px 20px rgba(0,0,0,0.07);
300
- border: 1px solid var(--border);
301
- max-height: 500px;
302
- }
303
-
304
- #svgroot {
305
- width: 100%;
306
- height: 100%;
307
- display: flex;
308
- align-items: center;
309
- justify-content: center;
310
- touch-action: none;
311
- cursor: grab;
312
- transition: transform 0.2s ease;
313
- position: relative;
314
- overflow: hidden;
315
- border-radius: 16px;
316
- }
317
-
318
- #svgroot::before {
319
- content: '';
320
- position: absolute;
321
- top: 0;
322
- left: 0;
323
- right: 0;
324
- bottom: 0;
325
- background:
326
- radial-gradient(circle at 30% 30%, rgba(255, 153, 51, 0.03) 0%, transparent 50%),
327
- radial-gradient(circle at 70% 70%, rgba(19, 136, 8, 0.03) 0%, transparent 50%);
328
- pointer-events: none;
329
- }
330
-
331
- #svgroot svg {
332
- max-width: 90%;
333
- max-height: 90%;
334
- width: auto;
335
- height: auto;
336
- transform-origin: center center;
337
- position: absolute;
338
- top: 50%;
339
- left: 50%;
340
- transform: translate(-50%, -50%);
341
- filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
342
- }
343
-
344
- #svgroot:active {
345
- cursor: grabbing;
346
- }
347
-
348
- .zoom-controls {
349
- position: absolute;
350
- top: 24px;
351
- right: 24px;
352
- display: none;
353
- flex-direction: column;
354
- gap: 6px;
355
- z-index: 1000;
356
- transition: all 0.3s ease;
357
- animation: slideInFromRight 0.5s ease-out;
358
- }
359
-
360
- @media (min-width: 768px) {
361
- .zoom-controls {
362
- display: flex;
363
- }
364
- }
365
-
366
- /* Enhanced zoom control styling */
367
- .center-btn {
368
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.2), rgba(255, 153, 51, 0.3));
369
- border: 1px solid var(--accent);
370
- color: var(--accent);
371
- box-shadow: 0 2px 8px rgba(255, 153, 51, 0.2);
372
- }
373
-
374
- .center-btn:hover {
375
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.3), rgba(255, 153, 51, 0.4));
376
- box-shadow: 0 4px 12px rgba(255, 153, 51, 0.3);
377
- transform: scale(1.05) translateY(-1px);
378
- }
379
-
380
- .zoom-btn {
381
- width: 44px;
382
- height: 44px;
383
- background: rgba(255,255,255,0.95);
384
- border: 1px solid var(--border);
385
- color: var(--text-primary);
386
- border-radius: 12px;
387
- cursor: pointer;
388
- display: flex;
389
- align-items: center;
390
- justify-content: center;
391
- font-size: 18px;
392
- font-weight: 600;
393
- transition: all 0.3s ease;
394
- box-shadow: 0 2px 8px rgba(0,0,0,0.10);
395
- }
396
-
397
- .zoom-btn:hover {
398
- background: rgba(232,119,34,0.08);
399
- border-color: var(--border-accent);
400
- transform: scale(1.05) translateY(-1px);
401
- }
402
-
403
- .zoom-btn:active {
404
- transform: scale(0.98) translateY(0px);
405
- }
406
-
407
- .zoom-level {
408
- background:
409
- linear-gradient(135deg,
410
- rgba(255, 153, 51, 0.2) 0%,
411
- rgba(255, 153, 51, 0.1) 100%
412
- );
413
- border: 1px solid var(--accent);
414
- color: var(--accent);
415
- padding: 10px 14px;
416
- border-radius: 12px;
417
- font-size: 13px;
418
- font-weight: 600;
419
- text-align: center;
420
- backdrop-filter: blur(15px);
421
- box-shadow:
422
- 0 4px 12px rgba(255, 153, 51, 0.2),
423
- inset 0 1px 0 rgba(255, 153, 51, 0.3);
424
- }
425
-
426
- #live-feed {
427
- scrollbar-width: thin;
428
- scrollbar-color: rgba(0,0,0,0.15) transparent;
429
- word-wrap: break-word;
430
- overflow-wrap: break-word;
431
- hyphens: auto;
432
- }
433
-
434
- #live-feed .activity-title {
435
- word-wrap: break-word;
436
- overflow-wrap: break-word;
437
- white-space: normal;
438
- line-height: 1.3;
439
- }
440
-
441
- #live-feed::-webkit-scrollbar { width: 4px; }
442
- #live-feed::-webkit-scrollbar-track { background: transparent; }
443
- #live-feed::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.15); border-radius: 2px; }
444
- #live-feed::-webkit-scrollbar-thumb:hover { background: rgba(0,0,0,0.25); }
445
-
446
- @keyframes slideIn {
447
- from {
448
- opacity: 0;
449
- transform: translateX(-20px);
450
- }
451
- to {
452
- opacity: 1;
453
- transform: translateX(0);
454
- }
455
- }
456
-
457
- @keyframes newItem {
458
- 0% { background: rgba(103, 126, 234, 0.3); }
459
- 100% { background: rgba(255,255,255,0.03); }
460
- }
461
-
462
- .loading {
463
- display: flex;
464
- flex-direction: column;
465
- align-items: center;
466
- justify-content: center;
467
- height: 100%;
468
- color: #e6eef8;
469
- text-align: center;
470
- }
471
-
472
- .loading-spinner {
473
- width: 50px;
474
- height: 50px;
475
- border: 4px solid rgba(255, 153, 51, 0.3);
476
- border-top: 4px solid #FF9933;
477
- border-radius: 50%;
478
- animation: spin 1s linear infinite;
479
- margin-bottom: 20px;
480
- }
481
-
482
- @keyframes spin {
483
- 0% { transform: rotate(0deg); }
484
- 100% { transform: rotate(360deg); }
485
- }
486
-
487
- .loading-text {
488
- font-size: 18px;
489
- font-weight: 600;
490
- margin-bottom: 8px;
491
- color: #FF9933;
492
- }
493
-
494
- .loading-subtext {
495
- font-size: 14px;
496
- color: #a0a0a0;
497
- margin-bottom: 20px;
498
- }
499
-
500
- .loading-progress {
501
- width: 200px;
502
- height: 4px;
503
- background: rgba(255, 255, 255, 0.1);
504
- border-radius: 2px;
505
- overflow: hidden;
506
- }
507
-
508
- .progress-bar {
509
- height: 100%;
510
- background: linear-gradient(90deg, #FF9933, #138808);
511
- width: 0%;
512
- transition: width 0.3s ease;
513
- animation: progress 3s ease-in-out infinite;
514
- }
515
-
516
- @keyframes progress {
517
- 0% { width: 0%; }
518
- 50% { width: 70%; }
519
- 100% { width: 100%; }
520
- }
521
-
522
- /* Responsive design for navbar + two-section layout */
523
-
524
- /* Large screens (1440px+) */
525
- @media (min-width: 1441px) {
526
- .info-section {
527
- width: 600px;
528
- min-width: 600px;
529
- }
530
-
531
- .navbar-title {
532
- font-size: 26px;
533
- }
534
- }
535
-
536
- /* Medium screens (1200px - 1440px) */
537
- @media (min-width: 1201px) and (max-width: 1440px) {
538
- .info-section {
539
- width: 480px;
540
- min-width: 480px;
541
- }
542
- }
543
-
544
- /* Mobile portrait (max 768px) */
545
- @media (max-width: 768px) {
546
- .navbar { padding: 0 1rem; }
547
- .nav-links { display: none; }
548
- .status-pill { display: none; }
549
- .hamburger-btn { display: flex; }
550
-
551
- .content-area {
552
- flex-direction: column;
553
- overflow-y: auto;
554
- display: block;
555
- overflow-x: hidden;
556
- padding-top: 62px;
557
- }
558
-
559
- .map-section {
560
- height: 55vh;
561
- min-height: 350px;
562
- padding: 12px;
563
- padding-bottom: 8px;
564
- border-left: none;
565
- flex: none;
566
- }
567
-
568
- .map-container {
569
- max-height: 55vh;
570
- }
571
-
572
- .info-section {
573
- width: 100%;
574
- min-width: 100%;
575
- max-width: 100%;
576
- height: auto;
577
- padding: 16px 16px 16px 4px;
578
- padding-top: 20px;
579
- border-left: none;
580
- border-top: 1px solid rgba(0, 0, 0, 0.1);
581
- overflow-x: hidden;
582
- }
583
-
584
- .info-content {
585
- padding: 16px 16px 16px 4px;
586
- overflow-x: hidden;
587
- max-width: 100%;
588
- box-sizing: border-box;
589
- }
590
-
591
- .state-name {
592
- font-size: 22px;
593
- padding: 24px 0 24px 0;
594
- text-align: left;
595
- }
596
-
597
- .zoom-controls {
598
- display: flex;
599
- top: 12px;
600
- right: 12px;
601
- }
602
-
603
- .zoom-btn {
604
- width: 36px;
605
- height: 36px;
606
- font-size: 16px;
607
- }
608
-
609
- .zoom-level {
610
- font-size: 11px;
611
- padding: 6px 10px;
612
- }
613
-
614
- /* Make status grid responsive on mobile */
615
- .info-content > div:first-of-type {
616
- display: grid !important;
617
- grid-template-columns: repeat(2, 1fr) !important;
618
- gap: 24px !important;
619
- padding: 28px !important;
620
- margin-bottom: 40px !important;
621
- }
622
-
623
- .info-content > div:first-of-type > div {
624
- padding: 24px !important;
625
- }
626
-
627
- .info-content > div:first-of-type > div > div:first-child {
628
- font-size: 16px !important;
629
- }
630
-
631
- .info-content > div:first-of-type > div > div:last-child {
632
- font-size: 11px !important;
633
- }
634
-
635
- /* Add more spacing between content sections */
636
- .info-content > div:nth-of-type(2) {
637
- margin-top: 40px !important;
638
- }
639
-
640
- .info-content > div:nth-of-type(3) {
641
- margin-top: 40px !important;
642
- }
643
- }
644
-
645
- @media (max-width: 480px) {
646
- .navbar { padding: 0 0.75rem; }
647
- .logo { font-size: 1rem; }
648
- .logo-badge { font-size: 0.55rem; }
649
- .nav-links { display: none; }
650
- .status-pill { display: none; }
651
- .hamburger-btn { display: flex; }
652
- }
653
-
654
- #tooltip {
655
- position: fixed;
656
- pointer-events: none;
657
- padding: 12px 16px;
658
- background: rgba(255, 255, 255, 0.98);
659
- color: #1A1A1A;
660
- border-radius: 10px;
661
- font-size: 13px;
662
- box-shadow: 0 8px 24px rgba(0,0,0,0.2);
663
- display: none;
664
- max-width: 280px;
665
- backdrop-filter: blur(10px);
666
- border: 1px solid rgba(255, 153, 51, 0.3);
667
- z-index: 9999;
668
- line-height: 1.4;
669
- }
670
-
671
- .info-content {
672
- padding: 24px;
673
- overflow-y: auto;
674
- flex: 1;
675
- /* Enhanced scrollbar for better UX */
676
- scrollbar-width: thin;
677
- scrollbar-color: #FF9933 transparent;
678
- }
679
-
680
- .info-content::-webkit-scrollbar {
681
- width: 8px;
682
- }
683
-
684
- .info-content::-webkit-scrollbar-track {
685
- background: rgba(0, 0, 0, 0.05);
686
- border-radius: 4px;
687
- }
688
-
689
- .info-content::-webkit-scrollbar-thumb {
690
- background: linear-gradient(180deg, #FF9933, #B45309);
691
- border-radius: 4px;
692
- border: 1px solid rgba(255, 153, 51, 0.3);
693
- }
694
-
695
- .info-content::-webkit-scrollbar-thumb:hover {
696
- background: linear-gradient(180deg, #ffb366, #FF9933);
697
- }
698
-
699
- .state-name {
700
- font-size: 28px;
701
- font-weight: 800;
702
- margin-bottom: 16px;
703
- background: linear-gradient(135deg, #FF9933 0%, #B45309 50%, #138808 100%);
704
- -webkit-background-clip: text;
705
- -webkit-text-fill-color: transparent;
706
- background-clip: text;
707
- text-align: center;
708
- padding: 16px 0;
709
- border-bottom: 2px solid rgba(255, 153, 51, 0.3);
710
- position: relative;
711
- animation: fadeInUp 0.6s ease-out;
712
- letter-spacing: -0.5px;
713
- }
714
-
715
- .state-name::after {
716
- content: '';
717
- position: absolute;
718
- bottom: -2px;
719
- left: 50%;
720
- transform: translateX(-50%);
721
- width: 60px;
722
- height: 2px;
723
- background: linear-gradient(90deg, #FF9933, #138808);
724
- border-radius: 1px;
725
- }
726
-
727
- .state-stats {
728
- background: rgba(255, 255, 255, 0.8);
729
- backdrop-filter: blur(10px);
730
- padding: 24px;
731
- border-radius: 16px;
732
- margin: 24px 0;
733
- border: 1px solid rgba(255, 153, 51, 0.2);
734
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
735
- animation: fadeInUp 0.8s ease-out;
736
- position: relative;
737
- overflow: hidden;
738
- }
739
-
740
- .state-stats::before {
741
- content: '';
742
- position: absolute;
743
- top: 0;
744
- left: 0;
745
- right: 0;
746
- height: 3px;
747
- background: linear-gradient(90deg, #FF9933, #FFFFFF, #138808);
748
- border-radius: 16px 16px 0 0;
749
- }
750
-
751
- .stat-row {
752
- display: flex;
753
- justify-content: space-between;
754
- align-items: center;
755
- margin: 16px 0;
756
- font-size: 15px;
757
- padding: 12px 16px;
758
- background: rgba(255, 255, 255, 0.6);
759
- border-radius: 10px;
760
- border: 1px solid rgba(0, 0, 0, 0.08);
761
- transition: all 0.3s ease;
762
- position: relative;
763
- }
764
-
765
- .stat-row:hover {
766
- background: rgba(255, 255, 255, 0.8);
767
- transform: translateX(4px);
768
- border-color: rgba(255, 153, 51, 0.2);
769
- box-shadow: 0 4px 12px rgba(255, 153, 51, 0.1);
770
- }
771
-
772
- .stat-row::before {
773
- content: '';
774
- position: absolute;
775
- left: 0;
776
- top: 0;
777
- bottom: 0;
778
- width: 3px;
779
- background: linear-gradient(180deg, #FF9933, #138808);
780
- border-radius: 10px 0 0 10px;
781
- opacity: 0;
782
- transition: opacity 0.3s ease;
783
- }
784
-
785
- .stat-row:hover::before {
786
- opacity: 1;
787
- }
788
-
789
- .stat-label {
790
- font-weight: 600;
791
- color: #718096;
792
- font-size: 14px;
793
- }
794
-
795
- .stat-value {
796
- font-weight: 700;
797
- font-size: 16px;
798
- color: #2d3748;
799
- }
800
-
801
- .risk-badge {
802
- padding: 6px 12px;
803
- border-radius: 16px;
804
- font-size: 12px;
805
- font-weight: 700;
806
- text-transform: uppercase;
807
- letter-spacing: 0.5px;
808
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
809
- }
810
-
811
- .risk-high {
812
- background: linear-gradient(135deg, var(--high-risk), #b02a37);
813
- color: white;
814
- box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3);
815
- }
816
- .risk-medium {
817
- background: linear-gradient(135deg, var(--medium-risk), #e0a800);
818
- color: #1a1a1a;
819
- box-shadow: 0 2px 8px rgba(255, 193, 7, 0.3);
820
- }
821
- .risk-low {
822
- background: linear-gradient(135deg, var(--low-risk), var(--green-light));
823
- color: white;
824
- box-shadow: 0 2px 8px rgba(19, 136, 8, 0.3);
825
- }
826
-
827
- /* Enhanced news feed sections */
828
- .news-section {
829
- margin: 28px 0;
830
- animation: fadeInUp 1s ease-out;
831
- }
832
-
833
- .news-section h3 {
834
- font-size: 18px;
835
- font-weight: 700;
836
- margin-bottom: 16px;
837
- display: flex;
838
- align-items: center;
839
- gap: 12px;
840
- padding: 12px 0;
841
- border-bottom: 2px solid rgba(255, 255, 255, 0.1);
842
- }
843
-
844
- .news-feed {
845
- background: rgba(255, 255, 255, 0.8);
846
- backdrop-filter: blur(10px);
847
- border-radius: 16px;
848
- padding: 20px;
849
- border: 1px solid rgba(255, 153, 51, 0.2);
850
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
851
- max-height: 400px;
852
- overflow-y: auto;
853
- scrollbar-width: thin;
854
- scrollbar-color: rgba(255, 153, 51, 0.3) transparent;
855
- }
856
-
857
- .news-feed::-webkit-scrollbar {
858
- width: 6px;
859
- }
860
-
861
- .news-feed::-webkit-scrollbar-track {
862
- background: rgba(0, 0, 0, 0.05);
863
- border-radius: 3px;
864
- }
865
-
866
- .news-feed::-webkit-scrollbar-thumb {
867
- background: linear-gradient(180deg, #FF9933, #B45309);
868
- border-radius: 3px;
869
- }
870
-
871
- .misinformation-feed {
872
- border-left: 4px solid var(--high-risk);
873
- }
874
-
875
- .real-news-feed {
876
- border-left: 4px solid var(--green);
877
- }
878
-
879
- .trending-topics {
880
- margin: 16px 0;
881
- }
882
-
883
- .topic-tag {
884
- display: inline-block;
885
- background: rgba(59, 130, 246, 0.2);
886
- color: #60a5fa;
887
- padding: 4px 8px;
888
- border-radius: 12px;
889
- font-size: 12px;
890
- margin: 2px;
891
- }
892
-
893
- .recent-headlines {
894
- margin: 16px 0;
895
- }
896
-
897
- .headline {
898
- background: rgba(255,255,255,0.03);
899
- padding: 12px;
900
- border-radius: 6px;
901
- margin: 8px 0;
902
- border-left: 3px solid var(--accent);
903
- font-size: 13px;
904
- line-height: 1.4;
905
- }
906
-
907
- .legend {
908
- margin-top: 20px;
909
- padding: 16px;
910
- background: rgba(255,255,255,0.05);
911
- border-radius: 8px;
912
- }
913
-
914
- .legend h4 {
915
- margin: 0 0 12px 0;
916
- font-size: 16px;
917
- }
918
-
919
- .legend-item {
920
- display: flex;
921
- align-items: center;
922
- margin: 8px 0;
923
- font-size: 13px;
924
- }
925
-
926
- .legend-color {
927
- width: 20px;
928
- height: 20px;
929
- border-radius: 4px;
930
- margin-right: 10px;
931
- }
932
-
933
- /* Enhanced Map styling */
934
- .map-path {
935
- fill: #88A4BC;
936
- stroke: #ffffff;
937
- stroke-width: 0.8;
938
- transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
939
- cursor: pointer;
940
- filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
941
- }
942
-
943
- .map-path:hover {
944
- stroke-width: 2;
945
- stroke: var(--accent);
946
- filter:
947
- brightness(1.2)
948
- drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3))
949
- drop-shadow(0 0 12px rgba(255, 153, 51, 0.4));
950
- transform: scale(1.02);
951
- }
952
-
953
- .selected {
954
- stroke: var(--accent);
955
- stroke-width: 3;
956
- filter:
957
- brightness(1.3)
958
- drop-shadow(0 6px 12px rgba(0, 0, 0, 0.4))
959
- drop-shadow(0 0 20px rgba(255, 153, 51, 0.6));
960
- animation: glow 2s infinite;
961
- }
962
-
963
- .loading {
964
- text-align: center;
965
- padding: 40px;
966
- opacity: 0.7;
967
- }
968
-
969
- .error {
970
- color: #ff6b6b;
971
- text-align: center;
972
- padding: 20px;
973
- }
974
-
975
- /* Mobile Menu Overlay */
976
- .mobile-menu-overlay {
977
- position: fixed;
978
- top: 0;
979
- left: 0;
980
- width: 100%;
981
- height: 100%;
982
- background: linear-gradient(135deg, rgba(255, 153, 51, 0.98), rgba(232, 119, 34, 0.95));
983
- z-index: 9999;
984
- transform: translateX(-100%);
985
- transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
986
- display: flex;
987
- flex-direction: column;
988
- justify-content: space-between;
989
- padding: 80px 2rem 2rem;
990
- overflow-x: hidden;
991
- }
992
- .mobile-menu-overlay.active {
993
- transform: translateX(0);
994
- }
995
- .mobile-menu-top {
996
- display: flex;
997
- flex-direction: column;
998
- gap: 1rem;
999
- }
1000
- .mobile-menu-link {
1001
- font-family: 'Outfit', sans-serif;
1002
- font-size: 1.5rem;
1003
- font-weight: 700;
1004
- color: #fff;
1005
- text-decoration: none;
1006
- padding: 1rem 0;
1007
- border-bottom: 1px solid rgba(255, 255, 255, 0.2);
1008
- transition: all 0.3s ease;
1009
- width: 100%;
1010
- box-sizing: border-box;
1011
- display: block;
1012
- }
1013
- .mobile-menu-link:hover {
1014
- padding-left: 1rem;
1015
- background: rgba(255, 255, 255, 0.1);
1016
- }
1017
- .mobile-menu-bottom {
1018
- display: flex;
1019
- flex-direction: column;
1020
- gap: 1rem;
1021
- padding-top: 2rem;
1022
- border-top: 1px solid rgba(255, 255, 255, 0.2);
1023
- background: rgba(232, 119, 34, 0.3);
1024
- padding: 2rem;
1025
- margin: 0 -2rem -2rem -2rem;
1026
- }
1027
- .mobile-menu-brand {
1028
- font-family: 'Outfit', sans-serif;
1029
- font-size: 1.2rem;
1030
- font-weight: 800;
1031
- color: #fff;
1032
- display: flex;
1033
- align-items: center;
1034
- gap: 8px;
1035
- }
1036
- .mobile-menu-brand .logo-badge {
1037
- background: #fff;
1038
- color: var(--accent);
1039
- }
1040
- .mobile-menu-brand .logo-name {
1041
- color: #fff;
1042
- }
1043
- .mobile-menu-tags {
1044
- display: flex;
1045
- gap: 8px;
1046
- flex-wrap: wrap;
1047
- }
1048
- .menu-tag {
1049
- font-size: 0.7rem;
1050
- font-weight: 700;
1051
- padding: 4px 10px;
1052
- border-radius: 12px;
1053
- text-transform: uppercase;
1054
- }
1055
- .menu-tag.alpha {
1056
- background: rgba(255, 255, 255, 0.2);
1057
- color: #fff;
1058
- }
1059
- .menu-tag.live {
1060
- background: #fff;
1061
- color: var(--green);
1062
- animation: pulse 2s infinite;
1063
- }
1064
- </style>
1065
- </head>
1066
- <body>
1067
- <div class="main-layout">
1068
- <!-- Navigation Bar -->
1069
- <nav class="navbar" id="navbar">
1070
- <div class="nav-inner">
1071
- <a href="/" class="logo">
1072
- <span class="logo-badge">IN</span>
1073
- <span>Misinformation</span><span class="logo-name">&nbsp;Heatmap</span>
1074
- </a>
1075
- <div class="nav-links">
1076
- <a href="/" class="nav-link">Home</a>
1077
- <a href="/dashboard" class="nav-link">Dashboard</a>
1078
- <a href="/map/enhanced-india-heatmap.html" class="nav-link active">Heatmap</a>
1079
- </div>
1080
- <div class="status-pill">
1081
- <span class="status-dot"></span>
1082
- <span id="nav-status">System Ready</span>
1083
- </div>
1084
- <button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle menu">
1085
- <span class="hamburger-line"></span>
1086
- <span class="hamburger-line"></span>
1087
- <span class="hamburger-line"></span>
1088
- </button>
1089
- </div>
1090
- </nav>
1091
-
1092
- <!-- Mobile Menu Overlay -->
1093
- <div class="mobile-menu-overlay" id="mobile-menu">
1094
- <div class="mobile-menu-top">
1095
- <a href="/" class="mobile-menu-link">Home</a>
1096
- <a href="/dashboard" class="mobile-menu-link">Dashboard</a>
1097
- <a href="/map/enhanced-india-heatmap.html" class="mobile-menu-link">Heatmap</a>
1098
- </div>
1099
- <div class="mobile-menu-bottom">
1100
- <div class="mobile-menu-brand">
1101
- <span class="logo-badge">IN</span>
1102
- <span>Misinformation</span><span class="logo-name">&nbsp;Heatmap</span>
1103
- </div>
1104
- <div class="mobile-menu-tags">
1105
- <span class="menu-tag alpha">Alpha v1.0</span>
1106
- <span class="menu-tag live">● Live</span>
1107
- </div>
1108
- </div>
1109
- </div>
1110
-
1111
- <!-- Content Area - ONE unified container -->
1112
- <div class="content-area">
1113
- <!-- Map Section (Left Half) -->
1114
- <div class="map-section">
1115
- <div class="map-container">
1116
- <div class="zoom-controls">
1117
- <button class="zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
1118
- <div class="zoom-level" id="zoom-level">100%</div>
1119
- <button class="zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
1120
- <button class="zoom-btn" onclick="resetZoom()" title="Reset View">⌂</button>
1121
- </div>
1122
- <div id="svgroot">
1123
- <div class="loading" id="loading-screen">
1124
- <div class="loading-spinner"></div>
1125
- <div class="loading-text">🗺️ Loading Enhanced India Map...</div>
1126
- <div class="loading-subtext">Fetching real-time misinformation data</div>
1127
- <div class="loading-progress">
1128
- <div class="progress-bar" id="progress-bar"></div>
1129
- </div>
1130
- </div>
1131
- </div>
1132
- <div id="tooltip"></div>
1133
- </div>
1134
- </div>
1135
-
1136
- <!-- Information Section (Right Half) -->
1137
- <div class="info-section">
1138
- <div class="info-content">
1139
- <!-- Status Bar -->
1140
- <div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 20px; padding: 16px; background: rgba(255,255,255,0.8); border-radius: 8px; border: 1px solid rgba(255, 153, 51, 0.2); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);">
1141
- <div style="text-align: center; padding: 8px;">
1142
- <div style="font-weight: 600; color: #FF9933; font-size: 18px;" id="total-events">Loading...</div>
1143
- <div style="opacity: 0.7; font-size: 12px; color: #718096;">Total Events</div>
1144
- </div>
1145
- <div style="text-align: center; padding: 8px;">
1146
- <div style="font-weight: 600; color: #138808; font-size: 18px;" id="active-states">Loading...</div>
1147
- <div style="opacity: 0.7; font-size: 12px; color: #718096;">Active States</div>
1148
- </div>
1149
- <div style="text-align: center; padding: 8px;">
1150
- <div style="font-weight: 600; color: #FF9933; font-size: 14px;" id="last-update">Loading...</div>
1151
- <div style="opacity: 0.7; font-size: 12px; color: #718096;">Last Update</div>
1152
- </div>
1153
- <div style="text-align: center; padding: 8px;">
1154
- <div style="font-weight: 600; color: #138808; font-size: 18px;" id="classification-accuracy">Loading...</div>
1155
- <div style="opacity: 0.7; font-size: 12px; color: #718096;">Accuracy</div>
1156
- </div>
1157
- </div>
1158
-
1159
- <div class="state-name" id="stateName">Select a State</div>
1160
- <div id="stateContent">
1161
- <div style="margin-top: 20px;">
1162
- <h4 style="font-size: 14px; margin-bottom: 10px; color: #667eea;">Risk Levels</h4>
1163
- <div style="display: flex; flex-direction: column; gap: 8px; font-size: 12px;">
1164
- <div style="display: flex; align-items: center; gap: 8px;">
1165
- <div style="width: 16px; height: 16px; background: #dc3545; border-radius: 2px;"></div>
1166
- <span>High Risk (≥60%)</span>
1167
- </div>
1168
- <div style="display: flex; align-items: center; gap: 8px;">
1169
- <div style="width: 16px; height: 16px; background: #ffc107; border-radius: 2px;"></div>
1170
- <span>Medium Risk (40-59%)</span>
1171
- </div>
1172
- <div style="display: flex; align-items: center; gap: 8px;">
1173
- <div style="width: 16px; height: 16px; background: #28a745; border-radius: 2px;"></div>
1174
- <span>Low Risk (<40%)</span>
1175
- </div>
1176
- </div>
1177
- </div>
1178
-
1179
- <div style="text-align: center; opacity: 0.7; margin-top: 20px; font-size: 12px;">
1180
- Click on a state to view detailed fake news analysis
1181
- </div>
1182
-
1183
- <div style="margin-top: 24px; border-top: 1px solid var(--border-accent); padding-top: 24px;">
1184
- <div class="news-section">
1185
- <h3 style="color: var(--high-risk);">
1186
- <span style="width: 12px; height: 12px; background: var(--high-risk); border-radius: 50%; animation: glow 2s infinite;"></span>
1187
- 🚨 Misinformation Alerts
1188
- </h3>
1189
- <div id="misinformation-feed" class="news-feed misinformation-feed">
1190
- <div style="opacity: 0.7; text-align: center; padding: 40px 20px;">
1191
- <div style="font-size: 32px; margin-bottom: 16px;">🚨</div>
1192
- <div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">Loading misinformation alerts...</div>
1193
- <div style="font-size: 14px; color: var(--text-muted);">Scanning for fake news across India</div>
1194
- </div>
1195
- </div>
1196
- </div>
1197
-
1198
- <div class="news-section">
1199
- <h3 style="color: var(--green);">
1200
- <span style="width: 12px; height: 12px; background: var(--green); border-radius: 50%; animation: glow 2s infinite;"></span>
1201
- ✅ Verified Real News
1202
- </h3>
1203
- <div id="real-news-feed" class="news-feed real-news-feed">
1204
- <div style="opacity: 0.7; text-align: center; padding: 40px 20px;">
1205
- <div style="font-size: 32px; margin-bottom: 16px;">📰</div>
1206
- <div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">Loading verified news...</div>
1207
- <div style="font-size: 14px; color: var(--text-muted);">Fetching latest authentic updates</div>
1208
- </div>
1209
- </div>
1210
- </div>
1211
- </div>
1212
- </div>
1213
- </div>
1214
- </div>
1215
- </div>
1216
- </div>
1217
-
1218
-
1219
- <script>
1220
- // ── API Configuration ──
1221
- const API_BASE = window.location.hostname.includes('netlify.app')
1222
- ? 'https://ndg07-heatmap.hf.space'
1223
- : '';
1224
-
1225
- // ── Hamburger menu toggle ──
1226
- const hamburgerBtn = document.getElementById('hamburger-btn');
1227
- const mobileMenu = document.getElementById('mobile-menu');
1228
-
1229
- hamburgerBtn.addEventListener('click', () => {
1230
- hamburgerBtn.classList.toggle('active');
1231
- mobileMenu.classList.toggle('active');
1232
- document.body.style.overflow = mobileMenu.classList.contains('active') ? 'hidden' : '';
1233
- });
1234
-
1235
- // Close menu when clicking on a link
1236
- document.querySelectorAll('.mobile-menu-link').forEach(link => {
1237
- link.addEventListener('click', () => {
1238
- hamburgerBtn.classList.remove('active');
1239
- mobileMenu.classList.remove('active');
1240
- document.body.style.overflow = '';
1241
- });
1242
- });
1243
-
1244
- // Close menu when clicking outside
1245
- mobileMenu.addEventListener('click', (e) => {
1246
- if (e.target === mobileMenu) {
1247
- hamburgerBtn.classList.remove('active');
1248
- mobileMenu.classList.remove('active');
1249
- document.body.style.overflow = '';
1250
- }
1251
- });
1252
-
1253
- // Global variables
1254
- let heatmapData = [];
1255
- let selectedState = null;
1256
- let svgMap = null;
1257
-
1258
- // Caching variables for performance
1259
- let dataCache = new Map();
1260
- let lastDataFetch = 0;
1261
- const CACHE_DURATION = 30000; // 30 seconds cache
1262
-
1263
- // Zoom and pan variables
1264
- let currentZoom = 1;
1265
- let currentX = 0;
1266
- let currentY = 0;
1267
- let isDragging = false;
1268
- let lastMouseX = 0;
1269
- let lastMouseY = 0;
1270
- const minZoom = 0.6;
1271
- const maxZoom = 4;
1272
- const zoomStep = 0.15;
1273
-
1274
- // Map bounds for consistent positioning
1275
- let mapBounds = {
1276
- width: 0,
1277
- height: 0,
1278
- centerX: 0,
1279
- centerY: 0
1280
- };
1281
-
1282
- // Initialize the enhanced heatmap
1283
- async function initializeEnhancedHeatmap() {
1284
- try {
1285
- // Load SVG first (map structure needed before colours)
1286
- await loadSVGMap();
1287
-
1288
- // Fire all data fetches in parallel — no sequential waiting
1289
- await Promise.all([
1290
- loadHeatmapData(),
1291
- updateStatusBar(),
1292
- updateLiveFeeds(),
1293
- ]);
1294
-
1295
- // Optimized refresh intervals for better performance
1296
- setInterval(async () => {
1297
- await loadHeatmapData();
1298
- }, 60000); // Update map every 60 seconds (less frequent)
1299
-
1300
- setInterval(async () => {
1301
- await updateStatusBar();
1302
- }, 30000); // Update status every 30 seconds
1303
-
1304
- setInterval(updateLiveFeeds, 15000); // Update live feeds every 15 seconds (less frequent)
1305
-
1306
- } catch (error) {
1307
- console.error('Failed to initialize heatmap:', error);
1308
- // Hide loading screen and show map without data
1309
- const loadingScreen = document.getElementById('loading-screen');
1310
- if (loadingScreen) {
1311
- loadingScreen.style.display = 'none';
1312
- }
1313
- }
1314
- }
1315
-
1316
- async function loadSVGMap() {
1317
- try {
1318
- const response = await fetch('./in.svg');
1319
- const svgText = await response.text();
1320
-
1321
- const svgRoot = document.getElementById('svgroot');
1322
- svgRoot.innerHTML = svgText;
1323
-
1324
- svgMap = svgRoot.querySelector('svg');
1325
- if (svgMap) {
1326
- svgMap.style.width = '100%';
1327
- svgMap.style.height = '100%';
1328
-
1329
- // Add click handlers to all paths
1330
- const paths = svgMap.querySelectorAll('path');
1331
- console.log(`SVG loaded successfully with ${paths.length} paths`);
1332
-
1333
- paths.forEach(path => {
1334
- path.classList.add('map-path');
1335
- path.addEventListener('click', handleStateClick);
1336
- path.addEventListener('mouseenter', handleStateHover);
1337
- path.addEventListener('mouseleave', handleStateLeave);
1338
- });
1339
-
1340
- // Initialize map bounds and setup zoom/pan
1341
- initializeMapBounds();
1342
- setupZoomAndPan();
1343
- centerMap();
1344
- } else {
1345
- console.error('SVG element not found in loaded content');
1346
- }
1347
- } catch (error) {
1348
- console.error('Failed to load SVG map:', error);
1349
- throw error;
1350
- }
1351
- }
1352
-
1353
- async function loadHeatmapData() {
1354
- try {
1355
- const now = Date.now();
1356
-
1357
- // Check cache first
1358
- if (dataCache.has('heatmap') && (now - lastDataFetch) < CACHE_DURATION) {
1359
- heatmapData = dataCache.get('heatmap');
1360
- applyHeatmapColors();
1361
- return;
1362
- }
1363
-
1364
- // Show loading progress
1365
- updateLoadingProgress(30);
1366
-
1367
- const response = await fetch(API_BASE + '/api/v1/heatmap/data');
1368
- const data = await response.json();
1369
-
1370
- updateLoadingProgress(70);
1371
-
1372
- heatmapData = data.heatmap_data || [];
1373
-
1374
- // Cache the data
1375
- dataCache.set('heatmap', heatmapData);
1376
- lastDataFetch = now;
1377
-
1378
- updateLoadingProgress(90);
1379
-
1380
- // Apply colors to map
1381
- applyHeatmapColors();
1382
-
1383
- updateLoadingProgress(100);
1384
-
1385
- // Hide loading screen after first load
1386
- const loadingScreen = document.getElementById('loading-screen');
1387
- if (loadingScreen) {
1388
- loadingScreen.style.display = 'none';
1389
- }
1390
-
1391
- } catch (error) {
1392
- console.error('Failed to load heatmap data:', error);
1393
- }
1394
- }
1395
-
1396
- function updateLoadingProgress(percent) {
1397
- const progressBar = document.getElementById('progress-bar');
1398
- if (progressBar) {
1399
- progressBar.style.width = percent + '%';
1400
- }
1401
- }
1402
-
1403
- function applyHeatmapColors() {
1404
- if (!svgMap || !heatmapData.length) {
1405
- console.log('Cannot apply colors:', { svgMap: !!svgMap, dataLength: heatmapData.length });
1406
- return;
1407
- }
1408
-
1409
- const paths = svgMap.querySelectorAll('path');
1410
- console.log(`Applying colors to ${paths.length} paths with ${heatmapData.length} data points`);
1411
-
1412
- let matchedCount = 0;
1413
- let unmatchedPaths = [];
1414
-
1415
- paths.forEach((path, index) => {
1416
- const stateData = findStateData(path);
1417
-
1418
- if (stateData) {
1419
- const color = getColorForRisk(stateData.fake_probability, stateData.risk_level);
1420
- path.style.fill = color;
1421
- path.style.stroke = '#ffffff';
1422
- path.style.strokeWidth = '0.5';
1423
- path.setAttribute('data-state', stateData.state);
1424
- path.setAttribute('data-events', stateData.event_count);
1425
- path.setAttribute('data-risk', stateData.risk_level);
1426
-
1427
- // Debug logging for first few matches
1428
- if (index < 5) {
1429
- console.log(`State: ${stateData.state}, Fake Ratio: ${(stateData.fake_probability * 100).toFixed(2)}%, Risk: ${stateData.risk_level}, Color: ${color}`);
1430
- }
1431
-
1432
- matchedCount++;
1433
- } else {
1434
- path.style.fill = '#88A4BC'; // Default color for unmatched states
1435
- path.style.stroke = '#ffffff';
1436
- path.style.strokeWidth = '0.5';
1437
- const pathName = path.getAttribute('name') || path.id || 'unknown';
1438
- unmatchedPaths.push(pathName);
1439
- }
1440
- });
1441
-
1442
- console.log(`Matched ${matchedCount} states, unmatched: ${unmatchedPaths.length}`);
1443
- if (unmatchedPaths.length > 0) {
1444
- console.log('Unmatched paths:', unmatchedPaths);
1445
- }
1446
- }
1447
-
1448
- // State name mapping for better matching
1449
- const stateNameMapping = {
1450
- 'andaman and nicobar': 'Andaman and Nicobar Islands',
1451
- 'andhra pradesh': 'Andhra Pradesh',
1452
- 'arunachal pradesh': 'Arunachal Pradesh',
1453
- 'assam': 'Assam',
1454
- 'bihar': 'Bihar',
1455
- 'chandigarh': 'Chandigarh',
1456
- 'chhattisgarh': 'Chhattisgarh',
1457
- 'dādra and nagar haveli and damān and diu': 'Dadra and Nagar Haveli and Daman and Diu',
1458
- 'delhi': 'Delhi',
1459
- 'goa': 'Goa',
1460
- 'gujarat': 'Gujarat',
1461
- 'haryana': 'Haryana',
1462
- 'himachal pradesh': 'Himachal Pradesh',
1463
- 'jharkhand': 'Jharkhand',
1464
- 'karnataka': 'Karnataka',
1465
- 'kerala': 'Kerala',
1466
- 'lakshadweep': 'Lakshadweep',
1467
- 'madhya pradesh': 'Madhya Pradesh',
1468
- 'maharashtra': 'Maharashtra',
1469
- 'manipur': 'Manipur',
1470
- 'meghalaya': 'Meghalaya',
1471
- 'mizoram': 'Mizoram',
1472
- 'nagaland': 'Nagaland',
1473
- 'orissa': 'Odisha',
1474
- 'punjab': 'Punjab',
1475
- 'puducherry': 'Puducherry',
1476
- 'rajasthan': 'Rajasthan',
1477
- 'sikkim': 'Sikkim',
1478
- 'tamil nadu': 'Tamil Nadu',
1479
- 'telangana': 'Telangana',
1480
- 'tripura': 'Tripura',
1481
- 'uttar pradesh': 'Uttar Pradesh',
1482
- 'uttaranchal': 'Uttarakhand',
1483
- 'west bengal': 'West Bengal',
1484
- 'jammu and kashmir': 'Jammu and Kashmir',
1485
- 'ladakh': 'Ladakh'
1486
- };
1487
-
1488
- function findStateData(pathElement) {
1489
- // Try to match state by various attributes
1490
- const pathId = pathElement.id;
1491
- const pathTitle = pathElement.getAttribute('title');
1492
- const pathName = pathElement.getAttribute('name');
1493
-
1494
- // Get the SVG state name (usually from name attribute)
1495
- let svgStateName = pathName || pathTitle || pathId;
1496
- if (!svgStateName) return null;
1497
-
1498
- svgStateName = svgStateName.toLowerCase();
1499
-
1500
- // Try direct mapping first
1501
- const mappedName = stateNameMapping[svgStateName];
1502
- if (mappedName) {
1503
- const found = heatmapData.find(state => state.state === mappedName);
1504
- if (found) return found;
1505
- }
1506
-
1507
- // Fallback to fuzzy matching
1508
- return heatmapData.find(state => {
1509
- const stateName = state.state.toLowerCase();
1510
- return (
1511
- svgStateName.includes(stateName) ||
1512
- stateName.includes(svgStateName) ||
1513
- svgStateName.replace(/\s+/g, '') === stateName.replace(/\s+/g, '') ||
1514
- // Handle common variations
1515
- (svgStateName === 'orissa' && stateName === 'odisha') ||
1516
- (svgStateName === 'uttaranchal' && stateName === 'uttarakhand')
1517
- );
1518
- });
1519
- }
1520
-
1521
- function getColorForRisk(fakeRatio, riskLevel) {
1522
- // Use actual fake news ratios for meaningful colors
1523
- if (riskLevel === 'insufficient_data') {
1524
- return '#88A4BC'; // Gray for insufficient data
1525
- }
1526
-
1527
- if (fakeRatio > 0.1) return '#dc3545'; // High risk - red (>10% fake)
1528
- if (fakeRatio > 0.05) return '#ff6b35'; // Medium risk - orange (5-10% fake)
1529
- if (fakeRatio > 0.02) return '#ffc107'; // Low-medium risk - yellow (2-5% fake)
1530
- if (fakeRatio > 0.001) return '#FF9933'; // Low risk - saffron (0.1-2% fake)
1531
- return '#138808'; // Very low risk - green (<0.1% fake)
1532
- }
1533
-
1534
- function handleStateClick(event) {
1535
- const path = event.target;
1536
- const stateData = findStateData(path);
1537
-
1538
- if (stateData) {
1539
- // Remove previous selection
1540
- svgMap.querySelectorAll('.selected').forEach(p => p.classList.remove('selected'));
1541
-
1542
- // Select current state
1543
- path.classList.add('selected');
1544
- selectedState = stateData.state;
1545
-
1546
- // Show state details
1547
- showStateDetails(stateData);
1548
-
1549
- // Load state-specific events
1550
- loadStateEvents(stateData.state);
1551
- }
1552
- }
1553
-
1554
- function handleStateHover(event) {
1555
- const path = event.target;
1556
- const stateData = findStateData(path);
1557
-
1558
- // Get state name from path
1559
- const pathName = path.getAttribute('name') || path.getAttribute('title') || path.id || 'Unknown';
1560
-
1561
- // Show tooltip with or without data
1562
- showTooltip(event, stateData || { state: pathName, event_count: 0, fake_count: 0, real_count: 0 });
1563
- }
1564
-
1565
- function handleStateLeave(event) {
1566
- hideTooltip();
1567
- }
1568
-
1569
- function showTooltip(event, stateData) {
1570
- let tooltip = document.getElementById('tooltip');
1571
-
1572
- // Create tooltip if it doesn't exist
1573
- if (!tooltip) {
1574
- tooltip = document.createElement('div');
1575
- tooltip.id = 'tooltip';
1576
- tooltip.style.cssText = `
1577
- position: absolute;
1578
- background: rgba(0, 0, 0, 0.9);
1579
- color: white;
1580
- padding: 10px;
1581
- border-radius: 6px;
1582
- font-size: 12px;
1583
- z-index: 1000;
1584
- pointer-events: none;
1585
- max-width: 200px;
1586
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1587
- `;
1588
- document.body.appendChild(tooltip);
1589
- }
1590
-
1591
- const fakePercentage = (stateData.fake_probability * 100).toFixed(2);
1592
- const aiConfidence = stateData.ai_confidence ? (stateData.ai_confidence * 100).toFixed(1) : 'N/A';
1593
-
1594
- // Check if state has data
1595
- const hasData = stateData && (stateData.event_count > 0 || stateData.fake_count > 0 || stateData.real_count > 0);
1596
-
1597
- if (!hasData) {
1598
- tooltip.innerHTML = `
1599
- <div style="font-weight: bold; margin-bottom: 4px;">${stateData.state}</div>
1600
- <div style="color: #888;">Currently no data available for this state</div>
1601
- `;
1602
- } else {
1603
- tooltip.innerHTML = `
1604
- <div style="font-weight: bold; margin-bottom: 4px;">${stateData.state}</div>
1605
- <div>Total Events: ${stateData.event_count || 0}</div>
1606
- <div>Fake News: ${stateData.fake_count || 0} (${fakePercentage}%)</div>
1607
- <div>Real News: ${stateData.real_count || 0}</div>
1608
- <div>AI Confidence: ${aiConfidence}%</div>
1609
- <div>Risk Level: <span style="color: ${stateData.risk_level === 'high' ? '#dc3545' : stateData.risk_level === 'medium' ? '#ffc107' : '#138808'};">${stateData.risk_level || 'low'}</span></div>
1610
- `;
1611
- }
1612
-
1613
- tooltip.style.display = 'block';
1614
- tooltip.style.left = event.clientX + 15 + 'px';
1615
- tooltip.style.top = event.clientY - 15 + 'px';
1616
- }
1617
-
1618
- function hideTooltip() {
1619
- const tooltip = document.getElementById('tooltip');
1620
- if (tooltip) {
1621
- tooltip.style.display = 'none';
1622
- }
1623
- }
1624
-
1625
- // Test Colors Debug Function - Toggle between colored and default
1626
- let isTestColorsActive = false;
1627
- let originalColors = new Map();
1628
-
1629
- function testMapColors() {
1630
- if (!svgMap) {
1631
- console.log('SVG map not loaded yet');
1632
- return;
1633
- }
1634
-
1635
- const paths = svgMap.querySelectorAll('path');
1636
-
1637
- if (!isTestColorsActive) {
1638
- // First click - save original colors and apply test colors
1639
- paths.forEach(path => {
1640
- originalColors.set(path, {
1641
- fill: path.style.fill || getComputedStyle(path).fill,
1642
- stroke: path.style.stroke || getComputedStyle(path).stroke,
1643
- strokeWidth: path.style.strokeWidth || getComputedStyle(path).strokeWidth
1644
- });
1645
-
1646
- // Apply different colors to test
1647
- const colors = ['#dc3545', '#ff6b35', '#FF9933', '#ffc107', '#138808'];
1648
- const pathIndex = Array.from(paths).indexOf(path);
1649
- const color = colors[pathIndex % colors.length];
1650
- path.style.fill = color;
1651
- path.style.stroke = '#ffffff';
1652
- path.style.strokeWidth = '1';
1653
- });
1654
-
1655
- isTestColorsActive = true;
1656
- console.log('Test colors applied');
1657
- } else {
1658
- // Second click - revert to original colors
1659
- paths.forEach(path => {
1660
- const original = originalColors.get(path);
1661
- if (original) {
1662
- path.style.fill = original.fill;
1663
- path.style.stroke = original.stroke;
1664
- path.style.strokeWidth = original.strokeWidth;
1665
- }
1666
- });
1667
-
1668
- isTestColorsActive = false;
1669
- originalColors.clear();
1670
- console.log('Original colors restored');
1671
- }
1672
- }
1673
-
1674
- // Zoom and Pan Functions
1675
- function initializeMapBounds() {
1676
- if (!svgMap) return;
1677
-
1678
- const svgRoot = document.getElementById('svgroot');
1679
- const rect = svgRoot.getBoundingClientRect();
1680
-
1681
- mapBounds = {
1682
- width: rect.width,
1683
- height: rect.height,
1684
- centerX: rect.width / 2,
1685
- centerY: rect.height / 2
1686
- };
1687
-
1688
- console.log('Map bounds initialized:', mapBounds);
1689
- }
1690
-
1691
- function setupZoomAndPan() {
1692
- const svgRoot = document.getElementById('svgroot');
1693
- if (!svgRoot || !svgMap) return;
1694
-
1695
- // Mouse wheel zoom
1696
- svgRoot.addEventListener('wheel', (e) => {
1697
- e.preventDefault();
1698
- const delta = e.deltaY > 0 ? -zoomStep : zoomStep;
1699
- const newZoom = Math.max(minZoom, Math.min(maxZoom, currentZoom + delta));
1700
-
1701
- // Calculate zoom position relative to mouse
1702
- const rect = svgRoot.getBoundingClientRect();
1703
- const mouseX = e.clientX - rect.left;
1704
- const mouseY = e.clientY - rect.top;
1705
-
1706
- const zoomRatio = newZoom / currentZoom;
1707
- currentX = mouseX - (mouseX - currentX) * zoomRatio;
1708
- currentY = mouseY - (mouseY - currentY) * zoomRatio;
1709
- currentZoom = newZoom;
1710
-
1711
- updateTransform();
1712
- updateZoomLevel();
1713
- }, { passive: false });
1714
-
1715
- // Mouse drag pan
1716
- svgRoot.addEventListener('mousedown', (e) => {
1717
- if (e.target !== svgRoot && !e.target.closest('svg')) return;
1718
- isDragging = true;
1719
- lastMouseX = e.clientX;
1720
- lastMouseY = e.clientY;
1721
- svgRoot.style.cursor = 'grabbing';
1722
- });
1723
-
1724
- document.addEventListener('mousemove', (e) => {
1725
- if (!isDragging) return;
1726
-
1727
- const dx = e.clientX - lastMouseX;
1728
- const dy = e.clientY - lastMouseY;
1729
-
1730
- currentX += dx;
1731
- currentY += dy;
1732
-
1733
- lastMouseX = e.clientX;
1734
- lastMouseY = e.clientY;
1735
-
1736
- updateTransform();
1737
- });
1738
-
1739
- document.addEventListener('mouseup', () => {
1740
- isDragging = false;
1741
- if (svgRoot) svgRoot.style.cursor = 'grab';
1742
- });
1743
-
1744
- // Touch events for mobile
1745
- let touchStartDistance = 0;
1746
- let touchStartZoom = 1;
1747
-
1748
- svgRoot.addEventListener('touchstart', (e) => {
1749
- if (e.touches.length === 2) {
1750
- touchStartDistance = getTouchDistance(e.touches);
1751
- touchStartZoom = currentZoom;
1752
- } else if (e.touches.length === 1) {
1753
- isDragging = true;
1754
- lastMouseX = e.touches[0].clientX;
1755
- lastMouseY = e.touches[0].clientY;
1756
- }
1757
- });
1758
-
1759
- svgRoot.addEventListener('touchmove', (e) => {
1760
- e.preventDefault();
1761
-
1762
- if (e.touches.length === 2) {
1763
- const distance = getTouchDistance(e.touches);
1764
- const zoomRatio = distance / touchStartDistance;
1765
- currentZoom = Math.max(minZoom, Math.min(maxZoom, touchStartZoom * zoomRatio));
1766
- updateTransform();
1767
- updateZoomLevel();
1768
- } else if (e.touches.length === 1 && isDragging) {
1769
- const dx = e.touches[0].clientX - lastMouseX;
1770
- const dy = e.touches[0].clientY - lastMouseY;
1771
-
1772
- currentX += dx;
1773
- currentY += dy;
1774
-
1775
- lastMouseX = e.touches[0].clientX;
1776
- lastMouseY = e.touches[0].clientY;
1777
-
1778
- updateTransform();
1779
- }
1780
- }, { passive: false });
1781
-
1782
- svgRoot.addEventListener('touchend', () => {
1783
- isDragging = false;
1784
- });
1785
-
1786
- console.log('Zoom and pan setup complete');
1787
- }
1788
-
1789
- function getTouchDistance(touches) {
1790
- const dx = touches[0].clientX - touches[1].clientX;
1791
- const dy = touches[0].clientY - touches[1].clientY;
1792
- return Math.sqrt(dx * dx + dy * dy);
1793
- }
1794
-
1795
- function updateTransform() {
1796
- if (!svgMap) return;
1797
- svgMap.style.transform = `translate(calc(-50% + ${currentX}px), calc(-50% + ${currentY}px)) scale(${currentZoom})`;
1798
- }
1799
-
1800
- function updateZoomLevel() {
1801
- const zoomLevelEl = document.getElementById('zoom-level');
1802
- if (zoomLevelEl) {
1803
- zoomLevelEl.textContent = Math.round(currentZoom * 100) + '%';
1804
- }
1805
- }
1806
-
1807
- function zoomIn() {
1808
- currentZoom = Math.min(maxZoom, currentZoom + zoomStep);
1809
- updateTransform();
1810
- updateZoomLevel();
1811
- }
1812
-
1813
- function zoomOut() {
1814
- currentZoom = Math.max(minZoom, currentZoom - zoomStep);
1815
- updateTransform();
1816
- updateZoomLevel();
1817
- }
1818
-
1819
- function resetZoom() {
1820
- currentZoom = 1;
1821
- currentX = 0;
1822
- currentY = 0;
1823
- updateTransform();
1824
- updateZoomLevel();
1825
- }
1826
-
1827
- function centerMap() {
1828
- resetZoom();
1829
- }
1830
-
1831
- // Add test colors button to zoom controls
1832
- function addTestColorsButton() {
1833
- const zoomControls = document.querySelector('.zoom-controls');
1834
- if (zoomControls && !document.querySelector('.test-colors-btn')) {
1835
- const testButton = document.createElement('button');
1836
- testButton.className = 'zoom-btn test-colors-btn';
1837
- testButton.onclick = testMapColors;
1838
- testButton.title = 'Test Colors';
1839
- testButton.textContent = '🎨';
1840
- zoomControls.appendChild(testButton);
1841
- }
1842
- }
1843
-
1844
- // ── Update Status Bar from /api/v1/stats ──────────────────────────────
1845
- async function updateStatusBar() {
1846
- try {
1847
- const ctrl = new AbortController();
1848
- const timer = setTimeout(() => ctrl.abort(), 5000);
1849
- const res = await fetch(API_BASE + '/api/v1/stats', { signal: ctrl.signal });
1850
- clearTimeout(timer);
1851
- const data = await res.json();
1852
-
1853
- const setText = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
1854
-
1855
- setText('total-events', (data.total_events || 0).toLocaleString('en-IN'));
1856
- setText('active-states', (data.total_states || 0) + ' States');
1857
- const lastUpd = data.last_updated
1858
- ? new Date(data.last_updated).toLocaleTimeString('en-IN', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Kolkata' }) + ' IST'
1859
- : 'Just now';
1860
- setText('last-update', lastUpd);
1861
- setText('classification-accuracy', ((data.classification_accuracy || 0) * 100).toFixed(1) + '%');
1862
-
1863
- const navStatus = document.getElementById('nav-status');
1864
- if (navStatus) navStatus.textContent = data.processing_active ? 'Live — Ingesting' : 'System Ready';
1865
- } catch (err) {
1866
- console.warn('updateStatusBar failed:', err.message);
1867
- }
1868
- }
1869
-
1870
- // ── Update Misinformation & Real News feeds ────────────────────────────
1871
- async function updateLiveFeeds() {
1872
- try {
1873
- const ctrl = new AbortController();
1874
- const timer = setTimeout(() => ctrl.abort(), 5000);
1875
- const res = await fetch(API_BASE + '/api/v1/events/live?limit=20', { signal: ctrl.signal });
1876
- clearTimeout(timer);
1877
- const data = await res.json();
1878
- const events = data.events || [];
1879
-
1880
- const fake = events.filter(e => e.classification === 'fake');
1881
- const real = events.filter(e => e.classification === 'real');
1882
-
1883
- renderFeed('misinformation-feed', fake, 'fake');
1884
- renderFeed('real-news-feed', real, 'real');
1885
- } catch (err) {
1886
- console.warn('updateLiveFeeds failed:', err.message);
1887
- }
1888
- }
1889
-
1890
- function renderFeed(containerId, items, type) {
1891
- const el = document.getElementById(containerId);
1892
- if (!el) return;
1893
- if (!items.length) {
1894
- el.innerHTML = `<div style="opacity:0.6;text-align:center;padding:30px 20px;font-size:13px;">No ${type === 'fake' ? 'misinformation' : 'verified'} events found yet.</div>`;
1895
- return;
1896
- }
1897
- const accent = type === 'fake' ? '#dc3545' : '#138808';
1898
- el.innerHTML = items.map(ev => `
1899
- <div style="padding:12px;border-radius:8px;margin-bottom:10px;background:rgba(255,255,255,0.6);border-left:3px solid ${accent};font-size:13px;line-height:1.4;">
1900
- <div style="font-weight:600;color:#2d3748;margin-bottom:4px;">${ev.title || 'Untitled'}</div>
1901
- <div style="color:#718096;font-size:11px;">${ev.state || 'India'} · ${ev.source || 'Unknown'} · ${ev.timestamp ? new Date(ev.timestamp).toLocaleTimeString('en-IN', {hour:'2-digit',minute:'2-digit'}) : ''}</div>
1902
- </div>`).join('');
1903
- }
1904
-
1905
- // ── Load state-specific events on map click ────────────────────────────
1906
- async function loadStateEvents(stateName) {
1907
- try {
1908
- const ctrl = new AbortController();
1909
- const timer = setTimeout(() => ctrl.abort(), 5000);
1910
- const res = await fetch(`${API_BASE}/api/v1/events/live?limit=40`, { signal: ctrl.signal });
1911
- clearTimeout(timer);
1912
- const data = await res.json();
1913
- const all = data.events || [];
1914
- const stateEvents = all.filter(e =>
1915
- (e.state || '').toLowerCase() === stateName.toLowerCase() ||
1916
- (e.state || '').toLowerCase() === 'national'
1917
- );
1918
- const fake = stateEvents.filter(e => e.classification === 'fake');
1919
- const real = stateEvents.filter(e => e.classification === 'real');
1920
- renderFeed('misinformation-feed', fake.length ? fake : all.filter(e => e.classification === 'fake').slice(0, 5), 'fake');
1921
- renderFeed('real-news-feed', real.length ? real : all.filter(e => e.classification === 'real').slice(0, 5), 'real');
1922
- } catch (err) {
1923
- console.warn('loadStateEvents failed:', err.message);
1924
- }
1925
- }
1926
-
1927
- // ── Show state details in the info panel ──────────────────────────────
1928
- function showStateDetails(stateData) {
1929
- const nameEl = document.getElementById('stateName');
1930
- if (nameEl) nameEl.textContent = stateData.state;
1931
-
1932
- const contentEl = document.getElementById('stateContent');
1933
- if (!contentEl) return;
1934
-
1935
- const riskColor = stateData.risk_level === 'high' ? '#dc3545'
1936
- : stateData.risk_level === 'medium' ? '#ffc107'
1937
- : stateData.risk_level === 'low_medium' ? '#fd7e14'
1938
- : '#138808';
1939
- const fakePercent = ((stateData.fake_probability || 0) * 100).toFixed(1);
1940
- const aiConf = ((stateData.ai_confidence || 0) * 100).toFixed(1);
1941
-
1942
- contentEl.innerHTML = `
1943
- <div class="state-stats">
1944
- <div class="stat-row"><span class="stat-label">Total Events</span><span class="stat-value">${(stateData.event_count || 0).toLocaleString('en-IN')}</span></div>
1945
- <div class="stat-row"><span class="stat-label">Fake News</span><span class="stat-value" style="color:#dc3545;">${(stateData.fake_count || 0).toLocaleString('en-IN')} (${fakePercent}%)</span></div>
1946
- <div class="stat-row"><span class="stat-label">Real News</span><span class="stat-value" style="color:#138808;">${(stateData.real_count || 0).toLocaleString('en-IN')}</span></div>
1947
- <div class="stat-row"><span class="stat-label">AI Confidence</span><span class="stat-value">${aiConf}%</span></div>
1948
- <div class="stat-row"><span class="stat-label">Risk Level</span><span class="risk-badge" style="background:${riskColor};color:${stateData.risk_level === 'medium' ? '#1a1a1a' : '#fff'}">${(stateData.risk_level || 'low').toUpperCase().replace('_',' ')}</span></div>
1949
- </div>
1950
- <div style="margin-top:24px;border-top:1px solid var(--border-accent);padding-top:24px;">
1951
- <div class="news-section">
1952
- <h3 style="color:var(--high-risk);"><span style="width:12px;height:12px;background:var(--high-risk);border-radius:50%;display:inline-block;margin-right:8px;"></span>🚨 Misinformation Alerts</h3>
1953
- <div id="misinformation-feed" class="news-feed misinformation-feed"><div style="text-align:center;padding:20px;opacity:0.6;font-size:13px;">Loading...</div></div>
1954
- </div>
1955
- <div class="news-section">
1956
- <h3 style="color:var(--green);"><span style="width:12px;height:12px;background:var(--green);border-radius:50%;display:inline-block;margin-right:8px;"></span>✅ Verified Real News</h3>
1957
- <div id="real-news-feed" class="news-feed real-news-feed"><div style="text-align:center;padding:20px;opacity:0.6;font-size:13px;">Loading...</div></div>
1958
- </div>
1959
- </div>`;
1960
- }
1961
-
1962
- // Initialize when page loads
1963
- document.addEventListener('DOMContentLoaded', () => {
1964
- initializeEnhancedHeatmap();
1965
- setupZoomAndPan();
1966
- initializeMapBounds();
1967
- setTimeout(addTestColorsButton, 1000);
1968
- });
1969
- </script>
1970
- </body>
1971
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/map/interactive-india-map.html DELETED
@@ -1,300 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>Interactive India Map</title>
7
- <link rel="icon" type="image/x-icon" href="/public/Misinfo.ico">
8
- <style>
9
- :root{--bg:#0f1720;--panel:#0b1220;--accent:#3b82f6}
10
- body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;padding:0;display:flex;height:100vh;overflow:hidden;overflow-x:hidden;max-width:100vw}
11
- #left{flex:1;display:flex;flex-direction:column}
12
- header{padding:12px 16px;background:linear-gradient(90deg,rgba(255,255,255,0.02),transparent);display:flex;gap:12px;align-items:center}
13
- header h1{font-size:18px;margin:0}
14
- #mapwrap{flex:1;overflow:hidden;position:relative}
15
- #svgroot{width:100%;height:100%;touch-action:none;cursor:grab;background:linear-gradient(180deg,#071027, #082035)}
16
- #tooltip{position:absolute;pointer-events:none;padding:8px 10px;background:#fff;color:#041026;border-radius:6px;font-size:13px;box-shadow:0 6px 18px rgba(4,8,16,0.6);display:none}
17
- aside{width:320px;background:var(--panel);border-left:1px solid rgba(255,255,255,0.03);padding:18px;box-sizing:border-box}
18
- .state-name{font-size:20px;font-weight:600;margin-bottom:6px}
19
- .meta{font-size:13px;opacity:0.85}
20
- .controls{display:flex;gap:8px;align-items:center}
21
- .btn{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:6px 8px;border-radius:8px;color:inherit;cursor:pointer}
22
- input[type=search]{flex:1;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);background:rgba(255,255,255,0.02);color:inherit}
23
- .legend{margin-top:12px;font-size:13px}
24
- .hint{margin-top:18px;opacity:0.7;font-size:13px}
25
- /* highlight style for svg paths */
26
- .map-path{fill:#88A4BC;stroke:#ffffff;stroke-width:0.6}
27
- .map-path:hover{fill:#3B729F}
28
- .selected{fill:var(--accent) !important}
29
- footer{padding-top:12px;font-size:12px;opacity:0.8}
30
-
31
- /* Mobile Responsiveness */
32
- @media (max-width: 768px) {
33
- body { display: block; }
34
- #left { width: 100%; height: 60vh; }
35
- aside {
36
- width: 100%;
37
- border-left: none;
38
- border-top: 1px solid rgba(255,255,255,0.1);
39
- padding: 12px;
40
- height: 40vh;
41
- overflow-y: auto;
42
- }
43
- header { padding: 8px 12px; }
44
- header h1 { font-size: 16px; }
45
- .controls { gap: 8px; }
46
- input[type=search] { font-size: 14px; }
47
- .btn { font-size: 12px; padding: 4px 6px; }
48
- .state-name { font-size: 18px; }
49
- .meta { font-size: 12px; }
50
- .legend { font-size: 12px; }
51
- .hint { font-size: 12px; }
52
- }
53
-
54
- @media (max-width: 480px) {
55
- #left { height: 50vh; }
56
- aside { height: 50vh; padding: 8px; }
57
- header { padding: 6px 8px; }
58
- header h1 { font-size: 14px; }
59
- .controls { flex-direction: column; gap: 6px; }
60
- input[type=search] { padding: 6px; }
61
- .btn { padding: 3px 6px; font-size: 11px; }
62
- .state-name { font-size: 16px; }
63
- .meta { font-size: 11px; }
64
- .legend { font-size: 11px; margin-top: 8px; }
65
- .hint { font-size: 11px; margin-top: 12px; }
66
- footer { font-size: 10px; }
67
- }
68
- </style>
69
- </head>
70
- <body>
71
- <div id="left">
72
- <header>
73
- <h1>Interactive India Map</h1>
74
- <div class="controls" style="flex:1">
75
- <input id="search" type="search" placeholder="Search state (e.g. Karnataka)" />
76
- <button id="reset" class="btn">Reset</button>
77
- </div>
78
- </header>
79
- <div id="mapwrap">
80
- <div id="svgroot">Loading map...</div>
81
- <div id="tooltip"></div>
82
- </div>
83
- </div>
84
- <aside>
85
- <div class="state-name" id="stateName">Click a state</div>
86
- <div class="meta" id="stateDesc">State description will appear here. Hover or click a state on the map.</div>
87
- <div class="legend" id="legend"></div>
88
- <div class="hint">Use mouse wheel to zoom, drag to pan. On mobile: pinch to zoom (if supported).</div>
89
- <footer>Files required: <code>in.svg</code> and <code>mapdata.js</code> in same folder.</footer>
90
- </aside>
91
-
92
- <script>
93
- // Basic utility and UI
94
- const svgRoot = document.getElementById('svgroot');
95
- const tooltip = document.getElementById('tooltip');
96
- const stateNameEl = document.getElementById('stateName');
97
- const stateDescEl = document.getElementById('stateDesc');
98
- const searchInput = document.getElementById('search');
99
- const resetBtn = document.getElementById('reset');
100
-
101
- // We'll load the SVG file (in.svg) and inject it so we can attach listeners
102
- async function loadSVG(){
103
- try{
104
- const res = await fetch('in.svg');
105
- if(!res.ok) throw new Error('SVG not found. Put in.svg beside this HTML file.');
106
- const text = await res.text();
107
- svgRoot.innerHTML = text;
108
- initMap();
109
- }catch(e){
110
- svgRoot.innerHTML = '<div style="padding:18px;color:salmon">Error loading in.svg: '+e.message+'</div>';
111
- }
112
- }
113
-
114
- // Load optional mapdata.js (user provided). It should define simplemaps_countrymap_mapdata
115
- function loadMapData(){
116
- return new Promise((resolve)=>{
117
- if(window.simplemaps_countrymap_mapdata){
118
- resolve(window.simplemaps_countrymap_mapdata);
119
- }else{
120
- // try to dynamically load mapdata.js
121
- const s = document.createElement('script');
122
- s.src = 'mapdata.js';
123
- s.onload = ()=> resolve(window.simplemaps_countrymap_mapdata || {});
124
- s.onerror = ()=> resolve({});
125
- document.head.appendChild(s);
126
- }
127
- })
128
- }
129
-
130
- // Map interaction state (pan/zoom)
131
- let scale = 1, tx = 0, ty = 0;
132
- let isPanning = false, startX=0, startY=0;
133
- let svgEl;
134
- let mapData = {};
135
- let selectedEl = null;
136
-
137
- function initMap(){
138
- // find the actual <svg>
139
- svgEl = svgRoot.querySelector('svg');
140
- if(!svgEl){ svgRoot.innerHTML = '<div style="padding:18px;color:salmon">in.svg does not contain an &lt;svg&gt; root.</div>'; return; }
141
- // wrap paths with class map-path for styling
142
- const paths = svgEl.querySelectorAll('path, polygon, rect, circle');
143
- paths.forEach(p => {
144
- if(!p.id) return;
145
- p.classList.add('map-path');
146
- });
147
-
148
- // initial transform group
149
- let g = document.createElementNS('http://www.w3.org/2000/svg','g');
150
- // move all children into g
151
- while(svgEl.firstChild){ g.appendChild(svgEl.firstChild); }
152
- svgEl.appendChild(g);
153
- svgEl.dataset.viewbox = svgEl.getAttribute('viewBox') || '';
154
-
155
- // pan/zoom handlers
156
- svgRoot.addEventListener('wheel', e=>{
157
- e.preventDefault();
158
- const delta = -e.deltaY * 0.001;
159
- const oldScale = scale;
160
- scale = Math.min(8, Math.max(0.5, scale * (1 + delta)));
161
- // zoom towards cursor
162
- const rect = svgRoot.getBoundingClientRect();
163
- const cx = e.clientX - rect.left;
164
- const cy = e.clientY - rect.top;
165
- tx -= (cx/oldScale - cx/scale);
166
- ty -= (cy/oldScale - cy/scale);
167
- updateTransform();
168
- }, {passive:false});
169
-
170
- svgRoot.addEventListener('mousedown', e=>{
171
- isPanning = true; startX = e.clientX; startY = e.clientY; svgRoot.style.cursor='grabbing';
172
- });
173
- window.addEventListener('mousemove', e=>{
174
- if(!isPanning) return;
175
- tx += (e.clientX - startX)/scale; ty += (e.clientY - startY)/scale; startX = e.clientX; startY = e.clientY; updateTransform();
176
- });
177
- window.addEventListener('mouseup', ()=>{ isPanning=false; svgRoot.style.cursor='grab'; });
178
-
179
- // load map data and attach state listeners
180
- loadMapData().then(md=>{
181
- mapData = md || {};
182
- attachStateHandlers();
183
- });
184
-
185
- // pointer hover for tooltip
186
- svgRoot.addEventListener('mousemove', e=>{
187
- const t = e.target;
188
- if(t && t.classList && t.classList.contains('map-path')){
189
- const id = t.id;
190
- const info = lookupStateInfo(id);
191
- showTooltip(e.clientX, e.clientY, info.name+(info.desc?('\n'+info.desc):''));
192
- } else { hideTooltip(); }
193
- });
194
-
195
- svgRoot.addEventListener('mouseleave', hideTooltip);
196
-
197
- // click to select
198
- svgRoot.addEventListener('click', e=>{
199
- const t = e.target;
200
- if(t && t.classList && t.classList.contains('map-path')){
201
- selectState(t.id, t);
202
- }
203
- });
204
-
205
- // search
206
- searchInput.addEventListener('keydown', e=>{ if(e.key==='Enter') doSearch(); });
207
- resetBtn.addEventListener('click', resetView);
208
- }
209
-
210
- function updateTransform(){
211
- const g = svgEl.querySelector('g');
212
- g.setAttribute('transform', `translate(${tx} ${ty}) scale(${scale})`);
213
- }
214
-
215
- function showTooltip(cx, cy, html){
216
- tooltip.style.display='block';
217
- tooltip.innerText = html;
218
- const mapRect = svgRoot.getBoundingClientRect();
219
- const left = Math.min(mapRect.width - 180, Math.max(8, cx - mapRect.left + 12));
220
- const top = Math.max(8, cy - mapRect.top + 12);
221
- tooltip.style.left = left+'px'; tooltip.style.top = top+'px';
222
- }
223
- function hideTooltip(){ tooltip.style.display='none'; }
224
-
225
- function lookupStateInfo(id){
226
- const empty = {name:id || 'Unknown', desc:''};
227
- if(!id) return empty;
228
- if(mapData && mapData.state_specific && mapData.state_specific[id]){
229
- const s = mapData.state_specific[id];
230
- return {name: s.name || id, desc: (s.description && s.description!=='default')?s.description:''};
231
- }
232
- // fallback: try matching by common names
233
- return {name: id, desc:''};
234
- }
235
-
236
- function selectState(id, el){
237
- if(selectedEl) selectedEl.classList.remove('selected');
238
- selectedEl = el;
239
- selectedEl.classList.add('selected');
240
- const info = lookupStateInfo(id);
241
- stateNameEl.innerText = info.name || id;
242
- stateDescEl.innerText = info.desc || (mapData.main_settings && mapData.main_settings.state_description) || 'No description available.';
243
- }
244
-
245
- function attachStateHandlers(){
246
- // populate legend from mapData if available
247
- if(mapData && mapData.legend && Array.isArray(mapData.legend.entries) && mapData.legend.entries.length){
248
- const legend = document.getElementById('legend');
249
- legend.innerHTML = '<strong>Legend</strong><br>' + mapData.legend.entries.join(', ');
250
- }
251
-
252
- // add pointer cursors
253
- const paths = svgEl.querySelectorAll('.map-path');
254
- paths.forEach(p=>{
255
- p.style.cursor='pointer';
256
- // attempt to set title attribute for accessibility
257
- const info = lookupStateInfo(p.id);
258
- p.setAttribute('data-name', info.name||p.id);
259
- // if SVG path has no fill set or fill=none, set a default fill
260
- if(!p.getAttribute('fill') || p.getAttribute('fill')==='none'){
261
- p.setAttribute('fill','#88A4BC');
262
- }
263
- });
264
- }
265
-
266
- function doSearch(){
267
- const q = searchInput.value.trim().toLowerCase();
268
- if(!q) return;
269
- // try to find a state in mapdata
270
- const m = mapData.state_specific || {};
271
- for(const k of Object.keys(m)){
272
- const name = (m[k].name||'').toLowerCase();
273
- if(name.includes(q) || k.toLowerCase()===q){
274
- // find element with id k
275
- const el = svgEl.querySelector('#'+CSS.escape(k));
276
- if(el){
277
- // center view on bbox
278
- const bbox = el.getBBox();
279
- // simple centering: move translate so bbox is roughly center
280
- const rect = svgRoot.getBoundingClientRect();
281
- const cx = bbox.x + bbox.width/2; const cy = bbox.y + bbox.height/2;
282
- // compute desired tx/ty so that cx,cy appear near center of container
283
- const targetScreenX = rect.width/2; const targetScreenY = rect.height/2;
284
- tx = targetScreenX/scale - cx; ty = targetScreenY/scale - cy;
285
- updateTransform();
286
- selectState(k, el);
287
- return;
288
- }
289
- }
290
- }
291
- alert('No state found for "'+q+'"');
292
- }
293
-
294
- function resetView(){ scale = 1; tx = 0; ty = 0; updateTransform(); if(selectedEl) { selectedEl.classList.remove('selected'); selectedEl=null; stateNameEl.innerText='Click a state'; stateDescEl.innerText='State description will appear here.'} }
295
-
296
- // initial load
297
- loadSVG();
298
- </script>
299
- </body>
300
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/privacy-policy.html ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, viewport-fit=cover">
6
+ <style>
7
+ .page-hero { padding-top: 100px; padding-bottom: 60px; }
8
+ .legal-content { max-width: 800px; margin: 0 auto; padding: 40px; background: #fff; border-radius: 24px; border: 1px solid #e2e8f0; }
9
+ h3 { font-size: 20px; font-weight: 900; margin: 32px 0 16px; color: #0f172a; }
10
+ p { margin-bottom: 16px; color: #475569; line-height: 1.8; }
11
+ @media (min-width: 1024px) { .page-hero { padding-top: 140px; padding-bottom: 80px; } }
12
+ </style>
13
+ <title>Privacy Policy - Misinformation Heatmap</title>
14
+ <link rel="stylesheet" href="/assets/css/styles.css">
15
+ <script type="module" src="/assets/js/main.js"></script>
16
+ </head>
17
+ <body data-page="privacy-policy" class="min-h-screen bg-slate-50 antialiased">
18
+ <div data-navbar></div>
19
+ <main class="page-hero px-6">
20
+ <h1 class="text-4xl font-black text-center text-slate-950">Privacy Policy</h1>
21
+ <p class="text-center mt-4 text-slate-500 italic">Last updated: May 2026</p>
22
+ </main>
23
+ <section class="pb-32 px-6 lg:px-8">
24
+ <div class="legal-content shadow-sm">
25
+ <h3>1. Data Collection</h3>
26
+ <p>Misinformation Heatmap collects public digital signals for the purpose of identifying and verifying misinformation clusters. We do not collect private personal communications or non-public identifiable information.</p>
27
+
28
+ <h3>2. Data Usage</h3>
29
+ <p>Processed data is used solely to generate geographical heatmaps and regional signal reports. Aggregated data may be shared with verified research and government partners.</p>
30
+
31
+ <h3>3. Regional Sovereignty</h3>
32
+ <p>All data collected within India is stored and processed on local servers in compliance with national data protection regulations.</p>
33
+ </div>
34
+ </section>
35
+ <div data-footer-placeholder></div>
36
+ <script>
37
+ fetch('/contact.html').then(r => r.text()).then(html => {
38
+ const parser = new DOMParser();
39
+ const doc = parser.parseFromString(html, 'text/html');
40
+ const footer = doc.querySelector('footer');
41
+ if(footer) document.querySelector('[data-footer-placeholder]').replaceWith(footer);
42
+ });
43
+ </script>
44
+ </body>
45
+ </html>
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
server.py CHANGED
@@ -218,6 +218,14 @@ app.add_middleware(
218
  allow_headers=["*"],
219
  )
220
 
 
 
 
 
 
 
 
 
221
  # Static files
222
  if MAP_DIR.exists():
223
  app.mount("/map", StaticFiles(directory=str(MAP_DIR)), name="map")
@@ -274,6 +282,7 @@ async def get_stats(response: Response = None):
274
  return cached
275
 
276
  total = fake_n = real_n = uncertain = 0
 
277
  try:
278
  with get_db() as conn:
279
  row = conn.execute("""
@@ -281,21 +290,35 @@ async def get_stats(response: Response = None):
281
  COUNT(*) AS total,
282
  SUM(CASE WHEN fake_news_verdict = 'fake' THEN 1 ELSE 0 END) AS fake,
283
  SUM(CASE WHEN fake_news_verdict = 'real' THEN 1 ELSE 0 END) AS real,
284
- SUM(CASE WHEN fake_news_verdict = 'uncertain' THEN 1 ELSE 0 END) AS uncertain
 
 
 
285
  FROM events
286
- WHERE timestamp > datetime('now', '-24 hours')
287
  """).fetchone()
288
  total, fake_n, real_n, uncertain = (row[k] or 0 for k in ("total","fake","real","uncertain"))
 
 
 
289
  except Exception as exc:
290
  logger.error(f"Stats DB error: {exc}")
291
 
 
 
 
 
 
 
292
  result = {
293
  "total_events": total,
294
  "fake_events": fake_n,
295
  "real_events": real_n,
296
  "uncertain_events": uncertain,
297
  "processing_active": _is_processing_active(),
298
- "classification_accuracy": 0.91 if total > 0 else 0.5,
 
 
 
299
  "system_status": "LIVE" if _is_processing_active() else "READY",
300
  "total_states": len(_INDIAN_STATES) or 36,
301
  "ml_ready": _ml_ready.is_set(),
@@ -365,9 +388,68 @@ async def get_heatmap_data(response: Response = None, days: int = Query(7, ge=1,
365
  _cache_set(cache_key, result)
366
  return result
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  # ─── API: LIVE EVENTS ────────────────────────────────────────────────────────
369
  @app.get("/api/v1/events/live", tags=["Events"])
370
- async def get_live_events(response: Response = None, limit: int = Query(10, ge=1, le=100)):
371
  """Recent events from the last hour."""
372
  if response:
373
  response.headers["Cache-Control"] = "public, max-age=30"
@@ -380,14 +462,7 @@ async def get_live_events(response: Response = None, limit: int = Query(10, ge=1
380
  rows = []
381
  try:
382
  with get_db() as conn:
383
- rows = conn.execute("""
384
- SELECT title, SUBSTR(content, 1, 150) as content, source, state,
385
- fake_news_confidence, fake_news_verdict, timestamp
386
- FROM events
387
- WHERE timestamp > datetime('now', '-24 hours')
388
- ORDER BY timestamp DESC
389
- LIMIT ?
390
- """, (limit,)).fetchall()
391
  except Exception as exc:
392
  logger.error(f"Live events error: {exc}")
393
 
@@ -431,7 +506,7 @@ async def sse_stream():
431
 
432
  # ─── API: STATE EVENTS ───────────────────────────────────────────────────────
433
  @app.get("/api/v1/events/state/{state}", tags=["Events"])
434
- async def get_state_events(state: str, response: Response = None, limit: int = Query(10, ge=1, le=50)):
435
  if response:
436
  response.headers["Cache-Control"] = "public, max-age=30"
437
 
@@ -443,12 +518,7 @@ async def get_state_events(state: str, response: Response = None, limit: int = Q
443
  rows = []
444
  try:
445
  with get_db() as conn:
446
- rows = conn.execute("""
447
- SELECT title, SUBSTR(content, 1, 150) as content, source,
448
- fake_news_confidence, fake_news_verdict, timestamp
449
- FROM events WHERE state = ?
450
- ORDER BY timestamp DESC LIMIT ?
451
- """, (state, limit)).fetchall()
452
  except Exception as exc:
453
  logger.error(f"State events error [{state}]: {exc}")
454
 
@@ -459,10 +529,11 @@ async def get_state_events(state: str, response: Response = None, limit: int = Q
459
  "title": r["title"] or "Processing…",
460
  "content": body[:200] + ("…" if len(body) > 200 else ""),
461
  "source": r["source"] or "Unknown",
 
462
  "fake_probability": round(r["fake_news_confidence"] or 0.5, 2),
463
  "classification": r["fake_news_verdict"] or "uncertain",
464
  "confidence": round(r["fake_news_confidence"] or 0.5, 2),
465
- "timestamp": r["timestamp"],
466
  })
467
 
468
  result = {"state": state, "events": events, "total_count": len(events)}
 
218
  allow_headers=["*"],
219
  )
220
 
221
+ @app.middleware("http")
222
+ async def add_security_headers(request, call_next):
223
+ response = await call_next(request)
224
+ response.headers["X-Frame-Options"] = "DENY"
225
+ response.headers["X-Content-Type-Options"] = "nosniff"
226
+ response.headers["X-XSS-Protection"] = "1; mode=block"
227
+ return response
228
+
229
  # Static files
230
  if MAP_DIR.exists():
231
  app.mount("/map", StaticFiles(directory=str(MAP_DIR)), name="map")
 
282
  return cached
283
 
284
  total = fake_n = real_n = uncertain = 0
285
+ avg_fake_conf = avg_real_conf = avg_conf = 0.0
286
  try:
287
  with get_db() as conn:
288
  row = conn.execute("""
 
290
  COUNT(*) AS total,
291
  SUM(CASE WHEN fake_news_verdict = 'fake' THEN 1 ELSE 0 END) AS fake,
292
  SUM(CASE WHEN fake_news_verdict = 'real' THEN 1 ELSE 0 END) AS real,
293
+ SUM(CASE WHEN fake_news_verdict = 'uncertain' THEN 1 ELSE 0 END) AS uncertain,
294
+ AVG(CASE WHEN fake_news_verdict = 'fake' THEN fake_news_confidence END) AS avg_fake_conf,
295
+ AVG(CASE WHEN fake_news_verdict = 'real' THEN fake_news_confidence END) AS avg_real_conf,
296
+ AVG(fake_news_confidence) AS avg_conf
297
  FROM events
 
298
  """).fetchone()
299
  total, fake_n, real_n, uncertain = (row[k] or 0 for k in ("total","fake","real","uncertain"))
300
+ avg_fake_conf = row["avg_fake_conf"] or 0.0
301
+ avg_real_conf = row["avg_real_conf"] or 0.0
302
+ avg_conf = row["avg_conf"] or 0.0
303
  except Exception as exc:
304
  logger.error(f"Stats DB error: {exc}")
305
 
306
+ # Dynamic model performance metrics derived from actual confidence scores
307
+ precision_val = round(avg_fake_conf, 4) if total > 0 else 0.0
308
+ recall_val = round(avg_real_conf, 4) if total > 0 else 0.0
309
+ f1_val = round(2 * precision_val * recall_val / (precision_val + recall_val), 4) if (precision_val + recall_val) > 0 else 0.0
310
+ accuracy_val = round(avg_conf, 4) if total > 0 else 0.0
311
+
312
  result = {
313
  "total_events": total,
314
  "fake_events": fake_n,
315
  "real_events": real_n,
316
  "uncertain_events": uncertain,
317
  "processing_active": _is_processing_active(),
318
+ "classification_accuracy": accuracy_val,
319
+ "precision": precision_val,
320
+ "recall": recall_val,
321
+ "f1_score": f1_val,
322
  "system_status": "LIVE" if _is_processing_active() else "READY",
323
  "total_states": len(_INDIAN_STATES) or 36,
324
  "ml_ready": _ml_ready.is_set(),
 
388
  _cache_set(cache_key, result)
389
  return result
390
 
391
+ def fetch_balanced_events(conn, base_where_clause: str, params: tuple, limit: int):
392
+ """
393
+ Fetches a balanced mix of fake and non-fake events to prevent starvation.
394
+ Ensures that both categories are represented if they exist in the DB.
395
+ """
396
+ half_limit = max(1, limit // 2)
397
+
398
+ # 1. Fetch fake events
399
+ fake_query = f"""
400
+ SELECT title, SUBSTR(content, 1, 150) as content, source, state,
401
+ fake_news_confidence, fake_news_verdict, timestamp
402
+ FROM events
403
+ WHERE {base_where_clause} AND fake_news_verdict = 'fake'
404
+ ORDER BY timestamp DESC
405
+ LIMIT ?
406
+ """
407
+ fake_rows = conn.execute(fake_query, params + (half_limit,)).fetchall()
408
+
409
+ # 2. Fetch non-fake events (filling the remaining limit)
410
+ other_limit = max(1, limit - len(fake_rows))
411
+ other_query = f"""
412
+ SELECT title, SUBSTR(content, 1, 150) as content, source, state,
413
+ fake_news_confidence, fake_news_verdict, timestamp
414
+ FROM events
415
+ WHERE {base_where_clause} AND fake_news_verdict != 'fake'
416
+ ORDER BY timestamp DESC
417
+ LIMIT ?
418
+ """
419
+ other_rows = conn.execute(other_query, params + (other_limit,)).fetchall()
420
+
421
+ # 3. If other_rows didn't fill the quota and we capped fake_rows, fetch more fake events
422
+ total_fetched = len(fake_rows) + len(other_rows)
423
+ if total_fetched < limit and len(fake_rows) == half_limit:
424
+ full_fake_query = f"""
425
+ SELECT title, SUBSTR(content, 1, 150) as content, source, state,
426
+ fake_news_confidence, fake_news_verdict, timestamp
427
+ FROM events
428
+ WHERE {base_where_clause} AND fake_news_verdict = 'fake'
429
+ ORDER BY timestamp DESC
430
+ LIMIT ?
431
+ """
432
+ fake_rows = conn.execute(full_fake_query, params + (limit - len(other_rows),)).fetchall()
433
+
434
+ combined = list(fake_rows) + list(other_rows)
435
+
436
+ def _get_timestamp_sort_key(row):
437
+ ts = row["timestamp"]
438
+ if ts is None:
439
+ return ""
440
+ if isinstance(ts, str):
441
+ return ts
442
+ if hasattr(ts, "isoformat"):
443
+ return ts.isoformat()
444
+ return str(ts)
445
+
446
+ combined.sort(key=_get_timestamp_sort_key, reverse=True)
447
+ return combined
448
+
449
+
450
  # ─── API: LIVE EVENTS ────────────────────────────────────────────────────────
451
  @app.get("/api/v1/events/live", tags=["Events"])
452
+ async def get_live_events(response: Response = None, limit: int = Query(10, ge=1, le=500)):
453
  """Recent events from the last hour."""
454
  if response:
455
  response.headers["Cache-Control"] = "public, max-age=30"
 
462
  rows = []
463
  try:
464
  with get_db() as conn:
465
+ rows = fetch_balanced_events(conn, "timestamp > datetime('now', '-24 hours')", (), limit)
 
 
 
 
 
 
 
466
  except Exception as exc:
467
  logger.error(f"Live events error: {exc}")
468
 
 
506
 
507
  # ─── API: STATE EVENTS ───────────────────────────────────────────────────────
508
  @app.get("/api/v1/events/state/{state}", tags=["Events"])
509
+ async def get_state_events(state: str, response: Response = None, limit: int = Query(30, ge=1, le=100)):
510
  if response:
511
  response.headers["Cache-Control"] = "public, max-age=30"
512
 
 
518
  rows = []
519
  try:
520
  with get_db() as conn:
521
+ rows = fetch_balanced_events(conn, "LOWER(state) = LOWER(?)", (state,), limit)
 
 
 
 
 
522
  except Exception as exc:
523
  logger.error(f"State events error [{state}]: {exc}")
524
 
 
529
  "title": r["title"] or "Processing…",
530
  "content": body[:200] + ("…" if len(body) > 200 else ""),
531
  "source": r["source"] or "Unknown",
532
+ "state": r["state"] or state,
533
  "fake_probability": round(r["fake_news_confidence"] or 0.5, 2),
534
  "classification": r["fake_news_verdict"] or "uncertain",
535
  "confidence": round(r["fake_news_confidence"] or 0.5, 2),
536
+ "timestamp": r["timestamp"].isoformat() if hasattr(r["timestamp"], "isoformat") else str(r["timestamp"] or ""),
537
  })
538
 
539
  result = {"state": state, "events": events, "total_count": len(events)}
tailwind.config.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ './frontend/index.html',
5
+ './frontend/dashboard.html',
6
+ './frontend/heatmap.html',
7
+ './frontend/assets/js/**/*.js'
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ colors: {
12
+ saffron: {
13
+ 50: '#fff7ed',
14
+ 100: '#ffedd5',
15
+ 200: '#fed7aa',
16
+ 300: '#fdba74',
17
+ 400: '#fb923c',
18
+ 500: '#FF9933', // Actual Indian flag saffron
19
+ 600: '#ea580c',
20
+ 700: '#c2410c',
21
+ 800: '#9a3412',
22
+ 900: '#7c2d12',
23
+ 950: '#431407',
24
+ },
25
+ 'india-green': {
26
+ 50: '#f0fdf4',
27
+ 100: '#dcfce7',
28
+ 200: '#bbf7d0',
29
+ 300: '#86efac',
30
+ 400: '#4ade80',
31
+ 500: '#138808', // Actual Indian flag green
32
+ 600: '#16a34a',
33
+ 700: '#15803d',
34
+ 800: '#166534',
35
+ 900: '#14532d',
36
+ 950: '#052e16',
37
+ },
38
+ 'ashoka-blue': {
39
+ 50: '#eff6ff',
40
+ 100: '#dbeafe',
41
+ 200: '#bfdbfe',
42
+ 300: '#93c5fd',
43
+ 400: '#60a5fa',
44
+ 500: '#000080', // Actual Indian flag navy blue
45
+ 600: '#2563eb',
46
+ 700: '#1d4ed8',
47
+ 800: '#1e40af',
48
+ 900: '#1e3a8a',
49
+ 950: '#172554',
50
+ },
51
+ },
52
+ boxShadow: {
53
+ 'soft': '0 1px 2px rgba(15, 23, 42, 0.06), 0 12px 32px rgba(15, 23, 42, 0.06)',
54
+ 'card': '0 1px 2px rgba(15, 23, 42, 0.06), 0 12px 32px rgba(15, 23, 42, 0.06)',
55
+ 'premium': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.05)',
56
+ },
57
+ animation: {
58
+ 'fade-in-up': 'fadeInUp 0.6s ease-out forwards',
59
+ 'fade-in': 'fadeIn 0.5s ease-out forwards',
60
+ 'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
61
+ 'premium-fade': 'premiumFade 8s ease-in-out infinite alternate',
62
+ float: 'float 3s ease-in-out infinite',
63
+ blink: 'blink 1s step-end infinite',
64
+ 'bounce-scroll': 'bounceScroll 4s ease-in-out infinite',
65
+ },
66
+ keyframes: {
67
+ bounceScroll: {
68
+ '0%, 15%, 100%': { transform: 'translateY(0)' },
69
+ '7.5%': { transform: 'translateY(-12px)' },
70
+ },
71
+ premiumFade: {
72
+ '0%': { opacity: '0.6' },
73
+ '100%': { opacity: '1' },
74
+ },
75
+ fadeInUp: {
76
+ '0%': { opacity: '0', transform: 'translateY(100vh) scale(0.1)', borderRadius: '10rem' },
77
+ '70%': { opacity: '1', transform: 'translateY(-20px) scale(1.02)' },
78
+ '100%': { opacity: '1', transform: 'translateY(0) scale(1)', borderRadius: '2.5rem' },
79
+ },
80
+ fadeOutDown: {
81
+ '0%': { opacity: '1', transform: 'translateY(0) scale(1)', borderRadius: '2.5rem' },
82
+ '100%': { opacity: '0', transform: 'translateY(100vh) scale(0.1)', borderRadius: '10rem' },
83
+ },
84
+ fadeIn: {
85
+ '0%': { opacity: '0' },
86
+ '100%': { opacity: '1' },
87
+ },
88
+ fadeOut: {
89
+ '0%': { opacity: '1' },
90
+ '100%': { opacity: '0' },
91
+ },
92
+ pulseSoft: {
93
+ '0%, 100%': { opacity: '1' },
94
+ '50%': { opacity: '0.7' },
95
+ },
96
+ float: {
97
+ '0%, 100%': { transform: 'translateY(0) scale(1) rotate(0deg)' },
98
+ '50%': { transform: 'translateY(-20px) scale(1.05) rotate(1deg)' },
99
+ },
100
+ blink: {
101
+ '0%, 100%': { opacity: '1' },
102
+ '50%': { opacity: '0' },
103
+ },
104
+ toastInRight: {
105
+ '0%': { opacity: '0', transform: 'translateX(100%)' },
106
+ '100%': { opacity: '1', transform: 'translateX(0)' },
107
+ },
108
+ toastOutRight: {
109
+ '0%': { opacity: '1', transform: 'translateX(0)' },
110
+ '100%': { opacity: '0', transform: 'translateX(100%)' },
111
+ },
112
+ },
113
+ },
114
+ },
115
+ plugins: [],
116
+ }
vite.config.js ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import { resolve } from 'path'
3
+
4
+ export default defineConfig({
5
+ root: 'frontend',
6
+ publicDir: '../public',
7
+ build: {
8
+ outDir: '../dist',
9
+ emptyOutDir: true,
10
+ rollupOptions: {
11
+ input: {
12
+ main: resolve(__dirname, 'frontend/index.html'),
13
+ dashboard: resolve(__dirname, 'frontend/dashboard.html'),
14
+ heatmap: resolve(__dirname, 'frontend/heatmap.html')
15
+ }
16
+ }
17
+ },
18
+ server: {
19
+ port: 5173,
20
+ proxy: {
21
+ '/api': {
22
+ target: 'http://127.0.0.1:8000',
23
+ changeOrigin: true,
24
+ timeout: 30000
25
+ },
26
+ '/health': {
27
+ target: 'http://127.0.0.1:8000',
28
+ changeOrigin: true,
29
+ timeout: 30000
30
+ }
31
+ }
32
+ }
33
+ })