Merge pull request #14 from Dhiman07-cyber/main
Browse filesMajor UI/UX Overhaul: Migration to Vite + Tailwind CSS with Full Responsiveness on all device screen
- backend/massive_data_ingestion.py +2 -2
- check_dates.py +20 -0
- check_db.py +23 -9
- {public → frontend/assets}/Misinfo.ico +0 -0
- frontend/assets/css/styles.css +1 -0
- frontend/assets/css/tailwind.css +138 -0
- frontend/assets/{indian-flag.png → ind.png} +2 -2
- frontend/assets/js/api.js +228 -0
- frontend/assets/js/components/bottomNavbar.js +75 -0
- frontend/assets/js/components/hamburger.js +132 -0
- frontend/assets/js/components/navbar.js +150 -0
- frontend/assets/js/components/ui.js +298 -0
- frontend/assets/js/main.js +29 -0
- frontend/assets/js/pages/dashboard.js +359 -0
- frontend/assets/js/pages/heatmap.js +1036 -0
- frontend/assets/js/pages/home.js +169 -0
- frontend/assets/js/utils/dom.js +113 -0
- frontend/assets/js/utils/formatters.js +186 -0
- frontend/assets/js/utils/security.js +203 -0
- frontend/{map → assets/map}/in.svg +0 -0
- frontend/{map → assets/map}/mapdata.js +0 -0
- frontend/contact.html +205 -0
- frontend/dashboard.html +461 -723
- frontend/heatmap.html +654 -0
- frontend/index.html +525 -558
- frontend/manifest.json +28 -0
- frontend/map/enhanced-india-heatmap.html +0 -1971
- frontend/map/interactive-india-map.html +0 -300
- frontend/privacy-policy.html +45 -0
- postcss.config.js +6 -0
- server.py +91 -20
- tailwind.config.js +116 -0
- vite.config.js +33 -0
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
|
| 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', '-
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
print(
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
print('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
.
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 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 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 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 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
<
|
| 401 |
-
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
</div>
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
</div>
|
|
|
|
| 413 |
</div>
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
</div>
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
<
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
</div>
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
</div>
|
| 435 |
-
<div class="
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 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 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
<
|
| 451 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
</div>
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 457 |
</div>
|
| 458 |
-
|
| 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 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 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 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
</div>
|
| 523 |
|
| 524 |
-
<!--
|
| 525 |
-
<div
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 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 & 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 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 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,'"')}">${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 |
-
|
|
|
|
|
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
.
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
.
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 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"> 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"> 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 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
|
|
|
|
|
|
| 395 |
</div>
|
| 396 |
-
</section>
|
| 397 |
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
</div>
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 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 & 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 |
-
|
| 426 |
-
|
| 427 |
-
|
| 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 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
<
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
</
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
<
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
</div>
|
| 463 |
</div>
|
|
|
|
| 464 |
</section>
|
| 465 |
|
| 466 |
-
<
|
| 467 |
-
|
| 468 |
-
<
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
</section>
|
| 478 |
|
| 479 |
-
<
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
|
|
|
|
|
|
| 485 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 487 |
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
<
|
| 493 |
-
<
|
|
|
|
| 494 |
</div>
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
/
|
| 511 |
-
|
| 512 |
-
|
| 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"> 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"> 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 <svg> 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":
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
| 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(
|
| 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
|
| 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 |
+
})
|