diff --git a/.github/workflows/bootstrap-publish.yml b/.github/workflows/bootstrap-publish.yml index d22f3bd..af16f6e 100644 --- a/.github/workflows/bootstrap-publish.yml +++ b/.github/workflows/bootstrap-publish.yml @@ -48,9 +48,11 @@ jobs: registry-url: 'https://registry.npmjs.org' cache: npm - - name: Upgrade npm to >= 11.5.1 (required for provenance attestation) + - name: Upgrade npm to 11.5.1 (required for provenance attestation) + # Pin to an exact version instead of @latest for reproducible builds + # and to satisfy Scorecard Pinned-Dependencies. run: | - npm install -g npm@latest + npm install -g npm@11.5.1 npm --version - name: Install dependencies diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7abd9c6..76fd8cb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -45,9 +45,11 @@ jobs: registry-url: 'https://registry.npmjs.org' cache: npm - - name: Upgrade npm to >= 11.5.1 (required by Trusted Publishing) + - name: Upgrade npm to 11.5.1 (required by Trusted Publishing) + # Pin to an exact version instead of @latest for reproducible builds + # and to satisfy Scorecard Pinned-Dependencies. run: | - npm install -g npm@latest + npm install -g npm@11.5.1 npm --version - name: Verify package.json version matches release tag diff --git a/docs/superpowers/plans/2026-06-24-seat-color-react-parity.md b/docs/superpowers/plans/2026-06-24-seat-color-react-parity.md new file mode 100644 index 0000000..138732f --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-seat-color-react-parity.md @@ -0,0 +1,568 @@ +# Seat colour React parity + class palette — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Angular seat colouring match React — drop the `colorfulSeatsByScore` gate and the `colorfulSeatsByClass` lightness tint — and add a data-driven `customSeatColorClasses` palette that the demo apps use to build the on/off toggles. + +**Architecture:** Score and class colours are resolved in the preparer into each seat's `color`/`originalColor` (`score ?? class ?? apiColor`). The seat component keeps the `seatAvailableColor`/`forceThemeSeatColors` force override and the availability handler keeps overriding with availability colours. No behavioural flags remain in the library; the demo composes config (`customSeatColorRanges` / `customSeatColorClasses` / `seatAvailableColor`) to express the toggles. + +**Tech Stack:** Angular 21 standalone components, Vitest unit tests (`ng test seatmap-lib`), Playwright e2e (`projects/seatmap-demo/e2e`). + +## Global Constraints + +- Branch: `dev` of `jets-seatmap-angular-lib`. Commit (and push) only to `dev`; admin merges to `main`. +- No Russian in code/comments/JSON or fenced code blocks — English only. +- Full priority order (verbatim from spec): **seatAvailableColor(force) > availability colour > score (`customSeatColorRanges`) > class (`customSeatColorClasses`) > API `seat.color` > theme default**. When both ranges and classes are defined, score wins. +- Unit suite command: `npx ng test seatmap-lib --watch=false` (runs the whole vitest suite, ~3s; there is no reliable single-test filter through the ng wrapper — run the whole suite). +- e2e command (dev demo already served on :4201): `PW_BASE_URL=http://localhost:4201 npx playwright test --config projects/seatmap-demo/e2e/playwright.config.ts --workers=1`. If :4201 is not running, start it first: `npx ng serve seatmap-demo --port 4201` from the repo root. +- `TCabinClass = 'E' | 'P' | 'B' | 'F' | 'A'` (`types.ts:2`). +- The husky pre-commit hook warns `prettier: command not found` — harmless, the commit still lands. + +--- + +### Task 1: Add `customSeatColorClasses` type, class-colour resolver, and merge validation (additive) + +Purely additive — the library still compiles and all tests stay green. + +**Files:** +- Modify: `projects/seatmap-lib/src/lib/types.ts` (IColorTheme, ~line 131-139) +- Modify: `projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts` (add `_calculateSeatColorByClass` near `_calculateSeatColorByScore:1244`; extend `mergeColorThemeWithConstraints:1264`) +- Test: `projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts` (new describe block near the `_calculateSeatColorByScore` describe at line 456) + +**Interfaces:** +- Produces: `IColorTheme.customSeatColorClasses?: Partial>` +- Produces: `static _calculateSeatColorByClass(classCode: string | undefined, classMap?: Partial>): string | null` +- Produces: `mergeColorThemeWithConstraints` now drops `customSeatColorClasses` entries whose value is not a non-empty string. + +- [ ] **Step 1: Write the failing tests** + +Add to `jets-seat-map-preparer.service.spec.ts` (after the existing `describe('_calculateSeatColorByScore', ...)` block, ~line 503): + +```typescript +describe('_calculateSeatColorByClass', () => { + it('returns the mapped colour for the seat class (case-insensitive)', () => { + const map = { F: '#ff0000', E: '#0000ff' }; + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('F', map)).toBe('#ff0000'); + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('e', map)).toBe('#0000ff'); + }); + + it('returns null when the class has no mapping', () => { + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('B', { F: '#ff0000' })).toBeNull(); + }); + + it('returns null for missing class code or map', () => { + expect(JetsSeatMapPreparerService._calculateSeatColorByClass(undefined, { F: '#ff0000' })).toBeNull(); + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('F', undefined)).toBeNull(); + }); + + it('returns null for an empty colour string', () => { + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('F', { F: '' })).toBeNull(); + }); +}); +``` + +Add inside the existing `describe('mergeColorThemeWithConstraints', ...)` block (after line 531): + +```typescript + it('should filter invalid customSeatColorClasses entries', () => { + const result = JetsSeatMapPreparerService.mergeColorThemeWithConstraints({ + customSeatColorClasses: { F: '#FF0000', E: '', B: 123 as unknown as string }, + }); + expect(result.customSeatColorClasses).toEqual({ F: '#FF0000' }); + }); +``` + +- [ ] **Step 2: Run the suite to verify the new tests fail** + +Run: `npx ng test seatmap-lib --watch=false` +Expected: FAIL — `_calculateSeatColorByClass is not a function` and the merge test sees `customSeatColorClasses` unchanged. + +- [ ] **Step 3: Add the type** + +In `types.ts`, inside `IColorTheme`, right after the `customSeatColorRanges` field (line 131), add: + +```typescript + /** + * Per-class flat colour palette. When a colour is set for a seat's cabin + * class, available seats of that class render in it (below score ranges, + * above the API seat colour). Data-driven counterpart to customSeatColorRanges. + */ + customSeatColorClasses?: Partial>; +``` + +- [ ] **Step 4: Add the resolver** + +In `jets-seat-map-preparer.service.ts`, immediately after `_calculateSeatColorByScore` (closing brace at line 1261), add: + +```typescript + static _calculateSeatColorByClass( + classCode: string | undefined, + classMap?: Partial> + ): string | null { + if (!classCode || !classMap) { + return null; + } + const color = classMap[classCode.toUpperCase() as TCabinClass]; + return typeof color === 'string' && color.length > 0 ? color : null; + } +``` + +Ensure `TCabinClass` is imported in this file. Check the existing import block; if absent, add `TCabinClass` to the `from '../types'` import. + +- [ ] **Step 5: Add merge validation** + +In `mergeColorThemeWithConstraints`, after the `customSeatColorRanges` validation block (line 1284, before `return merged;`), add: + +```typescript + // Validate customSeatColorClasses — keep only non-empty string colours + if (merged.customSeatColorClasses) { + const cleaned: Partial> = {}; + for (const key of Object.keys(merged.customSeatColorClasses) as TCabinClass[]) { + const value = merged.customSeatColorClasses[key]; + if (typeof value === 'string' && value.length > 0) { + cleaned[key] = value; + } + } + merged.customSeatColorClasses = cleaned; + } +``` + +- [ ] **Step 6: Run the suite to verify it passes** + +Run: `npx ng test seatmap-lib --watch=false` +Expected: PASS (all tests, including the new ones). + +- [ ] **Step 7: Commit** + +```bash +git add projects/seatmap-lib/src/lib/types.ts projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts +git commit -m "feat(seatmap-lib): add customSeatColorClasses palette resolver and validation" +``` + +--- + +### Task 2: Remove the score gate and wire the class palette into prepared seat colour + +**Files:** +- Modify: `projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts` (`_calculateSeatColorByScore:1244`, `_prepareRowNew:224` signature + caller `:185`, new-format seat colour `:315-324`, legacy seat colour `:830-839`) +- Modify: `projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts` (replace the gate test at lines 378-404) + +**Interfaces:** +- Consumes: `_calculateSeatColorByClass` (Task 1). +- Produces: prepared seat `color`/`originalColor` = `scoreRangeColour ?? classColour ?? apiColour ?? undefined`. `_calculateSeatColorByScore` no longer takes an `enabled` parameter. `_prepareRowNew` no longer takes a `colorfulSeatsByScore` parameter. + +- [ ] **Step 1: Replace the gate unit test with precedence tests** + +In the preparer spec, the `ranges` array is `[1,3]→#FF0000`, `[4,7]→#FFFF00`, `[8,10]→#00FF00` and `configWithRanges` uses it (lines 318-326). Delete the test at lines 378-404 (`'new format: API seat.color wins when colorfulSeatsByScore is false'`) and replace it with: + +```typescript + it('new format: class colour fills in when score has no matching range', () => { + const response: IApiSeatmapResponse = { + decks: [ + { + rows: [ + { + classCode: 'F', + seats: [ + { letter: 'A', seatNumber: '1A', type: 0, seatType: 0, score: 11, color: '#6CB64A' } as any, + ], + }, + ], + }, + ], + }; + const seat = service.prepareContent(response, { + ...baseConfig, + colorTheme: { customSeatColorRanges: ranges, customSeatColorClasses: { F: '#123456' } }, + })[0].rows[0].seats[0]; + // score 11 is out of every range -> class palette (#123456) wins over API #6CB64A + expect(seat.color).toBe('#123456'); + }); + + it('new format: score range wins over class palette when both match', () => { + const response: IApiSeatmapResponse = { + decks: [ + { + rows: [ + { + classCode: 'F', + seats: [ + { letter: 'A', seatNumber: '1A', type: 0, seatType: 0, score: 2, color: '#6CB64A' } as any, + ], + }, + ], + }, + ], + }; + const seat = service.prepareContent(response, { + ...baseConfig, + colorTheme: { customSeatColorRanges: ranges, customSeatColorClasses: { F: '#123456' } }, + })[0].rows[0].seats[0]; + // score 2 in [1,3] -> #FF0000 wins over class palette + expect(seat.color).toBe('#FF0000'); + }); +``` + +- [ ] **Step 2: Run the suite to verify the new tests fail** + +Run: `npx ng test seatmap-lib --watch=false` +Expected: FAIL — class palette is not yet wired, so `seat.color` falls back to API `#6CB64A` in the first new test. + +- [ ] **Step 3: Drop the `enabled` gate from `_calculateSeatColorByScore`** + +Replace the signature/body (lines 1244-1261) with: + +```typescript + static _calculateSeatColorByScore( + score: number | undefined, + colorRanges?: Array<{ range: [number, number]; color: string }> + ): string | null { + if ( + typeof score !== 'number' || + score < 1 || + score > 10 || + !Array.isArray(colorRanges) || + !colorRanges.length + ) { + return null; + } + const found = colorRanges.find(r => score >= r.range[0] && score <= r.range[1]); + return found?.color ?? null; + } +``` + +- [ ] **Step 4: Remove the gate param from `_prepareRowNew` and its caller** + +At line 234 remove the parameter `colorfulSeatsByScore = true` (delete the line and the trailing comma on the previous `units?: string,` line stays as the last param). At the caller (lines 175-186) delete the argument `config.colorfulSeatsByScore ?? true` (and the comma on the previous line `config.units,`). + +- [ ] **Step 5: Wire score + class into the new-format seat colour** + +In `_prepareRowNew`, move the `classCode` computation above `seatColor` and rewrite the colour (lines 315-324) to: + +```typescript + const classCode = (row.classCode ?? row.cabinClass ?? s.classType ?? 'E').toUpperCase(); + const seatColor = + JetsSeatMapPreparerService._calculateSeatColorByScore(s.score, colorTheme?.customSeatColorRanges) ?? + JetsSeatMapPreparerService._calculateSeatColorByClass(classCode, colorTheme?.customSeatColorClasses) ?? + s.color ?? + undefined; +``` + +(Delete the now-duplicate `const classCode = ...` that previously sat at line 324.) + +- [ ] **Step 6: Wire score + class into the legacy-format seat colour** + +In the legacy path, move `classCode` (line 839) above `seatColor` and rewrite (lines 830-839) to: + +```typescript + const classCode = (row.classCode ?? row.cabinClass ?? newSeat?.classType ?? 'E').toUpperCase(); + const seatColor = + JetsSeatMapPreparerService._calculateSeatColorByScore(seatScore, config.colorTheme?.customSeatColorRanges) ?? + JetsSeatMapPreparerService._calculateSeatColorByClass(classCode, config.colorTheme?.customSeatColorClasses) ?? + seatApiColor ?? + undefined; +``` + +(Delete the now-duplicate `const classCode = ...` at the old line 839.) + +- [ ] **Step 7: Run the suite to verify it passes** + +Run: `npx ng test seatmap-lib --watch=false` +Expected: PASS. The pre-existing tests `'range colour wins over API seat.color when score matches'` and `'API seat.color wins when score is out of every range'` still pass (those configs define no classes). + +- [ ] **Step 8: Commit** + +```bash +git add projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts +git commit -m "feat(seatmap-lib): drop score gate, resolve score>class>API seat colour (React parity)" +``` + +--- + +### Task 3: Remove the class lightness tint from the seat component and threading + +**Files:** +- Modify: `projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.ts` (import :21, `@Input` :119-121, tint branch :441-442) +- Modify: `projects/seatmap-lib/src/lib/components/jets-row/jets-row.component.ts` (:24, :35, :70) +- Modify: `projects/seatmap-lib/src/lib/components/jets-deck/jets-deck.component.ts` (:100, :199) +- Modify: `projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.html` (:118) +- Modify: `projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.ts` (`resolvedConfig` :187-188) +- Delete: `projects/seatmap-lib/src/lib/utils/color-tint.ts` +- Delete: `projects/seatmap-lib/src/lib/utils/color-tint.spec.ts` +- Modify: `projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.spec.ts` (delete the `describe('colorfulSeatsByClass', ...)` block, ~lines 494-543) + +**Interfaces:** +- Consumes: prepared `data.color` now already carries score/class/API colour (Task 2), so the available branch needs no class logic. + +- [ ] **Step 1: Remove the tint from `_resolveStyle`** + +In `jets-seat.component.ts`, the available branch (lines 436-445) becomes: + +```typescript + case 'available': { + const base = + force || availOverride + ? (theme.seatAvailableColor ?? def.seatAvailableColor) + : (this.data.color ?? def.seatAvailableColor); + fillColor = base; + break; + } +``` + +Delete the import at line 21 (`import { tintSeatColorForClass } from '../../utils/color-tint';`) and the `@Input() colorfulSeatsByClass = false;` plus its doc comment (lines 119-121). + +- [ ] **Step 2: Remove the threading** + +- `jets-row.component.ts`: delete `colorfulSeatsByClass: colorfulSeatsByClass,` (line 24), the `[colorfulSeatsByClass]="colorfulSeatsByClass"` binding (line 35), and the `@Input() colorfulSeatsByClass = false;` (line 70). +- `jets-deck.component.ts`: delete the `[colorfulSeatsByClass]="colorfulSeatsByClass"` binding (line 100) and the `@Input() colorfulSeatsByClass = false;` (line 199). +- `jets-seat-map.component.html`: delete the `[colorfulSeatsByClass]="resolvedConfig.colorfulSeatsByClass ?? false"` binding (line 118). +- `jets-seat-map.component.ts`: in the `resolvedConfig` object, delete `colorfulSeatsByClass: this.config?.colorfulSeatsByClass ?? false,` and `colorfulSeatsByScore: this.config?.colorfulSeatsByScore ?? true,` (lines 187-188). + +- [ ] **Step 3: Delete the tint utility and its spec** + +```bash +git rm projects/seatmap-lib/src/lib/utils/color-tint.ts projects/seatmap-lib/src/lib/utils/color-tint.spec.ts +``` + +- [ ] **Step 4: Remove the obsolete seat-component tint tests** + +In `jets-seat.component.spec.ts`, delete the entire `describe('colorfulSeatsByClass', () => { ... })` block (the comment banner at line 494 through the block's closing `});` around line 543). Verify no other test in this file references `colorfulSeatsByClass`. + +- [ ] **Step 5: Run the suite to verify it passes** + +Run: `npx ng test seatmap-lib --watch=false` +Expected: PASS. (If TypeScript complains about a leftover `colorfulSeatsByClass` reference, grep the lib `src` for it and remove the straggler.) + +- [ ] **Step 6: Commit** + +```bash +git add -A projects/seatmap-lib/src/lib +git commit -m "refactor(seatmap-lib): remove colorfulSeatsByClass tint and its threading" +``` + +--- + +### Task 4: Remove the dead IConfig/IColorTheme fields and clean up demo + e2e config references + +By now nothing in the library reads `colorfulSeatsByScore` / `colorfulSeatsByClass` / `seatClassTints`, so the fields can go. This also touches the demo and e2e configs that still set them. + +**Files:** +- Modify: `projects/seatmap-lib/src/lib/types.ts` (IConfig `colorfulSeatsByClass` :186-191, `colorfulSeatsByScore` :192-199; IColorTheme `seatClassTints` :132-137) +- Modify: `projects/seatmap-demo/src/app/flights.data.ts` (:49-50) +- Modify: `projects/seatmap-demo/e2e/exitIcons/exitIcons.spec.ts` (:37-38) +- Modify: `projects/seatmap-demo/e2e/customSeatColorRanges/customSeatColorRanges.spec.ts` (gate test :100-124) + +- [ ] **Step 1: Remove the type fields** + +In `types.ts`: +- Delete the `colorfulSeatsByClass?: boolean;` field and its doc comment (lines 186-191). +- Delete the `colorfulSeatsByScore?: boolean;` field and its doc comment (lines 192-199). +- Delete the `seatClassTints?: Partial>;` field and its doc comment (lines 132-137). + +- [ ] **Step 2: Remove the demo config flags** + +In `flights.data.ts`, delete lines 49-50 from `BASE_CONFIG`: + +```typescript + colorfulSeatsByClass: false, + colorfulSeatsByScore: true, +``` + +- [ ] **Step 3: Remove the exitIcons e2e config flags** + +In `exitIcons/exitIcons.spec.ts`, delete the two config lines (37-38): + +```typescript + colorfulSeatsByClass: false, + colorfulSeatsByScore: true, +``` + +- [ ] **Step 4: Rewrite the gate e2e test** + +The test `'disabled — colorfulSeatsByScore:false ignores ranges (QT888)'` (lines 100-124) asserted the removed gate. Replace it with a test that score ranges now always apply when defined. Read the surrounding spec (its sentinel-range helper and `applyConfigAndReady` usage at the top of the file) and rewrite the single `test(...)` block so it: +- applies a config with `customSeatColorRanges` mapping the whole `[1,10]` score band to one sentinel colour and NO `colorfulSeatsByScore` key; +- asserts at least one rendered seat body uses the sentinel colour (ranges are honoured purely from their presence). + +Keep the same helper imports already used at the top of the file. Concretely: + +```typescript + test('ranges always apply when present (no gate)', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady( + page, + { colorTheme: { customSeatColorRanges: [{ range: [1, 10], color: '#abcdef' }] } }, + { availability: [] } + ); + const hits = await page.evaluate(() => { + const seats = Array.from(document.querySelectorAll('.jets-seat--available')) as HTMLElement[]; + let n = 0; + for (const seat of seats) { + for (const p of Array.from(seat.querySelectorAll('svg path'))) { + if ((p.getAttribute('fill') || '').toLowerCase() === '#abcdef') { n++; break; } + } + } + return n; + }); + expect(hits, 'sentinel range colour must appear on available seats').toBeGreaterThan(0); + }); +``` + +(If the file's existing imports do not include `applyConfigAndReady`, add it from `../helpers/demo`.) + +- [ ] **Step 5: Verify unit + e2e** + +Run: `npx ng test seatmap-lib --watch=false` → Expected: PASS. +Then (dev demo on :4201): `PW_BASE_URL=http://localhost:4201 npx playwright test --config projects/seatmap-demo/e2e/playwright.config.ts customSeatColorRanges exitIcons --workers=1` → Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -A projects/seatmap-lib/src/lib/types.ts projects/seatmap-demo +git commit -m "refactor: remove colorfulSeatsBy* config fields and seatClassTints; update demo + e2e" +``` + +--- + +### Task 5: Demo colour-priority e2e (4 combinations + availability) + +Verify the demo can express all four toggle states purely through config, and that availability colours still win. + +**Files:** +- Create: `projects/seatmap-demo/e2e/seatColors/seatColors.spec.ts` + +**Interfaces:** +- Consumes: `applyConfigAndReady` from `../helpers/demo` (signature `(page, configOverrides, { availability? })`). + +- [ ] **Step 1: Write the e2e** + +```typescript +import { test, expect, type Page } from '@playwright/test'; +import { applyConfigAndReady } from '../helpers/demo'; + +const RANGES = [{ range: [1, 10] as [number, number], color: '#abcdef' }]; +const CLASSES = { F: '#111111', B: '#222222', P: '#333333', E: '#444444', A: '#555555' }; +const FLAT = '#0a0a0a'; + +async function bodyColours(page: Page): Promise> { + return new Set( + await page.evaluate(() => { + const grey = new Set(['rgb(169, 169, 169)', 'rgb(235, 235, 235)', 'rgb(255, 255, 255)', 'white', 'none']); + const out: string[] = []; + for (const seat of Array.from(document.querySelectorAll('.jets-seat--available')) as HTMLElement[]) { + for (const p of Array.from(seat.querySelectorAll('svg path'))) { + const f = (p.getAttribute('fill') || '').toLowerCase().trim(); + if (f && !grey.has(f)) { out.push(f); break; } + } + } + return out; + }) + ); +} + +test.describe('seat colour modes (config-driven)', () => { + test('both off: seatAvailableColor flattens every available seat', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { colorTheme: { seatAvailableColor: FLAT } }, { availability: [] }); + const colours = await bodyColours(page); + expect(colours.size).toBe(1); + expect([...colours][0]).toBe(FLAT); + }); + + test('score on: ranges colour the seats', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { colorTheme: { customSeatColorRanges: RANGES } }, { availability: [] }); + expect(await bodyColours(page)).toContain('#abcdef'); + }); + + test('class on: class palette overrides the API colour', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { colorTheme: { customSeatColorClasses: CLASSES } }, { availability: [] }); + const colours = await bodyColours(page); + expect([...colours].every(c => Object.values(CLASSES).map(v => v.toLowerCase()).includes(c))).toBe(true); + }); + + test('both on: score wins over class', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady( + page, + { colorTheme: { customSeatColorRanges: RANGES, customSeatColorClasses: CLASSES } }, + { availability: [] } + ); + const colours = await bodyColours(page); + expect(colours.has('#abcdef')).toBe(true); + }); + + test('availability colour overrides score', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady( + page, + { colorTheme: { customSeatColorRanges: RANGES } }, + { availability: [{ label: '*', price: 10, currency: 'USD', color: '#fe00fe' }] } + ); + expect(await bodyColours(page)).toContain('#fe00fe'); + }); +}); +``` + +- [ ] **Step 2: Run the e2e** + +Run: `PW_BASE_URL=http://localhost:4201 npx playwright test --config projects/seatmap-demo/e2e/playwright.config.ts seatColors --workers=1` +Expected: PASS (5 tests). If the dev server is stale, rebuild/restart per the Global Constraints note. + +- [ ] **Step 3: Commit** + +```bash +git add projects/seatmap-demo/e2e/seatColors/seatColors.spec.ts +git commit -m "test(seatmap-demo): e2e for config-driven seat colour priority + availability" +``` + +--- + +### Task 6: README documentation + +**Files:** +- Modify: `projects/seatmap-lib/README.md` (the score-colouring section, ~lines 530-541) + +- [ ] **Step 1: Document the new option and priority** + +After the existing "Priority order: Score-based color > Original seat color > Default color." line (531-541), add a subsection documenting: +- `customSeatColorRanges` — score → colour (already partly covered), now always applied when present (no flag). +- `customSeatColorClasses` — `Partial>`, per-class flat colour. +- The full priority: `seatAvailableColor (force) > availability colour > customSeatColorRanges (score) > customSeatColorClasses (class) > API seat.color > theme default`. +- A note that the removed `colorfulSeatsByScore` / `colorfulSeatsByClass` flags no longer exist; consumers express on/off by including/omitting the palettes (and `seatAvailableColor` for a flat look). + +```markdown +### Seat colour priority + +Available seats resolve their colour top-down: + +1. `colorTheme.seatAvailableColor` / `forceThemeSeatColors` — forces every available seat to one colour. +2. `availability[].color` (individual entry > `*` wildcard) when the `availability` input is supplied. +3. `colorTheme.customSeatColorRanges` — score → colour; applied whenever ranges are defined and the seat `score` ∈ [1,10] matches. +4. `colorTheme.customSeatColorClasses` — `Partial>`; per-class flat colour. +5. The seat's API `color`. +6. Theme default available colour. + +There is no `colorfulSeatsByScore` / `colorfulSeatsByClass` flag — enable each axis by providing the +corresponding palette; for a flat look set `seatAvailableColor`. +``` + +- [ ] **Step 2: Commit** + +```bash +git add projects/seatmap-lib/README.md +git commit -m "docs(seatmap-lib): document seat colour priority and customSeatColorClasses" +``` + +--- + +### Final: push and verify + +- [ ] Run the full unit suite once more: `npx ng test seatmap-lib --watch=false` → all green. +- [ ] Run the touched e2e: `PW_BASE_URL=http://localhost:4201 npx playwright test --config projects/seatmap-demo/e2e/playwright.config.ts seatColors customSeatColorRanges exitIcons --workers=1` → all green. +- [ ] `git push origin dev`. + +## Out of scope + +The separate promo demo repo (`jets-seatmap-angular-lib-demo`) is rewired to the new options in a +follow-up after a new lib version is published — on a `dev` branch off its `main`, never on `main`. diff --git a/docs/superpowers/specs/2026-06-24-seat-color-react-parity-design.md b/docs/superpowers/specs/2026-06-24-seat-color-react-parity-design.md new file mode 100644 index 0000000..ff3281c --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-seat-color-react-parity-design.md @@ -0,0 +1,162 @@ +# Seat colouring — React parity + data-driven class palette + +Date: 2026-06-24 +Status: Approved (design) +Scope target: `jets-seatmap-angular-lib` (library) + in-repo demo (`projects/seatmap-demo`). Branch: `dev`. + +## Problem + +The Angular library diverges from the upstream React library in how seats are coloured: + +1. **`colorfulSeatsByScore`** — an Angular-only boolean gate wrapped around React's always-on + score colouring. In React, score colouring is controlled *only* by the presence of + `colorTheme.customSeatColorRanges`. The extra gate means `colorfulSeatsByScore: false` + suppresses a defined `customSeatColorRanges` — behaviour React and the README do not have. +2. **`colorfulSeatsByClass`** — an Angular-only feature with no React counterpart. It applies a + subtle per-class HSL *lightness tint* (`utils/color-tint.ts`, `theme.seatClassTints`) on top of + the base colour. It reads as "not working" because it is a small shade shift, not a distinct + per-class palette. + +Neither flag is documented in the library README. The goal is to make the library resemble React, +and move presentation policy (the on/off toggles) into the demo apps. + +## Goal + +- Remove the `colorfulSeatsByScore` gate → score colouring is driven solely by + `customSeatColorRanges` (React parity). +- Remove the `colorfulSeatsByClass` lightness tint (and its `color-tint.ts` / `seatClassTints` + infrastructure). +- Add a new **data-driven** `customSeatColorClasses` palette (class code → colour). When defined, + available seats of that class render in that colour. This is the *one* deliberate, documented + divergence from React — but it is a palette map in the same style as `customSeatColorRanges`, + not a behavioural flag. +- The demo apps implement the `colorfulSeatsByScore` / `colorfulSeatsByClass` toggle UX on top of + these data-driven options. + +## Colour resolution scheme (library) + +Resolution depends first on seat **status**: + +| Status | Fill colour | +|---------------|-------------| +| `unavailable` | `notAvailableSeatsColor` (grey). Score/class/API are NOT applied. | +| `selected` | `seatSelectedColor` / seat `originalColor` (passenger seat) | +| `preferred` | `seatPreferredColor` | +| `extra` | `seatExtraColor` | +| `available` | the chain below | + +For an **available** seat, fill colour is the first match, top to bottom: + +| # | Source | When | +|---|--------|------| +| 1 | `colorTheme.seatAvailableColor` / `forceThemeSeatColors` | set → ALL available seats this one colour (the "flat" lever) | +| 2 | availability colour (individual `color` > wildcard `color`) | `availability` Input passed and the matching entry carries a `color` | +| 3 | score palette `customSeatColorRanges` | seat `score` ∈ [1,10] matches a range | +| 4 | class palette `customSeatColorClasses` (NEW) | a colour is defined for the seat's cabin class | +| 5 | API `seat.color` | the backend's original per-seat colour (itself score-derived on the sandbox flights) | +| 6 | theme default `seatAvailableColor` | nothing above matched | + +Precedence summary: **seatAvailableColor(force) > availability colour > score > class > API colour > default.** +(Decision: when both `customSeatColorRanges` and `customSeatColorClasses` are defined, **score wins**; +class only colours seats whose score produced no match — full React-style ordering.) + +### Where each step is computed + +- Steps 3–5 are computed in the **preparer** as the seat's `color` / `originalColor`: + `color = scorePalette(score, ranges) ?? classPalette(classCode, classes) ?? apiColor ?? undefined`. +- Step 2 (availability) is applied in `setAvailabilityHandler`, which already does + `color = source.color ?? seat.originalColor ?? seat.color` — so an availability colour overrides + the prepared score/class/API colour, and a colourless availability entry preserves it. +- Step 1 (force override) stays in the seat component `_resolveStyle` + (`force || availOverride ? seatAvailableColor : data.color`). + +## Availability interaction (must not break) + +`availability` operates on an orthogonal axis — it sets **status** and optionally **colour**: + +- Seats present in the availability map / matching the `*` wildcard → `available`; otherwise → + `unavailable` (and unavailable seats never reach the available chain, so score/class never apply + to them). +- If the availability entry has a `color` → it overrides score/class/API (step 2). If it has none + (e.g. a `{ label: '*', price }` wildcard) → the prepared `originalColor` (score/class/API) + survives. + +Result: availability and score/class coexist. Explicit availability colours win; otherwise +available seats keep their score/class colour; unavailable seats are grey. No conflict. + +## Demo behaviour (4 toggle combinations) + +The demo theme always defines a flat default colour `D`. + +| Toggle state | Demo passes to library | Result (per scheme) | +|---------------------|------------------------|---------------------| +| Both OFF | `seatAvailableColor = D` (no ranges/classes) | step 1 → all available seats flat `D` | +| Score ON, Class OFF | `customSeatColorRanges` (palette), no seatAvailableColor | step 3 → score colours | +| Score OFF, Class ON | `customSeatColorClasses`, no seatAvailableColor | step 4 → class colours (override API) | +| Both ON | `customSeatColorRanges` + `customSeatColorClasses` | step 3 wins; class fills score-less seats | + +Key rule: the demo sets `seatAvailableColor` **only** in the both-OFF state. `seatAvailableColor` +(a separate top-priority axis) is preferred over a single `[1,10]` range, because a single range +sits in the score slot (would mask the class palette and miss score-less seats). + +## Library changes + +- **Types (`types.ts`)**: remove `IConfig.colorfulSeatsByScore`, `IConfig.colorfulSeatsByClass`, + and `IColorTheme.seatClassTints`. Add `IColorTheme.customSeatColorClasses?: Partial>`. +- **Preparer (`jets-seat-map-preparer.service.ts`)**: drop the `enabled` gate param from + `_calculateSeatColorByScore` (always apply when ranges defined). Add a `_calculateSeatColorByClass` + step so the prepared `color`/`originalColor` is `score ?? class ?? apiColor`. Both old-format and + legacy-format seat paths. +- **Seat component (`jets-seat.component.ts`)**: remove the `colorfulSeatsByClass` `@Input` and the + `tintSeatColorForClass` call from `_resolveStyle`. Keep the `seatAvailableColor` / `forceThemeSeatColors` + force override. +- **Threading**: remove the `colorfulSeatsByClass` `@Input` plumbing through `jets-row`, `jets-deck`, + `jets-deck`→seat, and the binding in `jets-seat-map.component.html` + the `resolvedConfig` getter. +- **Remove `utils/color-tint.ts`** (after confirming no other consumer of `tintSeatColorForClass` / + `adjustLightness`). +- **Config merge**: add validation/constraints for `customSeatColorClasses` mirroring + `_applyColorRangesConstraints` (drop entries with invalid colour strings). + +## Demo changes (in-repo `projects/seatmap-demo`) + +- `flights.data.ts`: remove `colorfulSeatsByScore` / `colorfulSeatsByClass` from `BASE_CONFIG` and + per-flight configs. Keep `customSeatColorRanges` on qt888. +- Rewrite the e2e colour-mode tests (`horizontal/horizontalParity.spec.ts` helpers and the + `seat-color-modes` screenshot harness) to drive the 4 combinations via the new config + (`customSeatColorRanges` / `customSeatColorClasses` / `seatAvailableColor`) and assert the + priority order, including an availability-interaction case. + +## Out of scope (this pass) + +- The separate **promo demo repo** (`jets-seatmap-angular-lib-demo`) consumes the published npm + package and cannot compile against the new API until a new version is published. It will be rewired + (toggles → new options) in a follow-up after publish. Its current `colorfulSeatsBy*` usage keeps + working against the old published version until then. + +## Workflow / branches + +- Library + in-repo demo work: branch `dev` of `jets-seatmap-angular-lib` (this repo). +- The separate promo demo repo (`jets-seatmap-angular-lib-demo`) follow-up MUST be done on a `dev` + branch created off `main` — never commit directly to its `main`, so `main` cannot break. + +## Backward compatibility + +Breaking change for any consumer setting `colorfulSeatsByScore` / `colorfulSeatsByClass` or +`seatClassTints`. The README documents none of them, so external blast radius is low. After the +change, score colouring matches React (driven by `customSeatColorRanges`); per-class colouring is +available via the new `customSeatColorClasses` palette. + +## Testing (TDD) + +- **Unit (vitest)**: preparer colour resolution — score-only, class-only, score+class precedence, + fallback to API colour, fallback to default; `_calculateSeatColorByScore` with ranges absent/present + (no gate); `customSeatColorClasses` validation. Seat component `_resolveStyle` — `seatAvailableColor` + force override beats prepared colour; no class tint remains. +- **e2e (Playwright, against the in-repo demo)**: the 4 demo combinations produce the expected + rendered seat-body colours; availability colour overrides score/class; unavailable seats stay grey. + +## README + +Document `customSeatColorRanges` (already partly documented) and the new `customSeatColorClasses`, +and the priority order, so the colour behaviour is no longer a surprise. Note that `colorfulSeatsBy*` +are removed. diff --git a/package-lock.json b/package-lock.json index 2f84c9e..a1848f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@kwiket/jets-seatmap-angular-lib", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kwiket/jets-seatmap-angular-lib", - "version": "0.0.1", + "version": "0.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@angular/common": "^21.2.0", @@ -1179,21 +1179,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -1202,9 +1202,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -2693,14 +2693,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -3383,9 +3383,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", "cpu": [ "ppc64" ], @@ -3400,9 +3400,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", "cpu": [ "s390x" ], @@ -4095,9 +4095,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -5815,9 +5815,9 @@ } }, "node_modules/hono": { - "version": "4.12.24", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.24.tgz", - "integrity": "sha512-I36D1s+HgQc55KbhEr4iybfxv/9o1zdpw+XEM6dJa91LqQD0HCoSGdxpRJCZE+aavs87j4V3Ls2OJzq8C/U4iw==", + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", "dev": true, "license": "MIT", "engines": { @@ -7171,9 +7171,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -7877,9 +7877,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -7897,7 +7897,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8765,9 +8765,9 @@ "license": "MIT" }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.16", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz", + "integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -8942,9 +8942,9 @@ } }, "node_modules/undici": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", - "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -9039,17 +9039,17 @@ } }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -9065,7 +9065,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -9117,9 +9117,9 @@ } }, "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", "dev": true, "license": "MIT", "funding": { @@ -9127,9 +9127,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", "cpu": [ "arm64" ], @@ -9144,9 +9144,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", "cpu": [ "arm64" ], @@ -9161,9 +9161,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", "cpu": [ "x64" ], @@ -9178,9 +9178,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", "cpu": [ "x64" ], @@ -9195,9 +9195,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", "cpu": [ "arm" ], @@ -9212,9 +9212,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", "cpu": [ "arm64" ], @@ -9229,9 +9229,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", "cpu": [ "arm64" ], @@ -9246,9 +9246,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", "cpu": [ "x64" ], @@ -9263,9 +9263,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", "cpu": [ "x64" ], @@ -9280,9 +9280,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", "cpu": [ "arm64" ], @@ -9297,9 +9297,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", "cpu": [ "wasm32" ], @@ -9307,18 +9307,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", "cpu": [ "arm64" ], @@ -9333,9 +9333,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", "cpu": [ "x64" ], @@ -9350,21 +9350,21 @@ } }, "node_modules/vite/node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/vite/node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -9373,27 +9373,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" } }, "node_modules/vite/node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index bc9905b..94fe44c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kwiket/jets-seatmap-angular-lib", - "version": "0.0.1", + "version": "0.0.2", "description": "Angular seatmap component library", "repository": { "type": "git", diff --git a/projects/seatmap-demo/e2e/customSeatColorRanges/customSeatColorRanges.spec.ts b/projects/seatmap-demo/e2e/customSeatColorRanges/customSeatColorRanges.spec.ts index 26a1831..705290b 100644 --- a/projects/seatmap-demo/e2e/customSeatColorRanges/customSeatColorRanges.spec.ts +++ b/projects/seatmap-demo/e2e/customSeatColorRanges/customSeatColorRanges.spec.ts @@ -97,34 +97,23 @@ test.describe('customSeatColorRanges', () => { }); }); - test('disabled — colorfulSeatsByScore:false ignores ranges (QT888)', async ({ page }) => { - // Use sentinel colours the API never returns so we can tell the matcher - // didn't fire. (The production RANGES above happen to overlap with the - // sandbox API's per-seat colours, which would mask whether the gate - // worked.) - const SENTINELS = [ - { color: '#FF00FF', range: [1, 4] as [number, number] }, - { color: '#00FFFF', range: [4.01, 10] as [number, number] }, - ]; + test('ranges always apply when present (no gate)', async ({ page }) => { await page.goto('/'); await applyConfigAndReady( page, - { - colorfulSeatsByScore: false, - colorTheme: { customSeatColorRanges: SENTINELS }, - }, - { availability: WILDCARD_AVAILABILITY } - ); - - const hits = await countCustomFills( - page, - SENTINELS.map(r => r.color) + { colorTheme: { customSeatColorRanges: [{ range: [1, 10], color: '#abcdef' }] } }, + { availability: [] } ); - const total = Object.values(hits).reduce((a, b) => a + b, 0); - expect(total, `colorfulSeatsByScore:false must suppress sentinel range colours, got ${JSON.stringify(hits)}`).toBe( - 0 - ); - - await screenshotSeatMap(page, __dirname, 'customSeatColorRanges-disabled'); + const hits = await page.evaluate(() => { + const seats = Array.from(document.querySelectorAll('.jets-seat--available')) as HTMLElement[]; + let n = 0; + for (const seat of seats) { + for (const p of Array.from(seat.querySelectorAll('svg path'))) { + if ((p.getAttribute('fill') || '').toLowerCase() === '#abcdef') { n++; break; } + } + } + return n; + }); + expect(hits, 'sentinel range colour must appear on available seats').toBeGreaterThan(0); }); }); diff --git a/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied-fullpage.png b/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied-fullpage.png index ffbe8af..cf815e7 100644 Binary files a/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied-fullpage.png and b/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied-fullpage.png differ diff --git a/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied.png b/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied.png index 67adfe1..178ea9b 100644 Binary files a/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied.png and b/projects/seatmap-demo/e2e/customSeatColorRanges/screenshots/customSeatColorRanges-applied.png differ diff --git a/projects/seatmap-demo/e2e/exitIcons/exitIcons.spec.ts b/projects/seatmap-demo/e2e/exitIcons/exitIcons.spec.ts index b2d9893..c439f62 100644 --- a/projects/seatmap-demo/e2e/exitIcons/exitIcons.spec.ts +++ b/projects/seatmap-demo/e2e/exitIcons/exitIcons.spec.ts @@ -34,8 +34,6 @@ const CONFIG_JSON = { visibleFuselage: true, visibleSeatPriceLabels: true, flatBulks: false, - colorfulSeatsByClass: false, - colorfulSeatsByScore: true, width: 380, colorTheme: { exitIconUrlLeft: ICON_URL, diff --git a/projects/seatmap-demo/e2e/exitIcons/screenshots/exitIcons-ua953-fullpage.png b/projects/seatmap-demo/e2e/exitIcons/screenshots/exitIcons-ua953-fullpage.png index ab58e13..dd8c20c 100644 Binary files a/projects/seatmap-demo/e2e/exitIcons/screenshots/exitIcons-ua953-fullpage.png and b/projects/seatmap-demo/e2e/exitIcons/screenshots/exitIcons-ua953-fullpage.png differ diff --git a/projects/seatmap-demo/e2e/horizontal/horizontalParity.spec.ts b/projects/seatmap-demo/e2e/horizontal/horizontalParity.spec.ts new file mode 100644 index 0000000..44e8361 --- /dev/null +++ b/projects/seatmap-demo/e2e/horizontal/horizontalParity.spec.ts @@ -0,0 +1,129 @@ +/** + * Behavioural e2e for horizontal-layout React parity: + * - nose direction is controlled by `rightToLeft` (false → nose left, + * true → nose right), mirroring the React lib; + * - the built-in tooltip stays fully within the viewport in horizontal mode + * (regression guard for the P1b off-screen bug). + */ +import { test, expect } from '@playwright/test'; +import { applyConfigAndReady, clickFirstAvailableSeat, setConfig, waitForSeatMapReady } from '../helpers/demo'; + +test.describe('horizontal layout — React parity', () => { + test('nose points LEFT in horizontal LTR (rightToLeft:false)', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { horizontal: true, rightToLeft: false, visibleFuselage: true }); + + const nose = await page.locator('.jets-nose').first().boundingBox(); + const tail = await page.locator('.jets-tail').first().boundingBox(); + expect(nose, 'nose should render').not.toBeNull(); + expect(tail, 'tail should render').not.toBeNull(); + // Cabin is rotated 90deg; with the LTR flip the nose sits left of the tail. + expect(nose!.x).toBeLessThan(tail!.x); + }); + + test('nose points RIGHT in horizontal RTL (rightToLeft:true)', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { horizontal: true, rightToLeft: true, visibleFuselage: true }); + + const nose = await page.locator('.jets-nose').first().boundingBox(); + const tail = await page.locator('.jets-tail').first().boundingBox(); + expect(nose, 'nose should render').not.toBeNull(); + expect(tail, 'tail should render').not.toBeNull(); + expect(nose!.x).toBeGreaterThan(tail!.x); + }); + + test('demo stacks controls below the rotated seatmap in horizontal mode', async ({ page }) => { + // In horizontal mode the cabin rotates 90deg and can be far wider than the + // viewport. The demo's flex-row layout squeezed the controls column to a + // sliver pushed off to the right (the helper even needs `force:true` to + // click through). The demo must stack the controls *below* the map and keep + // them full-width and on-screen. + await page.goto('/'); + await applyConfigAndReady(page, { horizontal: true, rightToLeft: false }); + + const mapBox = await page.locator('.jets-seat-map').first().boundingBox(); + const controlsBox = await page.locator('.demo-controls-panel').first().boundingBox(); + const vp = page.viewportSize(); + expect(mapBox, 'seatmap should render').not.toBeNull(); + expect(controlsBox, 'controls panel should render').not.toBeNull(); + expect(vp, 'viewport size').not.toBeNull(); + + // 1) Controls sit BELOW the seatmap (stacked), not beside it. + expect(controlsBox!.y).toBeGreaterThanOrEqual(mapBox!.y + mapBox!.height - 2); + + // 2) Controls keep a usable width and stay within the viewport — not the + // collapsed 42px sliver shoved off-screen by the old row layout. + expect(controlsBox!.width).toBeGreaterThan(200); + expect(controlsBox!.x).toBeGreaterThanOrEqual(0); + expect(controlsBox!.x + controlsBox!.width).toBeLessThanOrEqual(vp!.width + 1); + }); + + test('reserves the rotated footprint when horizontal is toggled on an already-loaded map', async ({ page }) => { + // Regression: toggling `horizontal` via a config change (not a full flight + // reload) skipped the swapped-dimension measurement, so the container kept + // its tall vertical layout height (~3139px) and the rotated strip left a + // huge empty gap above the controls. The container must reserve the wide, + // short footprint instead. + await page.goto('/'); + await waitForSeatMapReady(page); // default vertical map loads first + await setConfig(page, { horizontal: true, visibleFuselage: true }); // in-place toggle + await waitForSeatMapReady(page); + + const map = await page.locator('.jets-seat-map').first().boundingBox(); + expect(map, 'seatmap should render').not.toBeNull(); + // A horizontal cabin is a wide, short strip: reserved height must be far + // smaller than width. (Collapsed bug: height === full tall layout > width.) + expect(map!.height).toBeLessThan(map!.width); + }); + + test('reserves room for cabin titles, wings and the nose in horizontal mode', async ({ page }) => { + // The rotated footprint must include the side cabin labels and wings (which + // stick ~20px past the fuselage box) and the nose/tail caps (which stick + // past it sideways). With the wrapper clipping overflow, anything outside + // the reserved container gets cut — the cabin titles were sheared off the + // bottom and the nose ran off the top-left. + await page.goto('/'); + await applyConfigAndReady(page, { + horizontal: true, + rightToLeft: false, + visibleFuselage: true, + visibleWings: true, + visibleCabinTitles: true, + }); + + const map = await page.locator('.jets-seat-map').first().boundingBox(); + const nose = await page.locator('.jets-nose').first().boundingBox(); + const label = await page.locator('.jets-cabin-label').first().boundingBox(); + expect(map, 'seatmap should render').not.toBeNull(); + expect(nose, 'nose should render').not.toBeNull(); + expect(label, 'cabin label should render').not.toBeNull(); + + // Reserved height must exceed the bare fuselage strip so the side overlays + // fit (the rotated nose height is the fuselage width). + expect(map!.height).toBeGreaterThan(nose!.height); + + // Nothing clips: nose stays within the container's top-left, the cabin + // label stays within its bottom. (1px slack for sub-pixel rounding.) + expect(nose!.y).toBeGreaterThanOrEqual(map!.y - 1); + expect(nose!.x).toBeGreaterThanOrEqual(map!.x - 1); + expect(label!.y + label!.height).toBeLessThanOrEqual(map!.y + map!.height + 1); + }); + + test('built-in tooltip stays within the viewport in horizontal mode (P1b)', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { horizontal: true, rightToLeft: false }); + await clickFirstAvailableSeat(page); + + const tip = page.locator('.jets-tooltip').first(); + await expect(tip).toBeVisible(); + + const box = await tip.boundingBox(); + const vp = page.viewportSize(); + expect(box, 'tooltip should have a box').not.toBeNull(); + expect(vp, 'viewport size').not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.y).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(vp!.width); + expect(box!.y + box!.height).toBeLessThanOrEqual(vp!.height); + }); +}); diff --git a/projects/seatmap-demo/e2e/playwright.config.ts b/projects/seatmap-demo/e2e/playwright.config.ts index 0f33ee1..e91dae0 100644 --- a/projects/seatmap-demo/e2e/playwright.config.ts +++ b/projects/seatmap-demo/e2e/playwright.config.ts @@ -2,6 +2,12 @@ import { defineConfig, devices } from '@playwright/test'; const isCI = !!process.env['CI']; +// Allow targeting an already-running dev server (e.g. the dev worktree on +// :4201) without editing this file. Defaults to the canonical :4200 server +// that `webServer` boots. When overridden, `reuseExistingServer` lets the +// suite attach to that server instead of spawning a duplicate. +const baseURL = process.env['PW_BASE_URL'] ?? 'http://localhost:4200'; + export default defineConfig({ testDir: '.', testMatch: '**/*.spec.ts', @@ -16,7 +22,7 @@ export default defineConfig({ ['html', { outputFolder: 'playwright-report', open: 'never' }], ], use: { - baseURL: 'http://localhost:4200', + baseURL, trace: 'on-first-retry', viewport: { width: 1440, height: 900 }, }, @@ -29,7 +35,7 @@ export default defineConfig({ ], webServer: { command: 'npm start --prefix ../../..', - url: 'http://localhost:4200', + url: baseURL, reuseExistingServer: !isCI, timeout: 180_000, stdout: 'pipe', diff --git a/projects/seatmap-demo/e2e/seatColors/seatColorModes.screenshots.spec.ts b/projects/seatmap-demo/e2e/seatColors/seatColorModes.screenshots.spec.ts new file mode 100644 index 0000000..88e7509 --- /dev/null +++ b/projects/seatmap-demo/e2e/seatColors/seatColorModes.screenshots.spec.ts @@ -0,0 +1,83 @@ +/** + * Screenshot harness — NOT an assertion test. Emulates the demo app's four + * colour-mode toggles using the library's data-driven config options, so each + * screenshot shows exactly what the demo renders for that flag combination: + * + * demo toggle state library config that produces it + * ───────────────────────── ────────────────────────────────────────── + * Score OFF, Class OFF -> colorTheme.seatAvailableColor (one flat colour) + * Score ON, Class OFF -> colorTheme.customSeatColorRanges (score palette) + * Score OFF, Class ON -> colorTheme.customSeatColorClasses (per-class palette) + * Score ON, Class ON -> ranges + classes (score wins; class fills the rest) + * + * All seats are rendered available (`availability: []`) so the colours are + * visible across the whole cabin. Run with the dev demo on :4201: + * PW_BASE_URL=http://localhost:4201 npx playwright test \ + * --config projects/seatmap-demo/e2e/playwright.config.ts \ + * seatColorModes.screenshots --workers=1 + * Output: projects/seatmap-demo/e2e/seatColors/screenshots/.png + */ +import { test } from '@playwright/test'; +import * as path from 'path'; +import { applyConfigAndReady, type ConfigOverrides } from '../helpers/demo'; + +// Realistic score palette (matches the demo's qt888 customSeatColorRanges). +const RANGES = [ + { color: '#c7683d', range: [1, 2.99] }, + { color: '#e6be3f', range: [3, 4.99] }, + { color: '#4071b9', range: [5, 6.5] }, + { color: '#8fb947', range: [6.51, 10] }, +]; + +// Vivid, deliberately distinct per-class colours so the cabins read clearly: +// First / Business / Premium / Economy / All. +const CLASSES = { F: '#d11149', B: '#ff7f0e', P: '#2ca02c', E: '#1f77b4', A: '#7f3fbf' }; + +// A single flat colour for the "both off" mode — one theme colour for every seat. +const FLAT = '#6b7a8f'; + +// The demo's visual theme (floor, fuselage, stroke, fonts) so each screenshot +// looks like the real app. Each mode's colorTheme replaces the demo's baked-in +// one (shallow merge), so we re-supply these and add only the mode's colour keys. +const BASE_THEME: ConfigOverrides = { + seatMapBackgroundColor: '#fff', + floorColor: '#595959', + deckLabelTitleColor: 'black', + seatStrokeColor: 'rgb(230, 230, 230)', + seatArmrestColor: '#cccccc', + notAvailableSeatsColor: 'dimgrey', + fuselageStrokeWidth: 10, + fuselageFillColor: 'lightgrey', + fuselageStrokeColor: 'darkgrey', + fuselageWindowsColor: 'darkgrey', + fontFamily: 'Montserrat, sans-serif', + cabinTitlesWidth: 85, +}; + +const OUT_DIR = path.join(__dirname, 'screenshots'); + +const MODES: Array<{ file: string; theme: ConfigOverrides }> = [ + { file: 'mode-1_score-OFF_class-OFF_(seatAvailableColor).png', theme: { ...BASE_THEME, seatAvailableColor: FLAT } }, + { file: 'mode-2_score-ON_class-OFF_(customSeatColorRanges).png', theme: { ...BASE_THEME, customSeatColorRanges: RANGES } }, + { file: 'mode-3_score-OFF_class-ON_(customSeatColorClasses).png', theme: { ...BASE_THEME, customSeatColorClasses: CLASSES } }, + { + file: 'mode-4_score-ON_class-ON_(ranges+classes_score-wins).png', + theme: { ...BASE_THEME, customSeatColorRanges: RANGES, customSeatColorClasses: CLASSES }, + }, +]; + +test.describe('seat colour modes — demo emulation screenshots', () => { + for (const mode of MODES) { + test(`screenshot: ${mode.file}`, async ({ page }) => { + await page.setViewportSize({ width: 900, height: 1400 }); + await page.goto('/'); + await applyConfigAndReady( + page, + { horizontal: false, visibleFuselage: true, visibleCabinTitles: true, colorTheme: mode.theme }, + { availability: [] } + ); + await page.waitForTimeout(400); + await page.locator('.demo-seatmap-wrapper').first().screenshot({ path: path.join(OUT_DIR, mode.file) }); + }); + } +}); diff --git a/projects/seatmap-demo/e2e/seatColors/seatColors.spec.ts b/projects/seatmap-demo/e2e/seatColors/seatColors.spec.ts new file mode 100644 index 0000000..ad7c4da --- /dev/null +++ b/projects/seatmap-demo/e2e/seatColors/seatColors.spec.ts @@ -0,0 +1,67 @@ +import { test, expect, type Page } from '@playwright/test'; +import { applyConfigAndReady } from '../helpers/demo'; + +const RANGES = [{ range: [1, 10] as [number, number], color: '#abcdef' }]; +const CLASSES = { F: '#111111', B: '#222222', P: '#333333', E: '#444444', A: '#555555' }; +const FLAT = '#0a0a0a'; + +async function bodyColours(page: Page): Promise> { + return new Set( + await page.evaluate(() => { + const grey = new Set(['rgb(169, 169, 169)', 'rgb(235, 235, 235)', 'rgb(255, 255, 255)', 'white', 'none', '#ffffff', '#fff']); + const out: string[] = []; + for (const seat of Array.from(document.querySelectorAll('.jets-seat--available')) as HTMLElement[]) { + for (const p of Array.from(seat.querySelectorAll('svg path'))) { + const f = (p.getAttribute('fill') || '').toLowerCase().trim(); + if (f && !grey.has(f)) { out.push(f); break; } + } + } + return out; + }) + ); +} + +test.describe('seat colour modes (config-driven)', () => { + test('both off: seatAvailableColor flattens every available seat', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { colorTheme: { seatAvailableColor: FLAT } }, { availability: [] }); + const colours = await bodyColours(page); + expect(colours.size).toBe(1); + expect([...colours][0]).toBe(FLAT); + }); + + test('score on: ranges colour the seats', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { colorTheme: { customSeatColorRanges: RANGES } }, { availability: [] }); + expect(await bodyColours(page)).toContain('#abcdef'); + }); + + test('class on: class palette overrides the API colour', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady(page, { colorTheme: { customSeatColorClasses: CLASSES } }, { availability: [] }); + const colours = await bodyColours(page); + expect(colours.size).toBeGreaterThan(0); + expect([...colours].every(c => Object.values(CLASSES).map(v => v.toLowerCase()).includes(c))).toBe(true); + }); + + test('both on: score wins over class', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady( + page, + { colorTheme: { customSeatColorRanges: RANGES, customSeatColorClasses: CLASSES } }, + { availability: [] } + ); + const colours = await bodyColours(page); + expect(colours.has('#abcdef')).toBe(true); + }); + + test('availability colour overrides score', async ({ page }) => { + await page.goto('/'); + await applyConfigAndReady( + page, + { colorTheme: { customSeatColorRanges: RANGES } }, + { availability: [{ label: '*', price: 10, currency: 'USD', color: '#fe00fe' }] } + ); + expect(await bodyColours(page)).toContain('#fe00fe'); + }); +}); diff --git a/projects/seatmap-demo/src/app/app.html b/projects/seatmap-demo/src/app/app.html index 71ce23a..39fa04e 100644 --- a/projects/seatmap-demo/src/app/app.html +++ b/projects/seatmap-demo/src/app/app.html @@ -15,7 +15,7 @@

✈ Angular Seatmap Demo

} -
+
(() => this.activeConfig().horizontal === true); + activeFlight = computed(() => { const base = this.flights[this.selectedIndex()].flight; const override = this.flightOverride(); @@ -162,7 +167,7 @@ export class App { } private updateTextareasForFlight(flight: DemoFlight): void { - const { config, availability, passengers, ...rest } = flight; + const { config } = flight; const { apiUrl, apiAppId, apiKey, colorTheme, ...displayConfig } = config; this.textareas.set({ config: JSON.stringify(displayConfig, null, 2), diff --git a/projects/seatmap-demo/src/app/flights.data.ts b/projects/seatmap-demo/src/app/flights.data.ts index e9e3e1c..d525f96 100644 --- a/projects/seatmap-demo/src/app/flights.data.ts +++ b/projects/seatmap-demo/src/app/flights.data.ts @@ -46,8 +46,6 @@ export const BASE_CONFIG: Omit = { visibleFuselage: true, visibleSeatPriceLabels: true, flatBulks: false, - colorfulSeatsByClass: false, - colorfulSeatsByScore: true, colorTheme: CABIN_THEME, }; diff --git a/projects/seatmap-lib/README.md b/projects/seatmap-lib/README.md index ad99b27..fbd3325 100644 --- a/projects/seatmap-lib/README.md +++ b/projects/seatmap-lib/README.md @@ -540,6 +540,20 @@ The seatmap supports dynamic seat coloring based on score values. When a seat ha **Priority order:** Score-based color > Original seat color > Default color. +### Seat colour priority + +Available seats resolve their colour top-down: + +1. `colorTheme.seatAvailableColor` / `forceThemeSeatColors` — forces every available seat to one colour. +2. `availability[].color` (individual entry > `*` wildcard) when the `availability` input is supplied. +3. `colorTheme.customSeatColorRanges` — score → colour; applied whenever ranges are defined and the seat `score` ∈ [1,10] matches. +4. `colorTheme.customSeatColorClasses` — `Partial>`; per-class flat colour. +5. The seat's API `color`. +6. Theme default available colour. + +There is no `colorfulSeatsByScore` / `colorfulSeatsByClass` flag — enable each axis by providing the +corresponding palette; for a flat look set `seatAvailableColor`. +   ### seatJumpTo diff --git a/projects/seatmap-lib/package.json b/projects/seatmap-lib/package.json index 77c6d36..dd51240 100644 --- a/projects/seatmap-lib/package.json +++ b/projects/seatmap-lib/package.json @@ -1,6 +1,6 @@ { "name": "@seatmaps.com/angular-lib", - "version": "0.0.1", + "version": "0.0.2", "description": "Jets seatmap angular library.", "keywords": [ "angular", diff --git a/projects/seatmap-lib/src/lib/components/jets-deck/jets-deck.component.ts b/projects/seatmap-lib/src/lib/components/jets-deck/jets-deck.component.ts index 1fc6291..0f773dc 100644 --- a/projects/seatmap-lib/src/lib/components/jets-deck/jets-deck.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-deck/jets-deck.component.ts @@ -97,7 +97,6 @@ interface ICabinSection { [colorTheme]="colorTheme" [showPrice]="showPrice" [currencyOverride]="currencyOverride" - [colorfulSeatsByClass]="colorfulSeatsByClass" [scale]="scale" [seatOverride]="seatOverride" [prevRowTopOffset]="i > 0 ? (deck.rows[i - 1].topOffset ?? 0) : 0" @@ -196,7 +195,6 @@ export class JetsDeckComponent { @Input() showPrice = false; @Input() currencyOverride?: string; @Input() flatBulks = false; - @Input() colorfulSeatsByClass = false; @Input() bodyWidth = 350; @Input() fuselageWidth = 350; @Input() visibleCabinTitles = true; diff --git a/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.spec.ts b/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.spec.ts index 21eb571..850d876 100644 --- a/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.spec.ts +++ b/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.spec.ts @@ -51,4 +51,41 @@ describe('JetsNoseComponent', () => { const html: string = fixture.nativeElement.innerHTML; expect(html).toContain('stroke-width:18'); }); + + // Horizontal nose direction — mirrors React Nose/index.js:41 + // transform = isHorizontal && !rightToLeft ? 'rotate(180deg)' : '' + const noseEl = () => fixture.nativeElement.querySelector('.jets-nose') as HTMLElement; + + it('does not rotate the nose in vertical mode', () => { + fixture.componentRef.setInput('horizontal', false); + fixture.detectChanges(); + expect(noseEl().style.transform).not.toContain('rotate'); + }); + + it('rotates the nose 180deg in horizontal LTR mode (nose flips to point left)', () => { + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', false); + fixture.detectChanges(); + expect(noseEl().style.transform).toContain('rotate(180deg)'); + }); + + it('does not rotate the nose in horizontal RTL mode', () => { + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', true); + fixture.detectChanges(); + expect(noseEl().style.transform).not.toContain('rotate'); + }); + + // Fuselage-join compensation: the nose is scaled up slightly so its outline + // lines up with the fuselage border (closes the visible step at the join). + it('scales the nose up to close the fuselage join', () => { + fixture.componentRef.setInput('width', 360); + fixture.componentRef.setInput('colorTheme', { fuselageStrokeWidth: 4 }); + fixture.detectChanges(); + const t = noseEl().style.transform; + expect(t).toContain('scale('); + const scale = Number(t.match(/scale\(([^)]+)\)/)?.[1]); + expect(scale).toBeGreaterThan(1); + expect(scale).toBeLessThan(1.05); + }); }); diff --git a/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.ts b/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.ts index 6669b8b..fb3ab67 100644 --- a/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-nose/jets-nose.component.ts @@ -10,7 +10,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, - template: `
`, + template: `
`, styles: [ ` .jets-nose { @@ -35,9 +35,49 @@ export class JetsNoseComponent implements OnChanges { /** Display scale (mirrors React's `params.scale`); pre-multiplies the SVG * outline so the nose contour matches the CSS body border thickness. */ @Input() displayScale = 1; + /** Horizontal cabin layout (whole map is rotated 90deg by the parent). */ + @Input() horizontal = false; + /** RTL flips the nose direction in horizontal mode (mirrors React). */ + @Input() rightToLeft = false; svgContent: SafeHtml = ''; + /** SVG viewBox width — every nose template uses 200. */ + private static readonly SVG_WIDTH = 200; + /** The nose outline path starts 1.5 viewBox units inside the SVG border + * (`M1.5,…`). Combined with the stroke half-width that leaves the outline + * centre sitting `1.5 - strokeWidth/2` units short of the fuselage border + * centre, so the nose renders slightly narrower than the fuselage and a step + * shows at the join. Mirrors React `Nose/index.js` `distanceFromBorder`. */ + private static readonly OUTLINE_INSET = 1.5; + + /** Transform for the nose div: + * - `scale()` closes the nose↔fuselage join (outline-inset compensation); + * - `rotate(180deg)` in horizontal LTR so the nose points the React way. */ + get noseTransform(): string { + const rotation = this.horizontal && !this.rightToLeft ? 'rotate(180deg)' : ''; + const scale = this._fuselageJoinScale(); + const scalePart = scale !== 1 ? `scale(${scale})` : ''; + return [rotation, scalePart].filter(Boolean).join(' '); + } + + /** + * Scale that pushes the nose outline out so its centre lines up with the + * fuselage border centre. `effectiveInset` (in viewBox units) is the outline + * inset minus the stroke half-width; scaling by `200/(200 - 2*inset)` moves + * the outline out by `inset` units on each side. + */ + private _fuselageJoinScale(): number { + const svg = JetsNoseComponent.SVG_WIDTH; + if (!this.width) return 1; + const t = this.colorTheme ?? {}; + const themedStroke = (t.fuselageStrokeWidth ?? DEFAULT_COLOR_THEME.fuselageStrokeWidth) * this.displayScale; + const strokeSvg = themedStroke / (this.width / svg); + const effectiveInset = JetsNoseComponent.OUTLINE_INSET - strokeSvg * 0.5; + if (effectiveInset <= 0) return 1; + return svg / (svg - 2 * effectiveInset); + } + constructor(private sanitizer: DomSanitizer) {} ngOnChanges(): void { diff --git a/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.spec.ts b/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.spec.ts index 25d5d7c..014b8cf 100644 --- a/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.spec.ts +++ b/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.spec.ts @@ -55,4 +55,68 @@ describe('JetsPlaneBodyComponent', () => { fixture.detectChanges(); expect(component.noseType).toBe('by-type'); }); + + // ─── Horizontal layout (React PlaneBody/index.js parity) ────────────────── + const fuselageEl = () => + fixture.nativeElement.querySelector('.jets-plane-body__fuselage') as HTMLElement; + // DOM order of the three structural blocks, top→bottom. + const blockOrder = (): string[] => + Array.from( + fixture.nativeElement.querySelectorAll( + '.jets-nose, .jets-plane-body__fuselage, .jets-tail' + ) + ).map(el => + (el as HTMLElement).classList.contains('jets-nose') + ? 'nose' + : (el as HTMLElement).classList.contains('jets-tail') + ? 'tail' + : 'fuselage' + ); + + it('does not rotate the deck wrapper in vertical mode', () => { + component.decks = [makeDeck()]; + fixture.componentRef.setInput('horizontal', false); + fixture.detectChanges(); + expect(fuselageEl().style.transform).toBe(''); + }); + + it('rotates the deck wrapper 180deg in horizontal LTR', () => { + component.decks = [makeDeck()]; + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', false); + fixture.detectChanges(); + expect(fuselageEl().style.transform).toBe('rotate(180deg)'); + }); + + it('does not rotate the deck wrapper in horizontal RTL', () => { + component.decks = [makeDeck()]; + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', true); + fixture.detectChanges(); + expect(fuselageEl().style.transform).toBe(''); + }); + + it('passes horizontal/rightToLeft down so the nose flips in horizontal LTR', () => { + component.decks = [makeDeck()]; + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', false); + fixture.detectChanges(); + const nose = fixture.nativeElement.querySelector('.jets-nose') as HTMLElement; + expect(nose.style.transform).toContain('rotate(180deg)'); + }); + + it('renders nose first, tail last in vertical mode', () => { + component.decks = [makeDeck()]; + fixture.componentRef.setInput('horizontal', false); + fixture.detectChanges(); + expect(blockOrder()).toEqual(['nose', 'fuselage', 'tail']); + }); + + it('swaps to tail first, nose last in horizontal LTR', () => { + component.decks = [makeDeck()]; + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', false); + fixture.detectChanges(); + expect(blockOrder()).toEqual(['tail', 'fuselage', 'nose']); + }); }); diff --git a/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.ts b/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.ts index 9c214c6..1afcfda 100644 --- a/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-plane-body/jets-plane-body.component.ts @@ -11,17 +11,34 @@ import { JetsTailComponent } from '../jets-tail/jets-tail.component'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
- - @if (showNose) { - + + @if (isHorizontalLtr) { + @if (showTail) { + + } + } @else { + @if (showNose) { + + } } - +
- - @if (showTail) { - + + @if (isHorizontalLtr) { + @if (showNose) { + + } + } @else { + @if (showTail) { + + } }
`, @@ -76,6 +113,23 @@ export class JetsPlaneBodyComponent { * border-width here so the visual thickness matches. */ @Input() displayScale = 1; + /** Horizontal cabin layout (whole map is rotated 90deg by the parent). */ + @Input() horizontal = false; + /** RTL flips the cabin direction in horizontal mode (mirrors React). */ + @Input() rightToLeft = false; + + /** Horizontal left-to-right — the orientation that flips nose/tail/decks. + * Mirrors React's `params.isHorizontal && !params.rightToLeft`. */ + get isHorizontalLtr(): boolean { + return this.horizontal && !this.rightToLeft; + } + + /** Deck-wrapper transform: flipped 180deg in horizontal LTR so the + * projected decks (and nose/tail) stay consistent. Mirrors React + * PlaneBody/index.js `decksWrapperStyle.transform`. */ + get deckWrapperTransform(): string { + return this.isHorizontalLtr ? 'rotate(180deg)' : ''; + } get showNose(): boolean { return this.visibleNose ?? this.visibleFuselage; diff --git a/projects/seatmap-lib/src/lib/components/jets-row/jets-row.component.ts b/projects/seatmap-lib/src/lib/components/jets-row/jets-row.component.ts index 38493fc..68f30ce 100644 --- a/projects/seatmap-lib/src/lib/components/jets-row/jets-row.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-row/jets-row.component.ts @@ -21,7 +21,6 @@ import { JetsSeatComponent } from '../jets-seat/jets-seat.component'; colorTheme: colorTheme, showPrice: showPrice, currencyOverride: currencyOverride, - colorfulSeatsByClass: colorfulSeatsByClass, scale: scale, } " @@ -32,7 +31,6 @@ import { JetsSeatComponent } from '../jets-seat/jets-seat.component'; [colorTheme]="colorTheme" [showPrice]="showPrice" [currencyOverride]="currencyOverride" - [colorfulSeatsByClass]="colorfulSeatsByClass" [scale]="scale" (seatClick)="seatClick.emit($event)" (seatMouseEnter)="seatMouseEnter.emit($event)" @@ -67,7 +65,6 @@ export class JetsRowComponent { @Input() colorTheme?: IColorTheme; @Input() showPrice = false; @Input() currencyOverride?: string; - @Input() colorfulSeatsByClass = false; @Input() prevRowTopOffset?: number; @Input() prevRowHeight = 0; @Input() scale = 1; diff --git a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.html b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.html index f1c0fb9..8b1686c 100644 --- a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.html +++ b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.html @@ -5,11 +5,11 @@
+
@if (content.length) { @for (deck of visibleDecks; track deck.number; let i = $index) { @@ -104,7 +115,6 @@ [showPrice]="resolvedConfig.visibleSeatPriceLabels ?? false" [currencyOverride]="resolvedConfig.currencySign || ''" [flatBulks]="resolvedConfig.flatBulks ?? false" - [colorfulSeatsByClass]="resolvedConfig.colorfulSeatsByClass ?? false" [bodyWidth]="resolvedConfig.width" [fuselageWidth]="fuselageBodyWidth" [visibleCabinTitles]="resolvedConfig.visibleCabinTitles !== false" @@ -146,6 +156,7 @@ } } +
diff --git a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.scss b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.scss index abcf623..92b8929 100644 --- a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.scss +++ b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.scss @@ -21,6 +21,14 @@ overflow: visible; margin: 0 auto; min-height: 480px; + + // Horizontal: the rotor pivots from the top-left, so align it to the start + // (not centred) and drop the vertical min-height — the container reserves the + // rotated footprint via the swapped inline width/height. + &--horizontal { + align-items: flex-start; + min-height: 0; + } } .jets-seatmap-header { diff --git a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.spec.ts b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.spec.ts index b6ed7b5..ce480ec 100644 --- a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.spec.ts +++ b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.spec.ts @@ -4,7 +4,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { JetsSeatMapComponent } from './jets-seat-map.component'; import { JetsSeatMapService } from '../../services/jets-seat-map.service'; import { resetCachedEnvironmentInfo } from '../../services/environment.service'; -import { IConfig, IDeckData, IFlight, IPassenger, ISeatData, IInitialLayoutData, TSeatAvailability } from '../../types'; +import { IConfig, IDeckData, IFlight, IPassenger, ISeatData, TSeatAvailability } from '../../types'; import { ENTITY_STATUS_MAP, ENTITY_TYPE_MAP } from '../../constants'; // ─── Test data factories ──────────────────────────────────────────────────── @@ -550,6 +550,23 @@ describe('JetsSeatMapComponent', () => { expect(component.visibleDecks).toHaveLength(1); }); + // Horizontal LTR reverses the stacking order so decks read correctly once + // the rotor is rotated 90deg (mirrors React PlaneBody decks.reverse()). + it('reverses deck order in horizontal LTR (stacked multi-deck)', () => { + component.config = makeConfig({ singleDeckMode: false, horizontal: true, rightToLeft: false }); + expect(component.visibleDecks.map(d => d.number)).toEqual([2, 1]); + }); + + it('keeps deck order in horizontal RTL', () => { + component.config = makeConfig({ singleDeckMode: false, horizontal: true, rightToLeft: true }); + expect(component.visibleDecks.map(d => d.number)).toEqual([1, 2]); + }); + + it('keeps deck order in vertical multi-deck', () => { + component.config = makeConfig({ singleDeckMode: false, horizontal: false }); + expect(component.visibleDecks.map(d => d.number)).toEqual([1, 2]); + }); + it('should hide deck selector when builtInDeckSelector is false', () => { component.config = makeConfig({ builtInDeckSelector: false }); expect(component.showDeckSelector).toBe(false); diff --git a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.ts b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.ts index 631678d..6041a37 100644 --- a/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-seat-map/jets-seat-map.component.ts @@ -117,6 +117,9 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { @Output() hasAvailabilityChanged = new EventEmitter(); @ViewChild('mapContainer') mapContainer!: ElementRef; + /** Inner wrapper that carries the horizontal `rotate(90deg)`; the cabin + * content lives here while the tooltip stays in the un-rotated container. */ + @ViewChild('rotor') rotor?: ElementRef; content: IDeckData[] = []; media: IMediaData | null = null; @@ -128,6 +131,17 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { isSelectAvailable = false; activeDeckIndex = 0; + /** Swapped container dimensions in horizontal mode (the rotor's pre-rotation + * size, measured post-render). `null` in vertical → falls back to width/auto. + * Mirrors React's outer container `width: scaledTotalDecksHeight`. */ + horizontalContainerWidth: number | null = null; + horizontalContainerHeight: number | null = null; + /** Pixels to nudge the rotor so its painted content (side cabin labels, + * wings, nose/tail caps that overflow the fuselage box) is pulled fully + * inside the reserved container instead of being clipped at the top/left. */ + horizontalOffsetX = 0; + horizontalOffsetY = 0; + private _flightId: string | null = null; private _prevLang: string | null = null; private _prevUnits: string | null = null; @@ -170,20 +184,28 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { visibleFuselage: this.config?.visibleFuselage ?? true, visibleSeatPriceLabels: this.config?.visibleSeatPriceLabels ?? false, flatBulks: this.config?.flatBulks ?? false, - colorfulSeatsByClass: this.config?.colorfulSeatsByClass ?? false, - colorfulSeatsByScore: this.config?.colorfulSeatsByScore ?? true, colorTheme: JetsSeatMapPreparerService.mergeColorThemeWithConstraints(this.config?.colorTheme), scaleType, }; } - /** CSS transform for horizontal layout mode */ + /** + * CSS transform for the horizontal rotor: rotate 90deg then lift by its own + * height so the rotated cabin sits at the container's top-left. Angular bakes + * the display scale into the deck rendering (not a wrapper zoom), so the + * offset is a plain `translateY(-100%)` for both scale types — unlike React, + * whose ZOOM wrapper needs `-100/scale%`. + */ get mapTransform(): string { - const cfg = this.resolvedConfig; - if (!cfg.horizontal) return ''; - const scaleType = cfg.scaleType ?? SCALE_TYPES.SCALE; - const offset = scaleType === SCALE_TYPES.ZOOM ? 'translateY(-100%)' : 'translateY(-100%)'; - return `rotate(90deg) ${offset}`; + if (!this.resolvedConfig.horizontal) return ''; + // The leading translate (applied last, in container space) pulls the + // rotated content's top-left overflow back inside the reserved box so the + // nose and side labels are not clipped. Zero until `_updateHorizontalDims` + // measures the painted footprint. + const tx = this.horizontalOffsetX || 0; + const ty = this.horizontalOffsetY || 0; + const lead = tx || ty ? `translate(${tx}px, ${ty}px) ` : ''; + return `${lead}rotate(90deg) translateY(-100%)`; } /** CSS transform-origin for horizontal layout */ @@ -288,10 +310,23 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { return items; } + /** Horizontal left-to-right — the orientation that flips the cabin + * (nose/tail/decks). Mirrors React's `isHorizontal && !rightToLeft`. */ + get isHorizontalLtr(): boolean { + return !!this.resolvedConfig.horizontal && !this.resolvedConfig.rightToLeft; + } + get visibleDecks(): IDeckData[] { if (this.resolvedConfig.singleDeckMode && this.content.length > 1) { + // Single-deck mode shows the active deck regardless of orientation; the + // reversed deckToShow in React resolves to the same deck, so no change. return [this.content[this.activeDeckIndex]].filter(Boolean); } + // Stacked multi-deck: reverse the order in horizontal LTR so the decks read + // correctly after the rotor's 90deg rotation (mirrors React decks.reverse()). + if (this.isHorizontalLtr && this.content.length > 1) { + return [...this.content].reverse(); + } return this.content; } @@ -611,6 +646,16 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { } // Re-emit legend when colorTheme changes (colors affect legend swatches) if (this.isSeatMapInited) this.legendReady.emit(this.legendItems); + + // A settings-only config change (no reload) can flip the horizontal + // layout — e.g. toggling `horizontal`/`rightToLeft`, or fuselage/nose/ + // tail visibility — which changes the rotated footprint. Re-measure the + // swapped container dimensions after the view re-renders; otherwise the + // container keeps its previous (often tall vertical) reservation and the + // rotated strip leaves a large empty gap below it. + if (this.isSeatMapInited) { + setTimeout(() => this._updateHorizontalDims(), 0); + } } // seatJumpTo: scroll to and open tooltip for a specific seat. Tracked by value @@ -728,6 +773,7 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { // Emit initial layout data after the next tick so DOM size is measurable. setTimeout(() => { if (this._flightId !== flightId) return; + this._updateHorizontalDims(); const layout = this._buildLayoutData(); const payload: IInitialLayoutData = { ...layout, @@ -812,10 +858,61 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { const flightId = this._flightId; setTimeout(() => { if (this._flightId !== flightId || !this.isSeatMapInited) return; + this._updateHorizontalDims(); this.layoutUpdated.emit(this._buildLayoutData()); }, 0); } + /** + * In horizontal mode the rotor is rotated 90deg, so its rendered footprint is + * its pre-rotation size swapped. Reserve that footprint on the un-rotated + * container (otherwise it keeps the tall vertical height and leaves a huge + * gap below). `offsetWidth/offsetHeight` are immune to the CSS transform. + * Mirrors React's outer `width: scaledTotalDecksHeight, height: config.width`. + */ + private _updateHorizontalDims(): void { + const rotorEl = this.rotor?.nativeElement; + const containerEl = this.mapContainer?.nativeElement; + if (!(this.resolvedConfig.horizontal && rotorEl && containerEl)) { + this.horizontalContainerWidth = null; + this.horizontalContainerHeight = null; + this.horizontalOffsetX = 0; + this.horizontalOffsetY = 0; + this.cdr.markForCheck(); + return; + } + + // `offsetWidth/offsetHeight` only cover the fuselage box; the side cabin + // labels and wings are absolutely positioned outside it and the nose/tail + // caps bleed past it, so they are excluded. Measure the union of every + // descendant's painted rect (screen coords, post-rotation) to reserve the + // true footprint. + const rotorRect = rotorEl.getBoundingClientRect(); + let minL = rotorRect.left; + let minT = rotorRect.top; + let maxR = rotorRect.right; + let maxB = rotorRect.bottom; + rotorEl.querySelectorAll('*').forEach(node => { + const r = (node as HTMLElement).getBoundingClientRect(); + if (r.width === 0 || r.height === 0) return; + if (r.left < minL) minL = r.left; + if (r.top < minT) minT = r.top; + if (r.right > maxR) maxR = r.right; + if (r.bottom > maxB) maxB = r.bottom; + }); + + this.horizontalContainerWidth = Math.ceil(maxR - minL) || null; + this.horizontalContainerHeight = Math.ceil(maxB - minT) || null; + + // Pull any content overflowing past the container's top-left back inside. + // Compensate for the offset already applied so repeated measurements + // converge instead of oscillating (content rects include the live offset). + const containerRect = containerEl.getBoundingClientRect(); + this.horizontalOffsetX = Math.max(0, Math.ceil(containerRect.left - minL + this.horizontalOffsetX)); + this.horizontalOffsetY = Math.max(0, Math.ceil(containerRect.top - minT + this.horizontalOffsetY)); + this.cdr.markForCheck(); + } + getDeckIndex(deck: IDeckData): number { return this.content.indexOf(deck); } @@ -975,7 +1072,8 @@ export class JetsSeatMapComponent implements OnInit, OnChanges, OnDestroy { element, this.mapContainer.nativeElement, nextPassenger, - this.lang + this.lang, + this.resolvedConfig.horizontal ?? false ); this.activeTooltip = tooltipData; diff --git a/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.spec.ts b/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.spec.ts index 8ee207b..30f384a 100644 --- a/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.spec.ts +++ b/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.spec.ts @@ -491,64 +491,4 @@ describe('JetsSeatComponent', () => { }); }); - // ─── colorfulSeatsByClass ───────────────────────────────────────────── - // - // When the flag is off, every available seat picks up the same theme - // colour regardless of cabin class. When on, the per-class HSL tint - // (or seatClassTints override) makes F / B / P / E visibly distinct. - - describe('colorfulSeatsByClass', () => { - const THEME = { seatAvailableColor: '#888888', forceThemeSeatColors: true }; - - function styleFor( - classType: string, - status: ISeatData['status'] = ENTITY_STATUS_MAP.available - ): { fillColor: string } { - component.colorTheme = THEME; - component.data = makeSeat({ status, color: undefined, classType }); - // Access the private method to assert the colour decision directly, - // without depending on which SVG template the seat-template-service - // happens to pick for a given (class, iconType) pair. - return (component as unknown as { _resolveStyle(c: string): { fillColor: string } })._resolveStyle(classType); - } - - it('default (off) — economy and first share the same fill', () => { - component.colorfulSeatsByClass = false; - expect(styleFor('E').fillColor).toBe('#888888'); - expect(styleFor('F').fillColor).toBe('#888888'); - }); - - it('on — economy renders a lighter tint, first a darker one', () => { - component.colorfulSeatsByClass = true; - const economy = styleFor('E').fillColor; - const first = styleFor('F').fillColor; - expect(economy).not.toBe('#888888'); - expect(first).not.toBe('#888888'); - expect(economy).not.toBe(first); - }); - - it('on — seatClassTints override beats the algorithmic tint', () => { - component.colorfulSeatsByClass = true; - component.colorTheme = { ...THEME, seatClassTints: { B: '#ff0000' } }; - component.data = makeSeat({ - status: ENTITY_STATUS_MAP.available, - color: undefined, - classType: 'B', - }); - const style = (component as unknown as { _resolveStyle(c: string): { fillColor: string } })._resolveStyle('B'); - expect(style.fillColor).toBe('#ff0000'); - }); - - it('on — unavailable seats are not tinted', () => { - component.colorfulSeatsByClass = true; - component.colorTheme = { ...THEME, seatUnavailableColor: '#cccccc' }; - component.data = makeSeat({ - status: ENTITY_STATUS_MAP.unavailable, - color: undefined, - classType: 'F', - }); - const style = (component as unknown as { _resolveStyle(c: string): { fillColor: string } })._resolveStyle('F'); - expect(style.fillColor).toBe('#cccccc'); - }); - }); }); diff --git a/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.ts b/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.ts index 3b98c41..60eaa3a 100644 --- a/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-seat/jets-seat.component.ts @@ -18,7 +18,6 @@ import { DEFAULT_SEAT_TYPE, } from '../../constants'; import { seatTemplateService, ISeatStyle } from '../../services/seat-template.service'; -import { tintSeatColorForClass } from '../../utils/color-tint'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @Component({ @@ -114,11 +113,6 @@ export class JetsSeatComponent implements OnChanges { * forced to a single currency without mutating data. */ @Input() currencyOverride?: string; - /** - * Tint `available` seats by cabin class so different cabins are visually - * distinguishable. Driven by `config.colorfulSeatsByClass`. Default false. - */ - @Input() colorfulSeatsByClass = false; @Input() scale = 1; @Output() seatClick = new EventEmitter<{ seat: ISeatData; @@ -434,13 +428,10 @@ export class JetsSeatComponent implements OnChanges { let fillColor: string; switch (this.data.status) { case 'available': { - let base = + const base = force || availOverride ? (theme.seatAvailableColor ?? def.seatAvailableColor) : (this.data.color ?? def.seatAvailableColor); - if (this.colorfulSeatsByClass) { - base = tintSeatColorForClass(base, classType, theme.seatClassTints); - } fillColor = base; break; } diff --git a/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.spec.ts b/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.spec.ts index 8b074bc..32a8d20 100644 --- a/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.spec.ts +++ b/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.spec.ts @@ -41,4 +41,39 @@ describe('JetsTailComponent', () => { const html: string = fixture.nativeElement.innerHTML; expect(html).toContain('stroke-width:18'); }); + + // Horizontal tail direction — mirrors React Tail/index.js:55 + // transform = isHorizontal && !rightToLeft ? 'rotate(180deg)' : '' + const tailEl = () => fixture.nativeElement.querySelector('.jets-tail') as HTMLElement; + + it('does not rotate the tail in vertical mode', () => { + fixture.componentRef.setInput('horizontal', false); + fixture.detectChanges(); + expect(tailEl().style.transform).not.toContain('rotate'); + }); + + it('rotates the tail 180deg in horizontal LTR mode', () => { + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', false); + fixture.detectChanges(); + expect(tailEl().style.transform).toContain('rotate(180deg)'); + }); + + it('does not rotate the tail in horizontal RTL mode', () => { + fixture.componentRef.setInput('horizontal', true); + fixture.componentRef.setInput('rightToLeft', true); + fixture.detectChanges(); + expect(tailEl().style.transform).not.toContain('rotate'); + }); + + it('scales the tail up to close the fuselage join', () => { + fixture.componentRef.setInput('width', 360); + fixture.componentRef.setInput('colorTheme', { fuselageStrokeWidth: 4 }); + fixture.detectChanges(); + const t = tailEl().style.transform; + expect(t).toContain('scale('); + const scale = Number(t.match(/scale\(([^)]+)\)/)?.[1]); + expect(scale).toBeGreaterThan(1); + expect(scale).toBeLessThan(1.05); + }); }); diff --git a/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.ts b/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.ts index 0d7c707..a5c0365 100644 --- a/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-tail/jets-tail.component.ts @@ -13,7 +13,7 @@ import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, - template: `
`, + template: `
`, styles: [ ` .jets-tail { @@ -36,9 +36,44 @@ export class JetsTailComponent implements OnChanges { /** Display scale (mirrors React's `params.scale`); pre-multiplies the SVG * outline so the tail contour matches the CSS body border thickness. */ @Input() displayScale = 1; + /** Horizontal cabin layout (whole map is rotated 90deg by the parent). */ + @Input() horizontal = false; + /** RTL flips the tail direction in horizontal mode (mirrors React). */ + @Input() rightToLeft = false; svgContent: SafeHtml = ''; + /** SVG viewBox width — the tail template uses 200. */ + private static readonly SVG_WIDTH = 200; + /** The tail outline meets the fuselage at viewBox x=1.5..198.5 (`…H1.5V0h197…`), + * i.e. inset 1.5 units inside the SVG border, so without compensation it + * renders slightly narrower than the fuselage and a step shows at the join. + * Mirrors React `Tail/index.js` `distanceFromBorder`. */ + private static readonly OUTLINE_INSET = 1.5; + + /** Transform for the tail div: + * - `scale()` closes the tail↔fuselage join (outline-inset compensation); + * - `rotate(180deg)` in horizontal LTR so the tail points the React way. */ + get tailTransform(): string { + const rotation = this.horizontal && !this.rightToLeft ? 'rotate(180deg)' : ''; + const scale = this._fuselageJoinScale(); + const scalePart = scale !== 1 ? `scale(${scale})` : ''; + return [rotation, scalePart].filter(Boolean).join(' '); + } + + /** Scale that lines the tail outline centre up with the fuselage border + * centre (outline inset minus the stroke half-width). See JetsNoseComponent. */ + private _fuselageJoinScale(): number { + const svg = JetsTailComponent.SVG_WIDTH; + if (!this.width) return 1; + const t = this.colorTheme ?? {}; + const themedStroke = (t.fuselageStrokeWidth ?? DEFAULT_COLOR_THEME.fuselageStrokeWidth) * this.displayScale; + const strokeSvg = themedStroke / (this.width / svg); + const effectiveInset = JetsTailComponent.OUTLINE_INSET - strokeSvg * 0.5; + if (effectiveInset <= 0) return 1; + return svg / (svg - 2 * effectiveInset); + } + constructor(private sanitizer: DomSanitizer) {} ngOnChanges(): void { diff --git a/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.scss b/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.scss index 183400e..231a7fd 100644 --- a/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.scss +++ b/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.scss @@ -35,6 +35,20 @@ border-bottom: 9px solid var(--tooltip-bg, #fff); } } + + // Horizontal layout: the tooltip lives outside the rotated rotor and is + // anchored by an explicit inline `left`/`top` (the seat's screen position). + // Release the `right: 8px` constraint (which would otherwise squeeze the + // width to a sliver) and drop the vertical translate + seat pointer. + &--horizontal { + right: auto; + width: max-content; + max-width: 320px; + + &::after { + display: none; + } + } } .jets-tooltip--body { diff --git a/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.spec.ts b/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.spec.ts index 2b64f6a..bad37b3 100644 --- a/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.spec.ts +++ b/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { TestBed, ComponentFixture } from '@angular/core/testing'; import { JetsTooltipComponent } from './jets-tooltip.component'; -import { ISeatData, ITooltipData, IPassenger } from '../../types'; +import { ISeatData, ITooltipData } from '../../types'; import { ENTITY_STATUS_MAP, ENTITY_TYPE_MAP } from '../../constants'; function makeSeat(overrides: Partial = {}): ISeatData { @@ -620,4 +620,42 @@ describe('JetsTooltipComponent', () => { expect(component.viewOverride).toBeTruthy(); }); }); + + // ─── Horizontal layout (P1b: tooltip must stay upright & on-screen) ─────── + // The tooltip lives OUTSIDE the rotated rotor (architectural fix), so it is + // never rotated; it is anchored to the seat with an explicit screen-space + // top/left instead of the vertical `left: 8px` + --arrow-left pointer. + describe('Horizontal layout', () => { + const tipEl = () => fixture.nativeElement.querySelector('.jets-tooltip') as HTMLElement; + + it('is not transformed in vertical mode', () => { + component.data = makeTooltipData({ horizontal: false }); + fixture.detectChanges(); + expect(tipEl().style.transform).toBe(''); + }); + + it('is not transformed in horizontal mode (rotation lives on the rotor)', () => { + component.data = makeTooltipData({ horizontal: true }); + fixture.detectChanges(); + expect(tipEl().style.transform).toBe(''); + }); + + it('tags the tooltip with the horizontal class', () => { + component.data = makeTooltipData({ horizontal: true }); + fixture.detectChanges(); + expect(tipEl().classList.contains('jets-tooltip--horizontal')).toBe(true); + }); + + it('anchors the tooltip with an explicit left in horizontal mode', () => { + component.data = makeTooltipData({ horizontal: true, left: 120, top: 60 }); + fixture.detectChanges(); + expect(tipEl().style.left).toBe('120px'); + }); + + it('keeps the CSS left (no inline left) in vertical mode', () => { + component.data = makeTooltipData({ horizontal: false, left: 120 }); + fixture.detectChanges(); + expect(tipEl().style.left).toBe(''); + }); + }); }); diff --git a/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.ts b/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.ts index 2c1adc2..0577990 100644 --- a/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.ts +++ b/projects/seatmap-lib/src/lib/components/jets-tooltip/jets-tooltip.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, inject, Input, Output, Type } from '@angular/core'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { CommonModule, NgComponentOutlet } from '@angular/common'; -import { IColorTheme, IPassenger, ISeatData, ISeatFeature, ITooltipData } from '../../types'; +import { IColorTheme, ISeatData, ISeatFeature, ITooltipData } from '../../types'; import { LOCALES_MAP } from '../../constants'; @Component({ @@ -31,7 +31,9 @@ import { LOCALES_MAP } from '../../constants'; class="jets-tooltip" [class.jets-tooltip--below]="!sidePanel && data.openBelow" [class.jets-tooltip--side-panel]="sidePanel" + [class.jets-tooltip--horizontal]="data.horizontal" [style.top.px]="sidePanel ? null : data.top" + [style.left.px]="data.horizontal ? data.left : null" [style.--arrow-left]="sidePanel ? null : data.left + 'px'" [style.font-family]="colorTheme?.fontFamily || null" [style.--tooltip-bg]="colorTheme?.tooltipBackgroundColor || null" diff --git a/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts b/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts index 1679233..50518b0 100644 --- a/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts +++ b/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.spec.ts @@ -375,21 +375,15 @@ describe('JetsSeatMapPreparerService', () => { expect(seat.color).toBe('#6CB64A'); }); - it('new format: API seat.color wins when colorfulSeatsByScore is false', () => { + it('new format: class colour fills in when score has no matching range', () => { const response: IApiSeatmapResponse = { decks: [ { rows: [ { + classCode: 'F', seats: [ - { - letter: 'A', - seatNumber: '1A', - type: 0, - seatType: 0, - score: 2, - color: '#6CB64A', - } as any, + { letter: 'A', seatNumber: '1A', type: 0, seatType: 0, score: 11, color: '#6CB64A' } as any, ], }, ], @@ -397,10 +391,34 @@ describe('JetsSeatMapPreparerService', () => { ], }; const seat = service.prepareContent(response, { - ...configWithRanges, - colorfulSeatsByScore: false, + ...baseConfig, + colorTheme: { customSeatColorRanges: ranges, customSeatColorClasses: { F: '#123456' } }, })[0].rows[0].seats[0]; - expect(seat.color).toBe('#6CB64A'); + // score 11 is out of every range -> class palette (#123456) wins over API #6CB64A + expect(seat.color).toBe('#123456'); + }); + + it('new format: score range wins over class palette when both match', () => { + const response: IApiSeatmapResponse = { + decks: [ + { + rows: [ + { + classCode: 'F', + seats: [ + { letter: 'A', seatNumber: '1A', type: 0, seatType: 0, score: 2, color: '#6CB64A' } as any, + ], + }, + ], + }, + ], + }; + const seat = service.prepareContent(response, { + ...baseConfig, + colorTheme: { customSeatColorRanges: ranges, customSeatColorClasses: { F: '#123456' } }, + })[0].rows[0].seats[0]; + // score 2 in [1,3] -> #FF0000 wins over class palette + expect(seat.color).toBe('#FF0000'); }); it('new format: API seat.color wins when no ranges are configured', () => { @@ -448,6 +466,56 @@ describe('JetsSeatMapPreparerService', () => { const seat = service.prepareContent(response, configWithRanges)[0].rows[0].seats[0]; expect(seat.color).toBe('#00FF00'); }); + + it('legacy format: class colour fills in when score has no matching range', () => { + const response: IApiSeatmapResponse = { + decks: [ + { + rows: [ + { + seatScheme: 'S', + seatType: 0, + number: 1, + name: '1', + classCode: 'F', + apiSeats: [{ letter: 'A', score: 11, color: '#6CB64A', available: true } as any], + } as any, + ], + }, + ], + }; + const seat = service.prepareContent(response, { + ...baseConfig, + colorTheme: { customSeatColorRanges: ranges, customSeatColorClasses: { F: '#123456' } }, + })[0].rows[0].seats[0]; + // score 11 is out of every range -> class palette (#123456) wins over API #6CB64A + expect(seat.color).toBe('#123456'); + }); + + it('legacy format: score range wins over class palette when both match', () => { + const response: IApiSeatmapResponse = { + decks: [ + { + rows: [ + { + seatScheme: 'S', + seatType: 0, + number: 1, + name: '1', + classCode: 'F', + apiSeats: [{ letter: 'A', score: 2, color: '#6CB64A', available: true } as any], + } as any, + ], + }, + ], + }; + const seat = service.prepareContent(response, { + ...baseConfig, + colorTheme: { customSeatColorRanges: ranges, customSeatColorClasses: { F: '#123456' } }, + })[0].rows[0].seats[0]; + // score 2 in [1,3] -> #FF0000 wins over class palette + expect(seat.color).toBe('#FF0000'); + }); }); }); @@ -489,13 +557,28 @@ describe('JetsSeatMapPreparerService', () => { expect(JetsSeatMapPreparerService._calculateSeatColorByScore(10, ranges)).toBe('#00FF00'); }); - it('returns null when the score gate is off, even with valid score and ranges', () => { - expect(JetsSeatMapPreparerService._calculateSeatColorByScore(5, ranges, false)).toBeNull(); + }); + + // ─── _calculateSeatColorByClass (static) ───────────────────────────────── + + describe('_calculateSeatColorByClass', () => { + it('returns the mapped colour for the seat class (case-insensitive)', () => { + const map = { F: '#ff0000', E: '#0000ff' }; + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('F', map)).toBe('#ff0000'); + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('e', map)).toBe('#0000ff'); }); - it('defaults the score gate to enabled', () => { - expect(JetsSeatMapPreparerService._calculateSeatColorByScore(5, ranges)).toBe('#FFFF00'); - expect(JetsSeatMapPreparerService._calculateSeatColorByScore(5, ranges, true)).toBe('#FFFF00'); + it('returns null when the class has no mapping', () => { + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('B', { F: '#ff0000' })).toBeNull(); + }); + + it('returns null for missing class code or map', () => { + expect(JetsSeatMapPreparerService._calculateSeatColorByClass(undefined, { F: '#ff0000' })).toBeNull(); + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('F', undefined)).toBeNull(); + }); + + it('returns null for an empty colour string', () => { + expect(JetsSeatMapPreparerService._calculateSeatColorByClass('F', { F: '' })).toBeNull(); }); }); @@ -537,6 +620,13 @@ describe('JetsSeatMapPreparerService', () => { expect(result.seatAvailableColor).toBe('#123456'); expect(result.floorColor).toBe('#654321'); }); + + it('should filter invalid customSeatColorClasses entries', () => { + const result = JetsSeatMapPreparerService.mergeColorThemeWithConstraints({ + customSeatColorClasses: { F: '#FF0000', E: '', B: 123 as unknown as string }, + }); + expect(result.customSeatColorClasses).toEqual({ F: '#FF0000' }); + }); }); // ─── prepareSeatAdditionalProps ─────────────────────────────────────────── @@ -709,6 +799,40 @@ describe('JetsSeatMapPreparerService', () => { expect(bulk?.height).toBeCloseTo(46 / 0.7, 5); }); + it('uses the real row bbox (per-seat topOffset) so a staggered Business pod does not falsely shrink the next bulk', () => { + // Repro: Lufthansa A350 LH470 row 4 — seats 4C/4H sit at topOffset=+45 inside the row, so + // the row's true bottom extends 45 units past `row.topOffset + max(seatH)`. With the + // pre-fix bbox (max-seatH only), the bottom is under-reported, the galley bulk just below + // looks merely lightly grazed, and the preparer shrinks it to ~22% — a sliver that renders + // as an invisible 11-px strip while the galley sticker still floats above. With the + // per-seat bbox the algorithm sees an overlap deep enough to trip the safety floor and + // keeps the bulk in its original geometry. + const response: IApiSeatmapResponse = { + decks: [ + { + rows: [ + { + topOffset: 1030, + seatType: 7, + seats: [ + { letter: 'A', seatNumber: '4A', topOffset: 0, available: true }, + { letter: 'C', seatNumber: '4C', topOffset: 45, available: true }, + { letter: 'D', seatNumber: '4D', topOffset: -172, available: true }, + { letter: 'H', seatNumber: '4H', topOffset: 45, available: true }, + { letter: 'K', seatNumber: '4K', topOffset: 0, available: true }, + ], + }, + ], + bulks: [{ id: '7', iconType: 'G', topOffset: 1066, height: 308, width: 400, align: 'center' }], + }, + ], + }; + const result = service.prepareContent(response, baseConfig); + const bulk = result[0].extras?.bulks?.[0]; + expect(bulk?.topOffset).toBe(1066); + expect(bulk?.height).toBe(308); + }); + it('respects a custom partitionGap larger than the default', () => { // Row [0,100], bulk [80,130], gap=20 → new top = 100 + 20 = 120, height = 50 - 40 = 10. const response: IApiSeatmapResponse = { diff --git a/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts b/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts index 303addd..7d42004 100644 --- a/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts +++ b/projects/seatmap-lib/src/lib/services/jets-seat-map-preparer.service.ts @@ -14,6 +14,7 @@ import { IRowData, ISeatData, ISeatFeature, + TCabinClass, TSeatStatus, TSeatType, IWingsInfo, @@ -21,7 +22,6 @@ import { import { API_SEAT_TYPE_MAP, DEFAULT_SEAT_SIZE, - FEATURE_ICONS, LOCALES_MAP, SEAT_FEATURES_ICONS, SEAT_LETTERS, @@ -33,7 +33,7 @@ import { ENTITY_TYPE_MAP, } from '../constants'; import { BULK_SCALE_BY_ID, DEFAULT_BULK_SCALE } from './bulk-template.service'; -import { getNativeRowHeight } from '../utils/cabin-utils'; +import { getNativeRowBBox, getNativeRowHeight } from '../utils/cabin-utils'; /** * Default native-unit gap inserted between a row's physical bbox and a @@ -181,8 +181,7 @@ export class JetsSeatMapPreparerService { config.lang, config.colorTheme, flightAmenities, - config.units, - config.colorfulSeatsByScore ?? true + config.units ); if (classChanged) rendered.cabinClassCode = cabinClass.toUpperCase(); // Always propagate cabinClassCode for cabin filtering @@ -230,8 +229,7 @@ export class JetsSeatMapPreparerService { lang = 'EN', colorTheme?: import('../types').IColorTheme, flightAmenities: ISeatFeature[] = [], - units?: string, - colorfulSeatsByScore = true + units?: string ): IRowData { const seats = row.seats ?? []; // Row-level seatType fallback (matches React's _rowSeatType) @@ -307,21 +305,13 @@ export class JetsSeatMapPreparerService { const seatAmenities = seatFeatures.filter(f => !f.key || !flightKeys.has(f.key)); const features = [...flightAmenities, ...seatAmenities]; // React parity (data-preparer.js:371): score-range colour wins over the - // API's `seat.color`. The customSeatColorRanges contract is "the theme - // overrides whatever the seat ships with for this score band" — so the - // matcher comes first; if it returns null (no ranges configured, gate - // off, score missing/out of band), we fall back to the per-seat API - // colour. + // API's `seat.color`. Resolution order: score range > class palette > API colour. + const classCode = (row.classCode ?? row.cabinClass ?? s.classType ?? 'E').toUpperCase(); const seatColor = - JetsSeatMapPreparerService._calculateSeatColorByScore( - s.score, - colorTheme?.customSeatColorRanges, - colorfulSeatsByScore - ) ?? + JetsSeatMapPreparerService._calculateSeatColorByScore(s.score, colorTheme?.customSeatColorRanges) ?? + JetsSeatMapPreparerService._calculateSeatColorByClass(classCode, colorTheme?.customSeatColorClasses) ?? s.color ?? undefined; - - const classCode = (row.classCode ?? row.cabinClass ?? s.classType ?? 'E').toUpperCase(); return { id: `seat-${rowIndex}-${i}`, uniqId: genFeatureId(), @@ -578,9 +568,9 @@ export class JetsSeatMapPreparerService { const rowBoxes: Array<{ top: number; bottom: number }> = []; for (const r of rows) { if (r.topOffset == null) continue; - const h = getNativeRowHeight(r); - if (h <= 0) continue; - rowBoxes.push({ top: r.topOffset, bottom: r.topOffset + h }); + const { topRel, bottomRel } = getNativeRowBBox(r); + if (bottomRel <= topRel) continue; + rowBoxes.push({ top: r.topOffset + topRel, bottom: r.topOffset + bottomRel }); } if (!rowBoxes.length) return bulks; @@ -826,17 +816,13 @@ export class JetsSeatMapPreparerService { const seatScore = newSeat?.score ?? legacyAny?.score; const seatApiColor = newSeat?.color ?? legacyAny?.color; const seatAvailable = newSeat?.available ?? legacyAny?.available; - // React parity — see new-format path above for the rationale. + // React parity — resolution order: score range > class palette > API colour. + const classCode = (row.classCode ?? row.cabinClass ?? newSeat?.classType ?? 'E').toUpperCase(); const seatColor = - JetsSeatMapPreparerService._calculateSeatColorByScore( - seatScore, - config.colorTheme?.customSeatColorRanges, - config.colorfulSeatsByScore ?? true - ) ?? + JetsSeatMapPreparerService._calculateSeatColorByScore(seatScore, config.colorTheme?.customSeatColorRanges) ?? + JetsSeatMapPreparerService._calculateSeatColorByClass(classCode, config.colorTheme?.customSeatColorClasses) ?? seatApiColor ?? undefined; - - const classCode = (row.classCode ?? row.cabinClass ?? newSeat?.classType ?? 'E').toUpperCase(); return { id: `seat-${rowIndex}-${i}`, uniqId: genFeatureId(), @@ -1243,11 +1229,9 @@ export class JetsSeatMapPreparerService { /** Map seat score (1-10) to color using configurable ranges */ static _calculateSeatColorByScore( score: number | undefined, - colorRanges?: Array<{ range: [number, number]; color: string }>, - enabled = true + colorRanges?: Array<{ range: [number, number]; color: string }> ): string | null { if ( - !enabled || typeof score !== 'number' || score < 1 || score > 10 || @@ -1260,6 +1244,17 @@ export class JetsSeatMapPreparerService { return found?.color ?? null; } + static _calculateSeatColorByClass( + classCode: string | undefined, + classMap?: Partial> + ): string | null { + if (!classCode || !classMap) { + return null; + } + const color = classMap[classCode.toUpperCase() as TCabinClass]; + return typeof color === 'string' && color.length > 0 ? color : null; + } + /** Merge user-provided color theme with defaults and apply constraints */ static mergeColorThemeWithConstraints( theme: import('../types').IColorTheme | undefined @@ -1282,6 +1277,17 @@ export class JetsSeatMapPreparerService { r.color.length > 0 ); } + // Validate customSeatColorClasses — keep only non-empty string colours + if (merged.customSeatColorClasses) { + const cleaned: Partial> = {}; + for (const key of Object.keys(merged.customSeatColorClasses) as TCabinClass[]) { + const value = merged.customSeatColorClasses[key]; + if (typeof value === 'string' && value.length > 0) { + cleaned[key] = value; + } + } + merged.customSeatColorClasses = cleaned; + } return merged; } diff --git a/projects/seatmap-lib/src/lib/services/jets-seat-map.service.ts b/projects/seatmap-lib/src/lib/services/jets-seat-map.service.ts index a9313ae..6b79157 100644 --- a/projects/seatmap-lib/src/lib/services/jets-seat-map.service.ts +++ b/projects/seatmap-lib/src/lib/services/jets-seat-map.service.ts @@ -287,14 +287,17 @@ export class JetsSeatMapService { seatElement: HTMLElement, mapElement: HTMLElement, nextPassenger: IPassenger | null, - lang: string + lang: string, + isHorizontal = false ): ITooltipData { const seatRect = seatElement.getBoundingClientRect(); const mapRect = mapElement.getBoundingClientRect(); // Determine whether to open tooltip above or below the seat. // Use the seat's position in the browser viewport (not the map container) - // so scrolling the page is accounted for. + // so scrolling the page is accounted for. Both axes use SCREEN coords, so + // this works in horizontal mode too (the seat is inside the rotated rotor, + // but the tooltip is anchored in the un-rotated container). const viewportHeight = window.innerHeight; const openBelow = seatRect.top < viewportHeight * 0.35; @@ -309,9 +312,19 @@ export class JetsSeatMapService { top = seatRect.top - mapRect.top + mapElement.scrollTop - gap; } - // Calculate the horizontal center of the seat relative to the tooltip's left edge. - // The tooltip has CSS `left: 8px`, so subtract that offset. - const arrowLeft = seatRect.left - mapRect.left + seatRect.width / 2 - 8; + // Seat centre, relative to the map container's left edge. + const seatCenterLeft = seatRect.left - mapRect.left + seatRect.width / 2; + + if (isHorizontal) { + // The rotated cabin is very wide, so anchor the tooltip at the seat's + // x (explicit `left`) instead of the vertical `left: 8px` + arrow. The + // tooltip lives outside the rotor, so screen-space coords place it + // correctly and upright. `--horizontal` CSS frees the `right` constraint. + return { seat, top, left: seatCenterLeft, nextPassenger, lang: lang as any, openBelow, horizontal: true }; + } + + // The tooltip has CSS `left: 8px`, so subtract that offset for the arrow. + const arrowLeft = seatCenterLeft - 8; return { seat, top, left: arrowLeft, nextPassenger, lang: lang as any, openBelow }; } diff --git a/projects/seatmap-lib/src/lib/types.ts b/projects/seatmap-lib/src/lib/types.ts index 98aa5a8..93352f4 100644 --- a/projects/seatmap-lib/src/lib/types.ts +++ b/projects/seatmap-lib/src/lib/types.ts @@ -130,11 +130,11 @@ export interface IColorTheme { // Score-based seat coloring customSeatColorRanges?: Array<{ range: [number, number]; color: string }>; /** - * Optional per-class override palette used when `colorfulSeatsByClass` - * is enabled in IConfig. If a key is present for a cabin class, the - * algorithmic HSL tint is skipped and this colour wins. + * Per-class flat colour palette. When a colour is set for a seat's cabin + * class, available seats of that class render in it (below score ranges, + * above the API seat colour). Data-driven counterpart to customSeatColorRanges. */ - seatClassTints?: Partial>; + customSeatColorClasses?: Partial>; /** When true, theme seat colors override API-provided per-seat colors */ forceThemeSeatColors?: boolean; } @@ -183,20 +183,6 @@ export interface IConfig { * upper/lower split. Default false keeps the existing two-tone look. */ flatBulks?: boolean; - /** - * When true, `available` seats are tinted by cabin class so the - * boundaries between F / B / P / E are visible. The tint is applied - * on top of any score-based or API colour. Default false. - */ - colorfulSeatsByClass?: boolean; - /** - * Gate the `IColorTheme.customSeatColorRanges` score-based seat - * colouring. Default true keeps the legacy behaviour — when the theme - * provides ranges and a seat has a `score`, the seat picks up the - * matched colour. Set false to ignore ranges and fall back to - * `seatAvailableColor` (so only `colorfulSeatsByClass` remains). - */ - colorfulSeatsByScore?: boolean; currencySign?: string; externalPassengerManagement?: boolean; scaleType?: TScaleType; @@ -428,6 +414,10 @@ export interface ITooltipData { nextPassenger: IPassenger | null; lang: TLang; openBelow?: boolean; + /** Horizontal layout — the tooltip counter-rotates so it stays upright and + * on-screen while the map itself is rotated 90deg. `top`/`left` then carry + * the seat's layout-space position (immune to the CSS rotation). */ + horizontal?: boolean; } // ─── Events ────────────────────────────────────────────────────────────────── diff --git a/projects/seatmap-lib/src/lib/utils/cabin-utils.ts b/projects/seatmap-lib/src/lib/utils/cabin-utils.ts index 9e4dcb6..33d1813 100644 --- a/projects/seatmap-lib/src/lib/utils/cabin-utils.ts +++ b/projects/seatmap-lib/src/lib/utils/cabin-utils.ts @@ -37,6 +37,36 @@ export function getNativeRowHeight(row: IRowData): number { return maxH; } +/** + * Native (unscaled) vertical bounding box of a row's non-aisle seats, + * relative to the row's own topOffset. Unlike `getNativeRowHeight`, which + * only returns `max(seatH)`, this includes per-seat `topOffset` so that + * staggered Business pods report their real extent. Example — Lufthansa + * A350 row 4: seat 4D sits at topOffset=-172 (pulled forward into the + * previous row), seats 4C/4H at +45 (pushed back behind 4A/4K). The true + * row bbox is [-172, 45 + 200] = [-172, 245], not [0, 200]. The collision + * pass in `_resolveBulkOverlaps` needs the real bbox: when the bottom edge + * is under-reported, a galley partition gets falsely shrunk to a sliver + * (its `nativeH` lands just above the 20% safety floor) and renders as + * an invisible 11-px strip. Aisle seats are ignored. + */ +export function getNativeRowBBox(row: IRowData): { topRel: number; bottomRel: number } { + const seats = row.seats; + if (!seats?.length) return { topRel: 0, bottomRel: 0 }; + let minTop = Infinity; + let maxBottom = -Infinity; + for (const s of seats) { + if (s.type === 'aisle') continue; + const entry = SEAT_SIZE_BY_TYPE[s.seatIconType ?? DEFAULT_SEAT_TYPE]; + const h = entry ? entry[1] : 100; + const t = s.topOffset ?? 0; + if (t < minTop) minTop = t; + if (t + h > maxBottom) maxBottom = t + h; + } + if (!isFinite(minTop)) return { topRel: 0, bottomRel: 0 }; + return { topRel: minTop, bottomRel: maxBottom }; +} + /** * Split a deck into per-cabin sub-decks with rebased coordinates. * Each sub-deck is self-contained: its rows start at topOffset ≈ 0, diff --git a/projects/seatmap-lib/src/lib/utils/color-tint.spec.ts b/projects/seatmap-lib/src/lib/utils/color-tint.spec.ts deleted file mode 100644 index 05186d1..0000000 --- a/projects/seatmap-lib/src/lib/utils/color-tint.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { tintSeatColorForClass, adjustLightness } from './color-tint'; - -describe('adjustLightness', () => { - it('returns the same hex when delta is 0', () => { - expect(adjustLightness('#888888', 0)).toBe('#888888'); - }); - - it('lightens a hex colour', () => { - const lighter = adjustLightness('#888888', 10); - expect(lighter).not.toBe('#888888'); - expect(parseInt(lighter.slice(1, 3), 16)).toBeGreaterThan(0x88); - }); - - it('darkens a hex colour', () => { - const darker = adjustLightness('#888888', -10); - expect(darker).not.toBe('#888888'); - expect(parseInt(darker.slice(1, 3), 16)).toBeLessThan(0x88); - }); - - it('accepts shorthand hex (#rgb)', () => { - const out = adjustLightness('#888', 10); - expect(out).toMatch(/^#[0-9a-f]{6}$/); - }); - - it('accepts rgb(...) notation', () => { - const out = adjustLightness('rgb(136, 136, 136)', 10); - expect(out).toMatch(/^#[0-9a-f]{6}$/); - }); - - it('returns input unchanged for unrecognised formats (named colour)', () => { - expect(adjustLightness('dimgrey', 10)).toBe('dimgrey'); - }); - - it('clamps lightness at 0 and 100', () => { - expect(adjustLightness('#000000', -50)).toBe('#000000'); - expect(adjustLightness('#ffffff', 50)).toBe('#ffffff'); - }); -}); - -describe('tintSeatColorForClass', () => { - const BASE = '#4cAF50'; - - it('returns base color for class A (no delta)', () => { - expect(tintSeatColorForClass(BASE, 'A')).toBe(BASE); - }); - - it('produces a lighter colour for economy (E) than for first (F)', () => { - const economy = tintSeatColorForClass(BASE, 'E'); - const first = tintSeatColorForClass(BASE, 'F'); - expect(economy).not.toBe(BASE); - expect(first).not.toBe(BASE); - expect(economy).not.toBe(first); - }); - - it('uppercases classType', () => { - expect(tintSeatColorForClass(BASE, 'e')).toBe(tintSeatColorForClass(BASE, 'E')); - }); - - it('lets themeOverrides win over algorithmic tint', () => { - expect(tintSeatColorForClass(BASE, 'B', { B: '#ff0000' })).toBe('#ff0000'); - }); - - it('falls back to algorithm when override key is missing for the class', () => { - const out = tintSeatColorForClass(BASE, 'E', { F: '#ff0000' }); - expect(out).not.toBe('#ff0000'); - expect(out).not.toBe(BASE); - }); - - it('returns base color for unknown class codes', () => { - expect(tintSeatColorForClass(BASE, 'X')).toBe(BASE); - }); -}); diff --git a/projects/seatmap-lib/src/lib/utils/color-tint.ts b/projects/seatmap-lib/src/lib/utils/color-tint.ts deleted file mode 100644 index 4200a9d..0000000 --- a/projects/seatmap-lib/src/lib/utils/color-tint.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { TCabinClass } from '../types'; - -/** - * Per-class lightness offset (in HSL L percent points) used when - * `colorfulSeatsByClass` is on and the theme does not provide an explicit - * override. Values chosen to be visible but subtle. - */ -const CLASS_LIGHTNESS_DELTA: Record = { - F: -10, - B: -5, - P: 5, - E: 10, - A: 0, -}; - -export function tintSeatColorForClass( - baseColor: string, - classType: string, - themeOverrides?: Partial> -): string { - const cls = (classType?.toUpperCase() ?? 'E') as TCabinClass; - const override = themeOverrides?.[cls]; - if (override) return override; - const delta = CLASS_LIGHTNESS_DELTA[cls]; - if (!delta) return baseColor; - return adjustLightness(baseColor, delta); -} - -/** - * Shift the HSL lightness of a hex (#rgb / #rrggbb) or rgb()/rgba() colour - * by `deltaPercent` (clamped to [0,100]). Unrecognised formats — for - * example named CSS colours like `dimgrey` — are returned unchanged. - */ -export function adjustLightness(color: string, deltaPercent: number): string { - const rgb = parseColorToRgb(color); - if (!rgb) return color; - const [h, s, l] = rgbToHsl(rgb.r, rgb.g, rgb.b); - const newL = clamp(l + deltaPercent, 0, 100); - const [r2, g2, b2] = hslToRgb(h, s, newL); - return rgbToHex(r2, g2, b2); -} - -function parseColorToRgb(color: string): { r: number; g: number; b: number } | null { - if (!color) return null; - const trimmed = color.trim(); - const hex = parseHex(trimmed); - if (hex) return hex; - const rgb = parseRgb(trimmed); - if (rgb) return rgb; - return null; -} - -function parseHex(color: string): { r: number; g: number; b: number } | null { - const m3 = /^#([0-9a-f]{3})$/i.exec(color); - if (m3) { - const [r, g, b] = m3[1].split('').map(c => parseInt(c + c, 16)); - return { r, g, b }; - } - const m6 = /^#([0-9a-f]{6})$/i.exec(color); - if (m6) { - const v = m6[1]; - return { - r: parseInt(v.slice(0, 2), 16), - g: parseInt(v.slice(2, 4), 16), - b: parseInt(v.slice(4, 6), 16), - }; - } - return null; -} - -function parseRgb(color: string): { r: number; g: number; b: number } | null { - const m = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color); - if (!m) return null; - return { - r: clamp(Math.round(parseFloat(m[1])), 0, 255), - g: clamp(Math.round(parseFloat(m[2])), 0, 255), - b: clamp(Math.round(parseFloat(m[3])), 0, 255), - }; -} - -function rgbToHsl(r: number, g: number, b: number): [number, number, number] { - const rn = r / 255, - gn = g / 255, - bn = b / 255; - const max = Math.max(rn, gn, bn); - const min = Math.min(rn, gn, bn); - const l = (max + min) / 2; - let h = 0, - s = 0; - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case rn: - h = (gn - bn) / d + (gn < bn ? 6 : 0); - break; - case gn: - h = (bn - rn) / d + 2; - break; - case bn: - h = (rn - gn) / d + 4; - break; - } - h *= 60; - } - return [h, s * 100, l * 100]; -} - -function hslToRgb(h: number, s: number, l: number): [number, number, number] { - const sn = s / 100, - ln = l / 100; - const c = (1 - Math.abs(2 * ln - 1)) * sn; - const hp = h / 60; - const x = c * (1 - Math.abs((hp % 2) - 1)); - let r = 0, - g = 0, - b = 0; - if (hp >= 0 && hp < 1) [r, g, b] = [c, x, 0]; - else if (hp < 2) [r, g, b] = [x, c, 0]; - else if (hp < 3) [r, g, b] = [0, c, x]; - else if (hp < 4) [r, g, b] = [0, x, c]; - else if (hp < 5) [r, g, b] = [x, 0, c]; - else [r, g, b] = [c, 0, x]; - const m = ln - c / 2; - return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)]; -} - -function rgbToHex(r: number, g: number, b: number): string { - const h = (v: number) => v.toString(16).padStart(2, '0'); - return `#${h(r)}${h(g)}${h(b)}`; -} - -function clamp(v: number, lo: number, hi: number): number { - return Math.max(lo, Math.min(hi, v)); -}