From d0d0d1fbab75a1536e77c09bd675e505c15c16fa Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 2 Aug 2026 12:12:43 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/maps):=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=83=D0=BA=D1=80=D0=B0=D0=B8=D0=BD=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=84=D0=BB=D0=B0=D0=B3=20Leaflet=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D1=82=D1=8C=20=D0=B0=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=B1=D1=83=D1=86=D0=B8=D1=8E=20OSM=20(#2621)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/trade-in/MapCard.tsx | 57 ++------- .../src/components/trade-in/MapPicker.tsx | 54 ++------- .../src/components/trade-in/SaleShareMap.tsx | 58 +++------- .../src/components/trade-in/v2/HeroBar.tsx | 61 +++------- .../components/trade-in/v2/ParamsPanel.tsx | 67 +++-------- .../src/components/trade-in/v2/SourcesMap.tsx | 61 +++------- tradein-mvp/frontend/src/lib/leaflet.ts | 108 ++++++++++++++++++ 7 files changed, 185 insertions(+), 281 deletions(-) create mode 100644 tradein-mvp/frontend/src/lib/leaflet.ts diff --git a/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx b/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx index cd0d962c..62093315 100644 --- a/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/MapCard.tsx @@ -2,60 +2,27 @@ /** * MapCard — карта аналитики: целевая квартира + аналоги (asking) + ДКП-сделки. - * Reuses the same CDN-loaded Leaflet + OSM approach as MapPicker.tsx (no npm dep). + * Leaflet + OSM грузятся через общий @/lib/leaflet (no npm dep). * Пины с null lat/lon пропускаются; карточка не рендерится без гео-точек. */ /* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */ import { useEffect, useMemo, useRef, useState } from "react"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; -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="; - // Цвета пинов: asking (синий --viz-1) / ДКП (бирюза --viz-3) / target (янтарь --viz-4). // Leaflet принимает строку-цвет напрямую (не CSS vars) — используем hex-значения токенов. const COLOR_ANALOG = "#1d4ed8"; // --viz-1 const COLOR_DEAL = "#14b8a6"; // --viz-3 const COLOR_TARGET = "#f59e0b"; // --viz-4 -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapPicker) */ -function loadLeaflet(): Promise { - 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(`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); - }); -} - interface Props { estimate: AggregatedEstimate; } @@ -123,10 +90,10 @@ export function MapCard({ estimate }: Props) { center = [firstPin.lat as number, firstPin.lon as number]; } if (!center) return; // guarded by totalPins>0, but satisfies TS - map = L.map(mapRef.current).setView(center, 14); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + map = createMap(L, mapRef.current).setView(center, 14); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx b/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx index e3296798..462b21d4 100644 --- a/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/MapPicker.tsx @@ -6,12 +6,14 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { API_BASE_URL } from "@/lib/api"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; -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 EKB_CENTER: [number, number] = [56.8389, 60.6057]; /** Precision значения от reverse endpoint, при которых имеет смысл двигать @@ -51,40 +53,6 @@ interface ReverseResponse { provider: string; } -/** Подгружает Leaflet с CDN один раз, резолвит window.L. */ -function loadLeaflet(): Promise { - 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(`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); - }); -} - interface Props { onPick: (address: string, coords?: { lat: number; lon: number }) => void; onClose: () => void; @@ -115,10 +83,10 @@ export function MapPicker({ onPick, onClose }: Props) { loadLeaflet() .then((L) => { if (cancelled || !mapRef.current) return; - map = L.map(mapRef.current).setView(EKB_CENTER, 12); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + map = createMap(L, mapRef.current).setView(EKB_CENTER, 12); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx b/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx index 6a64dd44..adaa9bf1 100644 --- a/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/SaleShareMap.tsx @@ -3,8 +3,8 @@ /** * SaleShareMap — карта домов вторичного рынка, окрашенных по доле квартир в * продаже (sale_share_pct): низкая → зелёный, высокая → красный. Размер маркера - * тоже растёт с долей. Reuses the same CDN-loaded Leaflet + OSM approach as - * MapCard.tsx / MapPicker.tsx (no npm dep). Дома без координат пропускаем. + * тоже растёт с долей. Leaflet + OSM грузятся через общий @/lib/leaflet + * (no npm dep). Дома без координат пропускаем. * * Грузится через next/dynamic({ ssr:false }) из page.tsx — window.L доступен * только в браузере. @@ -12,50 +12,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */ import { useEffect, useMemo, useRef, useState } from "react"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import type { BuildingSaleShare } from "@/types/sale-share"; import { fmtPct, heatColor, markerRadius } from "./saleShareUtils"; -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 EKB_CENTER: [number, number] = [56.8389, 60.6057]; -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard) */ -function loadLeaflet(): Promise { - 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(`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); - }); -} - /** Безопасное экранирование для вставки в HTML popup. */ function esc(s: string): string { return s.replace(/[&<>"']/g, (c) => @@ -126,10 +94,10 @@ export function SaleShareMap({ .then((L) => { if (cancelled || !mapRef.current) return; LRef.current = L; - const map = L.map(mapRef.current).setView(EKB_CENTER, 11); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + const map = createMap(L, mapRef.current).setView(EKB_CENTER, 11); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); layerRef.current = L.layerGroup().addTo(map); mapObj.current = map; diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx index ad42fcc6..c28f0f6c 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx @@ -3,6 +3,13 @@ import { useEffect, useRef, useState, type CSSProperties } from "react"; import { API_BASE_URL } from "@/lib/api"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import { safeUrl } from "@/lib/safeUrl"; import { tokens } from "./tokens"; @@ -36,50 +43,8 @@ function pdfDownloadHref(estimateId: string | null | undefined): string | null { // Replaces the old static building.png stock photo — user-reported bug: that // single asset was shown for EVERY estimate regardless of the real address, // misleading users into thinking they were looking at their own building. -// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported -// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this -// file stays a self-contained port with no shared runtime module and no npm -// Leaflet dep, same rationale as SourcesMap. -/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */ -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="; - -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror SourcesMap.tsx) */ -function loadLeaflet(): Promise { - 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(`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); - }); -} +// Leaflet + OSM грузятся через общий @/lib/leaflet (CDN + SRI, no npm dep). +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. @/lib/leaflet) */ // Overview zoom: close enough to recognise the actual building on a 560×152 // box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin @@ -117,7 +82,7 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) { loadLeaflet() .then((L) => { if (cancelled || !mapRef.current) return; - map = L.map(mapRef.current, { + map = createMap(L, mapRef.current, { scrollWheelZoom: false, // embedded in the page — must not steal page scroll dragging: false, // locator badge, not an explorable map touchZoom: false, @@ -125,9 +90,9 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) { zoomControl: false, // no room for +/- controls at this size keyboard: false, }).setView([lat, lon], HERO_MAP_ZOOM); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); L.circleMarker([lat, lon], { radius: 8, diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx index 17740970..efbb1b68 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx @@ -13,10 +13,8 @@ // // 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) */ +// basemap, загружаемый общим @/lib/leaflet (CDN + SRI, no npm Leaflet dep). +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. @/lib/leaflet) */ import { useEffect, @@ -38,6 +36,13 @@ import { type MapMarker, } from "./mappers"; import { useGeocodeSuggest } from "@/lib/trade-in-api"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import { CITY_LABELS, DEFAULT_CITY, @@ -157,53 +162,11 @@ 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"; +// Загрузчик/версия/SRI/тайлы/атрибуция — общие, из @/lib/leaflet. Зумы ниже +// специфичны для этой карточки (степперы +/− в UI ограничены этим диапазоном). 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 { - 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(`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); - }); -} +const MAX_MAP_ZOOM = OSM_MAX_ZOOM; /** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес — ввод * пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */ @@ -1035,14 +998,14 @@ export default function ParamsPanel({ coordsRef.current.lat, coordsRef.current.lon, ]; - map = L.map(mapContainerRef.current, { + map = createMap(L, 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, + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, maxZoom: MAX_MAP_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx index fc9df4e3..3c836620 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesMap.tsx @@ -6,10 +6,8 @@ * deal (estimate.actual_deals) that carries lat/lon, coloured per listing * source with a legend, target-property pin + analog-radius circle. * - * PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1 - * app/page.tsx tree, dead in prod — 307-redirected to /v2) — copied rather - * than imported so v2 stays a self-contained port with no runtime dependency - * on the legacy tree, and no npm Leaflet dep (same as MapCard). + * Leaflet + OSM грузятся через общий @/lib/leaflet (CDN + SRI, no npm dep) — + * тот же модуль, что и у остальных карт МЕРЫ. * * Deal honesty: deals are geocoded to STREET CENTROIDS (backend * scripts/geocode_deals_from_houses.py) — several deals on the same street @@ -22,6 +20,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in"; +import { + createMap, + loadLeaflet, + OSM_ATTRIBUTION, + OSM_MAX_ZOOM, + OSM_TILE_URL, +} from "@/lib/leaflet"; import { safeUrl } from "@/lib/safeUrl"; import { tokens } from "./tokens"; import { @@ -37,46 +42,6 @@ import { tierLabel, } from "./mappers"; -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="; - -/** Подгружает Leaflet с CDN один раз, резолвит window.L. (mirror MapCard.tsx) */ -function loadLeaflet(): Promise { - 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(`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); - }); -} - // ── Per-source categorical palette ────────────────────────────────────────── // dataviz-skill categorical order (fixed, never cycled), validated against the // v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow @@ -358,10 +323,10 @@ export function SourcesMap({ estimate }: Props) { // scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay // (SectionOverlay), захват колеса мыши иначе крадёт скролл панели. - map = L.map(mapRef.current, { scrollWheelZoom: false }).setView(center, 14); - L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { - attribution: "© OpenStreetMap", - maxZoom: 19, + map = createMap(L, mapRef.current, { scrollWheelZoom: false }).setView(center, 14); + L.tileLayer(OSM_TILE_URL, { + attribution: OSM_ATTRIBUTION, + maxZoom: OSM_MAX_ZOOM, }).addTo(map); setTimeout(() => map && map.invalidateSize(), 120); diff --git a/tradein-mvp/frontend/src/lib/leaflet.ts b/tradein-mvp/frontend/src/lib/leaflet.ts new file mode 100644 index 00000000..88abb95f --- /dev/null +++ b/tradein-mvp/frontend/src/lib/leaflet.ts @@ -0,0 +1,108 @@ +/** + * Единая точка работы с Leaflet для карт МЕРЫ. + * + * До этого модуля loadLeaflet() и константы LEAFLET_* были дословно + * скопированы в шести компонентах (MapCard, MapPicker, SaleShareMap, + * v2/HeroBar, v2/ParamsPanel, v2/SourcesMap). Копии разъезжались: правку + * атрибуции или версии приходилось вносить шесть раз, и любая пропущенная + * копия молча оставалась со старым поведением. Здесь один источник правды. + * + * Leaflet по-прежнему грузится с CDN (unpkg) с SRI-хэшами, а не как npm-дep — + * это сознательный статус-кво, менять его отдельной задачей. + */ +/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (window.L без типов) */ + +export const LEAFLET_VER = "1.9.4"; +export const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.css`; +export const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VER}/dist/leaflet.js`; +export const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="; +export const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="; + +/** Тайлы OSM (стандартный слой). */ +export const OSM_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"; + +/** Максимальный зум стандартного слоя OSM. */ +export const OSM_MAX_ZOOM = 19; + +/** + * Атрибуция тайлов OSM — обязательна по ODbL и Attribution Guidelines OSMF. + * + * Раньше во всех картах стояло просто "© OpenStreetMap": без ссылки на + * лицензию и без слова contributors, то есть требованиям не соответствовало. + * Guidelines требуют кредит «© OpenStreetMap contributors» со ссылкой на + * https://www.openstreetmap.org/copyright; само имя OpenStreetMap переводить + * нельзя, поэтому строка остаётся в канонической английской форме. + * + * Вставляется Leaflet'ом через innerHTML контрола атрибуции, поэтому это + * намеренно HTML. Строка — константа модуля, пользовательского ввода в ней + * нет. + */ +export const OSM_ATTRIBUTION = + '© OpenStreetMap contributors'; + +/** + * Подгружает Leaflet с CDN ровно один раз на страницу, резолвит window.L. + * + * Повторные вызовы либо сразу отдают уже готовый window.L, либо подписываются + * на load уже висящего в DOM