Visual parity fixes — drop pixel mismatch on 6+ pages
- 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.
This commit is contained in:
@@ -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}`);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -88,8 +88,11 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
|
||||
// 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<Date | null>(
|
||||
initialTab === "flight" && initialDate ? yyyymmddToDate(initialDate) : new Date(),
|
||||
initialTab === "flight" && initialDate ? yyyymmddToDate(initialDate) : null,
|
||||
);
|
||||
const [flightNumberError, setFlightNumberError] = useState<string | null>(null);
|
||||
|
||||
@@ -97,7 +100,7 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
const [routeDepartureCode, setRouteDepartureCode] = useState<string>(initialDeparture ?? "");
|
||||
const [routeArrivalCode, setRouteArrivalCode] = useState<string>(initialArrival ?? "");
|
||||
const [routeDate, setRouteDate] = useState<Date | null>(
|
||||
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<OnlineBoardFilterProps> = ({
|
||||
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<OnlineBoardFilterProps> = ({
|
||||
(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<OnlineBoardFilterProps> = ({
|
||||
<div className="filter-content">
|
||||
<CityAutocomplete
|
||||
label={t("SHARED.DEPARTURE_CITY")}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
placeholder={
|
||||
routeArrivalCode && !routeDepartureCode
|
||||
? t("SHARED.ALL_DIRECTIONS")
|
||||
: t("SHARED.CITY_PLACEHOLDER")
|
||||
}
|
||||
value={routeDepartureCode}
|
||||
onChange={setRouteDepartureCode}
|
||||
dictionaries={dictionaries}
|
||||
@@ -358,7 +365,11 @@ export const OnlineBoardFilter: FC<OnlineBoardFilterProps> = ({
|
||||
|
||||
<CityAutocomplete
|
||||
label={t("SHARED.ARRIVAL_CITY")}
|
||||
placeholder={t("SHARED.CITY_PLACEHOLDER")}
|
||||
placeholder={
|
||||
routeDepartureCode && !routeArrivalCode
|
||||
? t("SHARED.ALL_DIRECTIONS")
|
||||
: t("SHARED.CITY_PLACEHOLDER")
|
||||
}
|
||||
value={routeArrivalCode}
|
||||
onChange={setRouteArrivalCode}
|
||||
dictionaries={dictionaries}
|
||||
|
||||
@@ -458,6 +458,7 @@ export const OnlineBoardSearchPage: FC<OnlineBoardSearchPageProps> = ({
|
||||
loading={loading}
|
||||
onFlightClick={handleFlightClick}
|
||||
initialCurrentFlightId={initialCurrentFlightId}
|
||||
direction={params.type}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -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<FlightCardProps> = ({
|
||||
expandable,
|
||||
initialExpanded,
|
||||
onViewDetails,
|
||||
direction = "route",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { language } = useLocale();
|
||||
@@ -119,7 +125,13 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
? "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<string, string> = {
|
||||
Finished: "BOARDING-STATUSES.Finished",
|
||||
Expected: "BOARDING-STATUSES.Expected",
|
||||
@@ -254,10 +266,10 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{boarding && (
|
||||
{transition && (
|
||||
<div className="flight-card__detail-row">
|
||||
<div className="flight-card__detail-label">
|
||||
{t("DETAILS.BOARDING")}
|
||||
{t(transitionLabelKey)}
|
||||
</div>
|
||||
<div className="flight-card__detail-group">
|
||||
<div>
|
||||
@@ -265,29 +277,29 @@ export const FlightCard: FC<FlightCardProps> = ({
|
||||
{t("DETAILS.STATUS")}
|
||||
</span>
|
||||
<span
|
||||
className={`flight-card__detail-value flight-card__detail-status flight-card__detail-status--${boarding.status.toLowerCase()}`}
|
||||
className={`flight-card__detail-value flight-card__detail-status flight-card__detail-status--${transition.status.toLowerCase()}`}
|
||||
>
|
||||
<span className="flight-card__status-dot" aria-hidden="true" />
|
||||
{t(BOARDING_STATUS_KEY[boarding.status] ?? boarding.status)}
|
||||
{t(BOARDING_STATUS_KEY[transition.status] ?? transition.status)}
|
||||
</span>
|
||||
</div>
|
||||
{boarding.start?.local && (
|
||||
{transition.start?.local && (
|
||||
<div>
|
||||
<span className="flight-card__detail-caption">
|
||||
{t("SHARED.BOARDING-START")}
|
||||
</span>
|
||||
<span className="flight-card__detail-value">
|
||||
{formatLocalTime(boarding.start.local)}
|
||||
{formatLocalTime(transition.start.local)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{boarding.end?.local && (
|
||||
{transition.end?.local && (
|
||||
<div>
|
||||
<span className="flight-card__detail-caption">
|
||||
{t("SHARED.BOARDING-END")}
|
||||
</span>
|
||||
<span className="flight-card__detail-value">
|
||||
{formatLocalTime(boarding.end.local)}
|
||||
{formatLocalTime(transition.end.local)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<FlightListProps> = ({
|
||||
skeletonCount = 5,
|
||||
onFlightClick,
|
||||
initialCurrentFlightId,
|
||||
direction = "route",
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const cardRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
@@ -74,6 +81,7 @@ export const FlightList: FC<FlightListProps> = ({
|
||||
>
|
||||
<FlightCard
|
||||
flight={flight}
|
||||
direction={direction}
|
||||
expandable={Boolean(onFlightClick)}
|
||||
initialExpanded={flight.id === initialCurrentFlightId}
|
||||
{...(onFlightClick
|
||||
|
||||
@@ -63,7 +63,11 @@ export const OperatorLogo: FC<OperatorLogoProps> = ({ 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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user