diff --git a/scripts/visual-diff.mjs b/scripts/visual-diff.mjs index 7136dd13..f14345cb 100644 --- a/scripts/visual-diff.mjs +++ b/scripts/visual-diff.mjs @@ -52,15 +52,73 @@ function maskRect(png, x, y, w, h) { /** Apply the standard chrome masks to both PNGs in-place. */ function applyChromeMasks(png) { - const isMobile = png.width <= 500; // Top-left debug counter / orange "Тестовая версия" badge. - maskRect(png, 0, 0, isMobile ? 200 : 200, 90); + maskRect(png, 0, 0, 200, 90); // Top-right `rc/...` build tag. maskRect(png, png.width - 240, 0, 240, 50); // Bottom-right chat widget. maskRect(png, png.width - 90, png.height - 90, 90, 90); } +/** + * Shift the contents of a PNG vertically by `dy` pixels in-place. + * Negative `dy` moves content up (rows above are dropped, the gap at + * the bottom is filled white). Used to compensate for the Angular + * test-env chrome that pushes the main layout down by a fixed + * amount, so that content rows align with React's chrome-less render + * before pixel-matching. + */ +function shiftUp(png, dy) { + if (dy <= 0) return; + const w = png.width; + const h = png.height; + const stride = w * 4; + // Move row y+dy → row y. + for (let y = 0; y < h - dy; y++) { + png.data.copyWithin(y * stride, (y + dy) * stride, (y + dy + 1) * stride); + } + // Fill last `dy` rows with white. + for (let y = h - dy; y < h; y++) { + for (let x = 0; x < w; x++) { + const idx = (y * w + x) * 4; + png.data[idx] = 255; + png.data[idx + 1] = 255; + png.data[idx + 2] = 255; + png.data[idx + 3] = 255; + } + } +} + +/** + * Find the y-coordinate of the orange `Тестовая версия` badge in an + * Angular screenshot, if present. The badge is solid #ff9000 / similar + * orange — distinctive enough to detect by row-scanning. Returns -1 + * when no badge band is found. + */ +function findOrangeBadgeBottom(png) { + const w = png.width; + const h = Math.min(png.height, 200); + let bandStart = -1; + let bandEnd = -1; + for (let y = 0; y < h; y++) { + let orangeCount = 0; + // Sample first 200px of the row for orange pixels. + for (let x = 0; x < Math.min(200, w); x++) { + const idx = (y * w + x) * 4; + const r = png.data[idx]; + const g = png.data[idx + 1]; + const b = png.data[idx + 2]; + // ~ #ff9000 / #f37b09 family. + if (r > 220 && g > 100 && g < 180 && b < 60) orangeCount++; + } + if (orangeCount > 30) { + if (bandStart === -1) bandStart = y; + bandEnd = y; + } + } + return bandEnd === -1 ? -1 : bandEnd; +} + const files = readdirSync(SRC_DIR); const reactFiles = files.filter((f) => f.startsWith("react-") && f.endsWith(".png")); @@ -104,9 +162,9 @@ for (const reactFile of reactFiles) { const aP = pad(aPng); const rP = pad(rPng); - // Strip Angular-only chrome (orange test-env badge, build tag, chat - // widget) from both sides — those regions are environmental noise - // that shouldn't drag the parity score down. + // Strip Angular-only chrome (test-env badge, build tag, chat widget) + // from both sides — those regions are environmental noise that + // shouldn't drag the parity score down. applyChromeMasks(aP); applyChromeMasks(rP); diff --git a/src/features/schedule/components/ScheduleStartPage.tsx b/src/features/schedule/components/ScheduleStartPage.tsx index 769ec4e1..fbc3e119 100644 --- a/src/features/schedule/components/ScheduleStartPage.tsx +++ b/src/features/schedule/components/ScheduleStartPage.tsx @@ -81,13 +81,17 @@ export const ScheduleStartPage: FC = () => { const [departureAirport, setDepartureAirport] = useState(prefill.departure ?? ""); const [arrivalAirport, setArrivalAirport] = useState(prefill.arrival ?? ""); - const [dateFrom, setDateFrom] = useState(today); - const [dateTo, setDateTo] = useState(addDays(today, 7)); + // Start blank to match Angular's `ДД.ММ.ГГГГ - ДД.ММ.ГГГГ` placeholder + // (the "current week" pre-fill was a React-only convenience that + // pulled the date input out of parity). Submit handler defaults to + // current-week range when left untouched. + const [dateFrom, setDateFrom] = useState(null); + const [dateTo, setDateTo] = useState(null); const [timeRange, setTimeRange] = useState<[number, number]>([0, 1440]); const [directOnly, setDirectOnly] = useState(false); const [isRoundTrip, setIsRoundTrip] = useState(prefill.withReturn === true); - const [returnDateFrom, setReturnDateFrom] = useState(addDays(today, 7)); - const [returnDateTo, setReturnDateTo] = useState(addDays(today, 14)); + const [returnDateFrom, setReturnDateFrom] = useState(null); + const [returnDateTo, setReturnDateTo] = useState(null); const [returnTimeRange, setReturnTimeRange] = useState<[number, number]>([0, 1440]); // City autocomplete search @@ -114,9 +118,14 @@ export const ScheduleStartPage: FC = () => { : arrivalAirport.code); if (!dep || !arr) return; - if (!dateFrom || !dateTo) return; - const dateFromParam = dateToYyyymmdd(dateFrom); - const dateToParam = dateToYyyymmdd(dateTo); + // Empty dates default to the current week (today → today + 7) so + // the search proceeds even when the user leaves the placeholder + // untouched. Mirrors Angular's "by default it's the current week" + // hint copy on the start page. + const effectiveDateFrom = dateFrom ?? today; + const effectiveDateTo = dateTo ?? addDays(today, 7); + const dateFromParam = dateToYyyymmdd(effectiveDateFrom); + const dateToParam = dateToYyyymmdd(effectiveDateTo); let url: string; @@ -127,9 +136,10 @@ export const ScheduleStartPage: FC = () => { if (timeRange[1] < 1440) outbound.timeTo = minutesToTime(timeRange[1]).replace(":", ""); if (isRoundTrip) { - if (!returnDateFrom || !returnDateTo) return; - const retDateFromParam = dateToYyyymmdd(returnDateFrom); - const retDateToParam = dateToYyyymmdd(returnDateTo); + const effectiveReturnFrom = returnDateFrom ?? addDays(today, 7); + const effectiveReturnTo = returnDateTo ?? addDays(today, 14); + const retDateFromParam = dateToYyyymmdd(effectiveReturnFrom); + const retDateToParam = dateToYyyymmdd(effectiveReturnTo); const inbound: { departure: string; arrival: string; dateFrom: string; dateTo: string; timeFrom?: string; timeTo?: string } = { departure: arr, arrival: dep, dateFrom: retDateFromParam, dateTo: retDateToParam,