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} />
|
<ScoreCard data={data} onIsochronesResult={setIsochrones} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column — map + competitors */}
|
{/* Right column — map + competitors (sticky) */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
<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} />
|
<SiteMap data={data} isochrones={isochrones} />
|
||||||
<CompetitorTable
|
<CompetitorTable
|
||||||
competitors={data.competitors}
|
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 ───────────────────────────────────────────────────────────────
|
// ── Noise block ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
function NoiseBlock({ noise }: { noise: ParcelAnalysisNoise }) {
|
||||||
|
|
@ -536,6 +575,12 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
data.wind !== undefined ||
|
data.wind !== undefined ||
|
||||||
data.weather !== 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -545,80 +590,92 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
background: "#fff",
|
background: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Score header */}
|
{/* Sticky score header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "20px 24px",
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
background: "#f9fafb",
|
background: "#f9fafb",
|
||||||
borderBottom: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
|
padding: "12px 24px",
|
||||||
|
zIndex: 10,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "flex-start",
|
justifyContent: "space-between",
|
||||||
gap: 16,
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
title={data.score_explanation ?? undefined}
|
<span
|
||||||
style={{
|
title={data.score_explanation ?? undefined}
|
||||||
fontSize: 48,
|
style={{
|
||||||
fontWeight: 800,
|
fontSize: 28,
|
||||||
lineHeight: 1,
|
fontWeight: 800,
|
||||||
color: scoreColor(data.score),
|
lineHeight: 1,
|
||||||
cursor: data.score_explanation ? "help" : undefined,
|
color: scoreColor(data.score),
|
||||||
}}
|
cursor: data.score_explanation ? "help" : undefined,
|
||||||
>
|
}}
|
||||||
{data.score.toFixed(2)}
|
>
|
||||||
</div>
|
{data.score.toFixed(2)}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
</span>
|
||||||
{/* score_max_reference + label badge */}
|
{data.score_max_reference !== undefined && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
<span style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
{data.score_max_reference !== undefined && (
|
/ {data.score_max_reference.toFixed(0)}
|
||||||
<span style={{ fontSize: 14, color: "#6b7280" }}>
|
</span>
|
||||||
/ {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>
|
|
||||||
)}
|
)}
|
||||||
{data.location && <CenterDistanceRow location={data.location} />}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* District */}
|
{/* District */}
|
||||||
{data.district && (
|
{data.district && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px",
|
padding: "10px 24px",
|
||||||
borderBottom: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
}}
|
}}
|
||||||
|
|
@ -638,7 +695,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
{nearestTram !== null && (
|
{nearestTram !== null && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 24px",
|
padding: "8px 24px",
|
||||||
borderBottom: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
background: "#fff7ed",
|
background: "#fff7ed",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
|
|
@ -654,7 +711,7 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* POI breakdown */}
|
{/* POI breakdown */}
|
||||||
<div style={{ padding: "12px 24px" }}>
|
<div style={{ padding: "12px 24px", borderBottom: "1px solid #e5e7eb" }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -695,12 +752,12 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environmental factors */}
|
{/* Environmental factors (primary — open) */}
|
||||||
{hasEnvironmental && (
|
{hasEnvironmental && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px 16px",
|
padding: "12px 24px 16px",
|
||||||
borderTop: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -756,98 +813,24 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Market trend */}
|
{/* Market Trend (primary — open) */}
|
||||||
{"market_trend" in data && (
|
{"market_trend" in data && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "12px 24px 16px",
|
padding: "12px 24px 16px",
|
||||||
borderTop: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MarketTrendBlock trend={data.market_trend} />
|
<MarketTrendBlock trend={data.market_trend} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success recommendation */}
|
{/* Isochrones (primary — open) */}
|
||||||
{"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 */}
|
|
||||||
{data.isochrones_available === false ? (
|
{data.isochrones_available === false ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: "10px 24px 14px",
|
padding: "10px 24px 14px",
|
||||||
borderTop: "1px solid #e5e7eb",
|
borderBottom: "1px solid #e5e7eb",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -871,8 +854,62 @@ export function ScoreCard({ data, onIsochronesResult }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : data.isochrones_available === true && onIsochronesResult ? (
|
) : 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}
|
) : 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue