Gridlock / app /frontend /src /pages /Predict.tsx
parvmittal07's picture
Improved Map UI and minor fixes
2c64d54
Raw
History Blame Contribute Delete
22 kB
import { useEffect, useMemo, useState } from "react";
import {
Activity,
Users,
ShieldAlert,
Clock,
Flame,
Gauge as GaugeIcon,
ChevronDown,
MapPin,
Sparkles,
AlertTriangle,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input, Textarea, Label } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { SearchSelect } from "@/components/ui/search-select";
import { InfoTip } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/feedback";
import { Gauge } from "@/components/Gauge";
import { LocationPicker } from "@/components/LocationPicker";
import { api } from "@/lib/api";
import type { OptionsResponse, PredictRequest, PredictResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { LEVEL_STYLES, minutesToHuman, pct, titleCase } from "@/lib/format";
const DEFAULT_LAT = 12.9716;
const DEFAULT_LNG = 77.5946;
const CLOSURE_BASE = 0.072;
const PRIORITY_BASE = 0.622;
const HOTSPOT_BASE = 0.156;
function nowLocalInput(): string {
const d = new Date();
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 16);
}
// Treat the entered wall-clock time as Bengaluru local (IST, +05:30).
const toIST = (local: string): string => `${local}:00+05:30`;
const OPTIONAL_FIELDS: { key: keyof PredictRequest; label: string; search?: boolean }[] = [
{ key: "police_station", label: "Police station", search: true },
{ key: "junction", label: "Junction", search: true },
{ key: "corridor", label: "Corridor" },
{ key: "zone", label: "Zone" },
{ key: "direction", label: "Direction" },
{ key: "veh_type", label: "Vehicle type" },
{ key: "reason_breakdown", label: "Breakdown reason" },
{ key: "cargo_material", label: "Cargo material" },
];
export default function Predict() {
const [options, setOptions] = useState<OptionsResponse | null>(null);
const [lat, setLat] = useState(DEFAULT_LAT);
const [lng, setLng] = useState(DEFAULT_LNG);
const [startLocal, setStartLocal] = useState(nowLocalInput());
const [form, setForm] = useState<Partial<PredictRequest>>({
event_type: "unplanned",
event_cause: "vehicle_breakdown",
authenticated: "yes",
description: "",
});
const [showOptional, setShowOptional] = useState(false);
const [result, setResult] = useState<PredictResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api.options().then(setOptions).catch(() => setOptions(null));
}, []);
const cats = options?.categories ?? {};
const setField = (key: keyof PredictRequest, value: string | number | undefined) =>
setForm((f) => ({ ...f, [key]: value }));
async function onSubmit() {
setLoading(true);
setError(null);
try {
const payload: PredictRequest = {
...form,
latitude: lat,
longitude: lng,
start_datetime: toIST(startLocal),
} as PredictRequest;
const res = await api.predict(payload);
setResult(res);
} catch (e) {
setError((e as Error).message);
setResult(null);
} finally {
setLoading(false);
}
}
function pickStation(name: string | undefined) {
setField("police_station", name);
const st = options?.stations.find((s) => s.name === name);
if (st) {
setLat(st.lat);
setLng(st.lng);
}
}
return (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-12">
{/* ---------------- Form ---------------- */}
<div className="xl:col-span-5">
<div className="mb-4">
<h1 className="text-xl font-bold tracking-tight">Forecast an event</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enter what's known at report time. Only location and start time are
required — everything else sharpens the forecast.
</p>
</div>
<Card>
<CardContent className="space-y-5 pt-5">
{/* Required */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant="default" className="gap-1">
<Sparkles className="h-3 w-3" /> Required
</Badge>
</div>
<div>
<Label htmlFor="start">Start date &amp; time</Label>
<Input
id="start"
type="datetime-local"
value={startLocal}
onChange={(e) => setStartLocal(e.target.value)}
/>
</div>
<div>
<Label hint="quick fill">Jump to a police-station area</Label>
<SearchSelect
options={(options?.stations ?? []).map((s) => s.name)}
value={form.police_station}
onChange={pickStation}
placeholder="Search station to center map…"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="lat">Latitude</Label>
<Input
id="lat"
type="number"
step="0.0001"
value={lat}
onChange={(e) => setLat(Number(e.target.value))}
/>
</div>
<div>
<Label htmlFor="lng">Longitude</Label>
<Input
id="lng"
type="number"
step="0.0001"
value={lng}
onChange={(e) => setLng(Number(e.target.value))}
/>
</div>
</div>
<div>
<Label hint="click to set">
<MapPin className="h-3 w-3" /> Location on map
</Label>
<LocationPicker
lat={lat}
lng={lng}
onPick={(la, ln) => {
setLat(la);
setLng(ln);
}}
/>
</div>
</div>
<div className="h-px bg-border" />
{/* Event details */}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Event cause</Label>
<Select
value={form.event_cause}
onValueChange={(v) => setField("event_cause", v)}
>
<SelectTrigger>
<SelectValue placeholder="Select cause" />
</SelectTrigger>
<SelectContent>
{(cats.event_cause ?? []).map((c) => (
<SelectItem key={c} value={c}>
{titleCase(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Event type</Label>
<Select
value={form.event_type}
onValueChange={(v) => setField("event_type", v)}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{(cats.event_type ?? ["unplanned", "planned"]).map((c) => (
<SelectItem key={c} value={c}>
{titleCase(c)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label hint="optional">
Description
<InfoTip title="Why text helps">
The model reads the (English/Kannada) description with a
multilingual sentence-transformer plus a bilingual impact
lexicon. Words like “diversion”, “jam”, or “ಮುಚ್ಚ” move the
closure and duration forecasts.
</InfoTip>
</Label>
<Textarea
placeholder="e.g. Lorry breakdown blocking left lane near the flyover…"
value={form.description}
onChange={(e) => setField("description", e.target.value)}
/>
</div>
</div>
{/* Optional context */}
<div>
<button
type="button"
onClick={() => setShowOptional((s) => !s)}
className="flex w-full items-center justify-between rounded-md border border-border bg-secondary/40 px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary"
>
<span>Optional context ({OPTIONAL_FIELDS.length + 2} fields)</span>
<ChevronDown
className={cn(
"h-4 w-4 transition-transform",
showOptional && "rotate-180"
)}
/>
</button>
{showOptional && (
<div className="mt-4 grid grid-cols-2 gap-3 animate-fade-in">
{OPTIONAL_FIELDS.map(({ key, label, search }) => {
const opts = cats[key as string] ?? [];
return (
<div key={key as string}>
<Label>{label}</Label>
{search ? (
<SearchSelect
options={opts}
value={form[key] as string | undefined}
onChange={(v) => setField(key, v)}
placeholder="Search…"
/>
) : (
<Select
value={form[key] as string | undefined}
onValueChange={(v) => setField(key, v)}
>
<SelectTrigger>
<SelectValue placeholder="Any" />
</SelectTrigger>
<SelectContent>
{opts.map((c) => (
<SelectItem key={c} value={c}>
{titleCase(c)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
})}
<div>
<Label>Authenticated</Label>
<Select
value={form.authenticated}
onValueChange={(v) => setField("authenticated", v)}
>
<SelectTrigger>
<SelectValue placeholder="yes" />
</SelectTrigger>
<SelectContent>
<SelectItem value="yes">Yes</SelectItem>
<SelectItem value="no">No</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Truck age (yrs)</Label>
<Input
type="number"
step="1"
min="0"
placeholder="—"
value={form.age_of_truck ?? ""}
onChange={(e) =>
setField(
"age_of_truck",
e.target.value === "" ? undefined : Number(e.target.value)
)
}
/>
</div>
</div>
)}
</div>
<Button onClick={onSubmit} disabled={loading} className="w-full" size="lg">
{loading ? <Spinner /> : <Activity className="h-4 w-4" />}
{loading ? "Forecasting…" : "Run forecast"}
</Button>
</CardContent>
</Card>
</div>
{/* ---------------- Results ---------------- */}
<div className="xl:col-span-7">
<ResultPanel result={result} loading={loading} error={error} />
</div>
</div>
);
}
function ResultPanel({
result,
loading,
error,
}: {
result: PredictResponse | null;
loading: boolean;
error: string | null;
}) {
if (error) {
return (
<Card className="flex h-full min-h-[400px] items-center justify-center">
<div className="flex flex-col items-center gap-3 px-8 text-center">
<AlertTriangle className="h-7 w-7 text-warning" />
<p className="text-sm font-medium">Could not score this event</p>
<p className="text-xs text-muted-foreground">{error}</p>
</div>
</Card>
);
}
if (!result) {
return (
<Card className="flex h-full min-h-[400px] items-center justify-center border-dashed">
<div className="flex max-w-sm flex-col items-center gap-3 px-8 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-secondary">
{loading ? (
<Spinner className="h-6 w-6 text-primary" />
) : (
<GaugeIcon className="h-7 w-7 text-muted-foreground" />
)}
</div>
<p className="text-sm font-medium">
{loading ? "Scoring the event…" : "Your forecast appears here"}
</p>
<p className="text-xs text-muted-foreground">
Five leakage-controlled predictions plus operational recommendations,
all from report-time inputs.
</p>
</div>
</Card>
);
}
return <Results result={result} />;
}
function Results({ result }: { result: PredictResponse }) {
const level = result.manpower_level;
const style = LEVEL_STYLES[level];
return (
<div className="space-y-4 animate-fade-in">
{/* Manpower hero */}
<Card className={cn("relative overflow-hidden ring-1", style.ring)}>
<div className={cn("absolute inset-0 opacity-[0.07]", style.dot)} />
<CardContent className="relative flex flex-col items-start gap-4 pt-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded-2xl",
style.bg
)}
>
<Users className={cn("h-8 w-8", style.text)} />
</div>
<div>
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Manpower required
<InfoTip title="How manpower is derived">
A transparent blend of three independent signals:
<span className="mt-1 block font-mono text-[11px] text-foreground">
1.5·closure + 1.0·priority + 0.6·duration-tier
</span>
<span className="mt-1 block">
It deliberately doesn't trust priority alone (priority is
near-deterministic from corridor), so duration and closure
keep it sensible. A near-certain closure always gets a crew.
</span>
</InfoTip>
</div>
<div className={cn("text-4xl font-bold capitalize", style.text)}>
{level}
</div>
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-foreground">
{result.officers_suggested}
</div>
<div className="text-xs text-muted-foreground">officers suggested</div>
</div>
</CardContent>
</Card>
{/* Four predictions */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Closure */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<ShieldAlert className="h-4 w-4 text-danger" /> Road closure
<InfoTip title="Closure probability">
Calibrated chance this event needs a closure or diversion. Drives
barricading. Base rate is just 7.2%, so anything well above that
is a strong signal.
</InfoTip>
</CardTitle>
<Badge variant={result.closure_expected ? "danger" : "secondary"}>
{result.closure_expected ? "Expected" : "Unlikely"}
</Badge>
</CardHeader>
<CardContent className="flex items-center justify-center">
<Gauge
value={result.closure_probability}
colorClass="text-danger"
baseline={CLOSURE_BASE}
label={`base rate ${pct(CLOSURE_BASE, 0)}`}
/>
</CardContent>
</Card>
{/* Priority */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4 text-accent" /> High priority
<InfoTip title="Operational priority">
Probability the control room would tag this High priority — the
manpower-tier driver. The model infers it from geography, cause,
text and time (corridor is withheld to avoid a trivial lookup).
</InfoTip>
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center">
<Gauge
value={result.high_priority_probability}
colorClass="text-accent"
baseline={PRIORITY_BASE}
label={`base rate ${pct(PRIORITY_BASE, 0)}`}
/>
</CardContent>
</Card>
{/* Duration */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4 text-warning" /> Clearance time
<InfoTip title="Expected duration + 80% interval">
Point estimate plus a conformalised 80% prediction interval, so
you see the uncertainty — not just a single number.
</InfoTip>
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center">
<div className="text-3xl font-bold text-warning">
{minutesToHuman(result.expected_duration_min)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
expected to clear
</div>
</div>
<div className="mt-4">
<div className="mb-1 flex justify-between text-[11px] text-muted-foreground">
<span>{minutesToHuman(result.duration_low_min)}</span>
<span className="font-medium text-foreground">80% interval</span>
<span>{minutesToHuman(result.duration_high_min)}</span>
</div>
<div className="h-1.5 w-full rounded-full bg-secondary">
<div className="h-1.5 w-full rounded-full bg-warning/40" />
</div>
</div>
</CardContent>
</Card>
{/* Hotspot */}
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Flame className="h-4 w-4 text-primary" /> Chronic hotspot
<InfoTip title="Recurring-hotspot early warning">
Probability this ~110 m spot will reoffend (≥2 more events in 14
days). A flag means: stop firefighting and send a root-cause fix.
</InfoTip>
</CardTitle>
<Badge variant={result.hotspot_flag ? "warning" : "secondary"}>
{result.hotspot_flag ? "Emerging" : "Stable"}
</Badge>
</CardHeader>
<CardContent className="flex items-center justify-center">
<Gauge
value={result.hotspot_risk ?? 0}
colorClass="text-primary"
baseline={HOTSPOT_BASE}
label={`base rate ${pct(HOTSPOT_BASE, 0)}`}
/>
</CardContent>
</Card>
</div>
{/* Recommendations */}
<Card>
<CardHeader>
<CardTitle>Operational recommendation</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<RecRow label="Barricading" value={result.barricading} />
<RecRow label="Diversion" value={result.diversion} />
<div className="rounded-md bg-secondary/50 p-3 font-mono text-[11px] leading-relaxed text-muted-foreground">
{result.rationale}
</div>
</CardContent>
</Card>
</div>
);
}
function RecRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-3">
<div className="w-24 shrink-0 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="text-sm text-foreground">{value}</div>
</div>
);
}