fix(ptica): load Inter, surface map legend, drop flag, honest product chart, home link #1855
6 changed files with 89 additions and 11 deletions
|
|
@ -61,7 +61,8 @@ body {
|
|||
background: var(--bg-app);
|
||||
color: var(--fg-primary);
|
||||
font-family:
|
||||
Inter,
|
||||
var(--font-inter),
|
||||
"Inter",
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
system-ui,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import type { Metadata } from "next";
|
||||
import { IBM_Plex_Mono } from "next/font/google";
|
||||
import { IBM_Plex_Mono, Inter } from "next/font/google";
|
||||
|
||||
import { RouteGuard } from "@/components/auth/RouteGuard";
|
||||
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
// App UI typeface (Inter). next/font is bundled — no package.json change.
|
||||
// Cyrillic + latin subsets so RU labels render correctly across the app.
|
||||
const inter = Inter({
|
||||
subsets: ["latin", "cyrillic"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
// ПТИЦА cockpit numerals (mono). next/font is bundled — no package.json change.
|
||||
// Cyrillic + latin subsets so mono digits/labels render in the dark cockpit.
|
||||
const plexMono = IBM_Plex_Mono({
|
||||
|
|
@ -26,7 +35,7 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ru" className={plexMono.variable}>
|
||||
<html lang="ru" className={`${inter.variable} ${plexMono.variable}`}>
|
||||
<body>
|
||||
<Providers>
|
||||
<RouteGuard>{children}</RouteGuard>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
/* ===================== SCOPED TOKENS (dark) ===================== */
|
||||
.pticaRoot[data-theme="dark"] {
|
||||
--font-ui:
|
||||
"Inter", "Manrope", -apple-system, "Segoe UI", system-ui, sans-serif;
|
||||
var(--font-inter), "Inter", "Manrope", -apple-system, "Segoe UI",
|
||||
system-ui, sans-serif;
|
||||
--font-mono:
|
||||
var(--font-plex-mono), "IBM Plex Mono", "Roboto Mono", ui-monospace,
|
||||
monospace;
|
||||
|
|
@ -239,6 +240,18 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
/* Wordmark is the «На главную» link — visibly clickable, inherits cockpit
|
||||
colors (no default link blue/underline). */
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
/* Visible focus ring on the home link — matches cockpit pattern
|
||||
(.drawerClose / .gaugeButton :focus-visible). */
|
||||
.wordmark:focus-visible {
|
||||
outline: var(--line-strong) solid var(--accent-cyan);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.bird {
|
||||
width: 26px;
|
||||
|
|
@ -433,6 +446,12 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
.mapMount :global(.leaflet-container) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Contain Leaflet's panes (tile 200 / overlay 400 / marker 600 / popup 700)
|
||||
in their OWN stacking context below the cockpit overlay (.mapOverlay /
|
||||
.mapTools at z-index 4) so the legend is not buried under the tiles. */
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
|
|
@ -1193,6 +1212,12 @@
|
|||
}
|
||||
.hbarName {
|
||||
color: var(--text-muted);
|
||||
/* Keep one row per format — long bucket labels («5-80+ м²») must not wrap to
|
||||
2 lines and break the name / track / value column alignment across rows. */
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hbarTrack {
|
||||
height: 5px;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
Polyline,
|
||||
Popup,
|
||||
Tooltip,
|
||||
AttributionControl,
|
||||
useMap,
|
||||
useMapEvents,
|
||||
} from "react-leaflet";
|
||||
|
|
@ -434,9 +435,13 @@ export default function PticaMapInner({
|
|||
center={center}
|
||||
zoom={geom ? 15 : 11}
|
||||
zoomControl={false}
|
||||
attributionControl={false}
|
||||
scrollWheelZoom
|
||||
style={{ cursor: addMode ? "crosshair" : "grab" }}
|
||||
>
|
||||
{/* Drop the Leaflet 1.9 flag/«Leaflet» prefix but KEEP the per-tile data
|
||||
attribution (© Esri · Maxar / CARTO · OSM) required for licensing. */}
|
||||
<AttributionControl prefix={false} position="bottomright" />
|
||||
<FitToGeometry geom={geom} />
|
||||
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
|
||||
|
|
@ -319,7 +320,11 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
|
|||
{/* ── Main column ─────────────────────────────────────────────── */}
|
||||
<div className={styles.main}>
|
||||
<header className={styles.topbar}>
|
||||
<div className={styles.wordmark}>
|
||||
<Link
|
||||
href="/site-finder"
|
||||
className={styles.wordmark}
|
||||
aria-label="На главную — Site Finder"
|
||||
>
|
||||
<BirdMark className={styles.bird} />
|
||||
<div>
|
||||
<div className={styles.wordmarkTitle}>ПТИЦА</div>
|
||||
|
|
@ -329,7 +334,7 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
|
|||
информационно-цифрового анализа
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className={styles.tabs} aria-label="Режимы">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -10,7 +10,11 @@
|
|||
*/
|
||||
|
||||
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
|
||||
import type { ForecastReport, ProductMixEntry } from "@/types/forecast";
|
||||
import type {
|
||||
ForecastReport,
|
||||
ProductMixEntry,
|
||||
ReportProductTz,
|
||||
} from "@/types/forecast";
|
||||
import {
|
||||
DEFICIT_BALANCE_EPS,
|
||||
deficitTone,
|
||||
|
|
@ -18,6 +22,11 @@ import {
|
|||
} from "@/components/site-finder/ptica/scenarios/scenario-helpers";
|
||||
import type { DrawerKey } from "@/components/site-finder/ptica/drawers/drawer-keys";
|
||||
|
||||
/** Cap meaningful bars so long mixes stay readable (one row per format). */
|
||||
const MAX_BARS = 6;
|
||||
/** |deficit_index| at/above this is the degenerate rail value (no signal). */
|
||||
const DEFICIT_RAIL_EPS = 0.999;
|
||||
|
||||
interface Props {
|
||||
/** §22 forecast report — quartirografiya comes from `product_tz`. */
|
||||
forecastReport?: ForecastReport;
|
||||
|
|
@ -39,11 +48,20 @@ interface MixBar {
|
|||
tone: "good" | "warn" | "bad" | "neutral";
|
||||
}
|
||||
|
||||
function buildBars(mix: ProductMixEntry[]): {
|
||||
function buildBars(tz: ReportProductTz): {
|
||||
bars: MixBar[];
|
||||
byShare: boolean;
|
||||
} {
|
||||
const rows = mix.filter((m) => m.pct != null || m.deficit_index != null);
|
||||
// §22 mix can carry buckets across MULTIPLE obj_class values (e.g. 3 classes ×
|
||||
// 5 buckets = 15 rows). Keep ONLY the recommended class so the card shows the
|
||||
// single honest квартирография (≈5 rows); if no class is set, keep all.
|
||||
const classMix: ProductMixEntry[] =
|
||||
tz.obj_class != null
|
||||
? tz.mix.filter((m) => m.obj_class === tz.obj_class)
|
||||
: tz.mix;
|
||||
const rows = classMix.filter(
|
||||
(m) => m.pct != null || m.deficit_index != null,
|
||||
);
|
||||
const byShare = rows.some((m) => m.pct != null);
|
||||
const bars = rows.map((m, i): MixBar => {
|
||||
if (m.pct != null) {
|
||||
|
|
@ -80,10 +98,21 @@ export function RecommendedProductCard({
|
|||
const tz = forecastReport?.product_tz;
|
||||
const hasProduct =
|
||||
tz != null && (tz.obj_class != null || tz.mix.length > 0 || !!tz.summary);
|
||||
const { bars, byShare } = tz
|
||||
? buildBars(tz.mix)
|
||||
const { bars: allBars, byShare } = tz
|
||||
? buildBars(tz)
|
||||
: { bars: [], byShare: false };
|
||||
|
||||
// Degenerate signal: when bars are deficit-driven (not explicit доля) and every
|
||||
// bar carries the SAME width or sits at the ±1.0 rail, the per-format signal
|
||||
// holds no information — show the honest empty state instead of identical bars.
|
||||
const degenerateSignal =
|
||||
!byShare &&
|
||||
allBars.length > 0 &&
|
||||
(allBars.every((b) => b.widthPct === allBars[0].widthPct) ||
|
||||
allBars.every((b) => b.widthPct >= DEFICIT_RAIL_EPS * 100));
|
||||
|
||||
const bars = degenerateSignal ? [] : allBars.slice(0, MAX_BARS);
|
||||
|
||||
return (
|
||||
<div id="ptica-product" className={styles.card}>
|
||||
<div className={styles.cardHead}>
|
||||
|
|
@ -123,6 +152,10 @@ export function RecommendedProductCard({
|
|||
</p>
|
||||
)}
|
||||
</>
|
||||
) : degenerateSignal ? (
|
||||
<div className={styles.emptyState}>
|
||||
Сигнал по форматам в этом прогнозе недоступен
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.emptyState}>после прогноза (Сценарии)</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue