| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>GLOF Lake Susceptibility Viewer</title> |
|
|
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/PapaParse/5.4.1/papaparse.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> |
| <script src="https://cdn.jsdelivr.net/npm/apexcharts"></script> |
| <link href="https://fonts.googleapis.com/css2?family=Syne:wght@500;700;800&family=IBM+Plex+Mono:wght@400;500&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet"> |
|
|
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| |
| :root { |
| --bg: #080d12; |
| --surface: #0f1923; |
| --surface2: #162130; |
| --surface3: #1c2c40; |
| --border: #1f3048; |
| --accent: #38bdf8; |
| --accent-dim: rgba(56,189,248,0.12); |
| --text: #dde8f5; |
| --muted: #5a7a99; |
| --danger: #f87171; |
| --warn: #fb923c; |
| --ok: #4ade80; |
| } |
| |
| html, body { height: 100%; font-family: 'DM Sans', sans-serif; color: var(--text); background: var(--bg); overflow: hidden; } |
| |
| |
| #loading { |
| position: fixed; inset: 0; background: var(--bg); z-index: 9999; |
| display: flex; flex-direction: column; align-items: center; justify-content: center; |
| gap: 18px; transition: opacity 0.6s ease; |
| } |
| #loading.fade { opacity: 0; pointer-events: none; } |
| #loading.gone { display: none; } |
| |
| .loader-icon { |
| position: relative; width: 64px; height: 64px; |
| } |
| .loader-ring { |
| position: absolute; inset: 0; |
| border: 2px solid var(--border); |
| border-top-color: var(--accent); |
| border-radius: 50%; |
| animation: spin 0.85s linear infinite; |
| } |
| .loader-ring.ring2 { |
| inset: 10px; |
| border-top-color: transparent; |
| border-right-color: rgba(56,189,248,0.4); |
| animation-duration: 1.3s; |
| animation-direction: reverse; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| |
| .loader-title { |
| font-family: 'Syne', sans-serif; |
| font-size: 0.75rem; font-weight: 700; |
| letter-spacing: 0.2em; text-transform: uppercase; |
| color: var(--accent); |
| } |
| #load-msg { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.7rem; color: var(--muted); |
| max-width: 300px; text-align: center; |
| } |
| .load-bar-bg { |
| width: 240px; height: 2px; background: var(--border); border-radius: 1px; overflow: hidden; |
| } |
| .load-bar-fill { |
| height: 100%; background: var(--accent); border-radius: 1px; |
| width: 0%; transition: width 0.3s ease; |
| } |
| |
| |
| #app { display: flex; height: 100vh; } |
| |
| |
| #sidebar { |
| width: 298px; min-width: 298px; |
| background: var(--surface); |
| border-right: 1px solid var(--border); |
| display: flex; flex-direction: column; |
| overflow-y: auto; overflow-x: hidden; |
| z-index: 1000; |
| } |
| #sidebar::-webkit-scrollbar { width: 3px; } |
| #sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } |
| |
| |
| .sb-header { |
| padding: 22px 20px 18px; |
| border-bottom: 1px solid var(--border); |
| background: linear-gradient(135deg, var(--surface) 0%, var(--surface2) 100%); |
| } |
| .sb-header .chip { |
| display: inline-flex; align-items: center; gap: 5px; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.6rem; letter-spacing: 0.12em; text-transform: uppercase; |
| color: var(--accent); margin-bottom: 10px; |
| } |
| .chip-dot { |
| width: 5px; height: 5px; border-radius: 50%; |
| background: var(--accent); |
| animation: pulse 2s ease infinite; |
| } |
| @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } |
| |
| .sb-header h1 { |
| font-family: 'Syne', sans-serif; |
| font-size: 1.3rem; font-weight: 800; |
| line-height: 1.2; color: var(--text); |
| } |
| .sb-header .sub { |
| margin-top: 7px; font-size: 0.72rem; |
| color: var(--muted); line-height: 1.5; |
| } |
| |
| |
| .sb-sec { |
| padding: 16px 20px; |
| border-bottom: 1px solid var(--border); |
| } |
| .sb-sec-title { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.58rem; letter-spacing: 0.12em; |
| text-transform: uppercase; color: var(--muted); |
| margin-bottom: 12px; |
| } |
| |
| |
| .bm-wrap { display: flex; gap: 8px; } |
| .bm-btn { |
| flex: 1; padding: 8px 10px; |
| border-radius: 7px; border: 1px solid var(--border); |
| background: var(--surface2); color: var(--muted); |
| font-size: 0.72rem; font-family: 'DM Sans', sans-serif; |
| font-weight: 500; cursor: pointer; |
| transition: all 0.15s ease; text-align: center; |
| } |
| .bm-btn:hover { border-color: var(--accent); color: var(--text); } |
| .bm-btn.active { |
| background: var(--accent-dim); |
| border-color: var(--accent); color: var(--accent); |
| } |
| .bm-btn .bm-icon { font-size: 0.9rem; display: block; margin-bottom: 2px; } |
| |
| |
| .stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; } |
| .stat-card { |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| border-radius: 8px; padding: 10px 12px; |
| } |
| .stat-val { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 1.05rem; font-weight: 500; |
| color: var(--accent); line-height: 1; |
| } |
| .stat-lbl { font-size: 0.62rem; color: var(--muted); margin-top: 3px; } |
| |
| |
| .legend-gradient { |
| height: 10px; border-radius: 5px; margin-bottom: 6px; |
| background: linear-gradient(to right, #3b82f6, #22d3ee, #4ade80, #facc15, #f97316, #ef4444); |
| } |
| .legend-ticks { |
| display: flex; justify-content: space-between; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.6rem; color: var(--muted); |
| } |
| .cat-rows { margin-top: 12px; display: flex; flex-direction: column; gap: 5px; } |
| .cat-row { |
| display: flex; align-items: center; gap: 9px; |
| font-size: 0.72rem; color: var(--muted); |
| } |
| .cat-swatch { |
| width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; |
| } |
| .cat-count { |
| margin-left: auto; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.68rem; color: var(--text); |
| } |
| |
| |
| .filter-wrap { display: flex; flex-direction: column; gap: 10px; } |
| .range-row { display: flex; align-items: center; gap: 10px; } |
| .range-lbl { font-size: 0.68rem; color: var(--muted); width: 28px; flex-shrink: 0; } |
| .range-row input[type=range] { |
| flex: 1; -webkit-appearance: none; appearance: none; |
| height: 3px; border-radius: 2px; |
| background: var(--border); outline: none; cursor: pointer; |
| } |
| .range-row input[type=range]::-webkit-slider-thumb { |
| -webkit-appearance: none; width: 13px; height: 13px; |
| border-radius: 50%; background: var(--accent); cursor: pointer; |
| box-shadow: 0 0 0 2px rgba(56,189,248,0.25); |
| } |
| .range-display { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.68rem; color: var(--accent); width: 32px; text-align: right; |
| } |
| .filter-actions { display: flex; gap: 7px; margin-top: 2px; } |
| .btn-apply, .btn-reset { |
| flex: 1; padding: 7px; |
| border-radius: 6px; font-size: 0.72rem; |
| font-family: 'DM Sans', sans-serif; font-weight: 500; |
| cursor: pointer; transition: all 0.15s; |
| } |
| .btn-apply { |
| border: 1px solid var(--accent); |
| background: var(--accent-dim); color: var(--accent); |
| } |
| .btn-apply:hover { background: rgba(56,189,248,0.22); } |
| .btn-reset { |
| border: 1px solid var(--border); |
| background: var(--surface2); color: var(--muted); |
| } |
| .btn-reset:hover { border-color: var(--muted); color: var(--text); } |
| |
| |
| .sb-footer { |
| margin-top: auto; padding: 14px 20px; |
| border-top: 1px solid var(--border); |
| font-size: 0.62rem; color: var(--muted); line-height: 1.6; |
| } |
| |
| |
| #map { flex: 1; height: 100vh; } |
| |
| |
| .leaflet-control-zoom a { |
| background: var(--surface) !important; |
| color: var(--muted) !important; |
| border-color: var(--border) !important; |
| width: 28px !important; height: 28px !important; |
| line-height: 28px !important; |
| } |
| .leaflet-control-zoom a:hover { color: var(--accent) !important; } |
| .leaflet-control-attribution { |
| background: rgba(8,13,18,0.75) !important; |
| color: var(--muted) !important; font-size: 0.58rem !important; |
| backdrop-filter: blur(4px); |
| } |
| .leaflet-control-attribution a { color: var(--accent) !important; } |
| |
| |
| .leaflet-popup-content-wrapper { |
| background: var(--surface) !important; |
| border: 1px solid var(--border) !important; |
| border-radius: 10px !important; |
| box-shadow: 0 12px 40px rgba(0,0,0,0.6) !important; |
| color: var(--text) !important; |
| padding: 0 !important; |
| } |
| .leaflet-popup-tip { background: var(--surface) !important; } |
| .leaflet-popup-content { margin: 0 !important; } |
| |
| .popup-wrap { |
| padding: 14px 16px; |
| min-width: 380px; width: 420px; max-width: 600px; |
| font-family: 'DM Sans', sans-serif; |
| resize: both; |
| overflow: auto; |
| } |
| .popup-tabs { |
| display: flex; |
| border-bottom: 1px solid var(--border); |
| margin-bottom: 12px; |
| } |
| .tab-btn { |
| flex: 1; |
| background: transparent; |
| border: none; |
| color: var(--muted); |
| padding: 8px 0; |
| font-family: 'Syne', sans-serif; |
| font-size: 0.8rem; |
| font-weight: 700; |
| cursor: pointer; |
| border-bottom: 2px solid transparent; |
| transition: all 0.2s; |
| } |
| .tab-btn:hover { color: var(--text); } |
| .tab-btn.active { |
| color: var(--accent); |
| border-bottom: 2px solid var(--accent); |
| } |
| .popup-head { |
| font-family: 'Syne', sans-serif; |
| font-size: 0.8rem; font-weight: 700; |
| color: var(--accent); margin-bottom: 10px; |
| padding-bottom: 8px; |
| border-bottom: 1px solid var(--border); |
| display: flex; align-items: center; gap: 7px; |
| } |
| .popup-susc-row { |
| display: flex; align-items: center; gap: 9px; margin-bottom: 5px; |
| } |
| .susc-track { |
| flex: 1; height: 5px; background: var(--border); |
| border-radius: 3px; overflow: hidden; |
| } |
| .susc-fill { height: 100%; border-radius: 3px; transition: width 0.3s; } |
| .susc-num { |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.75rem; font-weight: 500; |
| } |
| .susc-label { font-size: 0.65rem; font-weight: 500; margin-bottom: 10px; } |
| .popup-attrs { |
| display: flex; flex-direction: column; gap: 4px; |
| max-height: 180px; overflow-y: auto; |
| padding-top: 8px; border-top: 1px solid var(--border); |
| } |
| .popup-attrs::-webkit-scrollbar { width: 3px; } |
| .popup-attrs::-webkit-scrollbar-thumb { background: var(--border); } |
| .popup-row { |
| display: flex; justify-content: space-between; |
| gap: 8px; font-size: 0.68rem; |
| } |
| .popup-row .pk { color: var(--muted); flex-shrink: 0; } |
| .popup-row .pv { |
| color: var(--text); text-align: right; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.66rem; word-break: break-all; |
| } |
| |
| |
| #chatbot-root { |
| position: fixed; |
| right: 22px; |
| bottom: 22px; |
| z-index: 2200; |
| font-family: 'DM Sans', sans-serif; |
| } |
| |
| .chat-launcher { |
| width: 58px; |
| height: 58px; |
| border: 1px solid rgba(56,189,248,0.45); |
| border-radius: 50%; |
| background: linear-gradient(145deg, #38bdf8, #0ea5e9); |
| color: #041018; |
| display: grid; |
| place-items: center; |
| cursor: pointer; |
| box-shadow: 0 18px 42px rgba(0,0,0,0.45), 0 0 0 7px rgba(56,189,248,0.12); |
| transition: transform 0.18s ease, box-shadow 0.18s ease; |
| } |
| .chat-launcher:hover { transform: translateY(-2px); box-shadow: 0 22px 52px rgba(0,0,0,0.5), 0 0 0 9px rgba(56,189,248,0.16); } |
| .chat-launcher:active { transform: translateY(0) scale(0.98); } |
| .chat-launcher .chat-icon { font-size: 1.55rem; line-height: 1; } |
| .chat-launcher .chat-pulse { |
| position: absolute; |
| width: 11px; |
| height: 11px; |
| top: 7px; |
| right: 7px; |
| border-radius: 50%; |
| background: var(--ok); |
| box-shadow: 0 0 0 4px rgba(74,222,128,0.16); |
| } |
| |
| .chat-panel { |
| position: absolute; |
| right: 0; |
| bottom: 76px; |
| width: min(390px, calc(100vw - 34px)); |
| height: min(620px, calc(100vh - 120px)); |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| border: 1px solid rgba(56,189,248,0.24); |
| border-radius: 8px; |
| background: rgba(15,25,35,0.96); |
| box-shadow: 0 24px 80px rgba(0,0,0,0.62); |
| backdrop-filter: blur(14px); |
| opacity: 0; |
| transform: translateY(12px) scale(0.98); |
| pointer-events: none; |
| transition: opacity 0.18s ease, transform 0.18s ease; |
| } |
| .chat-panel.open { |
| opacity: 1; |
| transform: translateY(0) scale(1); |
| pointer-events: auto; |
| } |
| |
| .chat-head { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| padding: 13px 14px; |
| border-bottom: 1px solid var(--border); |
| background: linear-gradient(135deg, rgba(22,33,48,0.94), rgba(15,25,35,0.94)); |
| } |
| .chat-mark { |
| width: 34px; |
| height: 34px; |
| border-radius: 8px; |
| display: grid; |
| place-items: center; |
| color: var(--accent); |
| background: var(--accent-dim); |
| border: 1px solid rgba(56,189,248,0.25); |
| flex-shrink: 0; |
| } |
| .chat-title { min-width: 0; flex: 1; } |
| .chat-title strong { |
| display: block; |
| font-family: 'Syne', sans-serif; |
| font-size: 0.86rem; |
| line-height: 1.1; |
| } |
| .chat-title span { |
| display: block; |
| margin-top: 3px; |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.58rem; |
| color: var(--muted); |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| } |
| .chat-close { |
| width: 30px; |
| height: 30px; |
| border: 1px solid var(--border); |
| border-radius: 7px; |
| background: var(--surface2); |
| color: var(--muted); |
| cursor: pointer; |
| font-size: 1rem; |
| } |
| .chat-close:hover { color: var(--text); border-color: var(--accent); } |
| |
| .chat-messages { |
| flex: 1; |
| overflow-y: auto; |
| padding: 14px; |
| display: flex; |
| flex-direction: column; |
| gap: 10px; |
| } |
| .chat-messages::-webkit-scrollbar { width: 4px; } |
| .chat-messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } |
| .chat-msg { |
| max-width: 88%; |
| border-radius: 8px; |
| padding: 10px 11px; |
| font-size: 0.8rem; |
| line-height: 1.45; |
| word-break: break-word; |
| animation: chatIn 0.18s ease; |
| } |
| @keyframes chatIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } |
| .chat-msg.bot { |
| align-self: flex-start; |
| background: var(--surface2); |
| border: 1px solid var(--border); |
| color: var(--text); |
| } |
| .chat-msg table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 0.75rem; } |
| .chat-msg th, .chat-msg td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; } |
| .chat-msg th { background: rgba(0,0,0,0.2); color: var(--accent); } |
| .chat-msg pre { background: #000; padding: 8px; border-radius: 4px; overflow-x: auto; margin: 8px 0; } |
| .chat-msg code { font-family: 'IBM Plex Mono', monospace; color: #fb923c; } |
| .chat-msg p { margin-bottom: 8px; } |
| .chat-msg p:last-child { margin-bottom: 0; } |
| .chat-msg ul, .chat-msg ol { padding-left: 20px; margin-bottom: 8px; } |
| .chat-msg.user { |
| align-self: flex-end; |
| background: var(--accent-dim); |
| border: 1px solid rgba(56,189,248,0.34); |
| color: #ecfeff; |
| } |
| .chat-meta { |
| margin-top: 8px; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 6px; |
| color: var(--muted); |
| font-family: 'IBM Plex Mono', monospace; |
| font-size: 0.58rem; |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| } |
| .chat-tag { |
| padding: 3px 6px; |
| border: 1px solid var(--border); |
| border-radius: 999px; |
| background: rgba(8,13,18,0.42); |
| } |
| |
| .chat-status { |
| margin: 0 14px 10px; |
| padding: 9px 10px; |
| border: 1px solid rgba(56,189,248,0.2); |
| border-radius: 8px; |
| background: rgba(8,13,18,0.45); |
| display: none; |
| } |
| .chat-status.active { display: block; } |
| .chat-status-row { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| margin-bottom: 8px; |
| color: var(--accent); |
| font-size: 0.7rem; |
| font-family: 'IBM Plex Mono', monospace; |
| } |
| .chat-spinner { |
| width: 13px; |
| height: 13px; |
| border-radius: 50%; |
| border: 2px solid var(--border); |
| border-top-color: var(--accent); |
| animation: spin 0.8s linear infinite; |
| flex-shrink: 0; |
| } |
| .chat-progress { |
| height: 3px; |
| border-radius: 999px; |
| background: var(--border); |
| overflow: hidden; |
| } |
| .chat-progress-fill { |
| height: 100%; |
| width: 0%; |
| background: linear-gradient(90deg, var(--accent), var(--ok)); |
| transition: width 0.3s ease; |
| } |
| .chat-steps { |
| margin-top: 8px; |
| display: flex; |
| flex-wrap: wrap; |
| gap: 5px; |
| } |
| .chat-step { |
| font-size: 0.58rem; |
| color: var(--muted); |
| border: 1px solid var(--border); |
| border-radius: 999px; |
| padding: 3px 6px; |
| } |
| .chat-step.done { color: var(--ok); border-color: rgba(74,222,128,0.35); } |
| .chat-step.active { color: var(--accent); border-color: rgba(56,189,248,0.45); background: var(--accent-dim); } |
| |
| .chat-quick { |
| display: flex; |
| gap: 7px; |
| padding: 0 14px 12px; |
| overflow-x: auto; |
| } |
| .chat-quick button { |
| white-space: nowrap; |
| border: 1px solid var(--border); |
| border-radius: 999px; |
| background: var(--surface2); |
| color: var(--muted); |
| padding: 7px 9px; |
| font-size: 0.68rem; |
| cursor: pointer; |
| } |
| .chat-quick button:hover { color: var(--accent); border-color: rgba(56,189,248,0.45); } |
| |
| .chat-form { |
| display: flex; |
| align-items: flex-end; |
| gap: 8px; |
| padding: 12px 14px 14px; |
| border-top: 1px solid var(--border); |
| background: rgba(8,13,18,0.34); |
| } |
| .chat-input { |
| flex: 1; |
| min-height: 38px; |
| max-height: 92px; |
| resize: none; |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| background: var(--surface2); |
| color: var(--text); |
| outline: none; |
| padding: 10px 11px; |
| font: 0.78rem/1.35 'DM Sans', sans-serif; |
| } |
| .chat-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(56,189,248,0.1); } |
| .chat-send { |
| width: 40px; |
| height: 38px; |
| border: 1px solid var(--accent); |
| border-radius: 8px; |
| background: var(--accent-dim); |
| color: var(--accent); |
| cursor: pointer; |
| font-size: 1rem; |
| flex-shrink: 0; |
| } |
| .chat-send:hover { background: rgba(56,189,248,0.2); } |
| .chat-send:disabled, .chat-input:disabled { |
| opacity: 0.55; |
| cursor: not-allowed; |
| } |
| |
| @media (max-width: 640px) { |
| #chatbot-root { right: 14px; bottom: 14px; } |
| .chat-panel { |
| position: fixed; |
| left: 12px; |
| right: 12px; |
| bottom: 84px; |
| width: auto; |
| height: min(590px, calc(100vh - 105px)); |
| } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="loading"> |
| <div class="loader-icon"> |
| <div class="loader-ring"></div> |
| <div class="loader-ring ring2"></div> |
| </div> |
| <div class="loader-title">Glacial Lake Viewer</div> |
| <div class="load-bar-bg"><div class="load-bar-fill" id="load-bar"></div></div> |
| <div id="load-msg">Initialising…</div> |
| </div> |
|
|
| <div id="app"> |
|
|
| |
| <div id="sidebar"> |
|
|
| |
| <div class="sb-header"> |
| |
| <h1>GLOF Lake<br>Susceptibility</h1> |
| <div class="sub">Glacial lake outburst flood risk — High Mountain Asia region</div> |
| </div> |
|
|
| |
| <div class="sb-sec"> |
| <div class="sb-sec-title">Basemap</div> |
| <div class="bm-wrap"> |
| <button class="bm-btn active" id="btn-osm" onclick="switchBasemap('osm')"> |
| <span class="bm-icon">🗺</span>Standard |
| </button> |
| <button class="bm-btn" id="btn-sat" onclick="switchBasemap('satellite')"> |
| <span class="bm-icon">🛰</span>Satellite |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="sb-sec"> |
| <div class="sb-sec-title">Dataset Statistics</div> |
| <div class="stats-grid"> |
| <div class="stat-card"> |
| <div class="stat-val" id="s-total">—</div> |
| <div class="stat-lbl">Total Lakes</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-val" id="s-visible">—</div> |
| <div class="stat-lbl">Visible</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-val" id="s-mean">—</div> |
| <div class="stat-lbl">Mean Hazard</div> |
| </div> |
| <div class="stat-card"> |
| <div class="stat-val" id="s-high" style="color: #f87171">—</div> |
| <div class="stat-lbl">High Risk (≥ 0.75)</div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="sb-sec"> |
| <div class="sb-sec-title">Hazard Scale</div> |
| <div class="legend-gradient"></div> |
| <div class="legend-ticks"> |
| <span>0.0</span><span>0.25</span><span>0.5</span><span>0.75</span><span>1.0</span> |
| </div> |
| <div class="cat-rows" id="cat-rows"></div> |
| </div> |
|
|
| |
| <div class="sb-sec"> |
| <div class="sb-sec-title">Filter by Hazard Level</div> |
| <div class="filter-wrap"> |
| <div class="range-row"> |
| <span class="range-lbl">Min</span> |
| <input type="range" id="f-min" min="0" max="1" step="0.01" value="0" oninput="syncRange()"> |
| <span class="range-display" id="d-min">0.00</span> |
| </div> |
| <div class="range-row"> |
| <span class="range-lbl">Max</span> |
| <input type="range" id="f-max" min="0" max="1" step="0.01" value="1" oninput="syncRange()"> |
| <span class="range-display" id="d-max">1.00</span> |
| </div> |
| <div class="filter-actions"> |
| <button class="btn-apply" onclick="applyFilter()">Apply Filter</button> |
| <button class="btn-reset" onclick="resetFilter()">Reset</button> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div class="sb-footer"> |
| ⚠ Google Satellite uses unofficial tiles — local/dev use only.<br> |
| Click any lake to inspect its attributes. |
| </div> |
|
|
| </div> |
|
|
| |
| <div id="map"></div> |
| </div> |
|
|
| |
| <div id="chatbot-root"> |
| <section class="chat-panel" id="chat-panel" aria-label="GLOF assistant" aria-hidden="true"> |
| <div class="chat-head"> |
| <div class="chat-mark" aria-hidden="true">💬</div> |
| <div class="chat-title"> |
| <strong>GLOF Assistant</strong> |
| |
| </div> |
| <button class="chat-close" id="chat-close" type="button" aria-label="Close chat">x</button> |
| </div> |
|
|
| <div class="chat-messages" id="chat-messages" aria-live="polite"> |
| <div class="chat-msg bot"> |
| GLOF assistant ready. Ask about lake risk, KG relations, nearby lakes, or forecast probability for a GLAKE_ID. |
| </div> |
| </div> |
|
|
| <div class="chat-status" id="chat-status" aria-live="polite"> |
| <div class="chat-status-row"> |
| <span class="chat-spinner" aria-hidden="true"></span> |
| <span id="chat-status-text">Working...</span> |
| </div> |
| <div class="chat-progress"><div class="chat-progress-fill" id="chat-progress-fill"></div></div> |
| <div class="chat-steps" id="chat-steps"></div> |
| </div> |
|
|
| |
| |
| |
| |
| |
|
|
| <form class="chat-form" id="chat-form"> |
| <textarea class="chat-input" id="chat-input" rows="1" placeholder="Ask the assistant..." autocomplete="off"></textarea> |
| <button class="chat-send" id="chat-send" type="submit" aria-label="Send message">➤</button> |
| </form> |
| </section> |
|
|
| <button class="chat-launcher" id="chat-toggle" type="button" aria-label="Open GLOF assistant" aria-expanded="false"> |
| <span class="chat-icon" aria-hidden="true">💬</span> |
| <span class="chat-pulse" aria-hidden="true"></span> |
| </button> |
| </div> |
|
|
| <script> |
| |
| |
| |
| const CSV_PATH = 'trial_1064_full_dataset_susceptibility_wgs84.csv'; |
| const GEOM_COL = 'geometry'; |
| const SUSC_COL = 'Hazard_prob'; |
| |
| |
| |
| |
| const STOPS = [ |
| [0.00, [59, 130, 246]], |
| [0.20, [34, 211, 238]], |
| [0.40, [74, 222, 128]], |
| [0.60, [250, 204, 21]], |
| [0.80, [249, 115, 22]], |
| [1.00, [239, 68, 68]], |
| ]; |
| |
| function getColor(v) { |
| v = isNaN(v) ? 0 : Math.max(0, Math.min(1, v)); |
| for (let i = 0; i < STOPS.length - 1; i++) { |
| const [t0, c0] = STOPS[i], [t1, c1] = STOPS[i + 1]; |
| if (v >= t0 && v <= t1) { |
| const t = (v - t0) / (t1 - t0); |
| return `rgb(${~~(c0[0]+(c1[0]-c0[0])*t)},${~~(c0[1]+(c1[1]-c0[1])*t)},${~~(c0[2]+(c1[2]-c0[2])*t)})`; |
| } |
| } |
| return 'rgb(239,68,68)'; |
| } |
| |
| function suscLabel(v) { |
| if (isNaN(v) || v === null) return { text: 'Unknown', color: '#5a7a99' }; |
| if (v < 0.25) return { text: 'Low Hazard', color: '#3b82f6' }; |
| if (v < 0.50) return { text: 'Medium-Low Hazard', color: '#22d3ee' }; |
| if (v < 0.75) return { text: 'Medium-High Hazard', color: '#f59e0b' }; |
| return { text: 'High Hazard', color: '#ef4444' }; |
| } |
| |
| |
| |
| |
| function parseCoord(s) { |
| const p = s.trim().split(/\s+/); |
| return [parseFloat(p[0]), parseFloat(p[1])]; |
| } |
| function parseRing(s) { |
| return s.trim().replace(/^\(|\)$/g, '').split(',').map(parseCoord); |
| } |
| function wktToGeoJSON(wkt) { |
| wkt = wkt.trim(); |
| const up = wkt.toUpperCase(); |
| if (up.startsWith('MULTIPOLYGON')) { |
| const body = wkt.slice(wkt.indexOf('(')); |
| const polys = []; |
| const polyRe = /\((\s*\([^()]+\)\s*,?\s*)+\)/g; |
| let pm; |
| while ((pm = polyRe.exec(body)) !== null) { |
| const rings = []; |
| const ringRe = /\([^()]+\)/g; |
| let rm; |
| while ((rm = ringRe.exec(pm[0])) !== null) rings.push(parseRing(rm[0])); |
| if (rings.length) polys.push(rings); |
| } |
| return { type: 'MultiPolygon', coordinates: polys }; |
| } |
| if (up.startsWith('POLYGON')) { |
| const body = wkt.slice(wkt.indexOf('(')); |
| const rings = []; |
| const re = /\([^()]+\)/g; |
| let m; |
| while ((m = re.exec(body)) !== null) rings.push(parseRing(m[0])); |
| return { type: 'Polygon', coordinates: rings }; |
| } |
| if (up.startsWith('POINT')) { |
| const inner = wkt.replace(/^POINT\s*\(/, '').replace(/\)\s*$/, ''); |
| return { type: 'Point', coordinates: parseCoord(inner) }; |
| } |
| return null; |
| } |
| |
| |
| |
| |
| const renderer = L.canvas({ padding: 0.5 }); |
| |
| const map = L.map('map', { |
| center: [33, 80], zoom: 5, |
| renderer, |
| zoomControl: true, |
| attributionControl: true, |
| }); |
| |
| const osmLayer = L.tileLayer( |
| 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', |
| { attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a> contributors', maxZoom: 19 } |
| ); |
| |
| const satLayer = L.tileLayer( |
| 'https://mt{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', |
| { |
| subdomains: ['0','1','2','3'], |
| attribution: 'Imagery © Google', |
| maxZoom: 20, |
| } |
| ); |
| |
| osmLayer.addTo(map); |
| |
| function switchBasemap(type) { |
| if (type === 'osm') { |
| map.removeLayer(satLayer); |
| osmLayer.addTo(map); |
| if (lakeLayer) lakeLayer.bringToFront(); |
| document.getElementById('btn-osm').classList.add('active'); |
| document.getElementById('btn-sat').classList.remove('active'); |
| } else { |
| map.removeLayer(osmLayer); |
| satLayer.addTo(map); |
| if (lakeLayer) lakeLayer.bringToFront(); |
| document.getElementById('btn-sat').classList.add('active'); |
| document.getElementById('btn-osm').classList.remove('active'); |
| } |
| } |
| |
| |
| |
| |
| let allFeatures = []; |
| let lakeLayer = null; |
| |
| function loadData() { |
| progress('Fetching data…', 10); |
| Papa.parse(CSV_PATH, { |
| download: true, |
| header: true, |
| skipEmptyLines: true, |
| complete(res) { |
| progress(`Parsed ${res.data.length.toLocaleString()} rows. Converting geometries…`, 25); |
| |
| setTimeout(() => processRows(res.data), 30); |
| }, |
| error(err) { progress('Error loading CSV: ' + err.message, 0); } |
| }); |
| } |
| |
| function processRows(rows) { |
| const features = []; |
| let skip = 0; |
| |
| |
| const keys = Object.keys(rows[0] || {}); |
| const geomKey = keys.find(k => k.toLowerCase() === GEOM_COL.toLowerCase()) || GEOM_COL; |
| |
| |
| let suscKey = keys.find(k => k.toLowerCase() === 'hazard_prob') || |
| keys.find(k => k.toLowerCase() === 'hazard') || |
| keys.find(k => k.toLowerCase() === 'susceptibility') || SUSC_COL; |
| |
| for (let i = 0; i < rows.length; i++) { |
| const row = rows[i]; |
| const wkt = row[geomKey]; |
| if (!wkt) { skip++; continue; } |
| try { |
| const geom = wktToGeoJSON(wkt); |
| if (!geom) { skip++; continue; } |
| const props = {}; |
| for (const k of keys) if (k !== geomKey) props[k] = row[k]; |
| |
| |
| props[SUSC_COL] = row[suscKey]; |
| |
| features.push({ type: 'Feature', geometry: geom, properties: props }); |
| } catch (_) { skip++; } |
| } |
| |
| progress(`Rendering ${features.length.toLocaleString()} lakes…`, 70); |
| allFeatures = features; |
| |
| setTimeout(() => { |
| renderLayer(features, true); |
| updateStats(features, SUSC_COL); |
| buildCatRows(features, SUSC_COL); |
| hideLoading(); |
| }, 30); |
| } |
| |
| |
| function lakeStyle(feature) { |
| const v = parseFloat(feature.properties[SUSC_COL]); |
| const c = getColor(v); |
| return { |
| fillColor: c, |
| fillOpacity: 0.72, |
| color: c, |
| weight: 0.6, |
| opacity: 1, |
| }; |
| } |
| |
| function renderLayer(features, fit) { |
| if (lakeLayer) map.removeLayer(lakeLayer); |
| |
| lakeLayer = L.geoJSON({ type: 'FeatureCollection', features }, { |
| renderer, |
| style: lakeStyle, |
| onEachFeature(feature, layer) { |
| layer.on({ |
| click(e) { openPopup(feature.properties, e.latlng); }, |
| mouseover(e) { this.setStyle({ fillOpacity: 1, weight: 1.5 }); }, |
| mouseout() { lakeLayer.resetStyle(this); }, |
| }); |
| }, |
| }); |
| |
| lakeLayer.addTo(map); |
| |
| if (fit && features.length) { |
| try { map.fitBounds(lakeLayer.getBounds(), { padding: [24, 24] }); } catch (_) {} |
| } |
| |
| document.getElementById('s-visible').textContent = features.length.toLocaleString(); |
| } |
| |
| |
| function openPopup(props, latlng) { |
| const raw = parseFloat(props[SUSC_COL]); |
| const v = isNaN(raw) ? 0 : raw; |
| const c = getColor(v); |
| const lbl = suscLabel(raw); |
| |
| |
| const skipKeys = new Set([SUSC_COL, SUSC_COL.toLowerCase()]); |
| let rows = ''; |
| for (const [k, val] of Object.entries(props)) { |
| if (skipKeys.has(k.toLowerCase()) || val === '' || val == null) continue; |
| const disp = String(val).length > 28 ? String(val).slice(0, 28) + '…' : val; |
| rows += `<div class="popup-row"><span class="pk">${k}</span><span class="pv">${disp}</span></div>`; |
| } |
| |
| const html = ` |
| <div class="popup-wrap"> |
| <div class="popup-tabs"> |
| <button class="tab-btn active" onclick="document.getElementById('tab-details').style.display='block'; document.getElementById('tab-weather').style.display='none'; this.classList.add('active'); this.nextElementSibling.classList.remove('active');">🏔 Lake Details</button> |
| <button class="tab-btn" onclick="document.getElementById('tab-details').style.display='none'; document.getElementById('tab-weather').style.display='block'; this.classList.add('active'); this.previousElementSibling.classList.remove('active');">🌦 Live Weather</button> |
| </div> |
| |
| <div id="tab-details" style="display:block;"> |
| <div class="popup-susc-row"> |
| <div class="susc-track"> |
| <div class="susc-fill" style="width:${(v*100).toFixed(1)}%;background:${c}"></div> |
| </div> |
| <span class="susc-num" style="color:${c}">${isNaN(raw)?'N/A':raw.toFixed(4)}</span> |
| </div> |
| <div class="susc-label" style="color:${lbl.color}">${lbl.text}</div> |
| ${rows ? `<div class="popup-attrs">${rows}</div>` : ''} |
| </div> |
| |
| <div id="tab-weather" style="display:none;"> |
| <div id="weather-chart" style="width: 100%; height: 260px; display: flex; align-items: center; justify-content: center; background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;"><span style="color:var(--muted); font-size: 0.7rem;">Loading live weather...</span></div> |
| </div> |
| </div>`; |
| |
| L.popup({ maxWidth: 600, minWidth: 380, closeButton: true }) |
| .setLatLng(latlng) |
| .setContent(html) |
| .openOn(map); |
| |
| fetchAndDrawChart(latlng.lat, latlng.lng); |
| } |
| |
| function fetchAndDrawChart(lat, lon) { |
| const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&hourly=temperature_2m,precipitation,snowfall,surface_pressure&past_days=3&forecast_days=1`; |
| |
| fetch(url) |
| .then(res => res.json()) |
| .then(data => { |
| const container = document.getElementById('weather-chart'); |
| if (!container) return; |
| container.innerHTML = ''; |
| |
| var options = { |
| series: [ |
| { name: 'Temperature (°C)', type: 'line', data: data.hourly.temperature_2m }, |
| { name: 'Precipitation (mm)', type: 'area', data: data.hourly.precipitation }, |
| { name: 'Snowfall (cm)', type: 'area', data: data.hourly.snowfall }, |
| { name: 'Pressure (hPa)', type: 'line', data: data.hourly.surface_pressure } |
| ], |
| chart: { |
| height: 260, |
| type: 'line', |
| toolbar: { show: false }, |
| background: 'transparent', |
| fontFamily: 'DM Sans, sans-serif', |
| animations: { enabled: true, easing: 'easeinout', speed: 800 }, |
| parentHeightOffset: 0 |
| }, |
| colors: ['#facc15', '#38bdf8', '#e2e8f0', '#a855f7'], |
| stroke: { curve: 'smooth', width: [3, 0, 0, 2] }, |
| fill: { type: 'solid', opacity: [1, 0.25, 0.4, 1] }, |
| xaxis: { |
| categories: data.hourly.time, |
| type: 'datetime', |
| labels: { style: { colors: '#5a7a99', fontSize: '10px' }, datetimeUTC: false, offsetY: -5 }, |
| axisBorder: { show: false }, |
| axisTicks: { show: false }, |
| tooltip: { enabled: false } |
| }, |
| yaxis: [ |
| { show: false }, |
| { show: false }, |
| { show: false }, |
| { show: false } |
| ], |
| theme: { mode: 'dark' }, |
| grid: { |
| borderColor: '#1f3048', |
| strokeDashArray: 4, |
| xaxis: { lines: { show: true } }, |
| yaxis: { lines: { show: false } }, |
| padding: { top: 0, right: 0, bottom: 0, left: 10 } |
| }, |
| legend: { show: true, fontSize: '10px', markers: { width: 8, height: 8 }, itemMargin: { horizontal: 5, vertical: 0 } }, |
| tooltip: { |
| theme: 'dark', |
| x: { format: 'dd MMM HH:mm' }, |
| style: { fontSize: '11px' } |
| } |
| }; |
| |
| var chart = new ApexCharts(container, options); |
| chart.render(); |
| |
| |
| const weatherTabBtn = document.querySelectorAll('.tab-btn')[1]; |
| if (weatherTabBtn) { |
| weatherTabBtn.addEventListener('click', () => { |
| setTimeout(() => { chart.windowResize(); }, 50); |
| }); |
| } |
| }) |
| .catch(err => { |
| const container = document.getElementById('weather-chart'); |
| if (container) container.innerHTML = '<span style="color:#ef4444; font-size:0.7rem;">Failed to load weather</span>'; |
| }); |
| } |
| |
| |
| function updateStats(features, suscKey) { |
| const col = suscKey || SUSC_COL; |
| const vals = features.map(f => parseFloat(f.properties[col])).filter(v => !isNaN(v)); |
| const mean = vals.length ? vals.reduce((a,b)=>a+b,0)/vals.length : NaN; |
| const high = vals.filter(v => v >= 0.75).length; |
| |
| document.getElementById('s-total').textContent = allFeatures.length.toLocaleString(); |
| document.getElementById('s-visible').textContent = features.length.toLocaleString(); |
| document.getElementById('s-mean').textContent = isNaN(mean) ? 'N/A' : mean.toFixed(3); |
| document.getElementById('s-high').textContent = high.toLocaleString(); |
| } |
| |
| function buildCatRows(features, suscKey) { |
| const col = suscKey || SUSC_COL; |
| const cats = [ |
| { lbl: 'Low (< 0.25)', r: [0, 0.25], c: '#3b82f6' }, |
| { lbl: 'Med-Low (0.25–0.5)', r: [0.25, 0.50], c: '#22d3ee' }, |
| { lbl: 'Med-High (0.5–0.75)', r: [0.50, 0.75], c: '#f59e0b' }, |
| { lbl: 'High (≥ 0.75)', r: [0.75, 1.01], c: '#ef4444' }, |
| ]; |
| document.getElementById('cat-rows').innerHTML = cats.map(cat => { |
| const n = features.filter(f => { |
| const v = parseFloat(f.properties[col]); |
| return !isNaN(v) && v >= cat.r[0] && v < cat.r[1]; |
| }).length; |
| return `<div class="cat-row"> |
| <div class="cat-swatch" style="background:${cat.c}"></div> |
| <span>${cat.lbl}</span> |
| <span class="cat-count">${n.toLocaleString()}</span> |
| </div>`; |
| }).join(''); |
| } |
| |
| |
| function syncRange() { |
| const mn = parseFloat(document.getElementById('f-min').value); |
| const mx = parseFloat(document.getElementById('f-max').value); |
| document.getElementById('d-min').textContent = mn.toFixed(2); |
| document.getElementById('d-max').textContent = mx.toFixed(2); |
| } |
| |
| function applyFilter() { |
| const mn = parseFloat(document.getElementById('f-min').value); |
| const mx = parseFloat(document.getElementById('f-max').value); |
| const filtered = allFeatures.filter(f => { |
| const v = parseFloat(f.properties[SUSC_COL]); |
| return !isNaN(v) && v >= mn && v <= mx; |
| }); |
| renderLayer(filtered, false); |
| updateStats(filtered); |
| } |
| |
| function resetFilter() { |
| document.getElementById('f-min').value = 0; |
| document.getElementById('f-max').value = 1; |
| syncRange(); |
| renderLayer(allFeatures, false); |
| updateStats(allFeatures); |
| } |
| |
| |
| function progress(msg, pct) { |
| document.getElementById('load-msg').textContent = msg; |
| if (pct !== undefined) |
| document.getElementById('load-bar').style.width = pct + '%'; |
| } |
| |
| function hideLoading() { |
| progress('Done!', 100); |
| setTimeout(() => { |
| const el = document.getElementById('loading'); |
| el.classList.add('fade'); |
| setTimeout(() => el.classList.add('gone'), 700); |
| }, 400); |
| } |
| |
| |
| |
| const CHAT_API_URL = window.location.protocol === 'file:' |
| ? 'http://127.0.0.1:8000/api/chat' |
| : '/api/chat'; |
| |
| const chatPanel = document.getElementById('chat-panel'); |
| const chatToggle = document.getElementById('chat-toggle'); |
| const chatClose = document.getElementById('chat-close'); |
| const chatMessages = document.getElementById('chat-messages'); |
| const chatForm = document.getElementById('chat-form'); |
| const chatInput = document.getElementById('chat-input'); |
| const chatSend = document.getElementById('chat-send'); |
| const chatStatus = document.getElementById('chat-status'); |
| const chatStatusText = document.getElementById('chat-status-text'); |
| const chatProgressFill = document.getElementById('chat-progress-fill'); |
| const chatSteps = document.getElementById('chat-steps'); |
| const chatRouteLabel = document.getElementById('chat-route-label') || document.createElement('span'); |
| |
| let chatBusy = false; |
| let chatProgressTimer = null; |
| |
| function toggleChat(forceOpen) { |
| const open = typeof forceOpen === 'boolean' ? forceOpen : !chatPanel.classList.contains('open'); |
| chatPanel.classList.toggle('open', open); |
| chatPanel.setAttribute('aria-hidden', open ? 'false' : 'true'); |
| chatToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); |
| chatToggle.setAttribute('aria-label', open ? 'Close GLOF assistant' : 'Open GLOF assistant'); |
| if (open) setTimeout(() => chatInput.focus(), 120); |
| } |
| |
| function escapeHtml(value) { |
| return String(value) |
| .replace(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/g, '''); |
| } |
| |
| function formatChatText(value) { |
| return escapeHtml(value).replace(/\n/g, '<br>'); |
| } |
| |
| function appendChatMessage(role, text, meta) { |
| const msg = document.createElement('div'); |
| msg.className = `chat-msg ${role}`; |
| |
| if (role === 'bot' && typeof marked !== 'undefined') { |
| msg.innerHTML = marked.parse(text); |
| } else { |
| msg.innerHTML = formatChatText(text); |
| } |
| |
| if (meta && meta.length) { |
| const metaRow = document.createElement('div'); |
| metaRow.className = 'chat-meta'; |
| metaRow.innerHTML = meta.map(item => `<span class="chat-tag">${escapeHtml(item)}</span>`).join(''); |
| msg.appendChild(metaRow); |
| } |
| |
| chatMessages.appendChild(msg); |
| chatMessages.scrollTop = chatMessages.scrollHeight; |
| } |
| |
| function startChatProgress() { |
| let tick = 0; |
| clearInterval(chatProgressTimer); |
| chatStatus.classList.add('active'); |
| chatRouteLabel.textContent = `Agentic RAG`; |
| chatSteps.innerHTML = ''; |
| |
| const draw = () => { |
| |
| const pct = Math.min(95, 15 + tick * 4 + Math.sin(tick) * 5); |
| chatStatusText.textContent = 'Agent is reasoning...'; |
| chatProgressFill.style.width = pct + '%'; |
| tick += 1; |
| }; |
| |
| draw(); |
| chatProgressTimer = setInterval(draw, 1000); |
| } |
| |
| function finishChatProgress() { |
| clearInterval(chatProgressTimer); |
| chatStatusText.textContent = 'Answer ready'; |
| chatProgressFill.style.width = '100%'; |
| setTimeout(() => chatStatus.classList.remove('active'), 750); |
| } |
| |
| function setChatBusy(isBusy) { |
| chatBusy = isBusy; |
| chatInput.disabled = isBusy; |
| chatSend.disabled = isBusy; |
| } |
| |
| async function sendChatQuestion(question) { |
| if (!question || chatBusy) return; |
| toggleChat(true); |
| appendChatMessage('user', question); |
| chatInput.value = ''; |
| chatInput.style.height = 'auto'; |
| |
| setChatBusy(true); |
| startChatProgress(); |
| |
| try { |
| const res = await fetch(CHAT_API_URL, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ question, route: 'auto' }), |
| }); |
| const data = await res.json().catch(() => ({})); |
| if (!res.ok || data.error) throw new Error(data.error || `Request failed (${res.status})`); |
| |
| finishChatProgress(); |
| const meta = [`route: ${data.route || 'agentic'}`]; |
| if (data.vector_evidence && data.vector_evidence.length) meta.push(`${data.vector_evidence.length} sources`); |
| if (data.has_kg_context) meta.push('KG'); |
| if (data.has_forecast_context) meta.push('forecast'); |
| appendChatMessage('bot', data.answer || 'No answer returned.', meta); |
| chatRouteLabel.textContent = 'GraphRAG ready'; |
| } catch (err) { |
| finishChatProgress(); |
| appendChatMessage( |
| 'bot', |
| `I could not reach the chatbot service.\n\nStart it with: python chatbot/chat_server.py\nThen open: http://127.0.0.1:8000/\n\nDetails: ${err.message}` |
| ); |
| chatRouteLabel.textContent = 'Service offline'; |
| } finally { |
| setChatBusy(false); |
| } |
| } |
| |
| chatToggle.addEventListener('click', () => toggleChat()); |
| chatClose.addEventListener('click', () => toggleChat(false)); |
| |
| chatForm.addEventListener('submit', event => { |
| event.preventDefault(); |
| sendChatQuestion(chatInput.value.trim()); |
| }); |
| |
| chatInput.addEventListener('keydown', event => { |
| if (event.key === 'Enter' && !event.shiftKey) { |
| event.preventDefault(); |
| chatForm.requestSubmit(); |
| } |
| }); |
| |
| chatInput.addEventListener('input', () => { |
| chatInput.style.height = 'auto'; |
| chatInput.style.height = Math.min(chatInput.scrollHeight, 92) + 'px'; |
| }); |
| |
| document.querySelectorAll('[data-chat-prompt]').forEach(button => { |
| button.addEventListener('click', () => sendChatQuestion(button.dataset.chatPrompt)); |
| }); |
| |
| loadData(); |
| </script> |
| </body> |
| </html> |
|
|