fix(tradein/v2): mobile layout — remove overflow floor + honest stacked mobile block (#2275) (#2389)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m20s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 47s

This commit is contained in:
lekss361 2026-07-04 07:02:56 +00:00
parent 4f0cae5c18
commit 293184ebc6
2 changed files with 597 additions and 488 deletions

View file

@ -411,16 +411,25 @@ export default function TradeInV2Page() {
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
// Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports // Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
// instead of clipping. Never upscale (capped at 1); recomputed on resize. // instead of clipping. Never upscale (capped at 1). #2275: this used to floor
// at 0.88, which on a 390px phone still rendered the artboard ~1350px wide —
// page-wide horizontal scroll + a hero clipped at the left edge + the summary
// rail pushed off-screen. Width is now the only constraint (a scaled artboard
// taller than the viewport just scrolls the page vertically, which is normal
// and not the bug being fixed here).
const [artboardScale, setArtboardScale] = useState(1); const [artboardScale, setArtboardScale] = useState(1);
// #2275: below this viewport width the fixed-width artboard can no longer
// read at a usable size even scaled-to-fit, so HeroBar + ObjectSummary (the
// "at a glance" content — report meta, PDF CTA, per-section totals) get a real
// fluid mobile block above the artboard instead of a tiny scaled-down copy.
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
// inside the scaled artboard — smaller on phones, but no longer overflowing.
const [isMobile, setIsMobile] = useState(false);
useEffect(() => { useEffect(() => {
const compute = () => const compute = () => {
setArtboardScale( setArtboardScale(Math.min(1, window.innerWidth / 1536));
Math.max( setIsMobile(window.innerWidth < 768);
0.88, };
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
),
);
compute(); compute();
window.addEventListener("resize", compute); window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute); return () => window.removeEventListener("resize", compute);
@ -573,7 +582,8 @@ export default function TradeInV2Page() {
const topNavUser = useMemo(() => { const topNavUser = useMemo(() => {
const scope = me.data; const scope = me.data;
if (!scope) return undefined; if (!scope) return undefined;
const nameParts = scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? []; const nameParts =
scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? [];
const initials = const initials =
nameParts.length >= 2 nameParts.length >= 2
? (nameParts[0][0] + nameParts[1][0]).toUpperCase() ? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
@ -693,172 +703,221 @@ export default function TradeInV2Page() {
} }
return ( return (
<div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}> <div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "100%",
}}
>
{/* #2275 mobile quick-view a real fluid, native-size block for the "at a
glance" content (HeroBar's report meta + PDF CTA, ObjectSummary's
per-section totals). Inside the scaled artboard below, these would read
as tiny/illegible text on a phone rather than the honest mobile layout
the design deserves so under the mobile breakpoint they render here
instead (and are skipped inside the artboard, see `!isMobile` below).
The rest of the dashboard (ParamsPanel / ResultPanel / overlays) has no
equivalent fluid mobile layout yet (follow-up) and stays inside the
scaled artboard: smaller on phones, but with the 0.88 floor removed
above never wider than the viewport. */}
{isMobile && (
<div
style={{
width: "100%",
maxWidth: 480,
margin: "0 auto",
padding: "14px 14px 20px",
boxSizing: "border-box",
fontFamily: tokens.font.sans,
color: tokens.ink,
}}
>
<HeroBar
compact
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
<div style={{ marginTop: 14 }}>{rightContent}</div>
</div>
)}
<div <div
style={{ style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
transform: `scale(${artboardScale})`,
transformOrigin: "top left",
}}
> >
<style>{HELPER_STYLES}</style> <div
style={{
position: "relative",
width: 1536,
height: 1024,
overflow: "hidden",
background: tokens.gradientBg,
fontFamily: tokens.font.sans,
color: tokens.ink,
transform: `scale(${artboardScale})`,
transformOrigin: "top left",
}}
>
<style>{HELPER_STYLES}</style>
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav, {/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
so give the document a real, visually-hidden <h1> (page-has-heading-one so give the document a real, visually-hidden <h1> (page-has-heading-one
+ a top of the heading order for the overlay/panel <h2>s). */} + a top of the heading order for the overlay/panel <h2>s). */}
<h1 <h1
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Мера оценка квартиры
</h1>
{/* Polite, visually-hidden status announcer for SR users. */}
<div
aria-live="polite"
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{statusMessage}
</div>
{/* OUTER HUD FRAME */}
<div
style={{
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
clipPath: FRAME_CLIP,
pointerEvents: "none",
}}
/>
{/* 4 corner brackets */}
{brackets.map((b) => (
<div
key={b.key}
style={{ style={{
position: "absolute", position: "absolute",
width: BRACKET_ARM, width: 1,
height: BRACKET_ARM, height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
Мера оценка квартиры
</h1>
{/* Polite, visually-hidden status announcer for SR users. */}
<div
aria-live="polite"
role="status"
style={{
position: "absolute",
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
whiteSpace: "nowrap",
border: 0,
}}
>
{statusMessage}
</div>
{/* OUTER HUD FRAME */}
<div
style={{
position: "absolute",
inset: FRAME_INSET,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
clipPath: FRAME_CLIP,
pointerEvents: "none", pointerEvents: "none",
...b.style,
}} }}
/> />
))} {/* 4 corner brackets */}
{brackets.map((b) => (
<div
key={b.key}
style={{
position: "absolute",
width: BRACKET_ARM,
height: BRACKET_ARM,
pointerEvents: "none",
...b.style,
}}
/>
))}
{/* CONTENT WRAP */} {/* CONTENT WRAP */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
inset: 14, inset: 14,
padding: "16px 28px", padding: "16px 28px",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
{/* TopNav stays interactive while a section overlay is open so the {/* TopNav stays interactive while a section overlay is open so the
user can switch sections directly from the overlay. Only the user can switch sections directly from the overlay. Only the
LocationDrawer (a full modal over the whole HUD) inerts it. The LocationDrawer (a full modal over the whole HUD) inerts it. The
dashboard body (<main>/<footer>) is still inerted behind ANY dashboard body (<main>/<footer>) is still inerted behind ANY
overlay/drawer preserving the focus + click guard from a11y overlay/drawer preserving the focus + click guard from a11y
audit #2081 for everything except the top tabs. */} audit #2081 for everything except the top tabs. */}
<nav <nav
aria-label="Разделы" aria-label="Разделы"
inert={drawerOpen ? true : undefined} inert={drawerOpen ? true : undefined}
style={{ display: "contents" }} style={{ display: "contents" }}
>
<TopNav
active={nav}
onNavigate={setNav}
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={logout}
/>
</nav>
<main
inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }}
>
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
<div
style={{
display: "grid",
gridTemplateColumns: "474px 1fr 250px",
gap: 18,
flex: 1,
minHeight: 0,
}}
> >
<ParamsPanel <TopNav
key={estimate?.estimate_id ?? "new"} active={nav}
onSubmit={handleSubmit} onNavigate={setNav}
isPending={mutation.isPending} reports={reportsCount ?? 0}
hasEstimate={hasEstimate} user={topNavUser}
error={apiError} onLogout={logout}
initialValues={initialValues}
markers={markers}
/> />
{middleContent} </nav>
{rightContent} <main
</div> inert={nav !== 0 || drawerOpen ? true : undefined}
</main> style={{ display: "contents" }}
>
{/* #2275: on mobile the readable HeroBar lives in the fluid
quick-view block above instead of a tiny scaled-down copy. */}
{!isMobile && (
<HeroBar
data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate}
/>
)}
<footer <div
inert={nav !== 0 || drawerOpen ? true : undefined} style={{
style={{ display: "contents" }} display: "grid",
> gridTemplateColumns: "474px 1fr 250px",
<Footer data={report} hasEstimate={hasEstimate} /> gap: 18,
</footer> flex: 1,
</div> minHeight: 0,
}}
>
<ParamsPanel
key={estimate?.estimate_id ?? "new"}
onSubmit={handleSubmit}
isPending={mutation.isPending}
hasEstimate={hasEstimate}
error={apiError}
initialValues={initialValues}
markers={markers}
/>
{middleContent}
{/* #2275: on mobile ObjectSummary is rendered fluid in the
quick-view block above instead of here (tiny scaled copy). */}
{!isMobile && rightContent}
</div>
</main>
{nav !== 0 && ( <footer
<SectionOverlay inert={nav !== 0 || drawerOpen ? true : undefined}
active={nav} style={{ display: "contents" }}
onClose={() => setNav(0)} >
onNavigate={setNav} <Footer data={report} hasEstimate={hasEstimate} />
history={historyData} </footer>
analytics={analyticsViewData} </div>
sources={sourcesData}
cache={cacheData} {nav !== 0 && (
<SectionOverlay
active={nav}
onClose={() => setNav(0)}
onNavigate={setNav}
history={historyData}
analytics={analyticsViewData}
sources={sourcesData}
cache={cacheData}
/>
)}
<LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
data={locationData}
/> />
)} </div>
<LocationDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
data={locationData}
/>
</div> </div>
</div> </div>
); );

View file

@ -45,6 +45,12 @@ interface HeroBarProps {
// are hidden, mirroring how the PDF control already gates on estimate presence. // are hidden, mirroring how the PDF control already gates on estimate presence.
hasEstimate: boolean; hasEstimate: boolean;
onOpenInfo: () => void; onOpenInfo: () => void;
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
// desktop one — meta/buttons stack, the decorative building photo (and the
// address/coef card baked into it) is dropped since it assumes a 560×152 box
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
// drawer, so no functionality is lost, only the redundant photo-card copy.
compact?: boolean;
} }
const pdfBtnStyle: CSSProperties = { const pdfBtnStyle: CSSProperties = {
@ -80,6 +86,7 @@ export default function HeroBar({
estimateId, estimateId,
hasEstimate, hasEstimate,
onOpenInfo, onOpenInfo,
compact = false,
}: HeroBarProps) { }: HeroBarProps) {
const pdfHref = hasEstimate ? pdfDownloadHref(estimateId) : null; const pdfHref = hasEstimate ? pdfDownloadHref(estimateId) : null;
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the // A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
@ -93,7 +100,13 @@ export default function HeroBar({
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195"; const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
return ( return (
<> <>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true"> <svg
width="18"
height="20"
viewBox="0 0 18 20"
fill="none"
aria-hidden="true"
>
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" /> <path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" /> <path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} /> <line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
@ -110,8 +123,9 @@ export default function HeroBar({
<div <div
style={{ style={{
display: "flex", display: "flex",
gap: 30, flexDirection: compact ? "column" : "row",
padding: "14px 2px 12px", gap: compact ? 16 : 30,
padding: compact ? "0" : "14px 2px 12px",
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
@ -134,10 +148,18 @@ export default function HeroBar({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
gap: 22, gap: compact ? 14 : 22,
}} }}
> >
<div style={{ display: "flex", gap: 26, alignItems: "flex-start" }}> <div
style={{
display: "flex",
flexWrap: compact ? "wrap" : "nowrap",
gap: compact ? 18 : 26,
rowGap: compact ? 8 : undefined,
alignItems: "flex-start",
}}
>
<div> <div>
<div <div
style={{ style={{
@ -215,7 +237,15 @@ export default function HeroBar({
)} )}
</div> </div>
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}> <div
style={{
display: "flex",
flexDirection: compact ? "column" : "row",
gap: 12,
width: "100%",
maxWidth: compact ? "none" : 440,
}}
>
{pdfHref ? ( {pdfHref ? (
<a <a
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"} className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
@ -265,367 +295,387 @@ export default function HeroBar({
</div> </div>
</div> </div>
{/* RIGHT: photo + overlays */} {/* RIGHT: photo + overlays dropped in compact mode (#2275): the box is
<div a fixed 560×152 with several absolutely-positioned children (address
style={{ card, compass, distance scale) pinned to that size, so it cannot
position: "relative", reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same
width: 560, location-coef drawer, so no functionality is lost. */}
height: 152, {!compact && (
flex: "0 0 auto",
border: `1px solid ${tokens.line3}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.photoBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}}
>
{!imgFailed && (
<img
src={`${BP}/trade-in-v2/building.png`}
alt=""
onError={() => setImgFailed(true)}
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.25) brightness(1.06) contrast(.95)",
}}
/>
)}
{/* blue duotone tint toward HUD accent recedes the photo (only over
the real image; skip when it 404s so the placeholder stays clean) */}
{!imgFailed && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}}
/>
)}
<div <div
style={{ style={{
position: "absolute", position: "relative",
inset: 0, width: 560,
background: height: 152,
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)", flex: "0 0 auto",
}} border: `1px solid ${tokens.line3}`,
/> borderRadius: 6,
<div overflow: "hidden",
style={{ background: tokens.photoBg,
position: "absolute", boxShadow: "0 6px 26px rgba(40,80,130,.10)",
inset: 0,
background:
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "34%",
height: 1,
background:
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
animation: "hero-scanv 6s linear infinite",
}}
/>
{/* address card */}
<div
style={{
position: "absolute",
left: 16,
top: "50%",
transform: "translateY(-50%)",
width: 182,
background: tokens.surface.w72,
backdropFilter: "blur(5px)",
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "11px 13px",
}} }}
> >
<div {!imgFailed && (
style={{ <img
fontSize: 9, src={`${BP}/trade-in-v2/building.png`}
letterSpacing: "2.5px", alt=""
color: tokens.muted2, onError={() => setImgFailed(true)}
marginBottom: 3,
}}
>
АДРЕС
</div>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
{data.object.address}
<br />
<span
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
>
{data.object.city}
</span>
</div>
<div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
<div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
// without switching the element to a <button> (which, with preflight
// off, would need a UA-chrome reset and risk the row's exact box
// geometry / negative-margin hover bleed). Opens the methodology drawer.
role="button"
tabIndex={0}
className="hero-coef-row"
onClick={onOpenInfo}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenInfo();
}
}}
aria-label="Пояснение к расчёту коэффициента локации"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
borderRadius: 4,
margin: "-3px -5px",
padding: "3px 5px",
transition: "background .15s",
}}
>
<span
style={{ style={{
fontSize: "8.5px", position: "absolute",
letterSpacing: "1.5px", inset: 0,
width: "100%",
height: "100%",
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.25) brightness(1.06) contrast(.95)",
}}
/>
)}
{/* blue duotone tint toward HUD accent recedes the photo (only over
the real image; skip when it 404s so the placeholder stays clean) */}
{!imgFailed && (
<div
style={{
position: "absolute",
inset: 0,
background: "rgba(46,139,255,.14)",
mixBlendMode: "multiply",
pointerEvents: "none",
}}
/>
)}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "34%",
height: 1,
background:
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
animation: "hero-scanv 6s linear infinite",
}}
/>
{/* address card */}
<div
style={{
position: "absolute",
left: 16,
top: "50%",
transform: "translateY(-50%)",
width: 182,
background: tokens.surface.w72,
backdropFilter: "blur(5px)",
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "11px 13px",
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2.5px",
color: tokens.muted2, color: tokens.muted2,
marginBottom: 3,
}} }}
> >
КОЭФ. ЛОКАЦИИ АДРЕС
</span> </div>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
{data.object.locationCoef === "—" ? ( {data.object.address}
<span <br />
style={{ <span
fontSize: "9px", style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
letterSpacing: ".5px", >
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA; {data.object.city}
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым». </span>
color: tokens.body2, </div>
background: tokens.badgeTint, <div
border: `1px solid ${tokens.line2}`, style={{
borderRadius: 10, height: 1,
padding: "1px 8px", background: tokens.lineSoft,
}} margin: "8px 0",
> }}
{/* #2317: coef is a live feature now (GET /location-coef) a />
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
<div
style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/>
<div
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
// without switching the element to a <button> (which, with preflight
// off, would need a UA-chrome reset and risk the row's exact box
// geometry / negative-margin hover bleed). Opens the methodology drawer.
role="button"
tabIndex={0}
className="hero-coef-row"
onClick={onOpenInfo}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpenInfo();
}
}}
aria-label="Пояснение к расчёту коэффициента локации"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
borderRadius: 4,
margin: "-3px -5px",
padding: "3px 5px",
transition: "background .15s",
}}
>
<span
style={{
fontSize: "8.5px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
КОЭФ. ЛОКАЦИИ
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
{data.object.locationCoef === "—" ? (
<span
style={{
fontSize: "9px",
letterSpacing: ".5px",
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA;
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым».
color: tokens.body2,
background: tokens.badgeTint,
border: `1px solid ${tokens.line2}`,
borderRadius: 10,
padding: "1px 8px",
}}
>
{/* #2317: coef is a live feature now (GET /location-coef) a
dash here means unavailable/loading for THIS estimate dash here means unavailable/loading for THIS estimate
(unavailable geo_source, no lat/lon, or query pending), (unavailable geo_source, no lat/lon, or query pending),
never "not built yet", so «скоро» would be stale/false. */} never "not built yet", so «скоро» would be stale/false. */}
нет данных нет данных
</span> </span>
) : ( ) : (
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span>
)}
<span <span
style={{ style={{
fontFamily: tokens.font.mono, display: "flex",
fontSize: 15, alignItems: "center",
fontWeight: 500, justifyContent: "center",
width: 14,
height: 14,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
fontSize: "8.5px",
color: tokens.accent, color: tokens.accent,
fontWeight: 600,
}} }}
aria-hidden="true"
> >
{data.object.locationCoef} ?
</span> </span>
)}
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 14,
height: 14,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
fontSize: "8.5px",
color: tokens.accent,
fontWeight: 600,
}}
aria-hidden="true"
>
?
</span> </span>
</div>
</div>
{/* compass */}
<div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
aria-hidden="true"
>
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}}
>
{data.object.compass}
</span> </span>
</div> </div>
</div>
{/* compass */} {/* distance scale */}
<div <div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{ style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: 16,
width: 170,
fontFamily: tokens.font.mono, fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}} }}
> >
{data.object.compass} <div
</span> style={{
</div> display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
height: 8,
borderBottom: "1px solid rgba(46,139,255,.55)",
}}
>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "7.5px",
color: tokens.muted,
marginTop: 3,
}}
>
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100м</span>
</div>
</div>
{/* distance scale */} {/* corner brackets */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: 16,
width: 170,
fontFamily: tokens.font.mono,
}}
>
<div <div
style={{ style={{
display: "flex", position: "absolute",
justifyContent: "space-between", left: 8,
alignItems: "flex-end", top: 8,
height: 8, width: 14,
borderBottom: "1px solid rgba(46,139,255,.55)", height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}} }}
> />
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
</div>
<div <div
style={{ style={{
display: "flex", position: "absolute",
justifyContent: "space-between", right: 8,
fontSize: "7.5px", top: 8,
color: tokens.muted, width: 14,
marginTop: 3, height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}} }}
> />
<span>0</span> <div
<span>25</span> style={{
<span>50</span> position: "absolute",
<span>75</span> left: 8,
<span>100м</span> bottom: 8,
</div> width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
</div> </div>
)}
{/* corner brackets */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
top: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
</div>
</div> </div>
); );
} }