/* ============================================================
Homeowner mode — split screen
============================================================ */
/* global React, HomeMapView, Icon, LineChart20, BarChart */
const { useState: useStateH, useMemo: useMemoH, useEffect: useEffectH } = React;
const { INR, NUM, suryaGharSubsidy, simplePayback, npv, emi, co2Tonnes, treesEquivalent, carKmEquivalent, dieselSavings, annualProduction, systemKwFromPanels, CO2_PER_KWH } = window.LEPTON_UTILS;
const DEFAULT_LAYERS = {
rgb: false,
annualFlux: true,
monthlyFlux: false,
shaded: false,
dsm: false,
mask: false,
segments: true,
panels: true,
};
const LAYER_DEFS = [
{ id: "rgb", label: "RGB orthophoto", group: "imagery", swatch: "swatch-rgb", kbd: "1" },
{ id: "mask", label: "Building mask", group: "imagery", swatch: "swatch-mask", kbd: "2" },
{ id: "annualFlux", label: "Annual flux", group: "data", swatch: "swatch-flux", kbd: "3" },
{ id: "monthlyFlux", label: "Monthly flux", group: "data", swatch: "swatch-monthly", kbd: "4" },
{ id: "shaded", label: "Sun hours / yr", group: "data", swatch: "swatch-shade", kbd: "5" },
{ id: "dsm", label: "DSM (heightmap)", group: "data", swatch: "swatch-dsm", kbd: "6" },
{ id: "segments", label: "Roof segments", group: "vector", swatch: "swatch-segments", kbd: "7" },
{ id: "panels", label: "Panel layout", group: "vector", swatch: "swatch-panels", kbd: "8" },
];
function LayerRow({ def, on, onToggle }) {
return (
onToggle(def.id)}
style={{ gridTemplateColumns: "20px 14px 1fr auto" }}
>
{def.label}
{def.kbd}
);
}
function LayerPanel({ layers, onToggle, building, monthIdx }) {
const groups = [
{ label: "Imagery", filter: "imagery" },
{ label: "Solar API rasters", filter: "data" },
{ label: "Vector", filter: "vector" },
];
const onCount = LAYER_DEFS.filter(l => layers[l.id]).length;
return (
{onCount} / {LAYER_DEFS.length}
{groups.map(g => (
{g.label}
{LAYER_DEFS.filter(l => l.group === g.filter).map(l => (
))}
))}
imagery date {building.imageryDate}
quality {building.imageryQuality}
);
}
function MonthScrubber({ monthIdx, setMonthIdx, building, playing, setPlaying, visible }) {
if (!visible) return null;
const factors = building.monthFlux.map(m => m.factor);
const maxF = Math.max(...factors);
return (
setPlaying(!playing)}>
{building.monthFlux.map((m, i) => (
setMonthIdx(i)}>
{m.m}
))}
{building.monthFlux[monthIdx].m}
×{building.monthFlux[monthIdx].factor.toFixed(2)}
);
}
function MapLegend({ activeLayers }) {
// pick the primary "data" layer for the legend
if (activeLayers.annualFlux) {
return (
Annual flux
500 1100 1750 kWh/m²
);
}
if (activeLayers.monthlyFlux) {
return (
);
}
if (activeLayers.shaded) {
return (
Sun hours / yr
shaded partial full sun
Σ over 12 × 24 hourly-shade bitmasks · max ≈ 4380 hrs (daylight half)
);
}
if (activeLayers.dsm) {
return (
DSM elevation
210m 225m 240m
);
}
if (activeLayers.segments && !activeLayers.annualFlux) {
return (
Roof segments
{["#f59e0b","#3b82f6","#10b981","#ec4899"].map((c,i) => (
S{i+1}
))}
);
}
return null;
}
/* ============================================================
Side panel: Stats / Segments / System / Financials / Tweaks
============================================================ */
function StatsBlock({ building, systemKw, annualKwh }) {
return (
Max array
{building.maxArrayPanelsCount}panels
≈ {building.maxArrayAreaMeters2.toFixed(1)} m² usable
Max system
{(building.maxArrayPanelsCount * building.panelCapacityWatts / 1000).toFixed(1)}kWp
@ {building.panelCapacityWatts}W panel
Max sunshine
{NUM(building.maxSunshineHoursPerYear)}hrs/yr
25th–95th pctile across roof
Annual production
{NUM(annualKwh)}kWh
{systemKw} kWp · {Math.round(annualKwh/systemKw)} kWh/kWp/yr
);
}
function SegmentTable({ building, hoveredSegment, onHoverSegment }) {
const segColors = ["#f59e0b", "#3b82f6", "#10b981", "#ec4899"];
return (
Roof segments
{building.roofSegments.length}
{(() => {
const totalKwh = building.roofSegments.reduce((a, s) => a + (s.yearlyKwhContribution || 0), 0);
const bestSeg = totalKwh > 0
? building.roofSegments.reduce((a, b) => (b.yearlyKwhContribution || 0) > (a.yearlyKwhContribution || 0) ? b : a)
: null;
return (
<>
id azimuth · pitch
panels
kWh/yr
sun h/yr
{building.roofSegments.map((seg, idx) => {
const isBest = bestSeg && seg.id === bestSeg.id && totalKwh > 0;
const sharePct = totalKwh > 0 ? Math.round((seg.yearlyKwhContribution || 0) / totalKwh * 100) : 0;
return (
onHoverSegment(seg.id)}
onMouseLeave={() => onHoverSegment(null)}
>
{seg.id}
{isBest && BEST }
{seg.azimuthDeg}° / {seg.pitchDeg}°
{seg.panelsAllocated || 0}
{NUM(seg.yearlyKwhContribution || 0)}
{sharePct > 0 && {sharePct}% }
{NUM(seg.sunshineHours)}
);
})}
{building.wholeRoofSunshineQuantiles && building.wholeRoofSunshineQuantiles.length >= 11 && (
Roof sunshine distribution
{building.wholeRoofSunshineQuantiles.map((q, i) => {
const max = Math.max(...building.wholeRoofSunshineQuantiles);
const h = max > 0 ? (q / max) * 100 : 0;
const labels = ["0%", "10", "20", "30", "40", "50", "60", "70", "80", "90", "100%"];
return (
);
})}
worst {NUM(building.wholeRoofSunshineQuantiles[0])} h
median {NUM(building.wholeRoofSunshineQuantiles[5])} h
best {NUM(building.wholeRoofSunshineQuantiles[10])} h
)}
>
);
})()}
);
}
function SystemSlider({ panelCount, setPanelCount, building, systemKw, annualKwh }) {
return (
System size
{systemKw} kWp
Panel count
{panelCount} / {building.maxArrayPanelsCount}
setPanelCount(+e.target.value)} />
min 4
{Math.round(annualKwh / 12)} kWh / month avg
max {building.maxArrayPanelsCount}
);
}
function TweaksBlock({ tweaks, setTweaks, discoms, monthlyBill, setMonthlyBill }) {
const T = tweaks;
const update = (k, v) => setTweaks({ ...T, [k]: v });
return (
Custom tweaks
India-specific
{/* Energy profile: DISCOM, bill or units, peak bill, sanctioned load */}
Your electricity
{discoms.find(d => d.id === T.discomId)?.name}
{(() => {
const discom = discoms.find(d => d.id === T.discomId);
const tariff = discom?.tariff || 7;
const monthlyKwh = Math.round(monthlyBill / tariff);
return (
<>
DISCOM {tariff.toFixed(2)} ₹/kWh
update("discomId", e.target.value)}>
{discoms.map(d => {d.name} · {d.region} )}
{/* Bill ↔ Units toggle */}
{T.billUnit === "inr" ? "Monthly bill" : "Monthly consumption"}
update("billUnit", "inr")}>₹
update("billUnit", "kwh")}>kWh
{T.billUnit === "inr" ? (
₹
setMonthlyBill(Math.max(0, +e.target.value || 0))}
style={{ paddingLeft: 22 }}
/>
) : (
setMonthlyBill(Math.round(Math.max(0, +e.target.value || 0) * tariff))}
style={{ paddingRight: 36 }}
/>
kWh
)}
{INR(monthlyBill)} / mo · {NUM(monthlyKwh)} kWh / mo
{NUM(monthlyKwh * 12)} kWh / yr
{/* Peak (summer) bill — optional */}
Peak summer bill (optional)
₹
update("peakBill", Math.max(0, +e.target.value || 0))}
style={{ paddingLeft: 22 }}
/>
{T.peakBill > 0
? `Peak ≈ ${(T.peakBill / monthlyBill).toFixed(1)}× average. We weight 3 peak months into annual usage.`
: "Leave blank if average bill is fine. Most Indian homes peak in May–Jul on AC."}
{/* Sanctioned load — optional */}
Sanctioned load (from DISCOM bill)
update("sanctionedLoadKw", Math.max(0, +e.target.value || 0))}
style={{ paddingRight: 32 }}
/>
kW
Net-metering is capped at sanctioned load in most states. Leave blank if unsure.
>
);
})()}
{/* EV charging plan */}
EV charging plan
{T.evEnabled ? `${T.evKmPerDay} km/d` : "off"}
update("evEnabled", !T.evEnabled)}
>
Plan to charge an EV at home
{T.evEnabled && (
<>
Distance / day {T.evKmPerDay} km
update("evKmPerDay", +e.target.value)} />
Adds ≈ {NUM(Math.round(T.evKmPerDay * 365 * 0.15))} kWh / yr to your load
({Math.round(T.evKmPerDay * 0.15 * 30)} kWh / mo · ~ ₹{NUM(Math.round(T.evKmPerDay * 365 * 0.15 * (discoms.find(d=>d.id===T.discomId)?.tariff || 7)))} / yr at grid rate).
Solar offsets this entirely if sized right.
>
)}
{/* Flat-roof tilt */}
Flat-roof tilt override
{T.tiltEnabled ? `${T.tiltDeg}°` : "off"}
update("tiltEnabled", !T.tiltEnabled)}
>
Mount terrace (S4) panels on tilted frames
{T.tiltEnabled && (
Tilt angle {T.tiltDeg}°
update("tiltDeg", +e.target.value)} />
Lat-optimal ≈ 24° in Gurugram. +{Math.round((1 - Math.abs(T.tiltDeg - 24) / 24) * 18)}% on flat segment.
)}
{/* Battery */}
Battery add-on
0 ? "on" : ""}`}>{T.batteryKwh > 0 ? `${T.batteryKwh} kWh` : "none"}
Capacity {T.batteryKwh} kWh
update("batteryKwh", +e.target.value)} />
+₹{NUM(T.batteryKwh * 32000)} CAPEX · {Math.min(95, 35 + T.batteryKwh * 4)}% self-consumption.
{/* Diesel offset */}
Diesel offset mode
{T.dieselEnabled ? `${T.dieselHrs} h/d` : "off"}
update("dieselEnabled", !T.dieselEnabled)}
>
Compare payback vs. diesel genset
{T.dieselEnabled && (
<>
>
)}
);
}
function FinancialsBlock({ building, systemKw, annualKwh, tariff, tweaks, monthlyBill, discom }) {
// CAPEX
const baseCapex = Math.round(systemKw * 62000);
const batteryCapex = tweaks.batteryKwh * 32000;
const totalCapex = baseCapex + batteryCapex;
// Subsidy
const subRes = suryaGharSubsidy(systemKw);
const netCapex = totalCapex - subRes.subsidy;
// Annual baseline consumption (kWh).
// If user provided a peak summer bill, weight 3 peak months at peak ₹ + 9 months at avg.
// EV adds extra load if user planned for it.
const effectiveAnnualBill = tweaks.peakBill > 0
? tweaks.peakBill * 3 + monthlyBill * 9
: monthlyBill * 12;
const evKwh = tweaks.evEnabled ? Math.round(tweaks.evKmPerDay * 365 * 0.15) : 0;
const annualUsageKwh = Math.round(effectiveAnnualBill / tariff) + evKwh;
// Savings — capped by usage
const exported = Math.max(0, annualKwh - annualUsageKwh);
const selfConsumed = annualKwh - exported;
// self-consumed @ tariff, exported @ feed-in (₹2/kWh net metering avg)
const exportRate = 2.0;
const annualSavings = Math.round(selfConsumed * tariff + exported * exportRate);
// CO2 — use Solar API's building-specific carbon factor when available
// (CEA's grid factor varies by region; API reports ~0.93 kg/kWh for North India,
// vs our generic 0.82 default).
const co2KgPerKwh = (building?.carbonOffsetKgPerKwh && building.carbonOffsetKgPerKwh > 0)
? building.carbonOffsetKgPerKwh
: CO2_PER_KWH;
const annualCo2Kg = Math.round(annualKwh * co2KgPerKwh);
// Lifetime — Solar API tells us panelLifetimeYears (typically 20).
const lifetimeYears = building?.panelLifetimeYears || 20;
// Payback / NPV — use the API-provided lifetime.
const paybackYears = simplePayback(netCapex, annualSavings);
const npv20 = npv(netCapex, annualSavings, lifetimeYears, 0.08, 0.04);
// EMI (SBI Surya Ghar): 6.75%, 10y
const emiAmount = Math.round(emi(netCapex, 0.0675, 10));
// Diesel
const dieselSav = tweaks.dieselEnabled
? Math.round(annualKwh * 0.28 * (tweaks.dieselHrs / 8) * tweaks.dieselPrice * 0.6)
: 0;
const dieselPayback = tweaks.dieselEnabled && dieselSav > 0 ? +(netCapex / (annualSavings + dieselSav)).toFixed(1) : null;
// Lifetime curve — Solar API tells us panelLifetimeYears (typically 20)
const escalation = 0.04;
const withSolar = [];
const withoutSolar = [];
{
let solarCum = -netCapex;
let nonCum = 0;
let s = annualSavings;
let g = annualUsageKwh * tariff;
for (let y = 0; y < lifetimeYears; y++) {
solarCum += s;
nonCum -= g;
withSolar.push(Math.round(solarCum));
withoutSolar.push(Math.round(nonCum));
s *= 1 + escalation;
g *= 1 + escalation;
}
}
const breakEvenYear = withSolar.findIndex(v => v >= 0) + 1;
return (
Financials · India math
PM Surya Ghar
Net payback
{paybackYears < 100 ? paybackYears.toFixed(1) : "—"}years
NPV @ 8% · {lifetimeYears}y
{INR(npv20)}
System CAPEX ({systemKw} kWp @ ₹62k/kWp)
{INR(baseCapex)}
{tweaks.batteryKwh > 0 && <>
Battery ({tweaks.batteryKwh} kWh)
+ {INR(batteryCapex)}
>}
PM Surya Ghar subsidy *
− {INR(subRes.subsidy)}
Net upfront
{INR(netCapex)}
{/* Sanctioned-load + EV usage callouts */}
{(tweaks.sanctionedLoadKw > 0 || tweaks.evEnabled) && (
{tweaks.sanctionedLoadKw > 0 && systemKw > tweaks.sanctionedLoadKw && (
System ({systemKw} kWp) exceeds your {tweaks.sanctionedLoadKw} kW sanctioned load. Most DISCOMs cap net-metering at sanctioned load — you'd need a load enhancement first.
)}
{tweaks.evEnabled && (
EV load: {NUM(evKwh)} kWh / yr included in consumption ({Math.round(evKwh / annualKwh * 100)}% of solar output goes to the EV).
)}
)}
{/* Subsidy bar */}
Subsidized {INR(subRes.subsidy)} ({((subRes.subsidy/totalCapex)*100).toFixed(0)}%)
Self-funded {INR(netCapex)}
*Residential only · empanelled vendor required · capped at 3 kW (₹78,000)
{/* Annual savings + CO2 */}
Annual savings
{INR(annualSavings)}
self-cons {NUM(selfConsumed)} · export {NUM(exported)} kWh
CO₂ offset
{(annualCo2Kg / 1000).toFixed(2)}t/yr
≡ {NUM(treesEquivalent(annualCo2Kg))} trees · {NUM(carKmEquivalent(annualCo2Kg))} car-km
{/* Lifetime curve (panelLifetimeYears from Solar API) */}
{lifetimeYears}-year cumulative
tariff esc 4%/yr
{/* SBI Surya Ghar EMI */}
SBI Surya Ghar loan
6.75% · 10 yr
Principal
{INR(netCapex)}
Monthly EMI
{INR(emiAmount)}
vs. monthly bill saved
−{INR(Math.round(annualSavings/12))}
Net monthly outlay
{INR(emiAmount - Math.round(annualSavings/12))}
{/* Diesel mode */}
{tweaks.dieselEnabled && (
<>
Diesel offset · alternative payback
{tweaks.dieselHrs} hrs/d @ ₹{tweaks.dieselPrice}/L
Diesel saved per year
{INR(dieselSav)}
Combined annual savings
{INR(annualSavings + dieselSav)}
Diesel-adjusted payback
{dieselPayback} years
>
)}
);
}
/* ============================================================
Main HomeownerMode
============================================================ */
function HomeownerMode() {
const data = window.useLeptonData();
const HOME_BUILDING = data.HOME_BUILDING;
const DISCOMS = data.DISCOMS;
const [layers, setLayers] = useStateH(DEFAULT_LAYERS);
const [monthIdx, setMonthIdx] = useStateH(3); // April
const [playing, setPlaying] = useStateH(false);
const [panelCount, setPanelCount] = useStateH(28);
const [hoveredSegment, setHoveredSegment] = useStateH(null);
const [monthlyBill, setMonthlyBill] = useStateH(4200);
const [tweaks, setTweaks] = useStateH({
discomId: "dhbvn",
// Energy profile
billUnit: "inr", // 'inr' | 'kwh' — drives which slider is shown
peakBill: 0, // 0 = not set; if set, used for summer-aware sizing
sanctionedLoadKw: 0, // 0 = not set; warns if proposed system exceeds
// Roof + add-ons
tiltEnabled: true,
tiltDeg: 20,
batteryKwh: 0,
// Future load
evEnabled: false,
evKmPerDay: 30,
// Backup
dieselEnabled: false,
dieselPrice: 95,
dieselHrs: 3,
});
// play month animation
useEffectH(() => {
if (!playing) return;
const t = setInterval(() => setMonthIdx(m => (m + 1) % 12), 700);
return () => clearInterval(t);
}, [playing]);
// keyboard shortcuts for layers
useEffectH(() => {
const handler = (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
const def = LAYER_DEFS.find(l => l.kbd === e.key);
if (def) {
setLayers(L => ({ ...L, [def.id]: !L[def.id] }));
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const toggleLayer = (id) => setLayers(L => ({ ...L, [id]: !L[id] }));
if (!HOME_BUILDING) {
return ;
}
const systemKw = systemKwFromPanels(panelCount, HOME_BUILDING.panelCapacityWatts);
const annualKwh = annualProduction({
building: HOME_BUILDING,
panels: panelCount,
tiltOverride: tweaks.tiltEnabled ? tweaks.tiltDeg : null,
});
const discom = DISCOMS.find(d => d.id === tweaks.discomId);
const pendingLocation = data.pendingLocation;
const pendingPolygon = data.pendingPolygon;
const selectionMode = data.selectionMode;
const lastSelection = data.lastSelection;
const isComputing = data.loading.home;
return (
{/* MAP */}
{pendingLocation && (
)}
{!pendingLocation && lastSelection && lastSelection.offsetMeters >= 15 && (
)}
{/* SIDE PANEL */}
);
}
function AlignmentBanner({ offsetMeters }) {
return (
Solar API resolved a building {Math.round(offsetMeters)} m from your selection (the red dot on the map).
Click closer to the rooftop or drag the pin to recompute.
);
}
function LocationPanel({ pending, polygon, isLoading }) {
const onCompute = () => {
window.LEPTON_LOAD.commitPending();
};
const onCancel = () => {
window.LEPTON_LOAD.clearPending();
};
return (
{polygon ? "Drawn polygon" : "Pending location"}
{pending.source}
{pending.label && (
{pending.label}
)}
{pending.lat.toFixed(5)}°N · {pending.lng.toFixed(5)}°E
{polygon && · {polygon.length} pts }
{isLoading ? (
<>
Computing solar potential…
>
) : (
<>
Compute solar potential
>
)}
);
}
function LoadingScreen({ label, error }) {
return (
L
Lepton Solar
{label || "Loading…"}
{error && (
{error}
)}
);
}
// Place-type → icon. Mirrors how Google Maps differentiates suggestion rows.
function iconForPlaceTypes(types = []) {
const t = new Set(types);
if (t.has("establishment") || t.has("point_of_interest") || t.has("store") || t.has("hospital") || t.has("school")) return "warehouse";
if (t.has("premise") || t.has("subpremise") || t.has("street_address") || t.has("route")) return "home";
if (t.has("locality") || t.has("sublocality") || t.has("sublocality_level_1") || t.has("neighborhood")) return "people";
if (t.has("administrative_area_level_1") || t.has("administrative_area_level_2") || t.has("country")) return "compass";
return "pin";
}
// Detect a raw "lat, lng" (or "lat lng" / "lat;lng") in the search input.
// Accepts signed decimals; validates lat ∈ [-90, 90] and lng ∈ [-180, 180].
const LATLNG_RE = /^\s*(-?\d{1,2}(?:\.\d+)?)\s*[,\s;]\s*(-?\d{1,3}(?:\.\d+)?)\s*$/;
function parseLatLng(s) {
const m = LATLNG_RE.exec(s);
if (!m) return null;
const lat = parseFloat(m[1]);
const lng = parseFloat(m[2]);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
return { lat, lng };
}
function AddrBar({ building, placeholder: placeholderProp, skipSetPending, defaultZoom = 18 }) {
const inputRef = React.useRef(null);
const [query, setQuery] = React.useState("");
const [open, setOpen] = React.useState(false);
const [suggestions, setSuggestions] = React.useState([]);
const [highlight, setHighlight] = React.useState(0);
const [focused, setFocused] = React.useState(false);
// Building is optional (city mode has no current building).
const placeholder = placeholderProp || (building && building.addressShort) || "Search place or lat, lng";
const fetchSuggestions = async (v) => {
if (v.length < 2) { setSuggestions([]); setOpen(false); return; }
// If the input parses as raw lat,lng, surface a synthetic suggestion ABOVE
// the place results — lets the user jump straight to coordinates.
const ll = parseLatLng(v);
const synthetic = ll ? [{
synthetic: true,
place_id: `_ll_${ll.lat}_${ll.lng}`,
lat: ll.lat, lng: ll.lng,
main: `${ll.lat.toFixed(5)}°, ${ll.lng.toFixed(5)}°`,
secondary: "Jump to coordinates",
description: `${ll.lat.toFixed(5)}, ${ll.lng.toFixed(5)}`,
types: ["_latlng"],
}] : [];
try {
const r = await fetch(`/api/places/autocomplete?q=${encodeURIComponent(v)}`);
const j = await r.json();
const preds = (j.predictions || []).slice(0, 6).map(p => ({
place_id: p.place_id,
main: p.structured_formatting?.main_text || p.description,
secondary: p.structured_formatting?.secondary_text || "",
description: p.description,
types: p.types || [],
}));
setSuggestions([...synthetic, ...preds]);
setOpen(true);
setHighlight(0);
} catch (e) {
// If autocomplete fails (e.g. quota), still show the synthetic latlng row.
if (synthetic.length) {
setSuggestions(synthetic);
setOpen(true);
setHighlight(0);
} else {
console.error(e);
}
}
};
const onChange = (e) => {
const v = e.target.value;
setQuery(v);
fetchSuggestions(v);
};
const clear = () => {
setQuery("");
setSuggestions([]);
setOpen(false);
inputRef.current?.focus();
};
const panAndMaybePin = (lat, lng, label, types) => {
const zoom =
types.includes("street_address") || types.includes("premise") || types.includes("subpremise") || types.includes("establishment") ? 20 :
types.includes("sublocality") || types.includes("neighborhood") || types.includes("sublocality_level_1") ? 18 :
types.includes("locality") || types.includes("administrative_area_level_3") ? 17 :
types.includes("administrative_area_level_2") ? 15 :
types.includes("administrative_area_level_1") ? 12 :
types.includes("_latlng") ? defaultZoom :
18;
window.dispatchEvent(new CustomEvent("lepton:pan-to", { detail: { lat, lng, zoom, label }}));
if (!skipSetPending) {
window.LEPTON_LOAD.setPending({ lat, lng, label, source: "search" });
}
};
const pickSuggestion = async (s) => {
setOpen(false);
setQuery("");
setSuggestions([]);
if (s.synthetic) {
// Direct lat-lng entry — no Places lookup needed.
panAndMaybePin(s.lat, s.lng, s.description, ["_latlng"]);
return;
}
try {
const r = await fetch(`/api/places/details?place_id=${encodeURIComponent(s.place_id)}`);
const j = await r.json();
const loc = j.result?.geometry?.location;
if (loc) {
const types = j.result?.types || s.types || [];
panAndMaybePin(loc.lat, loc.lng, s.description, types);
}
} catch (e) { console.error(e); }
};
const onKeyDown = (e) => {
if (!open || !suggestions.length) {
if (e.key === "Escape") inputRef.current?.blur();
return;
}
if (e.key === "ArrowDown") { e.preventDefault(); setHighlight(h => Math.min(suggestions.length - 1, h + 1)); }
else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight(h => Math.max(0, h - 1)); }
else if (e.key === "Enter") { e.preventDefault(); pickSuggestion(suggestions[highlight]); }
else if (e.key === "Escape") { e.preventDefault(); setOpen(false); inputRef.current?.blur(); }
};
const hasQuery = query.length > 0;
return (
{ setFocused(true); if (suggestions.length) setOpen(true); }}
onBlur={() => { setFocused(false); setTimeout(() => setOpen(false), 150); }}
onKeyDown={onKeyDown}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
style={{
border: 0, outline: 0, background: "transparent",
fontSize: 13.5, color: "var(--ink-1)", flex: 1, minWidth: 0,
fontFamily: "inherit", padding: 0,
}}
/>
{hasQuery && (
{ e.preventDefault(); clear(); }}
title="Clear (Esc)"
style={{
border: 0, background: "transparent", cursor: "pointer",
padding: 4, borderRadius: 4, display: "inline-flex",
color: "var(--ink-3)",
}}
onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-sunken)"}
onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}
>
)}
{open && suggestions.length > 0 && (
{suggestions.map((s, i) => (
{ e.preventDefault(); pickSuggestion(s); }}
onMouseEnter={() => setHighlight(i)}
style={{
padding: "10px 14px", cursor: "pointer",
display: "flex", alignItems: "center", gap: 12,
background: i === highlight ? "var(--bg-sunken)" : "transparent",
transition: "background 0.08s",
}}
>
{s.main}
{s.secondary && (
{s.secondary}
)}
))}
)}
);
}
// Floating pill on the map for switching to polygon-draw mode.
// Sits bottom-left so it doesn't compete with the search box at the top.
function PolygonToggle({ selectionMode }) {
const isDrawing = selectionMode === "polygon";
const toggle = () => {
if (isDrawing) {
window.LEPTON_LOAD.setSelectionMode("pin");
} else {
window.LEPTON_LOAD.setSelectionMode("polygon");
window.LEPTON_LOAD.setPolygon(null);
}
};
return (
{isDrawing ? "Drawing · click vertices, close at start" : "Draw polygon"}
);
}
window.HomeownerMode = HomeownerMode;
// Shared with CityMode so the polygon-draw control + search-bar are identical across tabs.
window.PolygonToggle = PolygonToggle;
window.AddrBar = AddrBar;