fix(tradein/v2): настоящая карта Leaflet+OSM вместо декоративного псевдо-SVG #2528
1 changed files with 372 additions and 223 deletions
|
|
@ -7,9 +7,16 @@
|
||||||
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
||||||
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
||||||
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
||||||
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
|
// РАДИУС is now wired (radius_m on submit + the real map circle below scales
|
||||||
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active
|
// with it); the CRM dropdown still has no backend → kept visually but disabled.
|
||||||
// + @keyframes live in a pp-prefixed local <style>.
|
// Hover/active + @keyframes live in a pp-prefixed local <style>.
|
||||||
|
//
|
||||||
|
// Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius
|
||||||
|
// rings) with no real coordinates behind it. It is now a real Leaflet + OSM
|
||||||
|
// basemap, ported from the same CDN-loader pattern as ./SourcesMap.tsx /
|
||||||
|
// ../MapPicker.tsx (copied rather than shared — same self-contained-port
|
||||||
|
// convention as SourcesMap.tsx, no npm Leaflet dep).
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -143,6 +150,97 @@ function comboKeyDown(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
|
||||||
|
// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx
|
||||||
|
// (duplicated on purpose — each v2 file is a self-contained port, no shared
|
||||||
|
// runtime module, no npm Leaflet dep).
|
||||||
|
const LEAFLET_VER = "1.9.4";
|
||||||
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`;
|
||||||
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`;
|
||||||
|
const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||||
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||||
|
const MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
|
||||||
|
const MAP_ATTRIBUTION = "© OpenStreetMap";
|
||||||
|
const DEFAULT_MAP_ZOOM = 16;
|
||||||
|
const MIN_MAP_ZOOM = 11;
|
||||||
|
const MAX_MAP_ZOOM = 19;
|
||||||
|
|
||||||
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */
|
||||||
|
function loadLeaflet(): Promise<any> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const w = window as any;
|
||||||
|
if (w.L) {
|
||||||
|
resolve(w.L);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!document.querySelector(`link[data-leaflet]`)) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = LEAFLET_CSS;
|
||||||
|
link.integrity = LEAFLET_CSS_SRI;
|
||||||
|
link.crossOrigin = "anonymous";
|
||||||
|
link.setAttribute("data-leaflet", "1");
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
const existing = document.querySelector<HTMLScriptElement>(`script[data-leaflet]`);
|
||||||
|
if (existing) {
|
||||||
|
existing.addEventListener("load", () => resolve(w.L));
|
||||||
|
existing.addEventListener("error", () => reject(new Error("leaflet load failed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = LEAFLET_JS;
|
||||||
|
script.integrity = LEAFLET_JS_SRI;
|
||||||
|
script.crossOrigin = "anonymous";
|
||||||
|
script.setAttribute("data-leaflet", "1");
|
||||||
|
script.onload = () => resolve(w.L);
|
||||||
|
script.onerror = () => reject(new Error("leaflet load failed"));
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес — ввод
|
||||||
|
* пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
|
||||||
|
function escapeMapHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, (c) =>
|
||||||
|
c === "&"
|
||||||
|
? "&"
|
||||||
|
: c === "<"
|
||||||
|
? "<"
|
||||||
|
: c === ">"
|
||||||
|
? ">"
|
||||||
|
: c === '"'
|
||||||
|
? """
|
||||||
|
: "'",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пин квартиры-предмета оценки — divIcon 1:1 повторяет прежний SVG-бабл
|
||||||
|
* (акцентная рамка, точка, адрес + площадь, треугольник-указатель), только
|
||||||
|
* теперь висит на реальных координатах реальной карты. */
|
||||||
|
function buildSubjectIcon(
|
||||||
|
L: any,
|
||||||
|
addressLabel: string,
|
||||||
|
areaLabel: string | null,
|
||||||
|
): any {
|
||||||
|
const html = `
|
||||||
|
<div style="position:relative;width:220px;height:56px;pointer-events:none">
|
||||||
|
<div style="position:absolute;left:50%;bottom:9px;transform:translateX(-50%);display:flex;align-items:center;gap:6px;background:${tokens.surface.w85};border:1px solid ${tokens.accent};border-radius:4px;padding:4px 8px;white-space:nowrap;box-shadow:0 3px 10px rgba(46,139,255,.25);font-family:${tokens.font.sans}">
|
||||||
|
<span style="width:7px;height:7px;border-radius:50%;background:${tokens.accent};flex:0 0 auto"></span>
|
||||||
|
<span style="display:flex;flex-direction:column;gap:1px">
|
||||||
|
<span style="font-size:11px;font-weight:600;color:${tokens.ink}">${escapeMapHtml(addressLabel)}</span>
|
||||||
|
${
|
||||||
|
areaLabel
|
||||||
|
? `<span style="font-size:9px;color:${tokens.muted}">${escapeMapHtml(areaLabel)}</span>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style="position:absolute;left:50%;bottom:0;transform:translateX(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:7px solid ${tokens.accent}"></div>
|
||||||
|
</div>`;
|
||||||
|
return L.divIcon({ html, className: "", iconSize: [220, 56], iconAnchor: [110, 56] });
|
||||||
|
}
|
||||||
|
|
||||||
function Dd({
|
function Dd({
|
||||||
open,
|
open,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
|
@ -425,21 +523,6 @@ const errorText: CSSProperties = {
|
||||||
color: tokens.danger,
|
color: tokens.danger,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
|
|
||||||
// (projected from the real estimate); the chrome matches the former fixture
|
|
||||||
// pins 1:1 — only the data source changed (Finding #2).
|
|
||||||
const analogPin: CSSProperties = {
|
|
||||||
position: "absolute",
|
|
||||||
background: tokens.surface.w70,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "3px 7px",
|
|
||||||
fontFamily: tokens.font.mono,
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
color: tokens.ink,
|
|
||||||
};
|
|
||||||
|
|
||||||
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
|
||||||
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
// shadow), sized to the narrow radius trigger and dropped just beneath it.
|
||||||
const radiusPanel: CSSProperties = {
|
const radiusPanel: CSSProperties = {
|
||||||
|
|
@ -468,8 +551,13 @@ interface ParamsPanelProps {
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
|
||||||
initialValues?: Partial<TradeInEstimateInput>;
|
initialValues?: Partial<TradeInEstimateInput>;
|
||||||
/** Analog price pins for the 01 map, projected from the real estimate via
|
/** Analog price pins, projected from the real estimate via mapMarkers() onto
|
||||||
* mapMarkers(). Default [] → no price pins (never the fabricated fixtures). */
|
* the OLD decorative SVG's fixed 0-100% grid (never a real geo scale — see
|
||||||
|
* mapMarkers() comment in ./mappers.ts). Kept in the prop contract for
|
||||||
|
* backward-compat with the caller (app/v2/page.tsx); intentionally NOT
|
||||||
|
* plotted on the real Leaflet map below, because their %-positions do not
|
||||||
|
* correspond to real lat/lon at the map's actual zoom — projecting them
|
||||||
|
* would be a new, subtler version of the honesty bug this map replaces. */
|
||||||
markers?: MapMarker[];
|
markers?: MapMarker[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,6 +583,12 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
||||||
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
|
||||||
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
|
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
|
||||||
|
|
||||||
|
// "Авто" sends no radius_m → the backend applies its two-tier default (1000 m
|
||||||
|
// primary / 2000 m fallback, see RADIUS_OPTIONS comment above). The map circle
|
||||||
|
// previews the PRIMARY tier so it is never wildly off from what the backend
|
||||||
|
// will actually use.
|
||||||
|
const AUTO_RADIUS_PREVIEW_M = 1000;
|
||||||
|
|
||||||
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
|
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
|
||||||
// default), so a re-submit never silently narrows the search.
|
// default), so a re-submit never silently narrows the search.
|
||||||
function initRadiusLabel(radiusM: number | null | undefined): string {
|
function initRadiusLabel(radiusM: number | null | undefined): string {
|
||||||
|
|
@ -509,7 +603,9 @@ export default function ParamsPanel({
|
||||||
hasEstimate = false,
|
hasEstimate = false,
|
||||||
error = null,
|
error = null,
|
||||||
initialValues,
|
initialValues,
|
||||||
markers = [],
|
// markers intentionally not destructured — see the ParamsPanelProps.markers
|
||||||
|
// doc comment: its %-positions belong to the retired decorative SVG grid and
|
||||||
|
// do not correspond to real lat/lon on the Leaflet map below.
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
|
// M4 — once a result is on screen the primary CTA is «СКАЧАТЬ PDF-ОТЧЁТ»
|
||||||
// (HeroBar); this button (a re-run) demotes to a secondary outline.
|
// (HeroBar); this button (a re-run) demotes to a secondary outline.
|
||||||
|
|
@ -518,11 +614,6 @@ export default function ParamsPanel({
|
||||||
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
// РАДИУС combobox a11y state (aria-activedescendant highlight + listbox id).
|
||||||
const radiusListId = useId();
|
const radiusListId = useId();
|
||||||
const [radiusActive, setRadiusActive] = useState(-1);
|
const [radiusActive, setRadiusActive] = useState(-1);
|
||||||
// Map zoom — applied as transform: scale() on the map content layer (not the
|
|
||||||
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp 1–2.5
|
|
||||||
// (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
|
|
||||||
// shrinking would only expose empty corners — zoom-in only).
|
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
const [address, setAddress] = useState(initialValues?.address ?? "");
|
const [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
const [area, setArea] = useState(
|
const [area, setArea] = useState(
|
||||||
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||||
|
|
@ -776,21 +867,138 @@ export default function ParamsPanel({
|
||||||
transition: "all .15s",
|
transition: "all .15s",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outer radius ring tracks the selected РАДИУС (subtle, best-effort): 500 м
|
// Real analysis-radius circle (metres), synced with the selected РАДИУС —
|
||||||
// keeps the design's r=112 exactly; other values scale gently (and clamp) so the
|
// L.circle takes a radius in metres, so this is a true geographic scale
|
||||||
// ring never blows past the map box. The inner two rings stay fixed.
|
// (unlike the old SVG ring, which was a clamped pixel best-effort).
|
||||||
const radiusM = parseInt(radius, 10);
|
const parsedRadiusM = parseInt(radius, 10);
|
||||||
const outerRingR = Number.isFinite(radiusM)
|
const circleRadiusM = Number.isFinite(parsedRadiusM)
|
||||||
? Math.round(
|
? parsedRadiusM
|
||||||
Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
|
: AUTO_RADIUS_PREVIEW_M;
|
||||||
)
|
|
||||||
: 112;
|
|
||||||
|
|
||||||
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
|
// Subject area caption for the map pin (M10) — the SUBJECT's own m², from the
|
||||||
// form, so the pin never borrows an analog's area. Empty area → no caption.
|
// form, so the pin never borrows an analog's area. Empty area → no caption.
|
||||||
const areaTrimmed = area.trim();
|
const areaTrimmed = area.trim();
|
||||||
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
|
const subjectAreaLabel = areaTrimmed ? `${areaTrimmed} м²` : null;
|
||||||
|
|
||||||
|
// ── 01 map — real Leaflet + OSM (see loadLeaflet()/buildSubjectIcon() above) ──
|
||||||
|
const hasCoords = coords != null;
|
||||||
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const leafletMapRef = useRef<any>(null);
|
||||||
|
const markerRef = useRef<any>(null);
|
||||||
|
const circleRef = useRef<any>(null);
|
||||||
|
const [mapZoom, setMapZoom] = useState(DEFAULT_MAP_ZOOM);
|
||||||
|
const [mapLoadError, setMapLoadError] = useState(false);
|
||||||
|
|
||||||
|
// "Latest value" refs, read inside the async loadLeaflet().then() callback
|
||||||
|
// below, which may resolve several renders after the effect fired (slow CDN
|
||||||
|
// load). Plain closure vars would go stale; refs assigned every render don't.
|
||||||
|
const addressRef = useRef(address);
|
||||||
|
addressRef.current = address;
|
||||||
|
const subjectAreaLabelRef = useRef(subjectAreaLabel);
|
||||||
|
subjectAreaLabelRef.current = subjectAreaLabel;
|
||||||
|
const circleRadiusMRef = useRef(circleRadiusM);
|
||||||
|
circleRadiusMRef.current = circleRadiusM;
|
||||||
|
const coordsRef = useRef(coords);
|
||||||
|
coordsRef.current = coords;
|
||||||
|
|
||||||
|
// Create/destroy the map when coordinates appear/disappear (not on every
|
||||||
|
// lat/lon value change — see the position-sync effect below for that). This
|
||||||
|
// is the only effect that mounts/tears down the Leaflet instance, so it is
|
||||||
|
// also the only place map.remove() needs to run, fixing the "Map container
|
||||||
|
// is already initialized" crash class.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasCoords) return; // no coords yet -> the placeholder renders instead
|
||||||
|
let map: any = null;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// Reset a previous CDN failure before retrying. Without this the error
|
||||||
|
// branch is a dead end: it replaces the map container in the render tree,
|
||||||
|
// so mapContainerRef stays null and no later attempt can ever succeed —
|
||||||
|
// one transient unpkg blip would kill the map for the rest of the session
|
||||||
|
// even after the user picks a different address.
|
||||||
|
setMapLoadError(false);
|
||||||
|
|
||||||
|
loadLeaflet()
|
||||||
|
.then((L) => {
|
||||||
|
if (cancelled || !mapContainerRef.current || !coordsRef.current) return;
|
||||||
|
const center: [number, number] = [
|
||||||
|
coordsRef.current.lat,
|
||||||
|
coordsRef.current.lon,
|
||||||
|
];
|
||||||
|
map = L.map(mapContainerRef.current, {
|
||||||
|
scrollWheelZoom: false,
|
||||||
|
zoomControl: false,
|
||||||
|
minZoom: MIN_MAP_ZOOM,
|
||||||
|
maxZoom: MAX_MAP_ZOOM,
|
||||||
|
}).setView(center, DEFAULT_MAP_ZOOM);
|
||||||
|
L.tileLayer(MAP_TILE_URL, {
|
||||||
|
attribution: MAP_ATTRIBUTION,
|
||||||
|
maxZoom: MAX_MAP_ZOOM,
|
||||||
|
}).addTo(map);
|
||||||
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
|
||||||
|
circleRef.current = L.circle(center, {
|
||||||
|
radius: circleRadiusMRef.current,
|
||||||
|
color: tokens.accent,
|
||||||
|
weight: 1,
|
||||||
|
fillColor: tokens.accent,
|
||||||
|
fillOpacity: 0.05,
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
markerRef.current = L.marker(center, {
|
||||||
|
icon: buildSubjectIcon(
|
||||||
|
L,
|
||||||
|
addressRef.current.trim() || "Адрес квартиры",
|
||||||
|
subjectAreaLabelRef.current,
|
||||||
|
),
|
||||||
|
interactive: false,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
leafletMapRef.current = map;
|
||||||
|
setMapZoom(DEFAULT_MAP_ZOOM);
|
||||||
|
map.on("zoomend", () => setMapZoom(map.getZoom()));
|
||||||
|
})
|
||||||
|
.catch(() => setMapLoadError(true));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (map) map.remove();
|
||||||
|
leafletMapRef.current = null;
|
||||||
|
markerRef.current = null;
|
||||||
|
circleRef.current = null;
|
||||||
|
};
|
||||||
|
// hasCoords is the only plain dependency this effect reads directly — the
|
||||||
|
// live lat/lon/radius/address values come from refs (assigned every
|
||||||
|
// render above), which exhaustive-deps correctly treats as stable.
|
||||||
|
}, [hasCoords]);
|
||||||
|
|
||||||
|
// Move the existing map/marker/circle to a newly picked address without
|
||||||
|
// tearing the map down (avoids a tile-reload flash on every pick).
|
||||||
|
useEffect(() => {
|
||||||
|
const map = leafletMapRef.current;
|
||||||
|
if (!map || !coords) return;
|
||||||
|
const center: [number, number] = [coords.lat, coords.lon];
|
||||||
|
map.setView(center, map.getZoom());
|
||||||
|
markerRef.current?.setLatLng(center);
|
||||||
|
circleRef.current?.setLatLng(center);
|
||||||
|
}, [coords]);
|
||||||
|
|
||||||
|
// Sync the radius circle in place when the РАДИУС АНАЛИЗА selection changes.
|
||||||
|
useEffect(() => {
|
||||||
|
circleRef.current?.setRadius(circleRadiusM);
|
||||||
|
}, [circleRadiusM]);
|
||||||
|
|
||||||
|
// Refresh the pin's address/area caption in place as the user edits the form.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!markerRef.current) return;
|
||||||
|
const w = window as any;
|
||||||
|
if (!w.L) return;
|
||||||
|
markerRef.current.setIcon(
|
||||||
|
buildSubjectIcon(w.L, address.trim() || "Адрес квартиры", subjectAreaLabel),
|
||||||
|
);
|
||||||
|
}, [address, subjectAreaLabel]);
|
||||||
|
|
||||||
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
|
// Address combobox render state (M6). `popupOpen` = the dropdown is shown at
|
||||||
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
|
// all (incl. loading / empty notes); `listboxOpen` = it holds real selectable
|
||||||
// options, which is when the input advertises aria-expanded + activedescendant.
|
// options, which is when the input advertises aria-expanded + activedescendant.
|
||||||
|
|
@ -870,7 +1078,12 @@ export default function ParamsPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* MAP */}
|
{/* MAP — real Leaflet + OSM basemap, centred on the geocoded address
|
||||||
|
(Fix, audit #2264 C7 follow-up): was a decorative SVG (grid + fake
|
||||||
|
streets + a clamped best-effort radius ring), no real coordinates
|
||||||
|
behind it at all. © OpenStreetMap attribution comes from Leaflet's
|
||||||
|
own attribution control on the tile layer below (OSM tile licence
|
||||||
|
requirement) — no separate caption needed. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|
@ -882,189 +1095,142 @@ export default function ParamsPanel({
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* zoomable map content — scaled by the +/− controls. The map controls
|
{hasCoords ? (
|
||||||
and corner bracket below are SIBLINGS (outside this layer) so they
|
mapLoadError ? (
|
||||||
never scale. zoom=1 → scale() identity → pixel-identical to design. */}
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
position: "absolute",
|
||||||
position: "absolute",
|
inset: 0,
|
||||||
inset: 0,
|
display: "flex",
|
||||||
transform: `scale(${zoom})`,
|
alignItems: "center",
|
||||||
transformOrigin: "center",
|
justifyContent: "center",
|
||||||
transition: "transform .15s ease-out",
|
padding: "0 16px",
|
||||||
}}
|
textAlign: "center",
|
||||||
>
|
fontSize: 10.5,
|
||||||
<svg
|
lineHeight: 1.4,
|
||||||
viewBox="0 0 440 210"
|
color: tokens.hint,
|
||||||
preserveAspectRatio="xMidYMid slice"
|
fontFamily: tokens.font.sans,
|
||||||
aria-hidden="true"
|
}}
|
||||||
style={{
|
>
|
||||||
position: "absolute",
|
Не удалось загрузить карту. Проверьте интернет-соединение.
|
||||||
inset: 0,
|
</div>
|
||||||
width: "100%",
|
) : (
|
||||||
height: "100%",
|
<>
|
||||||
}}
|
<div
|
||||||
>
|
ref={mapContainerRef}
|
||||||
<rect width="440" height="210" fill="#e6edf4" />
|
style={{ position: "absolute", inset: 0 }}
|
||||||
<g stroke="#dbe5ef" strokeWidth={1}>
|
aria-label="Карта расположения квартиры"
|
||||||
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
|
/>
|
||||||
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
|
{/* map controls — real Leaflet zoom (Leaflet's own zoomControl
|
||||||
</g>
|
is disabled above; these are the HUD-styled buttons). */}
|
||||||
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
|
<div
|
||||||
<path d="M-10 92 H450" />
|
style={{
|
||||||
<path d="M150 -10 V220" />
|
position: "absolute",
|
||||||
<path d="M-10 30 L260 0" />
|
right: 9,
|
||||||
</g>
|
bottom: 9,
|
||||||
<g stroke="#eef3f9" strokeWidth={5}>
|
display: "flex",
|
||||||
<path d="M300 -10 L470 150" />
|
flexDirection: "column",
|
||||||
<path d="M-10 170 H450" />
|
gap: 5,
|
||||||
</g>
|
}}
|
||||||
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
|
>
|
||||||
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
<button
|
||||||
<circle cx="208" cy="100" r="44" opacity={0.55} />
|
type="button"
|
||||||
<circle cx="208" cy="100" r="80" opacity={0.4} />
|
aria-label="Приблизить карту"
|
||||||
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
disabled={mapZoom >= MAX_MAP_ZOOM}
|
||||||
</g>
|
style={{
|
||||||
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
width: 24,
|
||||||
</svg>
|
height: 24,
|
||||||
|
background: tokens.surface.w80,
|
||||||
{/* center pin */}
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: mapZoom >= MAX_MAP_ZOOM ? "default" : "pointer",
|
||||||
|
transition: "all .15s",
|
||||||
|
fontSize: 15,
|
||||||
|
color: tokens.muted,
|
||||||
|
opacity: mapZoom >= MAX_MAP_ZOOM ? 0.4 : 1,
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
onClick={() => leafletMapRef.current?.zoomIn()}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Отдалить карту"
|
||||||
|
disabled={mapZoom <= MIN_MAP_ZOOM}
|
||||||
|
style={{
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
background: tokens.surface.w80,
|
||||||
|
border: `1px solid ${tokens.line}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
cursor: mapZoom <= MIN_MAP_ZOOM ? "default" : "pointer",
|
||||||
|
transition: "all .15s",
|
||||||
|
fontSize: 15,
|
||||||
|
color: tokens.muted,
|
||||||
|
opacity: mapZoom <= MIN_MAP_ZOOM ? 0.4 : 1,
|
||||||
|
padding: 0,
|
||||||
|
fontFamily: "inherit",
|
||||||
|
}}
|
||||||
|
onClick={() => leafletMapRef.current?.zoomOut()}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* No coordinates yet (address not typed/picked from suggest) — a
|
||||||
|
tidy placeholder, never an empty grey box or a broken map. */
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: "47%",
|
inset: 0,
|
||||||
top: "48%",
|
|
||||||
transform: "translate(-50%,-100%)",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "0 16px",
|
||||||
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M12 21s-7-7.2-7-12a7 7 0 1 1 14 0c0 4.8-7 12-7 12Z"
|
||||||
|
stroke={tokens.muted2}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="9" r="2.5" stroke={tokens.muted2} strokeWidth={1.5} />
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
fontSize: 10.5,
|
||||||
alignItems: "center",
|
lineHeight: 1.4,
|
||||||
gap: 6,
|
color: tokens.hint,
|
||||||
background: tokens.surface.w85,
|
fontFamily: tokens.font.sans,
|
||||||
border: `1px solid ${tokens.accent}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: "4px 8px",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
Выберите адрес из подсказок, чтобы увидеть карту
|
||||||
style={{
|
</span>
|
||||||
width: 7,
|
|
||||||
height: 7,
|
|
||||||
borderRadius: "50%",
|
|
||||||
background: tokens.accent,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/* M10: the SUBJECT pin must show the subject's OWN address + area,
|
|
||||||
never an analog's. The form area (subjectAreaLabel) is rendered
|
|
||||||
here so an analog pin that lands near the centre can no longer be
|
|
||||||
mistaken for the subject's caption. */}
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", flexDirection: "column", gap: 1 }}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 600 }}>
|
|
||||||
{address.trim() || "Адрес квартиры"}
|
|
||||||
</span>
|
|
||||||
{subjectAreaLabel && (
|
|
||||||
<span style={{ fontSize: 9, color: tokens.muted }}>
|
|
||||||
{subjectAreaLabel} · оцениваемая квартира
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
borderLeft: "5px solid transparent",
|
|
||||||
borderRight: "5px solid transparent",
|
|
||||||
borderTop: `7px solid ${tokens.accent}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* analog price pins — projected from the real estimate via mapMarkers().
|
{/* map corner bracket — decorative HUD chrome, shown regardless of
|
||||||
Empty markers (no estimate yet) → no price pins, just the subject pin +
|
map/placeholder state (unchanged from the prior design). */}
|
||||||
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
|
|
||||||
{markers.map((m, i) => (
|
|
||||||
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
|
|
||||||
{m.label}
|
|
||||||
<br />
|
|
||||||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* map controls */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 9,
|
|
||||||
bottom: 9,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: 5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Приблизить карту"
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
background: tokens.surface.w80,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "all .15s",
|
|
||||||
fontSize: 15,
|
|
||||||
color: tokens.muted,
|
|
||||||
padding: 0,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Отдалить карту"
|
|
||||||
disabled={zoom <= 1}
|
|
||||||
style={{
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
background: tokens.surface.w80,
|
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 4,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
cursor: zoom <= 1 ? "default" : "pointer",
|
|
||||||
transition: "all .15s",
|
|
||||||
fontSize: 15,
|
|
||||||
color: tokens.muted,
|
|
||||||
opacity: zoom <= 1 ? 0.4 : 1,
|
|
||||||
padding: 0,
|
|
||||||
fontFamily: "inherit",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* map corner bracket */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -1078,23 +1244,6 @@ export default function ParamsPanel({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fix #7 (audit) — the SVG above is decorative (grid + fake streets +
|
|
||||||
radius rings), not a real map, and the rings themselves aren't drawn
|
|
||||||
to scale (outerRingR is a clamped best-effort, not a true geometric
|
|
||||||
projection of РАДИУС). Same honesty-caption tone as SourcesMap's
|
|
||||||
footer disclosure. */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 9,
|
|
||||||
color: tokens.hint,
|
|
||||||
lineHeight: 1.4,
|
|
||||||
marginTop: 6,
|
|
||||||
flex: "0 0 auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Схематично · не географическая карта · радиус не в масштабе
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* radius row */}
|
{/* radius row */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue