From e7cf11e799e86f9bf8d7ac09e876d03dc25e4b6b Mon Sep 17 00:00:00 2001 From: gnezim Date: Sun, 19 Apr 2026 20:18:15 +0300 Subject: [PATCH] =?UTF-8?q?Visual=20parity=20fixes=20=E2=80=94=20drop=20pi?= =?UTF-8?q?xel=20mismatch=20on=206+=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OperatorLogo: accept BCP-47 codes (`ru-ru`) by trimming to first 2 chars before picking the en/ru asset variant. Fixes the Russian flight-details page rendering ROSSIYA (Latin) instead of РОССИЯ. - FlightCard / FlightList: thread `direction` from the search page so arrival results show Высадка (deboarding) instead of Посадка (boarding) — Angular parity. The arrival side reads from arrivalLeg.transition.deboarding when direction === 'arrival'. - OnlineBoardFilter: - Дата рейса starts blank with `ДД.ММ.ГГГГ` placeholder; submit handler defaults to today on empty. - Город вылета / Город прилета placeholders flip to `Все направления` when the opposite-direction field is filled. - Filter content row now flows with $space-l vertical gap to match Angular's accordion-content rhythm (was ~6 px tighter). - FlightsMiniList: `display: none` on mobile. Avoids the duplicate summary card that was floating above the main details on small viewports — Angular hides the sidebar mini-list there. - FlightsMap calendar trigger: override PrimeReact's filled-blue button to a transparent outline so it reads as a glyph (matches Angular's outline calendar icon). Pixel-mismatch results (re-diffed via scripts/visual-diff.mjs): en-onlineboard-route 5.50% → 4.62% onlineboard-arrival 5.53% → 4.63% onlineboard-departure 5.92% → 5.03% onlineboard-route 5.16% → 4.78% mobile-onlineboard-start 23.51% → 20.37% mobile-flight-details 18.82% → 17.92% flight-details carrier-logo verified visually; pixel count unchanged (height delta dominates) onlineboard-start 14.56% → 14.52% Larger remaining mismatches (schedule-route 14%, flights-map 34%, flight-details 11%) are dominated by structural Angular features the React port doesn't yet ship (day grouping, code-share bundling on schedule; geo-driven origin marker on map; height-delta on details). Tracked as P1 follow-ups in the comparison report. --- scripts/visual-diff.mjs | 107 ++++++++++++++++++ .../components/FlightsMapStartPage.scss | 17 +++ .../FlightsMiniList/FlightsMiniList.scss | 10 ++ .../components/OnlineBoardFilter.scss | 10 +- .../components/OnlineBoardFilter.tsx | 27 +++-- .../components/OnlineBoardSearchPage.tsx | 1 + src/ui/flights/FlightCard.tsx | 30 +++-- src/ui/flights/FlightList.tsx | 8 ++ src/ui/flights/OperatorLogo.tsx | 6 +- 9 files changed, 196 insertions(+), 20 deletions(-) create mode 100644 scripts/visual-diff.mjs diff --git a/scripts/visual-diff.mjs b/scripts/visual-diff.mjs new file mode 100644 index 00000000..6b482df3 --- /dev/null +++ b/scripts/visual-diff.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Generate pixel diffs for the side-by-side Angular/React screenshots + * captured into `comparison-report/visual/screenshots/full/` and emit a + * JSON file the report.html consumes for the quantitative side of the + * parity comparison. + * + * Pairs are inferred by filename: every `react-{stem}.png` is matched + * against `angular-{stem}.png` in the same directory. Diffs are written + * to `comparison-report/visual/diffs-full/{stem}.png` (white = match, + * red overlay = mismatched pixel) and stats land in + * `comparison-report/visual/diff-stats.json`. + * + * Image-dimension mismatches are handled by padding both sides to the + * larger canvas with white before pixel-matching — same approach as + * `tests/parity/visual/screenshot-diff-multi.ts`. + */ + +import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { resolve, join } from "node:path"; +import { PNG } from "pngjs"; +import pixelmatch from "pixelmatch"; + +const ROOT = resolve(process.cwd()); +const SRC_DIR = join(ROOT, "comparison-report/visual/screenshots/full"); +const DIFFS_DIR = join(ROOT, "comparison-report/visual/diffs-full"); +const STATS_PATH = join(ROOT, "comparison-report/visual/diff-stats.json"); + +mkdirSync(DIFFS_DIR, { recursive: true }); + +const files = readdirSync(SRC_DIR); +const reactFiles = files.filter((f) => f.startsWith("react-") && f.endsWith(".png")); + +const stats = {}; +let processed = 0; + +for (const reactFile of reactFiles) { + const stem = reactFile.slice("react-".length); // e.g. "onlineboard-start.png" + const angularFile = `angular-${stem}`; + if (!files.includes(angularFile)) { + console.log(` skip (no angular pair): ${reactFile}`); + continue; + } + + const aBuf = readFileSync(join(SRC_DIR, angularFile)); + const rBuf = readFileSync(join(SRC_DIR, reactFile)); + const aPng = PNG.sync.read(aBuf); + const rPng = PNG.sync.read(rBuf); + + const width = Math.max(aPng.width, rPng.width); + const height = Math.max(aPng.height, rPng.height); + + const pad = (src) => { + const out = new PNG({ width, height }); + for (let i = 0; i < out.data.length; i += 4) { + out.data[i] = 255; out.data[i + 1] = 255; out.data[i + 2] = 255; out.data[i + 3] = 255; + } + for (let y = 0; y < src.height; y++) { + for (let x = 0; x < src.width; x++) { + const srcIdx = (y * src.width + x) * 4; + const dstIdx = (y * width + x) * 4; + out.data[dstIdx] = src.data[srcIdx]; + out.data[dstIdx + 1] = src.data[srcIdx + 1]; + out.data[dstIdx + 2] = src.data[srcIdx + 2]; + out.data[dstIdx + 3] = src.data[srcIdx + 3]; + } + } + return PNG.sync.read(PNG.sync.write(out)); + }; + + const aP = pad(aPng); + const rP = pad(rPng); + const diffPng = new PNG({ width, height }); + + const mismatchCount = pixelmatch( + aP.data, rP.data, diffPng.data, width, height, + { threshold: 0.1, alpha: 0.3 }, + ); + + const totalPixels = width * height; + const mismatchPct = (mismatchCount / totalPixels) * 100; + const heightDiff = rPng.height - aPng.height; + + const diffName = stem; + writeFileSync(join(DIFFS_DIR, diffName), PNG.sync.write(diffPng)); + + const key = stem.replace(/\.png$/, ""); + stats[key] = { + angular: `screenshots/full/${angularFile}`, + react: `screenshots/full/${reactFile}`, + diff: `diffs-full/${diffName}`, + mismatchPct: Number(mismatchPct.toFixed(3)), + mismatchCount, + totalPixels, + heightDiff, + angular_w: aPng.width, angular_h: aPng.height, + react_w: rPng.width, react_h: rPng.height, + }; + + processed++; + console.log(` ${stem}: ${mismatchPct.toFixed(2)}% diff (${mismatchCount.toLocaleString()} / ${totalPixels.toLocaleString()} px)`); +} + +writeFileSync(STATS_PATH, JSON.stringify(stats, null, 2)); +console.log(`\nDone — ${processed} pairs diffed.`); +console.log(` Diffs: ${DIFFS_DIR}/`); +console.log(` Stats: ${STATS_PATH}`); diff --git a/src/features/flights-map/components/FlightsMapStartPage.scss b/src/features/flights-map/components/FlightsMapStartPage.scss index d2194e18..a24d6390 100644 --- a/src/features/flights-map/components/FlightsMapStartPage.scss +++ b/src/features/flights-map/components/FlightsMapStartPage.scss @@ -253,6 +253,23 @@ } } +// PrimeReact Calendar trigger inside the map filter: Angular renders +// an outline calendar icon (no background); the PrimeReact default is +// a filled-blue button. Strip the fill + tone the icon down to the +// neutral gray so it reads as a glyph rather than a CTA. +.flights-map-filter .p-calendar.p-calendar-w-btn .p-datepicker-trigger.p-button { + background: transparent !important; + border: 1px solid #e0e0e0 !important; + border-left: none !important; + color: #6b7280 !important; + box-shadow: none !important; +} +.flights-map-filter .p-calendar.p-calendar-w-btn .p-datepicker-trigger.p-button:hover, +.flights-map-filter .p-calendar.p-calendar-w-btn .p-datepicker-trigger.p-button:focus { + background: rgba(0, 0, 0, 0.04) !important; + color: #1a3a5c !important; +} + // Leaflet city tooltips: text-only with white text-shadow halo, matching // Angular's _leaflet-popup.scss treatment. .leaflet-tooltip.city-label { diff --git a/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss b/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss index 51dd28ce..19ab76c0 100644 --- a/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss +++ b/src/features/online-board/components/FlightsMiniList/FlightsMiniList.scss @@ -1,3 +1,5 @@ +@use "../../../../styles/screen" as screen; + .mini-list { display: flex; flex-direction: column; @@ -7,6 +9,14 @@ border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); + // Angular hides the sidebar mini-list on mobile (the main details + // card already shows everything). React's PageLayout floats the + // left column above the main content on mobile, so the mini-list + // would render as a duplicate summary card. Hide it. + @include screen.mobile { + display: none; + } + &__item { padding: 12px 14px; border-bottom: 1px solid #e8edf3; diff --git a/src/features/online-board/components/OnlineBoardFilter.scss b/src/features/online-board/components/OnlineBoardFilter.scss index e7b9db12..696c1f5f 100644 --- a/src/features/online-board/components/OnlineBoardFilter.scss +++ b/src/features/online-board/components/OnlineBoardFilter.scss @@ -273,11 +273,17 @@ } .calendar { - margin-top: vars.$space-xl; + // margin-top removed: vertical rhythm now driven by .filter-content gap. } .filter-content { - // container for form fields inside accordion content + // Vertical rhythm between filter rows. Angular's accordion content + // separates fields by ~$space-l (15px); the previous default + // packed inputs about ~6 px tighter and surfaced as a measurable + // pixel-diff against Angular on the start page. + display: flex; + flex-direction: column; + gap: vars.$space-l; } .filter-button { diff --git a/src/features/online-board/components/OnlineBoardFilter.tsx b/src/features/online-board/components/OnlineBoardFilter.tsx index fedb3af1..7ce35020 100644 --- a/src/features/online-board/components/OnlineBoardFilter.tsx +++ b/src/features/online-board/components/OnlineBoardFilter.tsx @@ -88,8 +88,11 @@ export const OnlineBoardFilter: FC = ({ // Flight number fields const [flightNumber, setFlightNumber] = useState(initialFlightNumber ?? ""); + // Start blank to match Angular's `ДД.ММ.ГГГГ` placeholder; the search + // handler defaults to today when the field is left empty so the + // legacy "search today" UX still works. const [flightDate, setFlightDate] = useState( - initialTab === "flight" && initialDate ? yyyymmddToDate(initialDate) : new Date(), + initialTab === "flight" && initialDate ? yyyymmddToDate(initialDate) : null, ); const [flightNumberError, setFlightNumberError] = useState(null); @@ -97,7 +100,7 @@ export const OnlineBoardFilter: FC = ({ const [routeDepartureCode, setRouteDepartureCode] = useState(initialDeparture ?? ""); const [routeArrivalCode, setRouteArrivalCode] = useState(initialArrival ?? ""); const [routeDate, setRouteDate] = useState( - initialDate ? yyyymmddToDate(initialDate) : new Date(), + initialDate ? yyyymmddToDate(initialDate) : null, ); const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]); @@ -177,8 +180,9 @@ export const OnlineBoardFilter: FC = ({ setFlightNumberError(error); if (error) return; - if (!flightDate) return; - const dateParam = dateToYyyymmdd(flightDate); + // Empty date defaults to today, matching Angular's behaviour when + // the placeholder ДД.ММ.ГГГГ is left untouched. + const dateParam = dateToYyyymmdd(flightDate ?? new Date()); const cleaned = flightNumber.trim().replace(/\s+/g, ""); const carrier = "SU"; const num = cleaned; @@ -193,8 +197,7 @@ export const OnlineBoardFilter: FC = ({ (e: FormEvent) => { e.preventDefault(); - if (!routeDate) return; - const dateParam = dateToYyyymmdd(routeDate); + const dateParam = dateToYyyymmdd(routeDate ?? new Date()); const depCode = routeDepartureCode.trim().toUpperCase(); const arrCode = routeArrivalCode.trim().toUpperCase(); if (!depCode || !arrCode) return; @@ -336,7 +339,11 @@ export const OnlineBoardFilter: FC = ({
= ({ = ({ loading={loading} onFlightClick={handleFlightClick} initialCurrentFlightId={initialCurrentFlightId} + direction={params.type} /> )} diff --git a/src/ui/flights/FlightCard.tsx b/src/ui/flights/FlightCard.tsx index 79129557..20ccb87c 100644 --- a/src/ui/flights/FlightCard.tsx +++ b/src/ui/flights/FlightCard.tsx @@ -33,6 +33,11 @@ export interface FlightCardProps { initialExpanded?: boolean; /** Fired when the user clicks 'Детали рейса' in the expanded panel. */ onViewDetails?: () => void; + /** + * Search direction. `arrival` swaps the boarding row to deboarding + * (label `Высадка` instead of `Посадка`) per Angular parity. + */ + direction?: "departure" | "arrival" | "route" | "flight"; } /** Extract the primary leg from a flight (first leg for multi-leg) */ @@ -75,6 +80,7 @@ export const FlightCard: FC = ({ expandable, initialExpanded, onViewDetails, + direction = "route", }) => { const { t } = useTranslation(); const { language } = useLocale(); @@ -119,7 +125,13 @@ export const FlightCard: FC = ({ ? "SHARED.ACTUAL" : "SHARED.EXPECTED"; - const boarding = departureLeg.transition?.boarding; + // Arrival pages show the deboarding (Высадка) transition; departure / + // route / flight-number views show boarding (Посадка). Matches Angular. + const isArrival = direction === "arrival"; + const transition = isArrival + ? arrivalLeg.transition?.deboarding + : departureLeg.transition?.boarding; + const transitionLabelKey = isArrival ? "DETAILS.DEBOARDING" : "DETAILS.BOARDING"; const BOARDING_STATUS_KEY: Record = { Finished: "BOARDING-STATUSES.Finished", Expected: "BOARDING-STATUSES.Expected", @@ -254,10 +266,10 @@ export const FlightCard: FC = ({
- {boarding && ( + {transition && (
- {t("DETAILS.BOARDING")} + {t(transitionLabelKey)}
@@ -265,29 +277,29 @@ export const FlightCard: FC = ({ {t("DETAILS.STATUS")}
- {boarding.start?.local && ( + {transition.start?.local && (
{t("SHARED.BOARDING-START")} - {formatLocalTime(boarding.start.local)} + {formatLocalTime(transition.start.local)}
)} - {boarding.end?.local && ( + {transition.end?.local && (
{t("SHARED.BOARDING-END")} - {formatLocalTime(boarding.end.local)} + {formatLocalTime(transition.end.local)}
)} diff --git a/src/ui/flights/FlightList.tsx b/src/ui/flights/FlightList.tsx index 05da04db..01d561b0 100644 --- a/src/ui/flights/FlightList.tsx +++ b/src/ui/flights/FlightList.tsx @@ -20,6 +20,12 @@ export interface FlightListProps { * to now; other days → first/last). */ initialCurrentFlightId?: string | null; + /** + * Search direction. Drives which transition (boarding vs deboarding) + * surfaces in the expanded card and what its row title reads. Matches + * Angular's `Посадка` / `Высадка` switch on departure vs arrival pages. + */ + direction?: "departure" | "arrival" | "route" | "flight"; } /** @@ -34,6 +40,7 @@ export const FlightList: FC = ({ skeletonCount = 5, onFlightClick, initialCurrentFlightId, + direction = "route", }) => { const { t } = useTranslation(); const cardRefs = useRef>(new Map()); @@ -74,6 +81,7 @@ export const FlightList: FC = ({ > = ({ carrier, locale, round, ti const style = useMemo(() => { const mapping = LOGO_PATHS[carrier]; if (!mapping) return undefined; - const src = locale === "ru" && mapping.ru ? mapping.ru : mapping.en; + // Accept either the short language code (`"ru"`) or a BCP-47 URL + // locale (`"ru-ru"`); only the first two chars matter for picking + // between the carrier's en/ru asset variants. + const lang = (locale ?? "").slice(0, 2).toLowerCase(); + const src = lang === "ru" && mapping.ru ? mapping.ru : mapping.en; return { backgroundImage: `url('${src}')` }; }, [carrier, locale]);