Fix SNR calculator, add score weight controls, highlight selected filter
SNR calculator: signalFromSB calibration was 4750× too small (0.001 vs ~4.75 at SB=21). Calibration is now derived consistently from the sky background constants: C[filter] = sky_e_s[filter] / 10^((21 - 21.5) / 2.5). Also made it filter-aware so narrowband filters use their own reference. Replaced the broken 500+/billions display with a proper per-filter result or a 'too faint for this setup' message when signal ≈ 0. Score weights: 'Best score tonight' sort now accepts score_alt/fov/time/moon query params (0.0–1.0, server-side normalised to sum=1). Frontend adds a ⚙ weights button next to the sort dropdown that reveals 4 sliders showing effective %, persisted to localStorage. Weights default to 40/30/20/10. Selected filter: clicking a filter pill in the Filters tab now highlights the row (bg + amber outline on the pill + ▶ marker) so it's clear which filter the SNR calculator and workflow card are showing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -65,6 +65,11 @@ pub struct TargetsQuery {
|
|||||||
pub mosaic_only: Option<bool>,
|
pub mosaic_only: Option<bool>,
|
||||||
pub not_imaged: Option<bool>,
|
pub not_imaged: Option<bool>,
|
||||||
pub show_custom: Option<bool>,
|
pub show_custom: Option<bool>,
|
||||||
|
// Score weights for "best tonight" sort (0.0–1.0 each, auto-normalised server-side)
|
||||||
|
pub score_alt: Option<f64>,
|
||||||
|
pub score_fov: Option<f64>,
|
||||||
|
pub score_time: Option<f64>,
|
||||||
|
pub score_moon: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
@@ -233,18 +238,32 @@ pub async fn list_targets(
|
|||||||
.unwrap_or(1.0);
|
.unwrap_or(1.0);
|
||||||
|
|
||||||
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
|
// "best" sort: composite score balancing altitude, FOV fill, usable time, moon separation.
|
||||||
|
// Weights are normalised to sum=1.0 so changing one factor doesn't need all others to change.
|
||||||
|
let raw_alt = params.score_alt.unwrap_or(0.40).clamp(0.0, 1.0);
|
||||||
|
let raw_fov = params.score_fov.unwrap_or(0.30).clamp(0.0, 1.0);
|
||||||
|
let raw_time = params.score_time.unwrap_or(0.20).clamp(0.0, 1.0);
|
||||||
|
let raw_moon = params.score_moon.unwrap_or(0.10).clamp(0.0, 1.0);
|
||||||
|
let weight_sum = raw_alt + raw_fov + raw_time + raw_moon;
|
||||||
|
let (w_alt, w_fov, w_time, w_moon) = if weight_sum > 0.0 {
|
||||||
|
(raw_alt / weight_sum, raw_fov / weight_sum, raw_time / weight_sum, raw_moon / weight_sum)
|
||||||
|
} else {
|
||||||
|
(0.4, 0.3, 0.2, 0.1)
|
||||||
|
};
|
||||||
// Multiplied by weather_weight so cloudy nights rank all targets lower.
|
// Multiplied by weather_weight so cloudy nights rank all targets lower.
|
||||||
let best_score_expr = format!(r#"(
|
let best_score_expr = format!(r#"(
|
||||||
COALESCE(nc.max_alt_deg, 0) / 90.0 * 0.40
|
COALESCE(nc.max_alt_deg, 0) / 90.0 * {w_alt:.4}
|
||||||
+ CASE
|
+ CASE
|
||||||
WHEN c.fov_fill_pct IS NULL THEN 0.15
|
WHEN c.fov_fill_pct IS NULL THEN {w_fov:.4} * 0.5
|
||||||
WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * 0.30
|
WHEN c.fov_fill_pct BETWEEN 20 AND 80 THEN (1.0 - ABS(c.fov_fill_pct - 50) / 50.0) * {w_fov:.4}
|
||||||
WHEN c.fov_fill_pct > 80 THEN 0.10
|
WHEN c.fov_fill_pct > 80 THEN {w_fov:.4} * 0.33
|
||||||
ELSE 0.05
|
ELSE {w_fov:.4} * 0.17
|
||||||
END
|
END
|
||||||
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * 0.20
|
+ MIN(COALESCE(nc.usable_min, 0), 300) / 300.0 * {w_time:.4}
|
||||||
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * 0.10
|
+ COALESCE(nc.moon_sep_deg, 90) / 180.0 * {w_moon:.4}
|
||||||
) * {weather_weight:.2} DESC"#, weather_weight = weather_weight);
|
) * {weather_weight:.2} DESC"#,
|
||||||
|
w_alt = w_alt, w_fov = w_fov, w_time = w_time, w_moon = w_moon,
|
||||||
|
weather_weight = weather_weight
|
||||||
|
);
|
||||||
let sort_col_owned: String = match params.sort.as_deref() {
|
let sort_col_owned: String = match params.sort.as_deref() {
|
||||||
Some("transit") => "nc.transit_utc".to_string(),
|
Some("transit") => "nc.transit_utc".to_string(),
|
||||||
Some("size") => "c.size_arcmin_maj DESC".to_string(),
|
Some("size") => "c.size_arcmin_maj DESC".to_string(),
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ export interface TargetsParams {
|
|||||||
mosaic_only?: boolean;
|
mosaic_only?: boolean;
|
||||||
not_imaged?: boolean;
|
not_imaged?: boolean;
|
||||||
show_custom?: boolean;
|
show_custom?: boolean;
|
||||||
|
score_alt?: number;
|
||||||
|
score_fov?: number;
|
||||||
|
score_time?: number;
|
||||||
|
score_moon?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -87,6 +91,10 @@ export const api = {
|
|||||||
if (params.mosaic_only) q.set('mosaic_only', 'true');
|
if (params.mosaic_only) q.set('mosaic_only', 'true');
|
||||||
if (params.not_imaged) q.set('not_imaged', 'true');
|
if (params.not_imaged) q.set('not_imaged', 'true');
|
||||||
if (params.show_custom === false) q.set('show_custom', 'false');
|
if (params.show_custom === false) q.set('show_custom', 'false');
|
||||||
|
if (params.score_alt !== undefined) q.set('score_alt', String(params.score_alt));
|
||||||
|
if (params.score_fov !== undefined) q.set('score_fov', String(params.score_fov));
|
||||||
|
if (params.score_time !== undefined) q.set('score_time', String(params.score_time));
|
||||||
|
if (params.score_moon !== undefined) q.set('score_moon', String(params.score_moon));
|
||||||
return get<TargetsResponse>(`/targets?${q}`);
|
return get<TargetsResponse>(`/targets?${q}`);
|
||||||
},
|
},
|
||||||
get: (id: string): Promise<Target> => get(`/targets/${id}`),
|
get: (id: string): Promise<Target> => get(`/targets/${id}`),
|
||||||
|
|||||||
@@ -133,18 +133,32 @@ const SKY_BG: Record<string, number> = {
|
|||||||
c2: 0.03,
|
c2: 0.03,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Source signal e-/px/s from surface brightness (mag/arcsec²), Bortle 5 calibration. */
|
// Signal calibration constants derived from sky background:
|
||||||
function signalFromSB(sb: number): number {
|
// At Bortle 5, sky ≈ 21.5 mag/arcsec² → sky_e_s = C * 10^((21-21.5)/2.5)
|
||||||
// Reference: SB=21 → ~0.001 e-/px/s at this aperture+scale
|
// Therefore C = sky_e_s / 10^(-0.2) ≈ sky_e_s / 0.631
|
||||||
return 0.001 * Math.pow(10, (21 - sb) / 2.5);
|
// This ensures signal(sky_SB) == sky_e_s, keeping the two constants consistent.
|
||||||
|
const SKY_SB_REF = 21.5;
|
||||||
|
const _skyFactor = Math.pow(10, (21 - SKY_SB_REF) / 2.5); // ≈ 0.631
|
||||||
|
const SIGNAL_CAL: Record<string, number> = {
|
||||||
|
uvir: SKY_BG.uvir / _skyFactor, // ≈ 4.75
|
||||||
|
sv260: SKY_BG.sv260 / _skyFactor, // ≈ 2.85
|
||||||
|
sv220: SKY_BG.sv220 / _skyFactor, // ≈ 0.063
|
||||||
|
c2: SKY_BG.c2 / _skyFactor, // ≈ 0.048
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Source signal e-/px/s from surface brightness (mag/arcsec²), filter-aware. */
|
||||||
|
function signalFromSB(sb: number, filterId: string): number {
|
||||||
|
const c = SIGNAL_CAL[filterId] ?? SIGNAL_CAL.uvir;
|
||||||
|
return c * Math.pow(10, (21 - sb) / 2.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
function subsNeeded(signal_e_s: number, sky_e_s: number, targetSnr: number): number {
|
function subsNeeded(signal_e_s: number, sky_e_s: number, targetSnr: number): number {
|
||||||
const s = signal_e_s * SUB_SEC; // signal per sub
|
const s = signal_e_s * SUB_SEC; // signal electrons per sub
|
||||||
const b = sky_e_s * SUB_SEC; // sky per sub
|
const b = sky_e_s * SUB_SEC; // sky electrons per sub
|
||||||
const d = DARK_E_PER_S * SUB_SEC; // dark per sub
|
const d = DARK_E_PER_S * SUB_SEC; // dark electrons per sub
|
||||||
const r2 = READ_NOISE_E * READ_NOISE_E;
|
const r2 = READ_NOISE_E * READ_NOISE_E;
|
||||||
if (s <= 0) return 999;
|
if (s <= 0) return Infinity;
|
||||||
|
// SNR of N stacked subs: sqrt(N)*s / sqrt(s+b+d+r²) → solve for N
|
||||||
const noise_per_sub = s + b + d + r2;
|
const noise_per_sub = s + b + d + r2;
|
||||||
return Math.ceil((targetSnr * targetSnr * noise_per_sub) / (s * s));
|
return Math.ceil((targetSnr * targetSnr * noise_per_sub) / (s * s));
|
||||||
}
|
}
|
||||||
@@ -154,10 +168,10 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
|
|||||||
const sb = target.surface_brightness;
|
const sb = target.surface_brightness;
|
||||||
const sky = SKY_BG[filterId] ?? 1.0;
|
const sky = SKY_BG[filterId] ?? 1.0;
|
||||||
|
|
||||||
const signal = sb != null ? signalFromSB(sb) : null;
|
const signal = sb != null ? signalFromSB(sb, filterId) : null;
|
||||||
const n = signal != null ? subsNeeded(signal, sky, targetSnr) : null;
|
const n = signal != null ? subsNeeded(signal, sky, targetSnr) : null;
|
||||||
const totalMin = n != null ? n * (SUB_SEC / 60) : null;
|
const totalH = n != null && isFinite(n) ? (n * SUB_SEC / 3600) : null;
|
||||||
const totalH = totalMin != null ? (totalMin / 60).toFixed(1) : null;
|
const tooFaint = n != null && !isFinite(n);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '12px 14px' }}>
|
<div style={{ background: 'var(--bg-deep)', border: '1px solid var(--border)', borderRadius: 4, padding: '12px 14px' }}>
|
||||||
@@ -168,6 +182,10 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
|
|||||||
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
<div style={{ color: 'var(--text-lo)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
Surface brightness not available for this object.
|
Surface brightness not available for this object.
|
||||||
</div>
|
</div>
|
||||||
|
) : tooFaint ? (
|
||||||
|
<div style={{ color: 'var(--warn)', fontFamily: 'var(--font-mono)', fontSize: 11 }}>
|
||||||
|
SB {sb.toFixed(1)} mag/″² — too faint for this setup with {filterId.toUpperCase()}.
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', gap: 24, alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 24, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -185,19 +203,19 @@ function ImagingCalculator({ target, filterId }: { target: Target; filterId: str
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Subs needed</div>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Subs needed</div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: n != null && n < 100 ? 'var(--good)' : 'var(--warn)' }}>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: n != null && n < 100 ? 'var(--good)' : 'var(--warn)' }}>
|
||||||
{n != null ? (n > 500 ? '500+' : n) : '—'}
|
{n != null ? n.toLocaleString() : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Total time</div>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Total time</div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--amber)' }}>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--amber)' }}>
|
||||||
{totalH != null ? `${totalH}h` : '—'}
|
{totalH != null ? `${totalH.toFixed(1)}h` : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Sessions ~2h</div>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>Sessions ~2h</div>
|
||||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--blue)' }}>
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 20, fontWeight: 700, color: 'var(--blue)' }}>
|
||||||
{totalH != null ? `×${Math.ceil(parseFloat(totalH) / 2)}` : '—'}
|
{totalH != null ? `×${Math.ceil(totalH / 2)}` : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -486,14 +504,25 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filtersData?.recommendations?.map(rec => (
|
{filtersData?.recommendations?.map(rec => {
|
||||||
<tr key={rec.filter_id} style={{ borderBottom: '1px solid var(--border)', opacity: rec.suitability === 'unsuitable' ? 0.4 : 1 }}>
|
const isSelected = rec.filter_id === selectedFilter;
|
||||||
|
return (
|
||||||
|
<tr key={rec.filter_id} style={{
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
opacity: rec.suitability === 'unsuitable' ? 0.4 : 1,
|
||||||
|
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
}}>
|
||||||
<td style={{ padding: '6px 8px 6px 0' }}>
|
<td style={{ padding: '6px 8px 6px 0' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedFilter(rec.filter_id)}
|
onClick={() => setSelectedFilter(rec.filter_id)}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{
|
||||||
|
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4,
|
||||||
|
outline: isSelected ? '2px solid var(--amber)' : 'none',
|
||||||
|
outlineOffset: 2, borderRadius: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className={`filter-pill ${rec.filter_id}`}>{rec.filter_id.toUpperCase()}</span>
|
<span className={`filter-pill ${rec.filter_id}`}>{rec.filter_id.toUpperCase()}</span>
|
||||||
|
{isSelected && <span style={{ fontFamily: 'var(--font-mono)', fontSize: 9, color: 'var(--amber)' }}>▶</span>}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', padding: '6px 8px',
|
<td style={{ fontSize: 11, fontFamily: 'var(--font-mono)', padding: '6px 8px',
|
||||||
@@ -518,7 +547,8 @@ export default function DetailDrawer({ target }: Props) {
|
|||||||
{rec.sessions_needed ? `×${rec.sessions_needed}` : '—'}
|
{rec.sessions_needed ? `×${rec.sessions_needed}` : '—'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ const SORT_OPTIONS = [
|
|||||||
const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal'];
|
const COL_HEADERS = ['Type', 'Name', 'Size', 'Fill', 'Mosaic', 'Mag', '★', 'Filter', 'Alt', 'Vis', 'Int', 'Goal'];
|
||||||
|
|
||||||
const LS_KEY = 'astronome_targets_filters_v2';
|
const LS_KEY = 'astronome_targets_filters_v2';
|
||||||
|
const LS_SCORE_KEY = 'astronome_score_weights';
|
||||||
|
|
||||||
|
interface ScoreWeights { alt: number; fov: number; time: number; moon: number; }
|
||||||
|
const DEFAULT_WEIGHTS: ScoreWeights = { alt: 40, fov: 30, time: 20, moon: 10 };
|
||||||
|
|
||||||
|
function loadWeights(): ScoreWeights {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_SCORE_KEY);
|
||||||
|
if (raw) return { ...DEFAULT_WEIGHTS, ...JSON.parse(raw) as ScoreWeights };
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return { ...DEFAULT_WEIGHTS };
|
||||||
|
}
|
||||||
|
|
||||||
interface FilterState {
|
interface FilterState {
|
||||||
typeFilters: string[];
|
typeFilters: string[];
|
||||||
@@ -104,6 +116,8 @@ export default function Targets() {
|
|||||||
const [minUsable, setMinUsable] = useState<number | null>(saved.minUsable);
|
const [minUsable, setMinUsable] = useState<number | null>(saved.minUsable);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [sort, setSort] = useState(saved.sort);
|
const [sort, setSort] = useState(saved.sort);
|
||||||
|
const [scoreWeights, setScoreWeights] = useState<ScoreWeights>(loadWeights);
|
||||||
|
const [showScoreWeights, setShowScoreWeights] = useState(false);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(urlTargetId ?? null);
|
const [expandedId, setExpandedId] = useState<string | null>(urlTargetId ?? null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [compareTargets, setCompareTargets] = useState<Target[]>([]);
|
const [compareTargets, setCompareTargets] = useState<Target[]>([]);
|
||||||
@@ -123,6 +137,10 @@ export default function Targets() {
|
|||||||
localStorage.setItem(LS_KEY, JSON.stringify(state));
|
localStorage.setItem(LS_KEY, JSON.stringify(state));
|
||||||
}, [typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, minAlt, minUsable, sort]);
|
}, [typeFilters, filterPill, tonight, notImaged, mosaicOnly, showCustom, minAlt, minUsable, sort]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(LS_SCORE_KEY, JSON.stringify(scoreWeights));
|
||||||
|
}, [scoreWeights]);
|
||||||
|
|
||||||
const toggleType = (t: string) => {
|
const toggleType = (t: string) => {
|
||||||
setTypeFilters(prev =>
|
setTypeFilters(prev =>
|
||||||
prev.includes(t) ? prev.filter(x => x !== t) : [...prev, t]
|
prev.includes(t) ? prev.filter(x => x !== t) : [...prev, t]
|
||||||
@@ -135,6 +153,15 @@ export default function Targets() {
|
|||||||
const effectiveMinUsable = accessible ? Math.max(minUsable ?? 0, 60) : (minUsable ?? undefined);
|
const effectiveMinUsable = accessible ? Math.max(minUsable ?? 0, 60) : (minUsable ?? undefined);
|
||||||
|
|
||||||
// When URL has a target ID, disable tonight-only filter so the target is found even if off-season
|
// When URL has a target ID, disable tonight-only filter so the target is found even if off-season
|
||||||
|
// Normalise score weights (0–100 sliders → 0.0–1.0, sum=1)
|
||||||
|
const wSum = scoreWeights.alt + scoreWeights.fov + scoreWeights.time + scoreWeights.moon || 1;
|
||||||
|
const normWeights = {
|
||||||
|
score_alt: scoreWeights.alt / wSum,
|
||||||
|
score_fov: scoreWeights.fov / wSum,
|
||||||
|
score_time: scoreWeights.time / wSum,
|
||||||
|
score_moon: scoreWeights.moon / wSum,
|
||||||
|
};
|
||||||
|
|
||||||
const { data: rawData, isLoading } = useTargets({
|
const { data: rawData, isLoading } = useTargets({
|
||||||
type: typeFilters.length ? typeFilters.join(',') : undefined,
|
type: typeFilters.length ? typeFilters.join(',') : undefined,
|
||||||
filter: filterPill || undefined,
|
filter: filterPill || undefined,
|
||||||
@@ -148,6 +175,7 @@ export default function Targets() {
|
|||||||
sort: sort || undefined,
|
sort: sort || undefined,
|
||||||
page,
|
page,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
|
...(sort === '' ? normWeights : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client-side accessible filter: difficulty ≤ 2 and moon_sep ≥ 45°
|
// Client-side accessible filter: difficulty ≤ 2 and moon_sep ≥ 45°
|
||||||
@@ -290,13 +318,28 @@ export default function Targets() {
|
|||||||
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SORT</span>
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)' }}>SORT</span>
|
||||||
<select
|
<select
|
||||||
value={sort}
|
value={sort}
|
||||||
onChange={e => setSort(e.target.value)}
|
onChange={e => { setSort(e.target.value); setShowScoreWeights(false); }}
|
||||||
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px' }}
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 11, padding: '2px 6px' }}
|
||||||
>
|
>
|
||||||
{SORT_OPTIONS.map(o => (
|
{SORT_OPTIONS.map(o => (
|
||||||
<option key={o.value} value={o.value}>{o.label}</option>
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{sort === '' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowScoreWeights(v => !v)}
|
||||||
|
title="Adjust score weights"
|
||||||
|
style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
color: showScoreWeights ? 'var(--amber)' : 'var(--text-lo)',
|
||||||
|
background: showScoreWeights ? 'var(--amber-glow)' : 'transparent',
|
||||||
|
border: `1px solid ${showScoreWeights ? 'var(--amber-dim)' : 'var(--border)'}`,
|
||||||
|
borderRadius: 3, padding: '2px 7px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚙ weights
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
@@ -342,6 +385,42 @@ export default function Targets() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Score weight panel — shown when sort = "best tonight" and ⚙ is toggled */}
|
||||||
|
{sort === '' && showScoreWeights && (
|
||||||
|
<div style={{ display: 'flex', gap: 24, alignItems: 'center', paddingTop: 8, flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', letterSpacing: '0.08em', textTransform: 'uppercase', whiteSpace: 'nowrap' }}>
|
||||||
|
Score weights
|
||||||
|
</span>
|
||||||
|
{([
|
||||||
|
{ key: 'alt', label: 'Altitude' },
|
||||||
|
{ key: 'fov', label: 'FOV fit' },
|
||||||
|
{ key: 'time', label: 'Usable time' },
|
||||||
|
{ key: 'moon', label: 'Moon sep' },
|
||||||
|
] as { key: keyof ScoreWeights; label: string }[]).map(({ key, label }) => {
|
||||||
|
const total = scoreWeights.alt + scoreWeights.fov + scoreWeights.time + scoreWeights.moon || 1;
|
||||||
|
const pct = Math.round((scoreWeights[key] / total) * 100);
|
||||||
|
return (
|
||||||
|
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', width: 72 }}>{label}</span>
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} step={5}
|
||||||
|
value={scoreWeights[key]}
|
||||||
|
onChange={e => setScoreWeights(w => ({ ...w, [key]: Number(e.target.value) }))}
|
||||||
|
style={{ accentColor: 'var(--amber)', width: 90 }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--amber)', width: 28 }}>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => setScoreWeights({ ...DEFAULT_WEIGHTS })}
|
||||||
|
style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--text-lo)', background: 'none', border: '1px solid var(--border)', borderRadius: 3, padding: '2px 8px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
|
|||||||
Reference in New Issue
Block a user