fix(site-finder): UX - sticky map, collapsible sections, score header sticky
This commit is contained in:
parent
99ca42e5d0
commit
199adc31e6
2 changed files with 192 additions and 144 deletions
|
|
@ -152,8 +152,19 @@ export default function SiteFinderPage() {
|
|||
<ScoreCard data={data} onIsochronesResult={setIsochrones} />
|
||||
</div>
|
||||
|
||||
{/* Right column — map + competitors */}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||||
{/* Right column — map + competitors (sticky) */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 16,
|
||||
alignSelf: "start",
|
||||
maxHeight: "calc(100vh - 32px)",
|
||||
overflowY: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<SiteMap data={data} isochrones={isochrones} />
|
||||
<CompetitorTable
|
||||
competitors={data.competitors}
|
||||
|
|
|
|||
|
|
@ -112,6 +112,45 @@ function formatTs(iso: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Collapsible section wrapper ───────────────────────────────────────────────
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div style={{ borderTop: "1px solid #e5e7eb", padding: "12px 24px" }}>
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: 0,
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span style={{ color: "#9ca3af", fontSize: 16, lineHeight: 1 }}>
|
||||
{open ? "−" : "+"}
|
||||
</span>
|
||||
</button>
|
||||
{open && <div style={{ marginTop: 12 }}>{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Noise block ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
||||
|
|
@ -536,6 +575,12 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
data.wind !== undefined ||
|
||||
data.weather !== undefined;
|
||||
|
||||
const scoreLabelTyped = data.score_label as ScoreLabel | undefined;
|
||||
const badgeBg = scoreLabelTyped ? SCORE_LABEL_BG[scoreLabelTyped] : "#f3f4f6";
|
||||
const badgeFg = scoreLabelTyped
|
||||
? SCORE_LABEL_COLOR[scoreLabelTyped]
|
||||
: "#6b7280";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -545,80 +590,92 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
background: "#fff",
|
||||
}}
|
||||
>
|
||||
{/* Score header */}
|
||||
{/* Sticky score header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 24px",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
background: "#f9fafb",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
padding: "12px 24px",
|
||||
zIndex: 10,
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 16,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
title={data.score_explanation ?? undefined}
|
||||
style={{
|
||||
fontSize: 48,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
color: scoreColor(data.score),
|
||||
cursor: data.score_explanation ? "help" : undefined,
|
||||
}}
|
||||
>
|
||||
{data.score.toFixed(2)}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
{/* score_max_reference + label badge */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{data.score_max_reference !== undefined && (
|
||||
<span style={{ fontSize: 14, color: "#6b7280" }}>
|
||||
/ {data.score_max_reference.toFixed(0)}
|
||||
</span>
|
||||
)}
|
||||
{data.score_label && (
|
||||
<span
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
padding: "2px 10px",
|
||||
background: SCORE_LABEL_BG[data.score_label],
|
||||
color: SCORE_LABEL_COLOR[data.score_label],
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{data.score_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
Социальный балл участка
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{data.poi_count} POI ·{" "}
|
||||
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
||||
</div>
|
||||
{data.score_explanation && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
fontStyle: "italic",
|
||||
maxWidth: 280,
|
||||
}}
|
||||
>
|
||||
{data.score_explanation}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<span
|
||||
title={data.score_explanation ?? undefined}
|
||||
style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 800,
|
||||
lineHeight: 1,
|
||||
color: scoreColor(data.score),
|
||||
cursor: data.score_explanation ? "help" : undefined,
|
||||
}}
|
||||
>
|
||||
{data.score.toFixed(2)}
|
||||
</span>
|
||||
{data.score_max_reference !== undefined && (
|
||||
<span style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
/ {data.score_max_reference.toFixed(0)}
|
||||
</span>
|
||||
)}
|
||||
{data.location && <CenterDistanceRow location={data.location} />}
|
||||
</div>
|
||||
{scoreLabelTyped && (
|
||||
<span
|
||||
style={{
|
||||
background: badgeBg,
|
||||
color: badgeFg,
|
||||
padding: "2px 10px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{scoreLabelTyped}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score detail (expanded) */}
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||
Социальный балл участка
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "#9ca3af" }}>
|
||||
{data.poi_count} POI ·{" "}
|
||||
{data.source === "cad_quarter" ? "квартал" : "участок"}
|
||||
</div>
|
||||
{data.score_explanation && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#6b7280",
|
||||
fontStyle: "italic",
|
||||
maxWidth: 280,
|
||||
}}
|
||||
>
|
||||
{data.score_explanation}
|
||||
</div>
|
||||
)}
|
||||
{data.location && <CenterDistanceRow location={data.location} />}
|
||||
</div>
|
||||
|
||||
{/* District */}
|
||||
{data.district && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px",
|
||||
padding: "10px 24px",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
fontSize: 13,
|
||||
}}
|
||||
|
|
@ -638,7 +695,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
{nearestTram !== null && (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 24px",
|
||||
padding: "8px 24px",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
background: "#fff7ed",
|
||||
fontSize: 13,
|
||||
|
|
@ -654,7 +711,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
)}
|
||||
|
||||
{/* POI breakdown */}
|
||||
<div style={{ padding: "12px 24px" }}>
|
||||
<div style={{ padding: "12px 24px", borderBottom: "1px solid #e5e7eb" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
|
|
@ -695,12 +752,12 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Environmental factors */}
|
||||
{/* Environmental factors (primary — open) */}
|
||||
{hasEnvironmental && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -756,98 +813,24 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Market trend */}
|
||||
{/* Market Trend (primary — open) */}
|
||||
{"market_trend" in data && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<MarketTrendBlock trend={data.market_trend} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success recommendation */}
|
||||
{"success_recommendation" in data && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<SuccessRecommendationBlock
|
||||
recommendation={data.success_recommendation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Seasonal weather */}
|
||||
{"seasonal_weather" in data && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hydrology */}
|
||||
{data.hydrology !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<HydrologyBlock hydrology={data.hydrology} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geotech risk */}
|
||||
{data.geotech_risk !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<GeotechRiskBlock risk={data.geotech_risk} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Geology */}
|
||||
{data.geology !== undefined && (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 24px 16px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Геология
|
||||
</div>
|
||||
<GeologyBlock geology={data.geology} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Isochrones */}
|
||||
{/* Isochrones (primary — open) */}
|
||||
{data.isochrones_available === false ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 24px 14px",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
borderBottom: "1px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -871,8 +854,62 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
) : data.isochrones_available === true && onIsochronesResult ? (
|
||||
<IsochronesPanel cadNum={data.cad_num} onResult={onIsochronesResult} />
|
||||
<div style={{ borderBottom: "1px solid #e5e7eb" }}>
|
||||
<IsochronesPanel
|
||||
cadNum={data.cad_num}
|
||||
onResult={onIsochronesResult}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Success recommendation (secondary — collapsed) */}
|
||||
{"success_recommendation" in data && (
|
||||
<CollapsibleSection title="💎 Что хорошо продаётся">
|
||||
<SuccessRecommendationBlock
|
||||
recommendation={data.success_recommendation}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Seasonal weather (secondary — collapsed) */}
|
||||
{"seasonal_weather" in data && (
|
||||
<CollapsibleSection title="🌡 Климат (нормали 30 лет)">
|
||||
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Hydrology (secondary — collapsed) */}
|
||||
{data.hydrology !== undefined && (
|
||||
<CollapsibleSection title="💧 Гидрология">
|
||||
<HydrologyBlock hydrology={data.hydrology} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Geotech risk (secondary — collapsed) */}
|
||||
{data.geotech_risk !== undefined && (
|
||||
<CollapsibleSection title="🏔 Геотехнический риск">
|
||||
<GeotechRiskBlock risk={data.geotech_risk} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Geology (secondary — collapsed) */}
|
||||
{data.geology !== undefined && (
|
||||
<CollapsibleSection title="⛰ Геология">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#6b7280",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Геология
|
||||
</div>
|
||||
<GeologyBlock geology={data.geology} />
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue