fix(tradein/maps): убрать украинский флаг Leaflet и починить атрибуцию OSM #2621
7 changed files with 185 additions and 281 deletions
|
|
@ -2,60 +2,27 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MapCard — карта аналитики: целевая квартира + аналоги (asking) + ДКП-сделки.
|
* 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 пропускаются; карточка не рендерится без гео-точек.
|
* Пины с null lat/lon пропускаются; карточка не рендерится без гео-точек.
|
||||||
*/
|
*/
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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";
|
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).
|
// Цвета пинов: asking (синий --viz-1) / ДКП (бирюза --viz-3) / target (янтарь --viz-4).
|
||||||
// Leaflet принимает строку-цвет напрямую (не CSS vars) — используем hex-значения токенов.
|
// Leaflet принимает строку-цвет напрямую (не CSS vars) — используем hex-значения токенов.
|
||||||
const COLOR_ANALOG = "#1d4ed8"; // --viz-1
|
const COLOR_ANALOG = "#1d4ed8"; // --viz-1
|
||||||
const COLOR_DEAL = "#14b8a6"; // --viz-3
|
const COLOR_DEAL = "#14b8a6"; // --viz-3
|
||||||
const COLOR_TARGET = "#f59e0b"; // --viz-4
|
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 {
|
interface Props {
|
||||||
estimate: AggregatedEstimate;
|
estimate: AggregatedEstimate;
|
||||||
}
|
}
|
||||||
|
|
@ -123,10 +90,10 @@ export function MapCard({ estimate }: Props) {
|
||||||
center = [firstPin.lat as number, firstPin.lon as number];
|
center = [firstPin.lat as number, firstPin.lon as number];
|
||||||
}
|
}
|
||||||
if (!center) return; // guarded by totalPins>0, but satisfies TS
|
if (!center) return; // guarded by totalPins>0, but satisfies TS
|
||||||
map = L.map(mapRef.current).setView(center, 14);
|
map = createMap(L, mapRef.current).setView(center, 14);
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer(OSM_TILE_URL, {
|
||||||
attribution: "© OpenStreetMap",
|
attribution: OSM_ATTRIBUTION,
|
||||||
maxZoom: 19,
|
maxZoom: OSM_MAX_ZOOM,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
setTimeout(() => map && map.invalidateSize(), 120);
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
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];
|
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||||||
|
|
||||||
/** Precision значения от reverse endpoint, при которых имеет смысл двигать
|
/** Precision значения от reverse endpoint, при которых имеет смысл двигать
|
||||||
|
|
@ -51,40 +53,6 @@ interface ReverseResponse {
|
||||||
provider: string;
|
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 {
|
interface Props {
|
||||||
onPick: (address: string, coords?: { lat: number; lon: number }) => void;
|
onPick: (address: string, coords?: { lat: number; lon: number }) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -115,10 +83,10 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
loadLeaflet()
|
loadLeaflet()
|
||||||
.then((L) => {
|
.then((L) => {
|
||||||
if (cancelled || !mapRef.current) return;
|
if (cancelled || !mapRef.current) return;
|
||||||
map = L.map(mapRef.current).setView(EKB_CENTER, 12);
|
map = createMap(L, mapRef.current).setView(EKB_CENTER, 12);
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer(OSM_TILE_URL, {
|
||||||
attribution: "© OpenStreetMap",
|
attribution: OSM_ATTRIBUTION,
|
||||||
maxZoom: 19,
|
maxZoom: OSM_MAX_ZOOM,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
setTimeout(() => map && map.invalidateSize(), 120);
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
/**
|
/**
|
||||||
* SaleShareMap — карта домов вторичного рынка, окрашенных по доле квартир в
|
* SaleShareMap — карта домов вторичного рынка, окрашенных по доле квартир в
|
||||||
* продаже (sale_share_pct): низкая → зелёный, высокая → красный. Размер маркера
|
* продаже (sale_share_pct): низкая → зелёный, высокая → красный. Размер маркера
|
||||||
* тоже растёт с долей. Reuses the same CDN-loaded Leaflet + OSM approach as
|
* тоже растёт с долей. Leaflet + OSM грузятся через общий @/lib/leaflet
|
||||||
* MapCard.tsx / MapPicker.tsx (no npm dep). Дома без координат пропускаем.
|
* (no npm dep). Дома без координат пропускаем.
|
||||||
*
|
*
|
||||||
* Грузится через next/dynamic({ ssr:false }) из page.tsx — window.L доступен
|
* Грузится через next/dynamic({ ssr:false }) из page.tsx — window.L доступен
|
||||||
* только в браузере.
|
* только в браузере.
|
||||||
|
|
@ -12,50 +12,18 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet */
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
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 type { BuildingSaleShare } from "@/types/sale-share";
|
||||||
import { fmtPct, heatColor, markerRadius } from "./saleShareUtils";
|
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];
|
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. */
|
/** Безопасное экранирование для вставки в HTML popup. */
|
||||||
function esc(s: string): string {
|
function esc(s: string): string {
|
||||||
return s.replace(/[&<>"']/g, (c) =>
|
return s.replace(/[&<>"']/g, (c) =>
|
||||||
|
|
@ -126,10 +94,10 @@ export function SaleShareMap({
|
||||||
.then((L) => {
|
.then((L) => {
|
||||||
if (cancelled || !mapRef.current) return;
|
if (cancelled || !mapRef.current) return;
|
||||||
LRef.current = L;
|
LRef.current = L;
|
||||||
const map = L.map(mapRef.current).setView(EKB_CENTER, 11);
|
const map = createMap(L, mapRef.current).setView(EKB_CENTER, 11);
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer(OSM_TILE_URL, {
|
||||||
attribution: "© OpenStreetMap",
|
attribution: OSM_ATTRIBUTION,
|
||||||
maxZoom: 19,
|
maxZoom: OSM_MAX_ZOOM,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
layerRef.current = L.layerGroup().addTo(map);
|
layerRef.current = L.layerGroup().addTo(map);
|
||||||
mapObj.current = map;
|
mapObj.current = map;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,13 @@
|
||||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
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 { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
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
|
// Replaces the old static building.png stock photo — user-reported bug: that
|
||||||
// single asset was shown for EVERY estimate regardless of the real address,
|
// single asset was shown for EVERY estimate regardless of the real address,
|
||||||
// misleading users into thinking they were looking at their own building.
|
// misleading users into thinking they were looking at their own building.
|
||||||
// PORTS the Leaflet-CDN loader pattern from ./SourcesMap.tsx (itself ported
|
// Leaflet + OSM грузятся через общий @/lib/leaflet (CDN + SRI, no npm dep).
|
||||||
// from the dead v1 tree's MapCard.tsx) — copied rather than imported so this
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. @/lib/leaflet) */
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overview zoom: close enough to recognise the actual building on a 560×152
|
// 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
|
// box without feeling zoomed-in on bare rooftops (SourcesMap's multi-pin
|
||||||
|
|
@ -117,7 +82,7 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
|
||||||
loadLeaflet()
|
loadLeaflet()
|
||||||
.then((L) => {
|
.then((L) => {
|
||||||
if (cancelled || !mapRef.current) return;
|
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
|
scrollWheelZoom: false, // embedded in the page — must not steal page scroll
|
||||||
dragging: false, // locator badge, not an explorable map
|
dragging: false, // locator badge, not an explorable map
|
||||||
touchZoom: false,
|
touchZoom: false,
|
||||||
|
|
@ -125,9 +90,9 @@ function HeroMiniMap({ lat, lon }: HeroMiniMapProps) {
|
||||||
zoomControl: false, // no room for +/- controls at this size
|
zoomControl: false, // no room for +/- controls at this size
|
||||||
keyboard: false,
|
keyboard: false,
|
||||||
}).setView([lat, lon], HERO_MAP_ZOOM);
|
}).setView([lat, lon], HERO_MAP_ZOOM);
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer(OSM_TILE_URL, {
|
||||||
attribution: "© OpenStreetMap",
|
attribution: OSM_ATTRIBUTION,
|
||||||
maxZoom: 19,
|
maxZoom: OSM_MAX_ZOOM,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
L.circleMarker([lat, lon], {
|
L.circleMarker([lat, lon], {
|
||||||
radius: 8,
|
radius: 8,
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,8 @@
|
||||||
//
|
//
|
||||||
// Fix (audit): the 01 map was a decorative SVG (grid + fake streets + radius
|
// 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
|
// 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 /
|
// basemap, загружаемый общим @/lib/leaflet (CDN + SRI, no npm Leaflet dep).
|
||||||
// ../MapPicker.tsx (copied rather than shared — same self-contained-port
|
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. @/lib/leaflet) */
|
||||||
// convention as SourcesMap.tsx, no npm Leaflet dep).
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any -- интероп с CDN-библиотекой Leaflet (см. SourcesMap.tsx) */
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useEffect,
|
useEffect,
|
||||||
|
|
@ -38,6 +36,13 @@ import {
|
||||||
type MapMarker,
|
type MapMarker,
|
||||||
} from "./mappers";
|
} from "./mappers";
|
||||||
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
import { useGeocodeSuggest } from "@/lib/trade-in-api";
|
||||||
|
import {
|
||||||
|
createMap,
|
||||||
|
loadLeaflet,
|
||||||
|
OSM_ATTRIBUTION,
|
||||||
|
OSM_MAX_ZOOM,
|
||||||
|
OSM_TILE_URL,
|
||||||
|
} from "@/lib/leaflet";
|
||||||
import {
|
import {
|
||||||
CITY_LABELS,
|
CITY_LABELS,
|
||||||
DEFAULT_CITY,
|
DEFAULT_CITY,
|
||||||
|
|
@ -157,53 +162,11 @@ function comboKeyDown(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
|
// ── 01 map — Leaflet + OSM (real coordinates) ───────────────────────────────
|
||||||
// Same CDN loader pattern/version/SRI as ./SourcesMap.tsx and ../MapPicker.tsx
|
// Загрузчик/версия/SRI/тайлы/атрибуция — общие, из @/lib/leaflet. Зумы ниже
|
||||||
// (duplicated on purpose — each v2 file is a self-contained port, no shared
|
// специфичны для этой карточки (степперы +/− в UI ограничены этим диапазоном).
|
||||||
// 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 DEFAULT_MAP_ZOOM = 16;
|
||||||
const MIN_MAP_ZOOM = 11;
|
const MIN_MAP_ZOOM = 11;
|
||||||
const MAX_MAP_ZOOM = 19;
|
const MAX_MAP_ZOOM = OSM_MAX_ZOOM;
|
||||||
|
|
||||||
/** Подгружает 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 (адрес — ввод
|
/** Экранирование перед вставкой в raw-HTML Leaflet divIcon (адрес — ввод
|
||||||
* пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
|
* пользователя). Тот же паттерн, что и esc() в SourcesMap.tsx. */
|
||||||
|
|
@ -1035,14 +998,14 @@ export default function ParamsPanel({
|
||||||
coordsRef.current.lat,
|
coordsRef.current.lat,
|
||||||
coordsRef.current.lon,
|
coordsRef.current.lon,
|
||||||
];
|
];
|
||||||
map = L.map(mapContainerRef.current, {
|
map = createMap(L, mapContainerRef.current, {
|
||||||
scrollWheelZoom: false,
|
scrollWheelZoom: false,
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
minZoom: MIN_MAP_ZOOM,
|
minZoom: MIN_MAP_ZOOM,
|
||||||
maxZoom: MAX_MAP_ZOOM,
|
maxZoom: MAX_MAP_ZOOM,
|
||||||
}).setView(center, DEFAULT_MAP_ZOOM);
|
}).setView(center, DEFAULT_MAP_ZOOM);
|
||||||
L.tileLayer(MAP_TILE_URL, {
|
L.tileLayer(OSM_TILE_URL, {
|
||||||
attribution: MAP_ATTRIBUTION,
|
attribution: OSM_ATTRIBUTION,
|
||||||
maxZoom: MAX_MAP_ZOOM,
|
maxZoom: MAX_MAP_ZOOM,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
setTimeout(() => map && map.invalidateSize(), 120);
|
setTimeout(() => map && map.invalidateSize(), 120);
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,8 @@
|
||||||
* deal (estimate.actual_deals) that carries lat/lon, coloured per listing
|
* deal (estimate.actual_deals) that carries lat/lon, coloured per listing
|
||||||
* source with a legend, target-property pin + analog-radius circle.
|
* source with a legend, target-property pin + analog-radius circle.
|
||||||
*
|
*
|
||||||
* PORTS the Leaflet-CDN loader pattern from ../MapCard.tsx (the v1
|
* Leaflet + OSM грузятся через общий @/lib/leaflet (CDN + SRI, no npm dep) —
|
||||||
* 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).
|
|
||||||
*
|
*
|
||||||
* Deal honesty: deals are geocoded to STREET CENTROIDS (backend
|
* Deal honesty: deals are geocoded to STREET CENTROIDS (backend
|
||||||
* scripts/geocode_deals_from_houses.py) — several deals on the same street
|
* 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 { CSSProperties } from "react";
|
||||||
|
|
||||||
import type { AggregatedEstimate, AnalogLot } from "@/types/trade-in";
|
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 { safeUrl } from "@/lib/safeUrl";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,46 +42,6 @@ import {
|
||||||
tierLabel,
|
tierLabel,
|
||||||
} from "./mappers";
|
} 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 ──────────────────────────────────────────
|
// ── Per-source categorical palette ──────────────────────────────────────────
|
||||||
// dataviz-skill categorical order (fixed, never cycled), validated against the
|
// dataviz-skill categorical order (fixed, never cycled), validated against the
|
||||||
// v2 card surface #eaf1f8: worst-adjacent CVD ΔE 24.2 (PASS); the aqua/yellow
|
// 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
|
// scrollWheelZoom:false — карта встроена в скроллящуюся панель overlay
|
||||||
// (SectionOverlay), захват колеса мыши иначе крадёт скролл панели.
|
// (SectionOverlay), захват колеса мыши иначе крадёт скролл панели.
|
||||||
map = L.map(mapRef.current, { scrollWheelZoom: false }).setView(center, 14);
|
map = createMap(L, mapRef.current, { scrollWheelZoom: false }).setView(center, 14);
|
||||||
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
L.tileLayer(OSM_TILE_URL, {
|
||||||
attribution: "© OpenStreetMap",
|
attribution: OSM_ATTRIBUTION,
|
||||||
maxZoom: 19,
|
maxZoom: OSM_MAX_ZOOM,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
setTimeout(() => map && map.invalidateSize(), 120);
|
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