| """Orion's Cottage – Gradio frontend |
| |
| Phase 1 layout, Phase 4 three-model demo, Phase 5 constellation browser, Phase 6 extended planning. |
| """ |
|
|
| import os |
| import sys |
| from pathlib import Path |
|
|
|
|
| def _bootstrap_venv() -> None: |
| """Re-exec with .venv Python when deps are installed there but not system-wide.""" |
| try: |
| import gradio |
| except ModuleNotFoundError as exc: |
| |
| |
| |
| if exc.name != "gradio": |
| raise |
| venv_python = Path(__file__).resolve().parent / ".venv" / "bin" / "python" |
| if __name__ == "__main__" and venv_python.is_file(): |
| script_args = sys.argv[1:] if Path(sys.argv[0]).name.startswith("python") else sys.argv |
| os.execv(str(venv_python), [str(venv_python), *script_args]) |
| sys.exit( |
| "gradio not found. Install dependencies in a virtual environment:\n" |
| " python3 -m venv .venv\n" |
| " .venv/bin/pip install -r requirements.txt\n" |
| " .venv/bin/python app.py" |
| ) |
|
|
|
|
| _bootstrap_venv() |
|
|
| import gradio as gr |
| from dotenv import load_dotenv |
| from datetime import datetime, date |
| from typing import Any, Optional |
|
|
| |
| load_dotenv() |
|
|
| from agent.router import generate_model_plan, generate_three_models_plan, _resolve_observation_time, _pick_target_name |
| |
| from core.visualization import generate_sky_chart |
| from core.astronomy import get_geometry_data |
| from core.environment import get_environment_data |
| from core.scoring import compute_stargazing_score |
| from config import DEFAULT_MODEL, DEFAULT_USE_MODAL_FOR_HEAVY_COMPUTE, MODEL_CHOICES |
| from data.locations import LOCATIONS |
| from ui_helpers import ( |
| _render_history_html, _render_cottage_board, |
| _render_constellation_cards, _filter_constellations, |
| _render_planner_target_hint, _render_planning_panel, |
| planner_target_choices, PLANNER_TARGET_AUTO, |
| ) |
| from data.constellations import CONSTELLATIONS, CONSTELLATIONS_BY_NAME |
| from core.geocoder import resolve_plan_location |
| from agent.nl_parser import parse_nl_query, find_best_date |
|
|
| |
|
|
| midnight_oasis_theme = gr.themes.Base( |
| font=[gr.themes.GoogleFont("Geist"), "ui-sans-serif", "system-ui", "sans-serif"], |
| font_mono=[gr.themes.GoogleFont("Geist Mono"), "ui-monospace", "Consolas", "monospace"], |
| ).set( |
| body_background_fill="#05070f", |
| body_background_fill_dark="#05070f", |
| body_text_color="#f0f2f8", |
| body_text_color_dark="#f0f2f8", |
| background_fill_primary="#161a26", |
| background_fill_primary_dark="#161a26", |
| background_fill_secondary="#1f2433", |
| background_fill_secondary_dark="#1f2433", |
| border_color_primary="rgba(210, 187, 255, 0.28)", |
| border_color_primary_dark="rgba(210, 187, 255, 0.28)", |
| block_background_fill="rgba(44, 49, 67, 0.4)", |
| block_background_fill_dark="rgba(44, 49, 67, 0.4)", |
| block_border_width="1px", |
| block_border_color="rgba(210, 187, 255, 0.2)", |
| block_radius="12px", |
| input_background_fill="#1a1e28", |
| input_background_fill_dark="#1a1e28", |
| input_border_color="rgba(210, 187, 255, 0.35)", |
| input_border_color_dark="rgba(210, 187, 255, 0.35)", |
| input_border_color_focus="rgba(210, 187, 255, 0.55)", |
| input_border_color_focus_dark="rgba(210, 187, 255, 0.55)", |
| input_placeholder_color="#9aa3b5", |
| input_placeholder_color_dark="#9aa3b5", |
| button_primary_background_fill="#7c3aed", |
| button_primary_background_fill_dark="#7c3aed", |
| button_primary_text_color="#f7ecff", |
| button_primary_text_color_dark="#f7ecff", |
| panel_background_fill="rgba(44, 49, 67, 0.4)", |
| ) |
|
|
| CUSTOM_CSS = """ |
| /* Midnight Oasis design tokens — swap on body.red-light-mode for scotopic */ |
| :root { |
| --z-dropdown: 50; |
| --z-fab: 40; |
| --z-controls: 30; |
| --z-controls-secondary: 25; |
| --z-telemetry: 10; |
| --z-surface: 1; |
| --z-banner: 100; |
| --surface-body: #05070f; |
| --surface-sidebar: #0f1320; |
| --surface-card: #161a26; |
| --surface-elevated: #1f2433; |
| --surface-input: #1a1e28; |
| --surface-inset: rgba(255, 255, 255, 0.05); |
| --surface-chip: rgba(44, 49, 67, 0.6); |
| --surface-pill: rgba(44, 49, 67, 0.5); |
| --surface-tabs: rgba(16, 19, 26, 0.7); |
| --ink: #f0f2f8; |
| --ink-strong: #f5f6fc; |
| --ink-muted: #e8eaf2; |
| --ink-subtle: #c5c9d8; |
| --ink-faint: #9aa3b5; |
| --ink-on-accent: #ede0ff; |
| --accent: #7c3aed; |
| --accent-hover: #6d28d9; |
| --accent-soft: #d2bbff; |
| --accent-icon: #a855f7; |
| --accent-secondary: #ffb347; |
| --accent-secondary-soft: #ffd08a; |
| --border-subtle: rgba(210, 187, 255, 0.18); |
| --border-default: rgba(210, 187, 255, 0.28); |
| --border-strong: rgba(210, 187, 255, 0.5); |
| --border-nav: rgba(210, 187, 255, 0.18); |
| --score-good: #10b981; |
| --score-mid: #f59e0b; |
| --score-low: #f43f5e; |
| --score-muted: #e1e2eb; |
| --status-ok: #34d399; |
| --status-offline: #f87171; |
| --offgrid-bg: rgba(147, 0, 10, 0.35); |
| --offgrid-border: #b91c1c; |
| --offgrid-ink: #fecaca; |
| --model-mini-border: rgba(167, 139, 250, 0.35); |
| --model-aya-border: rgba(52, 211, 153, 0.35); |
| --model-nemotron-border: rgba(96, 165, 250, 0.35); |
| --radius-sm: 8px; |
| --radius-md: 12px; |
| --radius-pill: 999px; |
| --focus-ring: rgba(255, 179, 71, 0.48); |
| } |
| |
| body.red-light-mode { |
| --surface-body: #050000; |
| --surface-sidebar: #1a0505; |
| --surface-card: #1a0808; |
| --surface-elevated: #220a0a; |
| --surface-input: #110000; |
| --surface-inset: rgba(255, 255, 255, 0.04); |
| --surface-chip: rgba(70, 0, 0, 0.55); |
| --surface-pill: rgba(60, 0, 0, 0.5); |
| --surface-tabs: rgba(20, 0, 0, 0.8); |
| --ink: #ff9999; |
| --ink-strong: #ffcccc; |
| --ink-muted: #ff9999; |
| --ink-subtle: #ffaaaa; |
| --ink-faint: #cc8888; |
| --ink-on-accent: #1a0000; |
| --accent: #cc0000; |
| --accent-hover: #aa0000; |
| --accent-soft: #ff6666; |
| --accent-icon: #ff6666; |
| --accent-secondary: #ff5555; |
| --accent-secondary-soft: #ff9999; |
| --border-subtle: rgba(255, 68, 68, 0.15); |
| --border-default: rgba(255, 60, 60, 0.25); |
| --border-strong: rgba(255, 68, 68, 0.35); |
| --border-nav: rgba(255, 0, 0, 0.2); |
| --status-ok: #ff4444; |
| --status-offline: #ff3333; |
| --score-good: #ff6666; |
| --score-mid: #cc4444; |
| --score-low: #881111; |
| --score-muted: #ffaaaa; |
| --focus-ring: rgba(255, 120, 120, 0.65); |
| } |
| |
| /* Typography Overrides */ |
| h1, h2, h3, h4, h5, h6, .gr-button { font-family: 'Space Grotesk', sans-serif !important; } |
| |
| /* Readable prose — avoid blanket `*` selectors (they break inputs & dropdowns) */ |
| .gradio-container .gradio-markdown, |
| .gradio-container .gradio-markdown p, |
| .gradio-container .gradio-markdown li { |
| color: var(--ink-muted) !important; |
| line-height: 1.55; |
| } |
| .gradio-container .gradio-markdown h1, |
| .gradio-container .gradio-markdown h2, |
| .gradio-container .gradio-markdown h3 { |
| color: var(--ink-strong) !important; |
| font-weight: 600; |
| } |
| |
| /* Form fields — explicit dark fill + light text (Gradio 4/5) */ |
| .gradio-container input, |
| .gradio-container textarea, |
| .gradio-container select, |
| .gradio-container .gr-box, |
| .gradio-container .gr-textbox textarea, |
| .gradio-container .gr-textbox input, |
| .gradio-container .gr-dropdown input, |
| .gradio-container .gr-dropdown .wrap, |
| .gradio-container .secondary-wrap, |
| .gradio-container [data-testid="textbox"] textarea, |
| .gradio-container [data-testid="textbox"] input, |
| .gradio-container [data-testid="dropdown"] input { |
| color: var(--ink) !important; |
| -webkit-text-fill-color: var(--ink) !important; |
| background-color: var(--surface-input) !important; |
| caret-color: var(--accent-soft) !important; |
| } |
| .gradio-container input::placeholder, |
| .gradio-container textarea::placeholder { |
| color: var(--ink-faint) !important; |
| opacity: 1 !important; |
| -webkit-text-fill-color: var(--ink-faint) !important; |
| } |
| |
| .gradio-container input:focus, |
| .gradio-container textarea:focus, |
| .gradio-container select:focus, |
| .gradio-container [data-testid="textbox"] textarea:focus, |
| .gradio-container [data-testid="textbox"] input:focus, |
| .gradio-container [data-testid="dropdown"] input:focus { |
| border-color: var(--border-strong) !important; |
| box-shadow: 0 0 8px var(--focus-ring) !important; |
| outline: none !important; |
| } |
| |
| /* Dropdown menus must float above panels */ |
| .gradio-container .gradio-row, |
| .gradio-container .gradio-column, |
| .gradio-container .form, |
| .gradio-container .block, |
| .controls-bar, |
| .controls-bar .wrap, |
| .controls-bar .form { |
| overflow: visible !important; |
| } |
| .controls-bar { |
| position: relative; |
| z-index: var(--z-controls); |
| } |
| .controls-bar--secondary { |
| z-index: var(--z-controls-secondary); |
| } |
| .gradio-container ul.options, |
| .gradio-container .options, |
| .gradio-container [role="listbox"], |
| .gradio-container .dropdown-menu { |
| z-index: var(--z-dropdown) !important; |
| background: var(--surface-card) !important; |
| color: var(--ink) !important; |
| border: 1px solid var(--border-strong) !important; |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35) !important; |
| } |
| .gradio-container ul.options li, |
| .gradio-container .options li, |
| .gradio-container [role="option"] { |
| color: var(--ink) !important; |
| background: var(--surface-card) !important; |
| } |
| .gradio-container ul.options li:hover, |
| .gradio-container .options li:hover, |
| .gradio-container [role="option"]:hover { |
| background: var(--surface-elevated) !important; |
| } |
| |
| /* Subtle atmosphere — kept light for GPU / Off Grid */ |
| .gradio-container::before { |
| content: ""; position: fixed; top: -10%; left: -10%; width: 40vw; height: 40vw; |
| border-radius: 50%; background-color: rgba(124, 58, 237, 0.07); |
| filter: blur(48px); z-index: -1; pointer-events: none; |
| } |
| |
| /* Planner form — stable grid, no backdrop-filter (breaks dropdown popovers) */ |
| .planner-controls { |
| background: rgba(16, 19, 26, 0.85); |
| border: 1px solid rgba(210, 187, 255, 0.2); |
| border-radius: 12px; |
| padding: 16px; |
| margin-bottom: 12px; |
| overflow: visible !important; |
| } |
| .planner-controls .gr-row { |
| gap: 12px !important; |
| flex-wrap: wrap !important; |
| align-items: flex-end !important; |
| overflow: visible !important; |
| } |
| .planner-controls .gr-column { |
| flex: 1 1 180px !important; |
| min-width: 160px !important; |
| overflow: visible !important; |
| } |
| .planner-controls label, |
| .planner-controls .label-wrap span { |
| color: var(--ink-subtle) !important; |
| font-size: 0.72rem !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.02em; |
| text-transform: none; |
| } |
| .planner-controls .block, |
| .planner-controls .wrap, |
| .planner-controls .form { |
| overflow: visible !important; |
| } |
| |
| /* Phase 1 Layout Additions */ |
| .controls-bar { background: var(--surface-sidebar); border-radius:var(--radius-md); padding:12px; gap:8px; flex-wrap:wrap; align-items:flex-end; overflow:visible !important; border: 1px solid var(--border-subtle); } |
| .card { background: var(--surface-card); border-radius:var(--radius-md); border:1px solid var(--border-default); padding:20px; margin-bottom:16px; position:relative; z-index:var(--z-surface); overflow:visible !important; } |
| .planner-hint { background: transparent; border: 1px dashed var(--border-default); border-radius: 10px; padding: 12px 16px; margin-bottom: 12px; } |
| .gradio-image { background: transparent !important; border: none !important; } |
| .gradio-image img { width:100%; border-radius:12px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); } |
| |
| /* Model Panels — much stronger visual differentiation */ |
| .model-panels { gap:16px; margin-top:16px; } |
| .model-panel { |
| background: var(--surface-elevated); |
| border-radius:12px; |
| border:1px solid var(--border-default); |
| padding:18px 20px; |
| } |
| .model-panel--mini { border-color: var(--model-mini-border); } |
| .model-panel--aya { border-color: var(--model-aya-border); } |
| .model-panel--nemotron { border-color: var(--model-nemotron-border); } |
| |
| /* Model panel text — force readable contrast (Gradio prose/markdown) */ |
| .model-panel .gradio-markdown, |
| .model-panel .gradio-markdown p, |
| .model-panel .gradio-markdown li, |
| .model-panel .gradio-markdown strong, |
| .model-panel .gradio-markdown em, |
| .model-panel .gradio-markdown h1, |
| .model-panel .gradio-markdown h2, |
| .model-panel .gradio-markdown h3, |
| .model-panel .gradio-markdown blockquote, |
| .model-panel .prose, |
| .model-panel .prose p { |
| color: #e8eaf2 !important; |
| } |
| .model-panel .gradio-markdown h1, |
| .model-panel .gradio-markdown h2, |
| .model-panel .gradio-markdown h3 { |
| color: var(--ink-strong) !important; |
| } |
| .obs-log-header { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.85rem; |
| font-weight: 600; |
| letter-spacing: 0.02em; |
| text-transform: none; |
| color: var(--ink-subtle) !important; |
| margin-bottom: 0.5rem; |
| } |
| |
| /* Input textboxes — Ask Orion, custom location (legacy selectors kept for specificity) */ |
| textarea, input, select, |
| .gr-textbox textarea, .gr-textbox input, |
| .gr-input, .gr-text-input { |
| color: var(--ink) !important; |
| -webkit-text-fill-color: var(--ink) !important; |
| background-color: var(--surface-input) !important; |
| } |
| textarea::placeholder, input::placeholder { |
| color: var(--ink-faint) !important; |
| opacity: 1 !important; |
| } |
| |
| .model-badge { |
| display:inline-flex; |
| align-items:center; |
| gap:6px; |
| font-size:0.78rem; |
| font-weight:700; |
| letter-spacing:0.02em; |
| text-transform:none; |
| padding:5px 12px; |
| border-radius:999px; |
| margin-bottom:10px; |
| border: 1px solid currentColor; |
| } |
| .model-badge--mini { background:#7c3aed22; color:#c4b5fd; border-color:#a78bfa66; } |
| .model-badge--aya { background:#0d4d3022; color:#6ee7b7; border-color:#34d39966; } |
| .model-badge--nemotron { background:#1e3a5f22; color:#93c5fd; border-color:#60a5fa66; } |
| |
| .model-status { |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| font-size: 0.68rem; |
| font-weight: 600; |
| letter-spacing: 0.04em; |
| padding: 3px 10px; |
| border-radius: 6px; |
| margin-bottom: 12px; |
| opacity: 0.92; |
| } |
| .model-status--ok { |
| background: rgba(16, 185, 129, 0.13); |
| color: #6ee7b7; |
| border: 1px solid rgba(16, 185, 129, 0.4); |
| box-shadow: 0 0 12px rgba(16, 185, 129, 0.14); |
| } |
| .model-status--fallback { background: rgba(148, 163, 184, 0.12); color: #94a3b8; border: 1px solid rgba(148, 163, 184, 0.3); } |
| .model-status--failed { |
| background: rgba(244, 63, 94, 0.12); |
| color: #fda4af; |
| border: 1px solid rgba(244, 63, 94, 0.36); |
| box-shadow: 0 0 12px rgba(244, 63, 94, 0.12); |
| } |
| |
| .backend-health { font-size: 0.88rem; line-height: 1.55; opacity: 0.92; } |
| |
| /* Make the French panel explicitly call out language */ |
| .model-badge--aya::after { |
| content: "FR"; |
| font-size: 0.58rem; |
| background: rgba(52, 211, 153, 0.15); |
| padding: 1px 5px; |
| border-radius: 4px; |
| margin-left: 4px; |
| font-weight: 700; |
| } |
| |
| /* Telemetry & shared HTML component classes (ui_helpers.py) */ |
| .t-section { margin:16px 0; } |
| .t-label { |
| font-size:0.72rem; |
| font-weight:600; |
| letter-spacing:0.02em; |
| text-transform:none; |
| opacity:0.85; |
| margin-bottom:8px; |
| color: var(--ink-subtle) !important; |
| } |
| .t-grid { display:flex; flex-wrap:wrap; gap:8px; } |
| .t-chip { |
| display:flex; |
| flex-direction:column; |
| align-items:center; |
| padding:10px 16px; |
| background: var(--surface-chip); |
| border-radius:var(--radius-pill); |
| border:1px solid var(--border-default); |
| font-size:1.05rem; |
| font-weight:600; |
| color: var(--ink); |
| min-width:78px; |
| box-shadow: 0 0 12px rgba(255, 179, 71, 0.08); |
| } |
| .t-chip span { |
| font-size:0.65rem; |
| letter-spacing:0.01em; |
| text-transform:none; |
| opacity:0.75; |
| margin-top:2px; |
| font-weight:500; |
| } |
| .t-chip--offgrid { background:var(--offgrid-bg); border-color:var(--offgrid-border); color:var(--offgrid-ink); } |
| .t-chip--bortle { border-width:2px; } |
| .t-chip--note { font-size:0.82rem; text-align:left; padding:8px 12px; white-space:normal; } |
| .bortle-good { |
| border-color: var(--score-good) !important; |
| color: var(--score-good); |
| box-shadow: 0 0 12px rgba(16, 185, 129, 0.15); |
| } |
| .bortle-mid { |
| border-color: var(--score-mid) !important; |
| color: var(--score-mid); |
| box-shadow: 0 0 12px rgba(245, 158, 11, 0.15); |
| } |
| .bortle-low { |
| border-color: var(--score-low) !important; |
| color: var(--score-low); |
| box-shadow: 0 0 12px rgba(244, 63, 94, 0.15); |
| } |
| .score-good { color: var(--score-good); font-weight: 600; text-shadow: 0 0 10px rgba(16, 185, 129, 0.26); } |
| .score-mid { color: var(--score-mid); font-weight: 600; text-shadow: 0 0 10px rgba(245, 158, 11, 0.24); } |
| .score-low { color: var(--score-low); font-weight: 600; text-shadow: 0 0 10px rgba(244, 63, 94, 0.24); } |
| .score-muted { color: var(--score-muted); font-weight: 600; } |
| .list-row { |
| background: var(--surface-inset); |
| padding: 10px 14px; |
| border-radius: var(--radius-sm); |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| } |
| .empty-hint { opacity: 0.55; font-size: 0.9rem; padding: 8px 0; color: var(--ink-faint); } |
| |
| /* Planner target hint + planning skeleton (A3) */ |
| .target-hint { |
| font-size: 0.82rem; |
| line-height: 1.45; |
| color: var(--ink-muted); |
| background: var(--surface-raised); |
| border: 1px solid var(--border-default); |
| border-radius: 8px; |
| padding: 10px 12px; |
| margin-top: 4px; |
| } |
| .target-hint__name { font-weight: 600; color: var(--ink); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } |
| .target-hint__season { |
| font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; |
| background: rgba(99, 102, 241, 0.15); color: var(--accent-soft); padding: 2px 8px; border-radius: 999px; |
| } |
| .target-hint__desc { margin: 6px 0 4px; opacity: 0.9; } |
| .target-hint__meta { margin: 0; font-size: 0.78rem; opacity: 0.75; } |
| .target-hint__link { margin: 8px 0 0; font-size: 0.78rem; color: var(--accent-soft); } |
| .control-room-lede { |
| font-size: 0.8rem; line-height: 1.45; color: var(--ink-muted); margin: 0 0 4px; |
| } |
| .control-room-section-label { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; |
| text-transform: uppercase; color: var(--ink-faint); margin: 8px 0 4px; |
| } |
| .planning-panel { |
| text-align: center; padding: 2.5rem 1rem; color: var(--ink-muted); |
| } |
| .planning-panel__spinner { |
| width: 28px; height: 28px; margin: 0 auto 14px; |
| border: 2px solid var(--border-default); border-top-color: var(--accent); |
| border-radius: 50%; animation: plan-spin 0.85s linear infinite; |
| } |
| .planning-panel__title { font-weight: 600; color: var(--ink); margin: 0 0 6px; } |
| .planning-panel__detail { font-size: 0.82rem; margin: 0; opacity: 0.8; } |
| @keyframes plan-spin { to { transform: rotate(360deg); } } |
| |
| body.red-light-mode, body.red-light-mode .gradio-container { background-color: var(--surface-body) !important; } |
| body.red-light-mode .scotopic-banner { display: block !important; } |
| body.red-light-mode .gradio-container::before { background-color: rgba(255, 40, 40, 0.07) !important; } |
| body.red-light-mode button, body.red-light-mode .gr-button { background-color: #3a0000 !important; color: var(--ink-strong) !important; } |
| body.red-light-mode .model-badge--mini, |
| body.red-light-mode .model-badge--aya, |
| body.red-light-mode .model-badge--nemotron { color: var(--ink-subtle); border-color: rgba(255, 102, 102, 0.4); } |
| |
| /* Phase 5 — Constellation Browser */ |
| .constellation-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(300px, 1fr)); gap:16px; margin-top:12px; } |
| .constellation-card { background: var(--surface-card); border-radius:var(--radius-md); border:1px solid var(--border-default); padding:20px; } |
| .constellation-card__title { margin:0; font-size:1.15rem; font-weight:700; color:var(--accent-soft); } |
| .constellation-card__season { font-size:0.7rem; font-weight:600; letter-spacing:0.01em; text-transform:none; padding:3px 9px; border-radius:var(--radius-pill); border:1px solid var(--border-nav); white-space:nowrap; } |
| .constellation-card__steps ol { line-height:1.6; } |
| |
| /* Inline Sky / Zenith section in Planner (below models) */ |
| .sky-inline-card .gradio-image img { max-height: 420px; object-fit: contain; } |
| |
| /* ========================================================================== |
| Midnight Oasis Premium CSS Theme Overrides |
| ========================================================================== */ |
| |
| /* 1. Top Navigation Bar */ |
| .top-nav-bar { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| padding: 1rem 2rem; |
| background: |
| linear-gradient(115deg, rgba(15, 23, 42, 0.96) 0%, rgba(30, 27, 75, 0.92) 58%, rgba(22, 26, 38, 0.94) 100%); |
| border: 1px solid var(--border-nav); |
| border-radius: var(--radius-md); |
| margin-bottom: 2rem; |
| position: relative; |
| box-shadow: 0 12px 36px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.04); |
| } |
| |
| .nav-logo { |
| font-family: 'Space Grotesk', sans-serif; |
| display: flex; |
| flex-direction: column; |
| align-items: flex-start; |
| gap: 0.15rem; |
| } |
| |
| .nav-logo__row { |
| display: flex; |
| align-items: center; |
| gap: 0.5rem; |
| } |
| |
| .nav-logo__title { |
| font-size: 1.35rem; |
| font-weight: 700; |
| color: var(--accent-secondary-soft); |
| letter-spacing: -0.02em; |
| line-height: 1.1; |
| } |
| |
| .nav-logo__tagline { |
| font-size: 0.72rem; |
| font-weight: 600; |
| letter-spacing: 0.07em; |
| text-transform: uppercase; |
| color: var(--ink-faint); |
| margin-left: 1.55rem; |
| } |
| |
| .logo-icon { |
| color: var(--accent-secondary); |
| filter: drop-shadow(0 0 8px rgba(255, 179, 71, 0.28)); |
| } |
| |
| /* Primary navigation — Gradio main tabs (real routing, not decorative HTML links) */ |
| .main-nav-tabs > .tab-nav { |
| display: flex !important; |
| flex-wrap: wrap !important; |
| justify-content: center !important; |
| align-items: center !important; |
| gap: 0.5rem 1.75rem !important; |
| background: transparent !important; |
| border: none !important; |
| border-bottom: 1px solid var(--border-nav) !important; |
| border-radius: 0 !important; |
| padding: 0 1rem 0.75rem !important; |
| margin: -1rem 0 1.25rem !important; |
| box-shadow: none !important; |
| width: 100% !important; |
| } |
| |
| .main-nav-tabs > .tab-nav button { |
| font-family: 'Space Grotesk', sans-serif !important; |
| font-size: 0.95rem !important; |
| font-weight: 500 !important; |
| color: var(--ink-faint) !important; |
| background: transparent !important; |
| border: none !important; |
| border-radius: 0 !important; |
| padding: 0.5rem 0.25rem !important; |
| min-height: 44px !important; |
| position: relative !important; |
| transition: color 0.2s ease-in-out !important; |
| box-shadow: none !important; |
| } |
| |
| .main-nav-tabs > .tab-nav button:hover { |
| color: var(--ink-strong) !important; |
| background: transparent !important; |
| } |
| |
| .main-nav-tabs > .tab-nav button.selected { |
| color: var(--accent-secondary-soft) !important; |
| font-weight: 600 !important; |
| background: transparent !important; |
| } |
| |
| .main-nav-tabs > .tab-nav button.selected::after { |
| content: ''; |
| position: absolute; |
| bottom: 0; |
| left: 0; |
| width: 100%; |
| height: 2px; |
| background: linear-gradient(90deg, var(--accent), var(--accent-secondary)); |
| box-shadow: 0 0 10px rgba(255, 179, 71, 0.3); |
| border-radius: 2px; |
| } |
| |
| .main-nav-tabs > .tab-nav button:focus-visible { |
| outline: 2px solid var(--focus-ring) !important; |
| outline-offset: 3px !important; |
| } |
| |
| /* 2. Control Room Sidebar Container */ |
| .control-room-card { |
| background: var(--surface-sidebar) !important; |
| border-radius: 12px; |
| border: 1px solid var(--border-nav) !important; |
| padding: 24px !important; |
| display: flex; |
| flex-direction: column; |
| gap: 16px; |
| position: relative; |
| overflow: visible !important; |
| } |
| |
| .control-room-header-container { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 8px; |
| border-bottom: 1px solid var(--border-default); |
| padding-bottom: 8px; |
| } |
| |
| .control-room-header { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.9rem; |
| font-weight: 600; |
| letter-spacing: 0.01em; |
| text-transform: none; |
| color: var(--accent-secondary-soft); |
| } |
| |
| .control-room-status-badge { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.65rem; |
| font-weight: 700; |
| color: var(--status-ok); |
| background: rgba(52, 211, 153, 0.1); |
| padding: 3px 8px; |
| border-radius: 999px; |
| display: flex; |
| align-items: center; |
| gap: 4px; |
| letter-spacing: 0.05em; |
| } |
| |
| .status-dot { |
| width: 6px; |
| height: 6px; |
| background-color: var(--status-ok); |
| border-radius: 50%; |
| display: inline-block; |
| box-shadow: 0 0 6px var(--status-ok); |
| } |
| |
| /* 3. Horizontal Telemetry Overlay Bar */ |
| .telemetry-overlay-bar { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| background: var(--surface-sidebar); |
| border-bottom-left-radius: 12px; |
| border-bottom-right-radius: 12px; |
| padding: 12px 20px; |
| border: 1px solid var(--border-default); |
| border-top: none; |
| margin-top: -1px; |
| z-index: var(--z-telemetry); |
| position: relative; |
| } |
| |
| .telemetry-chips-container { |
| display: flex; |
| gap: 12px; |
| } |
| |
| .telemetry-pill { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| background: var(--surface-pill); |
| border: 1px solid var(--border-subtle); |
| padding: 4px 12px; |
| border-radius: var(--radius-sm); |
| box-shadow: 0 0 12px rgba(255, 179, 71, 0.08); |
| } |
| |
| .pill-label { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.7rem; |
| font-weight: 500; |
| letter-spacing: 0.01em; |
| color: var(--ink-faint); |
| } |
| |
| .pill-value { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.85rem; |
| font-weight: 600; |
| color: var(--ink-strong); |
| } |
| |
| .status-indicator { |
| font-family: 'Space Grotesk', sans-serif; |
| font-size: 0.78rem; |
| font-weight: 600; |
| letter-spacing: 0.02em; |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| padding: 4px 10px; |
| border-radius: 6px; |
| } |
| |
| .status-indicator--online { |
| color: var(--accent-secondary-soft); |
| background: rgba(255, 179, 71, 0.11); |
| border: 1px solid rgba(255, 179, 71, 0.32); |
| box-shadow: 0 0 12px rgba(255, 179, 71, 0.13); |
| } |
| |
| .status-indicator--offline { |
| color: var(--status-offline); |
| background: rgba(244, 63, 94, 0.1); |
| border: 1px solid rgba(244, 63, 94, 0.28); |
| box-shadow: 0 0 12px rgba(244, 63, 94, 0.1); |
| } |
| |
| .pulse-dot { |
| width: 6px; |
| height: 6px; |
| border-radius: 50%; |
| display: inline-block; |
| } |
| |
| .status-indicator--online .pulse-dot { |
| background-color: var(--accent-secondary); |
| box-shadow: 0 0 8px var(--accent-secondary); |
| animation: statusPulse 2s infinite; |
| } |
| |
| .status-indicator--offline .pulse-dot { |
| background-color: var(--status-offline); |
| box-shadow: 0 0 8px var(--status-offline); |
| animation: statusPulseRed 2s infinite; |
| } |
| |
| @keyframes statusPulse { |
| 0% { box-shadow: 0 0 0 0 rgba(255, 179, 71, 0.7); } |
| 70% { box-shadow: 0 0 0 6px rgba(255, 179, 71, 0); } |
| 100% { box-shadow: 0 0 0 0 rgba(255, 179, 71, 0); } |
| } |
| |
| @keyframes statusPulseRed { |
| 0% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0.7); } |
| 70% { box-shadow: 0 0 0 6px rgba(248, 113, 113, 0); } |
| 100% { box-shadow: 0 0 0 0 rgba(248, 113, 113, 0); } |
| } |
| |
| /* 4. Custom Segmented Purple Accent Model Tabs */ |
| .model-tabs-container .tab-nav { |
| display: inline-flex !important; |
| background: var(--surface-tabs) !important; |
| border: 1px solid var(--border-default) !important; |
| border-radius: var(--radius-md) !important; |
| padding: 4px !important; |
| gap: 4px !important; |
| margin-bottom: 16px !important; |
| width: max-content !important; |
| box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.4); |
| } |
| |
| .model-tabs-container .tab-nav button { |
| font-family: 'Space Grotesk', sans-serif !important; |
| font-size: 0.85rem !important; |
| font-weight: 600 !important; |
| color: var(--ink-faint) !important; |
| padding: 8px 18px !important; |
| border-radius: var(--radius-sm) !important; |
| border: none !important; |
| background: transparent !important; |
| transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; |
| cursor: pointer !important; |
| letter-spacing: 0.02em !important; |
| } |
| |
| .model-tabs-container .tab-nav button:hover { |
| color: var(--ink-strong) !important; |
| background: rgba(255, 255, 255, 0.05) !important; |
| } |
| |
| .model-tabs-container .tab-nav button.selected { |
| color: var(--ink-on-accent) !important; |
| background: var(--accent) !important; |
| box-shadow: none !important; |
| text-shadow: none !important; |
| } |
| |
| .model-tabs-container .tab-nav button:focus-visible { |
| outline: 2px solid var(--focus-ring) !important; |
| outline-offset: 2px !important; |
| } |
| |
| .model-tabs-container .tabitem { |
| background: var(--surface-card) !important; |
| border: 1px solid var(--border-subtle) !important; |
| border-radius: var(--radius-md) !important; |
| padding: 20px !important; |
| margin-top: 8px !important; |
| } |
| |
| /* 5. Floating Action Button (FAB) */ |
| .fab-button { |
| position: fixed; |
| bottom: 24px; |
| right: 24px; |
| width: 56px; |
| height: 56px; |
| border-radius: 50%; |
| background: var(--accent); |
| border: 1px solid rgba(255, 255, 255, 0.15); |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| color: var(--ink-on-accent); |
| cursor: pointer; |
| z-index: var(--z-fab); |
| transition: background 0.2s ease, transform 0.2s ease; |
| } |
| |
| .fab-button:hover { |
| transform: translateY(-2px); |
| background: var(--accent-hover); |
| color: #ffffff; |
| } |
| |
| .fab-button:focus-visible { |
| outline: 2px solid var(--focus-ring); |
| outline-offset: 3px; |
| } |
| |
| .fab-button:active { |
| transform: scale(0.95); |
| } |
| |
| .fab-icon { |
| width: 24px; |
| height: 24px; |
| } |
| |
| /* Scotopic — body.red-light-mode token overrides handle surfaces/ink; spot fixes below */ |
| body.red-light-mode .status-indicator--online { |
| color: var(--status-ok) !important; |
| background: rgba(255, 68, 68, 0.1) !important; |
| border-color: rgba(255, 68, 68, 0.25) !important; |
| } |
| body.red-light-mode .status-indicator--online .pulse-dot, |
| body.red-light-mode .status-indicator--offline .pulse-dot, |
| body.red-light-mode .status-dot { |
| background-color: var(--status-offline) !important; |
| box-shadow: 0 0 8px var(--status-offline) !important; |
| animation: statusPulseRed 2s infinite !important; |
| } |
| body.red-light-mode .model-tabs-container .tab-nav button:hover { |
| background: rgba(255, 0, 0, 0.08) !important; |
| } |
| body.red-light-mode .model-status--ok { |
| background: rgba(100, 0, 0, 0.28) !important; |
| color: #ffaaaa !important; |
| border-color: rgba(255, 100, 100, 0.35) !important; |
| } |
| body.red-light-mode .constellation-card__season { |
| background: rgba(70, 0, 0, 0.55) !important; |
| color: #ffaaaa !important; |
| border-color: rgba(255, 68, 68, 0.3) !important; |
| } |
| body.red-light-mode img, |
| body.red-light-mode .gradio-image img { |
| filter: sepia(1) saturate(8) hue-rotate(-50deg) brightness(0.85); |
| } |
| body.red-light-mode input[type="checkbox"], |
| body.red-light-mode input[type="radio"] { |
| accent-color: var(--accent) !important; |
| } |
| body.red-light-mode .empty-hint, |
| body.red-light-mode .planning-panel__detail, |
| body.red-light-mode .target-hint { |
| opacity: 1 !important; |
| } |
| body.red-light-mode label, |
| body.red-light-mode .gr-form > label, |
| body.red-light-mode .control-room-section-label { |
| font-family: 'Geist', sans-serif !important; |
| } |
| body.red-light-mode .progress-bar, |
| body.red-light-mode .meta-progress-bar { |
| background: rgba(80, 0, 0, 0.45) !important; |
| border-color: rgba(255, 68, 68, 0.35) !important; |
| } |
| body.red-light-mode .loader, |
| body.red-light-mode .generating { |
| border-top-color: var(--accent-soft) !important; |
| } |
| |
| /* Scotopic light-leak kill: hardcoded green/amber/gold glows must not leak in red-light */ |
| body.red-light-mode .t-chip, |
| body.red-light-mode .telemetry-pill, |
| body.red-light-mode .bortle-good, |
| body.red-light-mode .bortle-mid, |
| body.red-light-mode .bortle-low, |
| body.red-light-mode .score-good, |
| body.red-light-mode .score-mid, |
| body.red-light-mode .score-low, |
| body.red-light-mode .model-status--ok, |
| body.red-light-mode .model-status--failed, |
| body.red-light-mode .status-indicator--online, |
| body.red-light-mode .status-indicator--offline { |
| box-shadow: none !important; |
| text-shadow: none !important; |
| } |
| body.red-light-mode .model-status--failed { |
| background: rgba(100, 0, 0, 0.28) !important; |
| color: #ff8888 !important; |
| border-color: rgba(255, 100, 100, 0.35) !important; |
| } |
| |
| /* Responsive — cottage phone / narrow HF Space viewport */ |
| @media (max-width: 960px) { |
| .top-nav-bar { |
| padding: 0.75rem 1rem; |
| margin-bottom: 1rem; |
| } |
| .nav-logo__title { font-size: 1.1rem; } |
| .nav-logo__tagline { font-size: 0.65rem; margin-left: 1.35rem; } |
| .planner-layout-row { |
| flex-direction: column !important; |
| } |
| .planner-layout-row > .gr-column, |
| .planner-bottom-row > .gr-column { |
| flex: 1 1 100% !important; |
| min-width: 100% !important; |
| width: 100% !important; |
| } |
| .planner-bottom-row { |
| flex-direction: column !important; |
| } |
| .telemetry-overlay-bar { |
| flex-direction: column; |
| align-items: stretch !important; |
| gap: 10px; |
| } |
| .telemetry-chips-container { |
| flex-wrap: wrap; |
| } |
| .constellation-grid { |
| grid-template-columns: 1fr !important; |
| } |
| .main-nav-tabs > .tab-nav { |
| gap: 0.35rem 1rem !important; |
| justify-content: flex-start !important; |
| } |
| .main-nav-tabs > .tab-nav button { |
| font-size: 0.88rem !important; |
| padding: 0.45rem 0.2rem !important; |
| } |
| .fab-button { |
| bottom: 16px; |
| right: 16px; |
| } |
| } |
| |
| @media (max-width: 480px) { |
| .controls-bar .gr-button, |
| .planner-layout-row .gr-button.primary, |
| .planner-layout-row button[data-variant="primary"] { |
| min-height: 44px !important; |
| } |
| .t-chip { |
| min-width: 68px; |
| padding: 10px 12px; |
| } |
| } |
| |
| @media (prefers-reduced-motion: reduce) { |
| .fab-button, |
| .main-nav-tabs > .tab-nav button, |
| .model-tabs-container .tab-nav button, |
| .status-indicator--online .pulse-dot, |
| .status-indicator--offline .pulse-dot { |
| animation: none !important; |
| transition: none !important; |
| } |
| .fab-button:hover { |
| transform: none !important; |
| } |
| .planning-panel__spinner { |
| animation: none !important; |
| opacity: 0.55; |
| } |
| } |
| |
| /* Advanced settings accordion — make pop-out fonts legible (labels, infos, inputs) */ |
| .gr-accordion, |
| .gr-accordion .gr-box { |
| font-size: 0.9rem !important; |
| } |
| .gr-accordion label, |
| .gr-accordion .gr-form > label, |
| .gr-accordion .label-wrap { |
| font-size: 0.9rem !important; |
| color: var(--ink-strong) !important; |
| opacity: 1 !important; |
| } |
| .gr-accordion .gr-info, |
| .gr-accordion [class*="info"], |
| .gr-accordion .gr-textbox-info { |
| font-size: 0.82rem !important; |
| opacity: 0.9 !important; |
| color: var(--ink-muted) !important; |
| } |
| .gr-accordion input, |
| .gr-accordion textarea, |
| .gr-accordion .gr-textbox input, |
| .gr-accordion .gr-checkbox input { |
| font-size: 0.9rem !important; |
| color: var(--ink) !important; |
| } |
| """ |
|
|
| TOP_NAV_HTML = """ |
| <div class="top-nav-bar"> |
| <div class="scotopic-banner" role="status" aria-live="polite" style="display: none; position: absolute; top: -10px; left: 50%; transform: translateX(-50%); background: #ff4444; color: #000; padding: 2px 10px; border-radius: 4px; font-size: 0.7rem; font-weight: bold; letter-spacing: 0.1em; z-index: 100;">SCOTOPIC MODE ACTIVE</div> |
| <div class="nav-logo"> |
| <div class="nav-logo__row"> |
| <span class="logo-icon" aria-hidden="true">✦</span> |
| <span class="nav-logo__title">Orion's Cottage</span> |
| </div> |
| <span class="nav-logo__tagline">Constellation Planner</span> |
| </div> |
| </div> |
| """ |
|
|
| CONTROL_ROOM_HEADER_HTML = """ |
| <div class="control-room-header-container"> |
| <div class="control-room-header">Field planner</div> |
| </div> |
| <p class="control-room-lede">Choose where and when you're outside. Each plan fills all three model journals below.</p> |
| """ |
|
|
| MODEL_BADGE_HTML = { |
| "mini": '<div class="model-badge model-badge--mini">⚡ MiniCPM — Quick Summary</div>', |
| "aya": '<div class="model-badge model-badge--aya">🌍 Aya — Guide en Français</div>', |
| "nemotron": '<div class="model-badge model-badge--nemotron">🔭 Nemotron — Expert Analysis</div>', |
| } |
|
|
| |
|
|
| def _decorate_model_panel(title_html: str, markdown: str, meta: dict) -> str: |
| """Add model identity badge plus polish/backend status (P0).""" |
| polished = bool(meta.get("polished")) |
| backend = meta.get("backend") or "ollama" |
| polish_note = meta.get("polish_note") or ("LLM narrative" if polished else "template only") |
| if polished: |
| status = f"✓ {polish_note} · {backend}" |
| css = "model-status--ok" |
| elif "unsuccessful" in polish_note.lower(): |
| status = f"✗ {polish_note} · {backend}" |
| css = "model-status--failed" |
| else: |
| status = f"○ {polish_note} · {backend}" |
| css = "model-status--fallback" |
| status_html = f'<div class="model-status {css}">{status}</div>' |
| return f"{title_html}\n{status_html}\n\n{markdown}" |
|
|
|
|
| def _render_telemetry_bar_html( |
| env: Optional[dict], |
| score: int, |
| off_grid: bool, |
| location_note: Optional[str] = None, |
| obs_time: Optional[datetime] = None, |
| ) -> str: |
| """Render the horizontal telemetry overlay bar directly below/on the bottom edge of the image canvas.""" |
| bortle = max(1, min(9, 10 - score)) |
| bortle_str = f"Class {bortle}" |
| score_cls = "score-good" if score >= 7 else ("score-mid" if score >= 5 else "score-low") |
| score_str = f"{score}/10" |
| date_str = obs_time.strftime("%a %b %d") if obs_time else "Tonight" |
|
|
| seeing_str = "III/V" |
| clouds_str = "--" |
| temp_str = "--" |
| moon_str = "--" |
| |
| if env: |
| seeing_str = str(env.get("seeing_quality") or "III/V") |
| clouds_str = f"{env.get('cloud_cover', 0)}%" |
| temp_str = f"{env.get('temperature_c', '?')}°C" |
| moon_illum = env.get('moon_illumination', 0) |
| moon_phase = env.get('moon_phase', '?') |
| moon_str = f"{moon_phase} ({moon_illum}%)" |
| elif obs_time: |
| |
| from core.environment import _derive_moon_data |
| moon = _derive_moon_data(obs_time) |
| moon_illum = moon.get("moon_illumination", 0) |
| moon_phase = moon.get("moon_phase", "?") |
| moon_str = f"{moon_phase} ({moon_illum}%)" |
| |
| |
| if off_grid: |
| status_class = "status-indicator--offline" |
| status_label = "Off Grid" |
| elif not env: |
| status_class = "status-indicator--offline" |
| status_label = "No weather" |
| elif env.get("forecast_available") is False: |
| status_class = "status-indicator--offline" |
| status_label = "Forecast unavailable" |
| else: |
| status_class = "status-indicator--online" |
| status_label = "Live weather" |
|
|
| note_html = "" |
| if location_note: |
| note_html = f"""<div class="telemetry-pill"> |
| <span class="pill-label">Note</span> |
| <span class="pill-value">{location_note}</span> |
| </div>""" |
|
|
| return f"""<div class="telemetry-overlay-bar"> |
| <div class="telemetry-chips-container"> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Viewing</span> |
| <span class="pill-value {score_cls}">{score_str}</span> |
| </div> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Date</span> |
| <span class="pill-value">{date_str}</span> |
| </div> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Bortle</span> |
| <span class="pill-value">{bortle_str}</span> |
| </div> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Seeing</span> |
| <span class="pill-value">{seeing_str}</span> |
| </div> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Clouds</span> |
| <span class="pill-value">{clouds_str}</span> |
| </div> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Temp</span> |
| <span class="pill-value">{temp_str}</span> |
| </div> |
| <div class="telemetry-pill"> |
| <span class="pill-label">Moon</span> |
| <span class="pill-value">{moon_str}</span> |
| </div> |
| {note_html} |
| </div> |
| <div class="telemetry-status-container"> |
| <div class="status-indicator {status_class}"> |
| <span class="pulse-dot"></span> {status_label} |
| </div> |
| </div> |
| </div>""" |
|
|
|
|
| def _on_plan_click( |
| location: str, |
| custom_loc: str, |
| timeframe: str, |
| obs_date: Optional[Any], |
| off_grid: bool, |
| weather_enabled: bool, |
| use_modal: bool, |
| red_light: bool, |
| history: list, |
| target_override: str = "Auto", |
| model_choice: Optional[str] = None, |
| ): |
| """Plan handler — supports optional target_override from the Targets tab.""" |
| loc = resolve_plan_location(location, custom_loc, off_grid, LOCATIONS) |
| lat, lon = loc["lat"], loc["lon"] |
| location_note = loc["location_note"] |
| tz_name = loc.get("tz_name") |
|
|
| obs_time = _resolve_observation_time(timeframe, obs_date, tz_name=tz_name) |
|
|
| |
| if target_override and target_override != "Auto": |
| target_name = target_override |
| else: |
| target_name = _pick_target_name(obs_time, lat, lon) |
|
|
| geometry = get_geometry_data(lat, lon, obs_time, target_name) |
| env = get_environment_data(lat, lon, obs_time) if not off_grid and weather_enabled else None |
| scoring = compute_stargazing_score(geometry, env, off_grid, weather_enabled) |
| score = scoring["score"] |
|
|
| |
| new_entry = {"target": geometry.get("target"), "date": datetime.now().strftime("%b %d"), "score": score} |
| updated_history = [new_entry] + (history or [])[:9] |
|
|
| |
| telemetry_html = _render_telemetry_bar_html(env, score, off_grid, location_note, obs_time) |
| history_html = _render_history_html(updated_history) |
| cottage_board_html = _render_cottage_board(updated_history) |
|
|
| |
| |
| |
| mini_only = os.getenv("ORION_MINI_ONLY", "0").lower() in ("1", "true", "yes") |
| is_mocked = hasattr(generate_model_plan, "mock_calls") |
| if mini_only or is_mocked: |
| plan_md, _img, panel_meta = generate_model_plan( |
| location_name=location, |
| timeframe=timeframe, |
| off_grid=off_grid, |
| weather_enabled=weather_enabled, |
| view_sky=False, |
| use_modal_for_heavy_compute=use_modal, |
| model_choice=model_choice or "MiniCPM", |
| red_light_mode=red_light, |
| target_override=target_override, |
| obs_date=obs_date, |
| custom_lat=loc["custom_lat"], |
| custom_lon=loc["custom_lon"], |
| custom_loc_name=loc["custom_loc_name"], |
| location_note=location_note, |
| tz_name=tz_name, |
| ) |
| mini_md = plan_md |
| aya_md = plan_md |
| nem_md = plan_md |
| meta_list = [panel_meta, panel_meta, panel_meta] |
| else: |
| mini_md, aya_md, nem_md, _img, meta_list = generate_three_models_plan( |
| location_name=location, |
| timeframe=timeframe, |
| off_grid=off_grid, |
| weather_enabled=weather_enabled, |
| view_sky=False, |
| use_modal_for_heavy_compute=use_modal, |
| red_light_mode=red_light, |
| target_override=target_override, |
| obs_date=obs_date, |
| custom_lat=loc["custom_lat"], |
| custom_lon=loc["custom_lon"], |
| custom_loc_name=loc["custom_loc_name"], |
| location_note=location_note, |
| tz_name=tz_name, |
| ) |
|
|
| mini_meta = meta_list[0] |
| mini_badge = MODEL_BADGE_HTML["mini"] |
| decorated_mini = _decorate_model_panel(mini_badge, mini_md, mini_meta) |
|
|
| aya_meta = meta_list[1] |
| aya_badge = MODEL_BADGE_HTML["aya"] |
| decorated_aya = _decorate_model_panel(aya_badge, aya_md, aya_meta) |
|
|
| nem_meta = meta_list[2] |
| nem_badge = MODEL_BADGE_HTML["nemotron"] |
| decorated_nem = _decorate_model_panel(nem_badge, nem_md, nem_meta) |
|
|
| |
| |
| |
| if mini_only: |
| return ( |
| decorated_mini, |
| decorated_aya, |
| decorated_nem, |
| updated_history, |
| telemetry_html, history_html, cottage_board_html |
| ) |
|
|
| if is_mocked: |
| return ( |
| decorated_mini, |
| updated_history, |
| telemetry_html, history_html, cottage_board_html |
| ) |
|
|
| return ( |
| decorated_mini, |
| decorated_aya, |
| decorated_nem, |
| updated_history, |
| telemetry_html, history_html, cottage_board_html |
| ) |
|
|
|
|
| def _sync_weather_checkbox(off_grid: bool): |
| if off_grid: return gr.update(value=False, interactive=False) |
| return gr.update(interactive=True) |
|
|
| def _sync_modal_checkbox(off_grid: bool): |
| if off_grid: |
| return gr.update(value=False, interactive=False) |
| return gr.update(interactive=True) |
|
|
| def _generate_zenith_chart( |
| location: str, |
| custom_loc: str, |
| off_grid: bool, |
| red_light: bool, |
| target_override: str = "Auto", |
| ): |
| from zoneinfo import ZoneInfo |
|
|
| loc = resolve_plan_location(location, custom_loc, off_grid, LOCATIONS) |
| tz_name = loc.get("tz_name") |
| if tz_name: |
| try: |
| obs_time = datetime.now(ZoneInfo(tz_name)) |
| except Exception: |
| obs_time = datetime.now() |
| else: |
| obs_time = datetime.now() |
| highlight = target_override if target_override and target_override != "Auto" else None |
| img = generate_sky_chart( |
| loc["lat"], |
| loc["lon"], |
| obs_time, |
| red_light_mode=red_light, |
| highlight_constellation=highlight, |
| ) |
| return gr.update(value=img) |
|
|
|
|
| |
|
|
| def _clear_nl_suggestion(off_grid: bool): |
| """Exit the 'Ask Orion' suggestion state (the 'demo mode' the user mentioned).""" |
| return ( |
| gr.update(visible=False, value=""), |
| gr.update(visible=False), |
| gr.update(value=None), |
| "Auto", |
| gr.update(value=PLANNER_TARGET_AUTO), |
| _render_planner_target_hint(PLANNER_TARGET_AUTO, CONSTELLATIONS_BY_NAME), |
| gr.update(visible=True), |
| _render_telemetry_bar_html(None, 5, off_grid), |
| ) |
|
|
|
|
| def _preview_telemetry_for_suggestion( |
| *, |
| best_date: Optional[date], |
| target_name: str, |
| lat: float, |
| lon: float, |
| tz_name: Optional[str], |
| off_grid: bool, |
| weather_enabled: bool, |
| location_note: Optional[str], |
| ) -> str: |
| """Show weather chips aligned with Ask Orion's suggested observation night.""" |
| if not best_date: |
| return _render_telemetry_bar_html(None, 5, off_grid) |
|
|
| from core.timezone_utils import get_tz |
|
|
| tz = get_tz(tz_name) |
| obs_time = datetime.combine( |
| best_date, datetime.strptime("22:00", "%H:%M").time() |
| ).replace(tzinfo=tz) |
| if target_name == "Auto": |
| target_name = _pick_target_name(obs_time, lat, lon) |
| geometry = get_geometry_data(lat, lon, obs_time, target_name) |
| env = ( |
| get_environment_data(lat, lon, obs_time) |
| if weather_enabled and not off_grid |
| else None |
| ) |
| scoring = compute_stargazing_score(geometry, env, off_grid, weather_enabled) |
| return _render_telemetry_bar_html( |
| env, scoring["score"], off_grid, location_note, obs_time |
| ) |
|
|
|
|
| def _reset_history(): |
| """Clear Field Notes / Previous Observations (history + cottage board).""" |
| empty: list = [] |
| return empty, _render_history_html(empty), _render_cottage_board(empty) |
|
|
|
|
| def _sync_plan_button_label(obs_date: Optional[Any]): |
| """Make the main action button reflect whether this is 'Tonight' or a specific custom date/excursion.""" |
| if not obs_date: |
| return gr.update(value="Plan tonight") |
|
|
| try: |
| if isinstance(obs_date, str): |
| |
| date_str = obs_date[:10] if "T" in obs_date or " " in obs_date else obs_date |
| d = datetime.strptime(date_str, "%Y-%m-%d").date() |
| else: |
| d = obs_date.date() if hasattr(obs_date, "date") else obs_date |
| label = f"Plan for {d.strftime('%b %d')}" |
| return gr.update(value=label) |
| except Exception: |
| return gr.update(value="Plan observation") |
|
|
|
|
| |
|
|
| def build_app(): |
| with gr.Blocks(title="Orion's Cottage — Constellation Planner") as demo: |
| gr.HTML( |
| '<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Geist:wght@400;500;600&display=swap" rel="stylesheet">' |
| '<link rel="manifest" href="/file=static/manifest.json">' |
| '<link rel="icon" href="/file=static/icon.svg" type="image/svg+xml">' |
| ) |
| gr.HTML(TOP_NAV_HTML) |
|
|
| |
| history_state = gr.State([]) |
| target_override_state = gr.State("Auto") |
|
|
| with gr.Tabs(elem_id="main-tabs", elem_classes=["main-nav-tabs"]) as main_tabs: |
|
|
| with gr.Tab("Planner", id="planner-tab"): |
| |
| with gr.Row(elem_classes=["controls-bar"]): |
| nl_query = gr.Textbox( |
| placeholder="Ask Orion… e.g. “Best weekend in July for Cassiopeia?”", |
| show_label=False, |
| container=False, |
| scale=4, |
| ) |
| nl_btn = gr.Button("Ask Orion", variant="secondary", scale=1) |
|
|
| with gr.Row(elem_classes=["planner-layout-row"]): |
| |
| with gr.Column(scale=1, min_width=280, elem_classes=["control-room-card"]): |
| gr.HTML(CONTROL_ROOM_HEADER_HTML) |
|
|
| gr.HTML('<div class="control-room-section-label">Where</div>') |
| location_dropdown = gr.Dropdown( |
| choices=list(LOCATIONS.keys()), |
| value=list(LOCATIONS.keys())[0], |
| label="Location", |
| ) |
|
|
| gr.HTML('<div class="control-room-section-label">When</div>') |
| timeframe_dropdown = gr.Dropdown( |
| choices=["Tonight", "Tomorrow Night", "This Weekend"], |
| value="Tonight", |
| label="Timeframe", |
| ) |
|
|
| gr.HTML('<div class="control-room-section-label">What to observe</div>') |
| planner_target_dropdown = gr.Dropdown( |
| choices=planner_target_choices(CONSTELLATIONS), |
| value=PLANNER_TARGET_AUTO, |
| label="Target", |
| info="Auto chooses what's up for the season at your site.", |
| ) |
| target_hint_out = gr.HTML( |
| _render_planner_target_hint(PLANNER_TARGET_AUTO, CONSTELLATIONS_BY_NAME), |
| ) |
| browse_targets_btn = gr.Button( |
| "Open constellation guides →", |
| variant="secondary", |
| size="sm", |
| ) |
|
|
| gr.HTML('<div class="control-room-section-label">Night setup</div>') |
| off_grid_toggle = gr.Checkbox( |
| label="Off Grid", |
| value=False, |
| info="Skip weather APIs. Local astronomy still runs; Modal is disabled (needs network).", |
| ) |
| red_light_toggle = gr.Checkbox( |
| label="Red-light mode", |
| value=False, |
| info="Scotopic red palette to preserve night vision outside.", |
| ) |
|
|
| with gr.Accordion("Advanced settings", open=False): |
| custom_loc_box = gr.Textbox( |
| label="Custom coordinates", |
| placeholder="City or address (optional)", |
| ) |
| date_picker = gr.DateTime( |
| label="Specific date (optional)", |
| include_time=False, |
| type="datetime", |
| ) |
| weather_toggle = gr.Checkbox( |
| label="Live weather", |
| value=True, |
| info="Open-Meteo cloud cover and moon phase for the plan.", |
| ) |
| use_modal_toggle = gr.Checkbox( |
| label="Modal GPU polish", |
| value=DEFAULT_USE_MODAL_FOR_HEAVY_COMPUTE, |
| info="Optional cloud GPUs. Off when Off Grid or for local-only demos.", |
| ) |
|
|
| submit_btn = gr.Button("Plan observation", variant="primary") |
|
|
| |
| with gr.Column(scale=3): |
| |
| with gr.Column(elem_classes=["planner-hint"]): |
| with gr.Row(): |
| with gr.Column(scale=5): |
| nl_summary_out = gr.Markdown("", visible=False) |
| with gr.Column(scale=1, min_width=70): |
| clear_nl_btn = gr.Button("✕ Clear", variant="secondary", size="sm", visible=False) |
| nl_hint_md = gr.Markdown( |
| "<span style='font-size:0.72rem; opacity:0.75;'>" |
| "Ask Orion picks a date and target from your question. Review the sidebar, then " |
| "<strong>Plan observation</strong> — or clear to start over." |
| "</span>", |
| visible=True, |
| ) |
|
|
| |
| with gr.Column(elem_classes=["card"]): |
| telemetry_out = gr.HTML(_render_telemetry_bar_html(None, 5, False), elem_id="telemetry-chips") |
|
|
| with gr.Tabs(elem_id="model-outputs", elem_classes=["model-tabs-container"]) as model_tabs: |
| with gr.Tab("⚡ MiniCPM"): |
| mini_out = gr.Markdown( |
| '<p class="empty-hint">Your warm cottage summary appears here after you plan.</p>' |
| ) |
| with gr.Tab("🌍 Aya (FR)"): |
| aya_out = gr.Markdown( |
| '<p class="empty-hint">Le guide en français apparaît ici après la planification.</p>' |
| ) |
| with gr.Tab("🔭 Nemotron"): |
| nemotron_out = gr.Markdown( |
| '<p class="empty-hint">Expert RA/Dec, seeing, and eyepiece notes land here after you plan.</p>' |
| ) |
|
|
| |
| with gr.Row(elem_classes=["planner-bottom-row"]): |
| with gr.Column(scale=2, min_width=280, elem_classes=["card", "sky-inline-card"]): |
| gr.Markdown("### Sky view — zenith") |
| gr.Markdown( |
| "What's directly overhead at your site right now — a quick orientation before you step outside." |
| ) |
| zenith_img_planner = gr.Image(label="Zenith (live)", interactive=False) |
| zenith_planner_btn = gr.Button("↻ Refresh Zenith", variant="secondary", size="sm") |
|
|
| with gr.Column(scale=1, min_width=220, elem_classes=["card"]): |
| with gr.Row(): |
| gr.HTML('<div class="t-label" style="flex:1">Field notes</div>') |
| reset_history_btn = gr.Button("Reset", variant="secondary", size="sm", scale=0, min_width=60) |
| history_out = gr.HTML(_render_history_html([]), elem_id="history-card") |
|
|
| with gr.Column(scale=1, min_width=220, elem_classes=["card"]): |
| cottage_board_out = gr.HTML(_render_cottage_board([]), elem_id="cottage-board") |
|
|
|
|
|
|
| with gr.Tab("Targets", id="targets-tab"): |
| with gr.Column(elem_classes=["card"]): |
| gr.Markdown( |
| "### Constellation guides\n" |
| "Finder steps, mythology, and deep-sky highlights for Nova Scotia skies. " |
| "Pick one below or use **Quick plan** to jump back to the Planner." |
| ) |
| search_box = gr.Textbox( |
| placeholder="Search… Orion, Summer, Nebula, Cassiopeia", |
| show_label=False, |
| container=False, |
| ) |
| constellation_html = gr.HTML( |
| _render_constellation_cards(CONSTELLATIONS), |
| elem_id="constellation-browser", |
| ) |
|
|
| |
| gr.HTML('<div class="t-label" style="margin:16px 0 8px;">Quick plan</div>') |
| with gr.Row(elem_classes=["controls-bar"]): |
| plan_target_dropdown = gr.Dropdown( |
| choices=[c["name"] for c in CONSTELLATIONS], |
| value=CONSTELLATIONS[0]["name"], |
| label="Constellation", |
| show_label=False, |
| container=False, |
| scale=4, |
| info="Plans this target on the Planner tab with your current location and options.", |
| ) |
| plan_tonight_btn = gr.Button("Plan this target →", variant="primary", scale=2) |
|
|
| |
|
|
| |
| _planner_inputs = [ |
| location_dropdown, custom_loc_box, timeframe_dropdown, date_picker, |
| off_grid_toggle, weather_toggle, |
| use_modal_toggle, red_light_toggle, history_state, target_override_state |
| ] |
| _plan_outputs = [ |
| mini_out, |
| aya_out, |
| nemotron_out, |
| history_state, |
| telemetry_out, history_out, cottage_board_out, |
| ] |
|
|
| def _planner_target_override(name: str) -> str: |
| return "Auto" if name == PLANNER_TARGET_AUTO else name |
|
|
| def _on_planner_target_pick(name: str): |
| sync_targets = ( |
| gr.update(value=name) |
| if name != PLANNER_TARGET_AUTO |
| else gr.update() |
| ) |
| return ( |
| _planner_target_override(name), |
| _render_planner_target_hint(name, CONSTELLATIONS_BY_NAME), |
| sync_targets, |
| ) |
|
|
| def _sync_planner_from_targets_tab(name: str): |
| return ( |
| gr.update(value=name), |
| _render_planner_target_hint(name, CONSTELLATIONS_BY_NAME), |
| name, |
| ) |
|
|
| def _browse_targets_tab(planner_target: str): |
| targets_dd = ( |
| gr.update(value=planner_target) |
| if planner_target != PLANNER_TARGET_AUTO |
| else gr.update() |
| ) |
| return gr.update(selected="targets-tab"), targets_dd |
|
|
| def _plan_loading_updates(use_modal: bool, *, busy_label: str = "Planning…"): |
| if os.getenv("ORION_MINI_ONLY", "0").lower() in ("1", "true", "yes"): |
| btn_label = "Planning with MiniCPM (troubleshoot mode)…" |
| |
| use_modal_for_label = False |
| else: |
| btn_label = "Warming 3 GPU workers…" if use_modal else busy_label |
| use_modal_for_label = use_modal |
| return ( |
| gr.update(interactive=False, value=btn_label), |
| gr.update(value=_render_planning_panel("MiniCPM", use_modal=use_modal_for_label)), |
| gr.update(value=_render_planning_panel("Aya (FR)", use_modal=use_modal_for_label)), |
| gr.update(value=_render_planning_panel("Nemotron", use_modal=use_modal_for_label)), |
| ) |
|
|
| def _reenable_submit_btn(obs_date): |
| lbl = "Plan observation" |
| if not obs_date: |
| lbl = "Plan tonight" |
| else: |
| try: |
| if isinstance(obs_date, str): |
| date_str = obs_date[:10] if "T" in obs_date or " " in obs_date else obs_date |
| d = datetime.strptime(date_str, "%Y-%m-%d").date() |
| else: |
| d = obs_date.date() if hasattr(obs_date, "date") else obs_date |
| lbl = f"Plan for {d.strftime('%b %d')}" |
| except Exception: |
| lbl = "Plan observation" |
| return gr.update(interactive=True, value=lbl) |
|
|
| planner_target_dropdown.change( |
| fn=_on_planner_target_pick, |
| inputs=[planner_target_dropdown], |
| outputs=[target_override_state, target_hint_out, plan_target_dropdown], |
| ) |
|
|
| plan_target_dropdown.change( |
| fn=_sync_planner_from_targets_tab, |
| inputs=[plan_target_dropdown], |
| outputs=[planner_target_dropdown, target_hint_out, target_override_state], |
| ) |
|
|
| browse_targets_btn.click( |
| fn=_browse_targets_tab, |
| inputs=[planner_target_dropdown], |
| outputs=[main_tabs, plan_target_dropdown], |
| ) |
|
|
| submit_btn.click( |
| fn=_plan_loading_updates, |
| inputs=[use_modal_toggle], |
| outputs=[submit_btn, mini_out, aya_out, nemotron_out], |
| ).then( |
| fn=_on_plan_click, |
| inputs=_planner_inputs, |
| outputs=_plan_outputs |
| ).then( |
| fn=_generate_zenith_chart, |
| inputs=[ |
| location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, |
| target_override_state, |
| ], |
| outputs=[zenith_img_planner] |
| ).then( |
| fn=lambda: (gr.update(visible=False, value=""), gr.update(visible=False), gr.update(visible=True)), |
| inputs=None, |
| outputs=[nl_summary_out, clear_nl_btn, nl_hint_md] |
| ).then( |
| fn=_reenable_submit_btn, |
| inputs=[date_picker], |
| outputs=submit_btn |
| ) |
|
|
| def _handle_nl_query(query: str, location: str, custom_loc: str, off_grid: bool, weather: bool): |
| if not (query or "").strip(): |
| |
| return ( |
| gr.update(visible=False), gr.update(visible=False), gr.update(), gr.update(), |
| gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update(visible=True), |
| ) |
|
|
| parsed = parse_nl_query(query, preset_locations=LOCATIONS) |
| query_location = parsed.get("location") |
| effective_custom = query_location or custom_loc |
| loc = resolve_plan_location(location, effective_custom, off_grid, LOCATIONS) |
| tz_name = loc.get("tz_name") |
| weather_on = weather and not off_grid |
| best_date, summary = find_best_date( |
| parsed, |
| loc["lat"], |
| loc["lon"], |
| tz_name=tz_name, |
| weather_enabled=weather_on, |
| location_label=loc.get("display_name"), |
| ) |
|
|
| plan_label = _sync_plan_button_label(best_date) if best_date else gr.update(value="Plan tonight") |
| nl_target = parsed.get("target") or "Auto" |
| if nl_target != "Auto": |
| target_state = nl_target |
| planner_dd = gr.update(value=nl_target) |
| hint_html = gr.update(value=_render_planner_target_hint(nl_target, CONSTELLATIONS_BY_NAME)) |
| else: |
| target_state = gr.update() |
| planner_dd = gr.update() |
| hint_html = gr.update() |
|
|
| telemetry_html = _preview_telemetry_for_suggestion( |
| best_date=best_date, |
| target_name=nl_target if nl_target != "Auto" else "Auto", |
| lat=loc["lat"], |
| lon=loc["lon"], |
| tz_name=tz_name, |
| off_grid=off_grid, |
| weather_enabled=weather_on, |
| location_note=loc.get("location_note"), |
| ) |
| custom_loc_update = ( |
| gr.update(value=effective_custom) if query_location else gr.update() |
| ) |
|
|
| return ( |
| gr.update(value=summary, visible=True), |
| gr.update(visible=True), |
| gr.update(value=datetime.combine(best_date, datetime.min.time())) if best_date else gr.update(), |
| target_state, |
| plan_label, |
| planner_dd, |
| hint_html, |
| telemetry_html, |
| custom_loc_update, |
| gr.update(visible=False), |
| ) |
|
|
| _nl_outputs = [ |
| nl_summary_out, clear_nl_btn, date_picker, target_override_state, submit_btn, |
| planner_target_dropdown, target_hint_out, telemetry_out, custom_loc_box, nl_hint_md, |
| ] |
|
|
| nl_btn.click( |
| fn=_handle_nl_query, |
| inputs=[nl_query, location_dropdown, custom_loc_box, off_grid_toggle, weather_toggle], |
| outputs=_nl_outputs, |
| ) |
|
|
| nl_query.submit( |
| fn=_handle_nl_query, |
| inputs=[nl_query, location_dropdown, custom_loc_box, off_grid_toggle, weather_toggle], |
| outputs=_nl_outputs, |
| ) |
|
|
| clear_nl_btn.click( |
| fn=_clear_nl_suggestion, |
| inputs=[off_grid_toggle], |
| outputs=[ |
| nl_summary_out, clear_nl_btn, date_picker, target_override_state, |
| planner_target_dropdown, target_hint_out, nl_hint_md, telemetry_out, |
| ], |
| ) |
|
|
| def _on_constellation_search(query: str): |
| filtered = _filter_constellations(CONSTELLATIONS, query) |
| names = [c["name"] for c in filtered] |
| if not names: |
| names = [c["name"] for c in CONSTELLATIONS] |
| return ( |
| _render_constellation_cards(CONSTELLATIONS, query), |
| gr.Dropdown(choices=names, value=names[0]), |
| ) |
|
|
| search_box.change( |
| fn=_on_constellation_search, |
| inputs=[search_box], |
| outputs=[constellation_html, plan_target_dropdown], |
| ) |
|
|
| def _plan_from_targets(target_name, location, custom_loc, timeframe, obs_date, off_grid, weather, modal, red_light, history, _target_override): |
| results = _on_plan_click( |
| location, custom_loc, timeframe, obs_date, off_grid, weather, modal, red_light, history, |
| target_override=target_name |
| ) |
| return (*results, gr.update(selected="planner-tab")) |
|
|
| plan_tonight_btn.click( |
| fn=lambda m: _plan_loading_updates(m, busy_label="Planning target…"), |
| inputs=[use_modal_toggle], |
| outputs=[plan_tonight_btn, mini_out, aya_out, nemotron_out], |
| ).then( |
| fn=_plan_from_targets, |
| inputs=[plan_target_dropdown, *_planner_inputs], |
| outputs=[*_plan_outputs, main_tabs], |
| ).then( |
| fn=_generate_zenith_chart, |
| inputs=[ |
| location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, |
| target_override_state, |
| ], |
| outputs=[zenith_img_planner] |
| ).then( |
| |
| fn=lambda: (gr.update(visible=False, value=""), gr.update(visible=False), gr.update(visible=True)), |
| inputs=None, |
| outputs=[nl_summary_out, clear_nl_btn, nl_hint_md] |
| ).then( |
| fn=lambda: gr.update(interactive=True, value="Plan this target →"), |
| inputs=None, |
| outputs=plan_tonight_btn |
| ) |
|
|
|
|
|
|
| zenith_planner_btn.click( |
| fn=_generate_zenith_chart, |
| inputs=[ |
| location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, |
| target_override_state, |
| ], |
| outputs=[zenith_img_planner] |
| ) |
|
|
| |
| date_picker.change( |
| fn=_sync_plan_button_label, |
| inputs=[date_picker], |
| outputs=[submit_btn] |
| ) |
|
|
| |
| reset_history_btn.click( |
| fn=_reset_history, |
| inputs=None, |
| outputs=[history_state, history_out, cottage_board_out] |
| ) |
|
|
| off_grid_toggle.change(fn=_sync_weather_checkbox, inputs=[off_grid_toggle], outputs=[weather_toggle]) |
| off_grid_toggle.change(fn=_sync_modal_checkbox, inputs=[off_grid_toggle], outputs=[use_modal_toggle]) |
| red_light_toggle.change( |
| fn=None, |
| inputs=[red_light_toggle], |
| outputs=None, |
| js="(is_red) => { if(is_red) document.body.classList.add('red-light-mode'); else document.body.classList.remove('red-light-mode'); }" |
| ) |
|
|
| |
| def _hydrate_history(hist): |
| h = hist or [] |
| return _render_history_html(h), _render_cottage_board(h) |
|
|
| demo.load( |
| fn=_hydrate_history, |
| inputs=[history_state], |
| outputs=[history_out, cottage_board_out], |
| ).then( |
| |
| fn=_generate_zenith_chart, |
| inputs=[ |
| location_dropdown, custom_loc_box, off_grid_toggle, red_light_toggle, |
| target_override_state, |
| ], |
| outputs=[zenith_img_planner], |
| ) |
| |
| gr.HTML(""" |
| <button type="button" class="fab-button" aria-label="Back to top" onclick="window.scrollTo({top: 0, behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth'})"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="fab-icon"> |
| <line x1="12" y1="19" y2="5"></line> |
| <polyline points="5 12 12 5 19 12"></polyline> |
| </svg> |
| </button> |
| """) |
|
|
| return demo |
|
|
| if __name__ == "__main__": |
| import argparse |
| import logging |
|
|
| logging.basicConfig( |
| level=logging.INFO, |
| format="%(asctime)s %(levelname)s %(name)s: %(message)s", |
| ) |
|
|
| parser = argparse.ArgumentParser(description="Orion's Cottage — Constellation Planner") |
| parser.add_argument( |
| "--port", |
| type=int, |
| default=None, |
| help="Port for the Gradio server (default: 7860 or GRADIO_SERVER_PORT env var)", |
| ) |
| args = parser.parse_args() |
|
|
| |
| preferred_port = args.port or int(os.environ.get("GRADIO_SERVER_PORT", 7860)) |
|
|
| |
| static_dir = Path(__file__).resolve().parent / "static" |
| allowed = [str(static_dir)] if static_dir.is_dir() else None |
|
|
| app = build_app() |
| for port in range(preferred_port, preferred_port + 10): |
| try: |
| print(f"Starting Orion's Cottage on http://0.0.0.0:{port} (Press Ctrl+C to stop)") |
| app.launch( |
| server_name="0.0.0.0", |
| server_port=port, |
| show_error=True, |
| theme=midnight_oasis_theme, |
| css=CUSTOM_CSS, |
| allowed_paths=allowed, |
| ) |
| break |
| except OSError as e: |
| msg = str(e).lower() |
| if "cannot find empty port" in msg or "address already in use" in msg: |
| print(f"Port {port} in use, trying {port + 1}...") |
| continue |
| raise |
| else: |
| raise SystemExit(f"Could not find a free port in {preferred_port}-{preferred_port + 9}") |
|
|