fix(tradein/maps): убрать украинский флаг Leaflet и починить атрибуцию OSM
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m2s

Leaflet 1.9.4 по умолчанию вшивает украинский флаг в prefix контрола
атрибуции (leaflet-src.js: `var ukrainianFlag = '<svg ...>'` →
`prefix: '<a href="https://leafletjs.com" ...>' + ukrainianFlag + ' Leaflet</a>'`).
Ни в одной карте МЕРЫ prefix не переопределялся, поэтому флаг висел на всех
шести картах. Политического символа в продуктовом UI быть не должно — гасим
через `map.attributionControl.setPrefix(false)` (уносит и подпись Leaflet,
это законно: BSD не требует упоминания в UI).

`attributionControl: false` намеренно НЕ используем — он снёс бы весь контрол
вместе с атрибуцией тайлов, обязательной по ODbL.

Заодно чиним саму атрибуцию тайлов: во всех картах стояло голое
"© OpenStreetMap" — без ссылки на лицензию и без "contributors", что не
соответствует Attribution Guidelines OSMF. Теперь канонический кредит со
ссылкой на openstreetmap.org/copyright.

loadLeaflet() и константы LEAFLET_* были дословно скопированы в шести
компонентах: правку атрибуции пришлось бы вносить шесть раз, и любая забытая
копия молча осталась бы со старым поведением. Вынесено в src/lib/leaflet.ts —
единый загрузчик, константы тайлов/атрибуции и обёртка createMap(), которая
не даёт забыть setPrefix при добавлении новой карты. Версия Leaflet и
SRI-хэши сохранены как есть; переход на npm-пакет — отдельная задача.
This commit is contained in:
bot-backend 2026-08-02 13:17:08 +03:00
parent f488cbcf03
commit ed0ed9787f
7 changed files with 185 additions and 281 deletions

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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,

View file

@ -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);

View file

@ -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);

View 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;
}