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
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:
parent
0b20d98b45
commit
8107c23f18
9 changed files with 497 additions and 6 deletions
|
|
@ -118,6 +118,7 @@ export default function SverdlMarketPage() {
|
|||
</Section>
|
||||
|
||||
<div
|
||||
className="gd-grid-sidebar"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1fr",
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export default function RecommendPage() {
|
|||
</p>
|
||||
|
||||
<div
|
||||
className="gd-grid-sidebar"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(300px, 360px) 1fr",
|
||||
|
|
|
|||
336
frontend/src/app/data-sources/page.tsx
Normal file
336
frontend/src/app/data-sources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -88,3 +88,114 @@ body {
|
|||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* ── Responsive (issue #66) ───────────────────────────────────────────────────
|
||||
* 3 контекста (НЕ Tailwind sm/md/lg): phone <768, tablet 768–1280, 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue