fix(tradein/maps): убрать украинский флаг Leaflet и починить атрибуцию OSM #2621
7 changed files with 185 additions and 281 deletions
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
||||
/** Безопасное экранирование для вставки в 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;
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
||||
|
|
|
|||
108
tradein-mvp/frontend/src/lib/leaflet.ts
Normal file
108
tradein-mvp/frontend/src/lib/leaflet.ts
Normal file
|
|
@ -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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright" target="_blank"' +
|
||||
' rel="noopener noreferrer">OpenStreetMap</a> contributors';
|
||||
|
||||
/**
|
||||
* Подгружает Leaflet с CDN ровно один раз на страницу, резолвит window.L.
|
||||
*
|
||||
* Повторные вызовы либо сразу отдают уже готовый window.L, либо подписываются
|
||||
* на load уже висящего в DOM <script data-leaflet> — параллельные карты на
|
||||
* одной странице не тянут библиотеку дважды.
|
||||
*/
|
||||
export 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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка над L.map(): создаёт карту и сразу гасит prefix контрола атрибуции.
|
||||
*
|
||||
* Зачем: Leaflet 1.9.4 по умолчанию подставляет в prefix украинский флаг
|
||||
* (leaflet-src.js: `ukrainianFlag` + `prefix: '<a ...>' + flag + 'Leaflet</a>'`).
|
||||
* Политического флага в продуктовых картах быть не должно. setPrefix(false)
|
||||
* убирает флаг вместе с собственной подписью Leaflet — это законно, BSD-лицензия
|
||||
* Leaflet не требует упоминания в UI.
|
||||
*
|
||||
* Что НЕ делаем: `attributionControl: false`. Это снесло бы весь контрол вместе
|
||||
* с атрибуцией тайлов OSM, которая обязательна по лицензии. Контрол остаётся,
|
||||
* из него уходит только prefix.
|
||||
*
|
||||
* Возвращает ту же карту, что и L.map(), поэтому чейнинг `.setView(...)`
|
||||
* работает как раньше.
|
||||
*/
|
||||
export function createMap(
|
||||
L: any,
|
||||
container: HTMLElement,
|
||||
options?: Record<string, unknown>,
|
||||
): any {
|
||||
const map = L.map(container, options);
|
||||
map.attributionControl?.setPrefix(false);
|
||||
return map;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue