fix(ptica): load Inter, surface map legend, drop flag, honest product chart, home link #1855

Merged
bot-backend merged 1 commit from fix/ptica-inter-map-product into main 2026-06-20 20:42:30 +00:00
6 changed files with 89 additions and 11 deletions

View file

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

View file

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

View file

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

View file

@ -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} />

View file

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

View file

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