feat(frontend): mobile responsiveness (#66) + data-sources страница (#80)
Some checks failed
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Successful in 1m3s
CI / changes (pull_request) Successful in 6s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 50s
Deploy / changes (push) Has been cancelled
Deploy / build-frontend (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled

#66: responsive CSS-layer в globals.css (3-контекста <768/768-1280/>1280, не Tailwind
sm/md/lg) + ResizeObserver в ChartShell (единственный echarts импортёр → все charts
рефлоу) + class-применение (grid-collapse, nav horizontal-scroll, map-stack) на
site-finder/analytics. overflow-x guard.
#80: /data-sources attribution-страница (OSM ODbL/ДОМ.РФ/Объектив/Open-Meteo/Росреестр/
НСПД, server component, tokens) + footer-link.

Closes #66
Closes #80
This commit is contained in:
Light1YT 2026-06-13 23:12:31 +05:00
parent 0b20d98b45
commit 8107c23f18
9 changed files with 497 additions and 6 deletions

View file

@ -118,6 +118,7 @@ export default function SverdlMarketPage() {
</Section>
<div
className="gd-grid-sidebar"
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",

View file

@ -93,6 +93,7 @@ export default function RecommendPage() {
</p>
<div
className="gd-grid-sidebar"
style={{
display: "grid",
gridTemplateColumns: "minmax(300px, 360px) 1fr",

View file

@ -0,0 +1,336 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Database, ExternalLink, MapPin } from "lucide-react";
export const metadata: Metadata = {
title: "Источники данных и атрибуция · GenDesign",
description:
"Перечень внешних источников данных GenDesign, их лицензии и требования к атрибуции: OpenStreetMap, ДОМ.РФ, Объектив, Open-Meteo, Росреестр, НСПД.",
};
// ── Source registry ───────────────────────────────────────────────────────────
// Перечень источников + лицензия + строка атрибуции. Содержит фактическую
// атрибуцию; юридическая интерпретация ToS ведётся отдельно (vault
// decisions/Data_Licenses_Audit.md). Эта страница — публичная attribution-
// поверхность, требуемая лицензиями (в первую очередь OSM ODbL).
interface DataSource {
name: string;
/** Что именно используется из источника. */
usage: string;
/** Короткое имя лицензии / режима использования. */
license: string;
/** Строка атрибуции в том виде, как её требует источник. */
attribution: string;
/** Канонический URL источника / условий. */
href: string;
hrefLabel: string;
}
const SOURCES: DataSource[] = [
{
name: "OpenStreetMap",
usage:
"Базовая картографическая подложка (тайлы) на всех картах Leaflet, геокодирование точек интереса.",
license: "ODbL 1.0 (данные) · CC BY-SA (картография)",
attribution: "© участники OpenStreetMap",
href: "https://www.openstreetmap.org/copyright",
hrefLabel: "openstreetmap.org/copyright",
},
{
name: "ДОМ.РФ",
usage:
"Реестр строящегося жилья (портфель новостроек, площади, sold %, сроки ввода), сведения ПЗЗ.",
license: "Публичные данные · по условиям наш.дом.рф",
attribution:
"Источник: Единая информационная система жилищного строительства (наш.дом.рф)",
href: "https://наш.дом.рф",
hrefLabel: "наш.дом.рф",
},
{
name: "Объектив",
usage:
"Цены предложения и скорость продаж по жилым комплексам (sale-graph, velocity).",
license: "Коммерческий доступ по договору (платный тариф)",
attribution: "Данные о ценах предложения предоставлены сервисом «Объектив»",
href: "https://objced.ru",
hrefLabel: "objced.ru",
},
{
name: "Open-Meteo",
usage:
"Сезонная погода и климатические нормали для оценки строительного сезона участка.",
license: "CC BY 4.0 · коммерческое использование по платному тарифу",
attribution: "Погодные данные © Open-Meteo.com",
href: "https://open-meteo.com/en/license",
hrefLabel: "open-meteo.com/license",
},
{
name: "Росреестр / ЕГРН",
usage:
"Сведения ЕГРН по участку (категория, ВРИ, площадь), ДДУ-сделки (Rosreestr deals) для рыночной аналитики.",
license: "Сведения ЕГРН · по условиям предоставления Росреестра",
attribution: "Источник: Росреестр (ЕГРН), Публичная кадастровая карта",
href: "https://rosreestr.gov.ru",
hrefLabel: "rosreestr.gov.ru",
},
{
name: "НСПД",
usage:
"Национальная система пространственных данных: зонирование, красные линии, ЗОУИТ, инженерные сети рядом с участком.",
license: "Публичные пространственные данные · по условиям nspd.gov.ru",
attribution:
"Источник: Национальная система пространственных данных (НСПД)",
href: "https://nspd.gov.ru",
hrefLabel: "nspd.gov.ru",
},
];
// ── Cell styles ────────────────────────────────────────────────────────────────
const thStyle: React.CSSProperties = {
textAlign: "left",
padding: "10px 12px",
fontSize: 11,
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-secondary)",
borderBottom: "1px solid var(--border-card)",
whiteSpace: "nowrap",
};
const tdStyle: React.CSSProperties = {
padding: "12px 12px",
fontSize: 13,
color: "var(--fg-primary)",
borderBottom: "1px solid var(--border-soft)",
verticalAlign: "top",
lineHeight: 1.5,
};
export default function DataSourcesPage() {
return (
<main style={{ background: "var(--bg-app)", minHeight: "100vh" }}>
<div
style={{
maxWidth: 1024,
margin: "0 auto",
padding: "24px 24px 48px",
}}
>
{/* Header */}
<header style={{ marginBottom: 24 }}>
<Link
href="/"
style={{
fontSize: 13,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
GenDesign
</Link>
<h1
style={{
margin: "8px 0 0",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
Источники данных и атрибуция
</h1>
<p
style={{
margin: "8px 0 0",
maxWidth: 720,
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
GenDesign объединяет открытые и лицензированные источники
пространственных и рыночных данных. Ниже перечень источников,
режим их использования и требуемая атрибуция. Картографическая
подложка предоставлена участниками OpenStreetMap по лицензии ODbL.
</p>
</header>
{/* Sources table */}
<section
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
}}
>
<Database
size={20}
strokeWidth={1.5}
color="var(--fg-secondary)"
aria-hidden="true"
/>
<h2
style={{
margin: 0,
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
Перечень источников
</h2>
</div>
{/* На узких экранах таблица скроллится горизонтально (issue #66). */}
<div className="gd-table-scroll">
<table
style={{
width: "100%",
minWidth: 720,
borderCollapse: "collapse",
}}
>
<thead>
<tr>
<th style={thStyle}>Источник</th>
<th style={thStyle}>Что используется</th>
<th style={thStyle}>Лицензия / режим</th>
<th style={thStyle}>Атрибуция</th>
</tr>
</thead>
<tbody>
{SOURCES.map((s) => (
<tr key={s.name}>
<td style={{ ...tdStyle, whiteSpace: "nowrap" }}>
<span
style={{ fontWeight: 600, color: "var(--fg-primary)" }}
>
{s.name}
</span>
<a
href={s.href}
target="_blank"
rel="noopener noreferrer"
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 4,
fontSize: 12,
color: "var(--accent)",
textDecoration: "none",
}}
>
<ExternalLink size={12} strokeWidth={1.5} />
{s.hrefLabel}
</a>
</td>
<td style={{ ...tdStyle, minWidth: 240 }}>{s.usage}</td>
<td
style={{
...tdStyle,
color: "var(--fg-secondary)",
minWidth: 180,
}}
>
{s.license}
</td>
<td
style={{
...tdStyle,
color: "var(--fg-secondary)",
minWidth: 200,
}}
>
{s.attribution}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* OSM attribution note */}
<section
style={{
marginTop: 16,
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
display: "flex",
gap: 12,
alignItems: "flex-start",
}}
>
<MapPin
size={20}
strokeWidth={1.5}
color="var(--fg-secondary)"
aria-hidden="true"
style={{ flexShrink: 0, marginTop: 2 }}
/>
<div>
<h2
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
Картография OpenStreetMap
</h2>
<p
style={{
margin: "6px 0 0",
fontSize: 13,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
Все интерактивные карты используют тайлы OpenStreetMap. Данные
распространяются по лицензии{" "}
<a
href="https://opendatacommons.org/licenses/odbl/"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
Open Data Commons Open Database License (ODbL)
</a>{" "}
и сопровождаются строкой «© участники OpenStreetMap» в правом
нижнем углу каждой карты.
</p>
</div>
</section>
{/* Legal caveat */}
<p
style={{
marginTop: 16,
fontSize: 12,
color: "var(--fg-tertiary)",
lineHeight: 1.6,
}}
>
Перечень носит информационный характер и отражает текущий состав
используемых источников. По вопросам условий использования и
лицензирования данных свяжитесь с командой GenDesign.
</p>
</div>
</main>
);
}

View file

@ -88,3 +88,114 @@ body {
a {
color: inherit;
}
/*
* Responsive (issue #66)
* 3 контекста (НЕ Tailwind sm/md/lg): phone <768, tablet 7681280, desktop >1280.
* Утилиты ниже единственный источник правды для брейкпоинтов. Компоненты
* подключаются через className; инлайн-стили остаются desktop-видом по умолчанию.
*/
/* Никакого горизонтального скролла на узких экранах (acceptance #66). */
html,
body {
max-width: 100%;
overflow-x: hidden;
}
/* Sidebar-grid (напр. "240px 1fr" / "minmax(300px,360px) 1fr"): на планшете
* и ниже схлопывается в одну колонку, сайдбар уезжает над контентом. */
@media (max-width: 1024px) {
.gd-grid-sidebar {
grid-template-columns: 1fr !important;
}
/* sticky-сайдбар внутри схлопнутого грида не должен «прилипать». */
.gd-grid-sidebar > .gd-sidebar {
position: static !important;
top: auto !important;
height: auto !important;
}
}
/* Карта/контент рядом с фиксированным сайдбаром: на планшете в колонку. */
@media (max-width: 1024px) {
.gd-split {
flex-direction: column !important;
overflow: visible !important;
}
.gd-split > .gd-split-aside {
width: 100% !important;
flex-shrink: 1 !important;
/* в desktop у сайдбара не было левого паддинга (он стоял справа от карты);
* в колонке возвращаем симметрию. */
padding-left: 16px !important;
padding-top: 0 !important;
}
}
/* Nav-tabs: на телефоне — горизонтальный скролл со снапом, НЕ hamburger. */
@media (max-width: 767px) {
.gd-nav-scroll {
overflow-x: auto;
flex-wrap: nowrap;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.gd-nav-scroll::-webkit-scrollbar {
display: none;
}
.gd-nav-scroll > * {
flex: 0 0 auto;
scroll-snap-align: start;
}
}
/* Таблицы с фиксированной шириной колонок — оборачивать в .gd-table-scroll. */
.gd-table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* KPI-grid: на телефоне одна колонка во всю ширину (ui-conventions). */
@media (max-width: 767px) {
.gd-kpi-grid {
grid-template-columns: 1fr !important;
}
}
/* Карта Leaflet на узких экранах гарантированная высота, чтобы не
* схлопнулась в 0 когда flex-родитель встаёт в колонку. */
@media (max-width: 1024px) {
.gd-map-shell {
min-height: 420px;
}
}
/* Section-nav сайдбара анализа: на планшете/телефоне (когда грид схлопнут)
* вертикальный список превращается в горизонтальную скролл-полоску со снапом
* чтобы не съедать пол-экрана высоты над контентом (issue #66). */
@media (max-width: 1024px) {
.gd-section-nav {
flex-direction: row !important;
overflow-x: auto;
padding: 8px 4px !important;
scroll-snap-type: x proximity;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.gd-section-nav::-webkit-scrollbar {
display: none;
}
.gd-section-nav > * {
display: flex;
flex: 0 0 auto;
scroll-snap-align: start;
}
.gd-section-nav button {
width: auto !important;
white-space: nowrap;
padding-left: 12px !important;
padding-right: 12px !important;
}
}

View file

@ -699,6 +699,12 @@ export default function HomePage() {
>
ПКК Росреестра
</a>
<a
href="/data-sources"
style={{ color: "var(--accent)", textDecoration: "none" }}
>
Источники данных
</a>
<span>GenDesign {new Date().getFullYear()}</span>
</footer>

View file

@ -134,8 +134,10 @@ export function AnalysisPageContent({ cad }: Props) {
/>
</div>
{/* Page layout: sidebar (240px) + main scroll */}
{/* Page layout: sidebar (240px) + main scroll. На планшете/телефоне
.gd-grid-sidebar схлопывает грид в одну колонку (issue #66). */}
<div
className="gd-grid-sidebar"
style={{
display: "grid",
gridTemplateColumns: "240px 1fr",
@ -146,6 +148,7 @@ export function AnalysisPageContent({ cad }: Props) {
>
{/* ── Sidebar nav ───────────────────────────────────────────────── */}
<aside
className="gd-sidebar"
style={{
position: "sticky",
top: 72,
@ -280,6 +283,7 @@ function AnalysisSidebarNav() {
return (
<nav
className="gd-section-nav"
style={{
background: "var(--bg-card, #FFFFFF)",
border: "1px solid var(--border-card, #E6E8EC)",

View file

@ -165,8 +165,10 @@ export default function SiteFinderPage() {
<FilterBarBridge filters={filters} onChange={setFilters} bbox={bbox} />
</div>
{/* Main layout */}
{/* Main layout. На планшете/телефоне .gd-split встаёт в колонку
(карта над сайдбаром), .gd-map-shell держит высоту карты (issue #66). */}
<div
className="gd-split"
style={{
flex: 1,
display: "flex",
@ -177,6 +179,7 @@ export default function SiteFinderPage() {
>
{/* Map area — position: relative so ParcelDrawer can anchor absolutely here */}
<div
className="gd-map-shell"
style={{
flex: 1,
padding: 16,
@ -204,6 +207,7 @@ export default function SiteFinderPage() {
{/* Right sidebar — always visible; CadInput accessible parallel with drawer */}
<div
className="gd-split-aside"
style={{
width: 300,
flexShrink: 0,

View file

@ -14,10 +14,11 @@ export function AnalyticsNav() {
const pathname = usePathname();
return (
<nav
className="gd-nav-scroll"
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid #e6e8ec",
borderBottom: "1px solid var(--border-card)",
marginBottom: 16,
}}
>
@ -30,12 +31,13 @@ export function AnalyticsNav() {
style={{
padding: "10px 14px",
borderBottom: active
? "2px solid #1d4ed8"
? "2px solid var(--accent)"
: "2px solid transparent",
color: active ? "#1d4ed8" : "#374151",
color: active ? "var(--accent)" : "var(--fg-secondary)",
fontWeight: active ? 600 : 500,
textDecoration: "none",
fontSize: 14,
whiteSpace: "nowrap",
}}
>
{tab.label}

View file

@ -1,7 +1,9 @@
"use client";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import type { EChartsInstance } from "echarts-for-react";
const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false });
@ -20,13 +22,37 @@ export function ChartShell({
loading,
notMerge,
}: Props) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const instanceRef = useRef<EChartsInstance | null>(null);
// ECharts canvas only reflows on window.resize by default. On mobile/tablet
// the chart container width changes via CSS (sidebar grid collapse, flex
// column) WITHOUT a window resize, so the canvas keeps its desktop width and
// overflows. A ResizeObserver on the wrapper resizes the instance whenever its
// box changes — covers every chart that goes through ChartShell (issue #66).
// The instance is captured via onChartReady (a plain prop) rather than a ref,
// because next/dynamic does not forward refs to the wrapped component.
useEffect(() => {
const el = wrapRef.current;
if (el == null || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
instanceRef.current?.resize();
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<div style={{ width: "100%", height, ...style }}>
<div ref={wrapRef} style={{ width: "100%", height, ...style }}>
<ReactECharts
option={option}
style={{ height: "100%", width: "100%" }}
showLoading={loading}
notMerge={notMerge}
onChartReady={(instance: EChartsInstance) => {
instanceRef.current = instance;
}}
/>
</div>
);