mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S20): ajuste lateral da foto do card time sem limite artificial (Closes #1216)
This commit is contained in:
parent
8b74aa705a
commit
e652f0ae45
8 changed files with 524 additions and 37 deletions
1
docs/changelog/2026-09-19-s20-card-ajuste-lateral.md
Normal file
1
docs/changelog/2026-09-19-s20-card-ajuste-lateral.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S20 — Card "Time de você": ajuste lateral da foto sem limite artificial (2026-09-19):** o visitante empurrava a própria foto e ela parava no meio do caminho (curso lateral de 248px de card, ≈±124px do centro, numa foto 3:4) porque o clamp do S13/S18 prendia o canvas inteiro do recorte à janela da arte — o padding transparente consumia o gesto. A math pura `clampCardPhotoTransform` ganha o contexto opcional `CardPhotoClamp = { minZoom?, anchorBox? }` (omitido = S13/S18 byte a byte, os 3 modelos e o `renderPhotoCard` intocados): no eixo X o range vira a **união** do alcance de hoje com o da âncora — o intervalo em que a caixa-âncora toca as duas bordas da janela `{286,439,592,577}` — e o eixo Y permanece o de hoje (ajuste lateral, sem surpresa vertical). A âncora é `resolveCardPhotoAnchor(bbox, face)` (regra `face usável ? face : silhueta`, fonte única reusada pelo enquadramento S18 via `isUsableCardFace`, agora exigindo `x/y/width/height` finitos); caixa mais larga que a janela ou fora da foto cai no clamp de hoje (nunca pior, sem gap). O `CardCutoutState.ready` passa a guardar `bbox`/`face` (antes descartados), o composer memoiza a âncora e leva o mesmo contexto ao arrasto, setas, teclado e zoom, e `TeamCardRenderArgs.anchorBox` mantém desenho e write-back idempotentes — sem snap-back; o PNG continua saindo do canvas já pintado. Por construção o rosto desenhado na referência 103 dá curso `592 − 103 = 489px` (≈±244,5 do centro inicial, ~2× o baseline) e o fallback sem rosto herda o piso pela união. Sem controle/copy/migration/Consent, 100% no aparelho; detector, harmonização, banners, troca de foto e demais cards intocados. Testes: unit do clamp com âncora (curso derivado, grid zoom×âncora×offset "nunca pior", range disjunto fora da fonte, idempotência, Y intocado) + `renderTeamCard` com/sem âncora; e2e `frontend` 32/32 sem edição (S14/S15/S17/S18 pinam o inicial e o write-back). Craft no browser com stub determinístico (foto 3:4): 21 passos à direita + 41 à esquerda até as bordas (~489px), sem snap-back e sem erro de console. Verificação: `pnpm gate:fast` verde (4182 unit), e2e da superfície verde, knip/cycles verdes.
|
||||
172
docs/plans/cards-time-de-voce-ajuste-lateral-impl.md
Normal file
172
docs/plans/cards-time-de-voce-ajuste-lateral-impl.md
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
# Impl: Card "Time de você": ajuste lateral da foto sem limite artificial
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-19
|
||||
Issue: #1216
|
||||
Intenção: docs/plans/cards-time-de-voce-ajuste-lateral.md
|
||||
Appetite restante: herdado — ~0,5–1 dia eng; sem migration; 3 fases somam ≤1 dia (tracer puro ~0,4 + threading ~0,3–0,4 + gates ~0,2)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o visitante empurra a própria foto lateralmente até o rosto medido (caixa do detector) tocar as bordas da área livre da arte (janela `{x:286,y:439,w:592,h:577}`); por construção o curso X vira `592 − largura do rosto desenhado` (≈489px com a referência 103 → ≈±244px a partir do centro inicial, ~2× os ~248px de hoje). Sem controle novo; sem rosto medido a âncora é a silhueta e o alcance nunca fica pior que o de hoje; enquadramento automático S18, banners, harmonização, os 3 modelos anteriores e o download ficam intocados; tudo no aparelho.
|
||||
- **O que NÃO negociar:**
|
||||
- O enquadramento inicial continua o de hoje, centrado, com o clamp de hoje — o alcance maior é só do ajuste do visitante (a S18 e o e2e do slider não podem mudar).
|
||||
- Os 3 modelos S13/S14 (minZoom default 1, clamp cover/contain) e `renderPhotoCard` ficam intocados; `frameCardPhotoOnBbox` tem assinatura/comportamento pinados; `frameCardPhotoOnFace` é o caminho do time e pode evoluir.
|
||||
- Sem rosto: âncora pela silhueta (alpha bbox), alcance NUNCA pior que o de hoje, sem aviso/erro novo.
|
||||
- Sem controle/copy novos, sem migration/Consent/access, sem telemetria/persistência; a caixa do rosto é a do momento do recorte (nunca re-detecta em pan/zoom).
|
||||
- Ajuste LATERAL (X); Y do time não regride.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- "O clamp é local a `cardPhotoTransform.ts`": o clamp precisa chegar também a `cardRender.renderTeamCard` — o render re-limita e devolve (`cardRender.ts:240-271`), e o composer grava de volta (`CardComposer.tsx:276-278`); se divergirem, o alcance é desfeito no paint seguinte.
|
||||
- "O state `ready` já tem o que precisa": hoje `useCardCutout` **descarta** `bbox`/`face` (`useCardCutout.ts:88-95`) — sem eles não há âncora no momento do pan/zoom.
|
||||
- "±244px é simétrico": é a viagem a partir do centro inicial, com o rosto desenhado no tamanho da referência; o invariante real é o **curso total = janela − largura da caixa-âncora desenhada** (muda com o zoom; a âncora é a caixa inteira dentro da janela — é por isso 244 e não 296).
|
||||
- "A janela garante não cobrir rostos dos candidatos": não garante sozinha (c2/Solla/c4 invadem a janela — achado 9 da exploração). A barreira aprovada no gate é o limite da janela; colisão por caixa vira **limite registrado**, não código (ver D2/D4 e Riscos).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
CUT["cardCutout — canvas + bbox + face"] --> HOOK["useCardCutout — ready guarda bbox + face"]
|
||||
HOOK --> RES["resolveCardPhotoAnchor — face usável ? face : bbox"]
|
||||
RES --> CLAMP["clampCardPhotoTransform — contexto { minZoom, anchorBox }"]
|
||||
CLAMP -->|"X: união (alcance de hoje ∪ âncora)"| PAN["pan/zoom — arrasto, setas, teclado"]
|
||||
CLAMP -->|"Y: clamp de hoje"| PAN
|
||||
PAN --> REN["renderTeamCard — mesmo contexto no desenho e no retorno"]
|
||||
REN --> WB["write-back idempotente — sem snap-back"]
|
||||
REN --> DL["PNG do canvas já pintado (sem re-render)"]
|
||||
TODAY["sem anchorBox — 3 modelos S13/S14"] -.-> CLAMP
|
||||
INIT["frameCardPhotoOnFace — enquadramento inicial de hoje"] -.->|"só minZoom"| CLAMP
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) régua pura parametrizada por uma caixa-âncora (rosto ou silhueta) + contexto de clamp opcional threaded até o render, com X = união do alcance de hoje | B) liberar o pan do time só no composer e parar de re-limitar no render | C) clamp com colisão contra as caixas dos rostos dos candidatos | D) mexer no enquadramento inicial ou no clamp global dos 3 modelos.
|
||||
**Recomendação:** A — concentra a política no dono (`src/lib/cardPhotoTransform.ts`), preserva os 3 modelos e o `renderPhotoCard` pelo default omitido, e cobre o aceite por construção: a união nunca remove um transform válido de hoje, então o enquadramento inicial da S18 (clampado pela regra de hoje) nunca é reescrito no write-back e o e2e do slider permanece verde.
|
||||
**Rejeitadas:** B porque o render é auto-suficiente por contrato (desenha e devolve o transform para o write-back) e um pan não re-clampado seria desfeito no paint seguinte; C porque a análise mostra que seria um novo limite artificial (evitar Solla `x336-485` cortaria ~200px do alcance à esquerda com o rosto na referência 103; evitar c4 `x762-865` cortaria ~115px à direita) e ainda não protegeria o corpo, além de contrariar a regra aprovada ("rosto em qualquer ponto da área livre"); D porque reabre a S18, muda o e2e do slider e o claim dos 3 modelos sem evidência.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Onde vive a regra e a forma do contexto: `CardPhotoClamp` opcional no dono.**
|
||||
Opções: A) contexto opcional `CardPhotoClamp = { minZoom?: number; anchorBox?: CardAlphaBbox | null }` como último parâmetro de `clampCardPhotoTransform`, `cardPhotoDrawRect` e `panCardPhotoTransform` (substituindo o `minZoom` posicional), e campo `anchorBox?` nas options já existentes de `zoomCardPhotoTransform`; omitido = comportamento de hoje | B) `anchorBox` posicional depois de `minZoom` | C) funções gêmeas do time (`clampTeamCardPhotoTransform`/`cardTeamPhotoDrawRect`) | D) clamp calculado no composer e transform já clampado entregue ao render.
|
||||
Recomendação: A — uma única política no dono, sem `undefined`/`null` posicional no slot do meio (a limpeza que a S18 fez no `zoom`); o default omitido preserva literalmente os 3 modelos, `renderPhotoCard`, `frameCardPhotoOnBbox` e o inicial; a âncora é uma `CardAlphaBbox` (o `CardFaceBox` tem a mesma forma `{x,y,width,height}` — sem tipo novo). Nome: `anchorBox` para a âncora de enquadramento; `anchor` continua sendo o pivô do zoom (colisão evitada e comentada).
|
||||
Rejeitadas: B — `pan` chegaria a 7 posicionais e o call site compartilhado dos 3 modelos passaria `undefined` no meio; C — gêmea rasa que duplicaria a regra de Y e criaria superfície nova no knip; D — o render é a fonte do PNG e do retorno do write-back; tem de re-clampar com a MESMA política.
|
||||
|
||||
2. **Regra do X: união do alcance de hoje com o alcance da âncora.**
|
||||
Opções: A) `newX = união(todayX, anchorX)` quando `anchorX` existe, senão `todayX` | B) X = só `anchorX` | C) X = hoje (mexer só no inicial) | D) âncora + colisão por caixa de candidato.
|
||||
Recomendação: A — cobre o aceite e garante "nunca pior que hoje" por construção. Fórmula (por eixo X; `start=window.x`, `size=window.width`, `a=coverScale(source,window)·zoom`):
|
||||
|
||||
```text
|
||||
drawnW = source.width · a
|
||||
todayX = [start + size − drawnW, start] se drawnW ≥ size (cover, S13)
|
||||
[start, start + size − drawnW] se drawnW < size (contain, S18)
|
||||
anchorX = [start − box.x·a, start + size − (box.x + box.width)·a]
|
||||
quando box.width·a ≤ size; senão não existe (caixa mais larga que a janela)
|
||||
newX = união(todayX, anchorX) quando anchorX existe
|
||||
todayX caso contrário
|
||||
```
|
||||
|
||||
Propriedades que o unit pina: (i) `anchorX` sempre intersecta `todayX` quando existe (`A.min ≤ T.max` e `A.max ≥ T.min` valem nos dois regimes, porque `0 ≤ box.x` e `box.x + box.width ≤ source.width`), então a união é um **intervalo único** (sem buraco); (ii) `anchorX ⊇ todayX` no regime contain, logo o curso nunca encolhe; (iii) com o rosto na referência (103), curso `= 592 − 103 = 489` e viagem a partir do centro inicial `≈±244,5`; (iv) a caixa inteira fica dentro da janela quando o visitante está dentro de `anchorX` — o que toca a borda é a caixa, não o centro. Nota: no regime cover (zoom alto) a união pode manter alcance de hoje além de `anchorX` em um dos lados — é o "nunca pior" e o comportamento aceito da S13, não uma liberdade nova.
|
||||
Rejeitadas: B — em zoom ≥ 1 (cover) um rosto fora do centro teria a âncora **cortando** alcance de hoje (ex.: rosto centrado a 900px de uma fonte 1200px em zoom 2: hoje o X vai até 286; só a âncora pararia em 174) e poderia invalidar o próprio transform inicial → write-back e regressão da S18; C — não resolve o relato (o visitante quer empurrar **depois** do automático) e mexer no inicial contraria o aceite; D — os números acima (~200px/~115px) mostram que a colisão destruiria o ganho; a barreira aprovada é a janela.
|
||||
|
||||
3. **Sem rosto: âncora = bbox da silhueta com a MESMA regra; piso de hoje preservado pela união.**
|
||||
Opções: A) `anchorBox = face usável ? face : bbox`, mesma regra X da decisão 2 | B) sem rosto = clamp de hoje literal (sem âncora) | C) âncora sempre a bbox (ignorar o rosto) | D) sem rosto = "âncora no desenho inteiro" (o contain de hoje).
|
||||
Recomendação: A — uma régua só, sem caminho especial: no enquadramento S15 a bbox cobre a janela (âncora mais larga que a janela → `anchorX` inexistente → clamp de hoje literal); quando o visitante afasta o zoom e a silhueta cabe na janela, o intervalo da âncora é superconjunto do contain de hoje (propriedade ii). "Nunca pior" é propriedade de construção, não caso de teste solto.
|
||||
Rejeitadas: B — duplicaria a política de clamp e não daria âncora nenhuma à silhueta quando ela cabe na janela (quem não tem rosto medido também quer posicionar a pessoa); C — perde a âncora do caso principal (o relato é sobre o rosto); D — é exatamente o limite de hoje (nada muda).
|
||||
|
||||
4. **Y permanece o de hoje — decisão explícita.**
|
||||
Opções: A) Y = `clampPhotoOffset` atual (cover/contain por eixo, `minZoom` do time) | B) Y também ancorado pela caixa | C) Y livre até as bordas do card | D) congelar o pan vertical.
|
||||
Recomendação: A — a intenção é lateral; a Cena 3 fala "posição horizontal"; o clamp de Y protege a composição vertical (topo do recorte abaixo do banner, base dentro da arte) e o "nunca pior" fica literal também no eixo Y; nenhuma evidência de produto para mexer. Registrado como decisão (não silenciosa): só X ganha âncora.
|
||||
Rejeitadas: B — permitiria o recorte flutuar/sair da janela verticalmente sem pedido e com risco visual novo (céu aparecendo sob o busto); C — descobre a arte sem caso de uso e contraria a barreira; D — removeria um controle que existe hoje (regressão de capacidade).
|
||||
|
||||
5. **Threading até o render/write-back: mesmo contexto no pan, no zoom e no `renderTeamCard`.**
|
||||
Opções: A) `TeamCardRenderArgs` ganha `anchorBox?`; `renderTeamCard` usa o contexto em `cardPhotoDrawRect` (`:240-245`) **e** no retorno (`:266-271`); o composer passa o mesmo contexto no arrasto (`:387-389`), nas setas (`:396-400`), no teclado e no zoom (`:402-406`) | B) o render recebe um transform já clampado e não re-clampa | C) wrapper de render só do time.
|
||||
Recomendação: A — clamp de intervalo é idempotente (`clamp(clamp(t)) = clamp(t)`), então o `cardPhotoTransformsEqual` do composer (`:276-278`) nunca dispara write-back espúrio; o download não re-renderiza (`handleDownload` faz `toBlob` do canvas já pintado), logo preview e PNG ficam idênticos por construção; `renderPhotoCard` intocado.
|
||||
Rejeitadas: B — o render deixaria de ser auto-suficiente (o retorno é o contrato do write-back) e um futuro chamador desenharia diferente do que devolve; C — superfície nova sem necessidade (o `renderTeamCard` já é o caminho do time).
|
||||
|
||||
6. **State `ready`: guardar `bbox` + `face` crus; a âncora é derivada numa função pura.**
|
||||
Opções: A) `CardCutoutState.ready` ganha `bbox: CardAlphaBbox` e `face: CardFaceBox | null` (espelho fiel do `CardCutoutResult`); o composer resolve `resolveCardPhotoAnchor(bbox, face)` (face usável ? face : bbox) com `useMemo` por recorte; um predicado privado `isUsableCardFace` é extraído e reusado por `frameCardPhotoOnFace` | B) `ready` guarda a âncora já resolvida | C) guardar bbox + face + âncora.
|
||||
Recomendação: A — o hook continua espelho do resultado (não vira dono da política de clamp); a regra de escolha da âncora vive uma vez na lib pura (mesma validação do inicial); o composer já é o dono do contexto. A caixa é usada como veio do detector/segmentador (coords da fonte), sem reescala nem re-detecção.
|
||||
Rejeitadas: B — esconde a regra no hook e duplica conhecimento com `frameCardPhotoOnFace`; C — campo derivado redundante.
|
||||
|
||||
7. **e2e: nenhum teste novo ou alterado.**
|
||||
Opções: A) manter o describe S18 como está (o `ok`/`noface` já atravessa os dois ramos de âncora pelo composer + render) | B) estender com um arrasto e asserção de posição no canvas | C) novo describe S20 com stub dedicado.
|
||||
Recomendação: A — o e2e não mede alcance em pixels de card sem um seam novo (frágil, mediria a geometria do stub, não a regra); o que importa no e2e é que o inicial e o write-back não regridem, e é exatamente o que os asserts do slider (`noface=1`, `ok<0.5`) e da amostra harmonizada em `(582,550)` já pinam; a régua fica pinada em unit (grid de fontes/zooms/âncoras). O e2e da superfície roda de qualquer forma pelo mapping `src/components/cards` + `src/lib/card` → `frontend` (`scripts/lib/e2e-affected-manifest.mjs:92-98`).
|
||||
Rejeitadas: B/C — custo de CI e superfície de seam para uma propriedade já coberta em unit.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`CardPhotoClamp` + `resolveCardPhotoAnchor` + `isUsableCardFace`** (`src/lib/cardPhotoTransform.ts`): o contexto de clamp (`{ minZoom?: number; anchorBox?: CardAlphaBbox | null }`), a resolução canônica da âncora (face usável ? face : bbox; a âncora reusa `CardAlphaBbox`, sem tipo novo) e o predicado de rosto usável extraído de `frameCardPhotoOnFace` e reusado por ambos. Reusa `CardAlphaBbox`/`CardFaceBox`, `coverScale`.
|
||||
- **`clampCardPhotoTransform`, `cardPhotoDrawRect`, `panCardPhotoTransform`, `zoomCardPhotoTransform`** (`src/lib/cardPhotoTransform.ts`): ganham o contexto opcional; X = união (fórmula da D2), Y = `clampPhotoOffset` de hoje; `minZoom` default `CARD_PHOTO_MIN_ZOOM` preservado. `frameCardPhotoOnBbox` intocado; `frameCardPhotoOnFace` só troca a chamada interna para `{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }` (inicial **sem** âncora).
|
||||
- **`CardCutoutState.ready`** (`src/components/cards/useCardCutout.ts:13-26`): ganha `bbox`/`face`; a chamada de enquadramento (`:69-77`), run id/retry/progresso e o `error: 'empty'` continuam iguais.
|
||||
- **`CardComposer`** (`src/components/cards/CardComposer.tsx`): `photoAnchorBox` memoizado de `teamReady`; o contexto `{ minZoom: photoMinZoom, anchorBox: photoAnchorBox }` (âncora `null` nos modelos de foto = default) entra em `withTransform`/arrasto/setas/teclado/zoom; a chamada de `renderTeamCard` (`:263-274`) ganha `anchorBox`. Nenhuma mudança de UI, controle, copy ou estado de erro.
|
||||
- **`renderTeamCard` / `TeamCardRenderArgs`** (`src/lib/cardRender.ts`): campo `anchorBox?`; desenho (`:240-245`) e retorno (`:266-271`) com o mesmo contexto. `renderPhotoCard` (`:198-207`) intocado.
|
||||
- **Testes** (editar/novo): `tests/unit/cardPhotoTransform.unit.spec.ts` (forma das chamadas que passam `minZoom` + describe novo da âncora: união/curso ±244,5, superconjunto, âncora mais larga → hoje, Y intocado, idempotência); `tests/unit/cardRender.unit.spec.ts` (novo: `renderTeamCard` com `anchorBox` preserva o X fora do alcance antigo e sem `anchorBox` clampa como hoje). `cardModels.unit.spec.ts`, `cardFaceDetection.unit.spec.ts` e o e2e ficam como estão.
|
||||
- **`docs/changelog/2026-09-19-s20.md`** (novo).
|
||||
- **Migration:** sem migration — nenhuma collection/global/field; `push:false` intocado (100% client-side).
|
||||
- **Access / Consent:** não se aplica — nenhuma superfície Payload; a âncora é geometria efêmera no aparelho; sem chave de Consent.
|
||||
- **UI:** Impeccable C — comportamento, sem controle novo. O artefato `docs/plans/cards-time-de-voce-ajuste-lateral-ui-design.html` (Cenas 3/4) é a referência de gate; craft no browser com fotos reais (com/sem rosto, arrasto até as bordas, zoom antes/depois) como na S18. Sem shape novo.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
Não se aplica — nenhum KPI, mapa ou série; nenhum dado apresentado/coletado/enviado. A única "forma" é geometria do card (literais medidos) e a posição efêmera no aparelho, exatamente como a intenção fixou.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer — régua pura do clamp** — quota ~0,4 dia. `CardPhotoClamp`, `resolveCardPhotoAnchor`/`isUsableCardFace`; X = união e Y = hoje em `clampCardPhotoTransform`/`cardPhotoDrawRect`/`panCardPhotoTransform`/`zoomCardPhotoTransform`; units novos.
|
||||
Prova: `pnpm gate:fast` verde; asserts existentes seguem valendo (só muda a forma das chamadas que passam `minZoom`); unit pina: com rosto na referência 103 o intervalo X tem largura 489 e a viagem a partir do centro é ≈±244,5; o alcance de hoje é subconjunto do novo num grid de fontes/zooms/âncoras; âncora mais larga que a janela → byte a byte o clamp de hoje; Y idêntico; `clamp(clamp(t)) = clamp(t)`.
|
||||
2. **Threading — hook → composer → render** — quota ~0,3–0,4 dia. `ready` com `bbox`/`face`; memo da âncora no composer; contexto no arrasto/setas/teclado/zoom; `TeamCardRenderArgs.anchorBox`; testes de render com/sem âncora.
|
||||
Prova: `pnpm gate:fast` verde; unit do render; no browser (engine real + stub): com rosto, arrastar até a caixa do rosto tocar a borda da janela, sem snap do write-back e com zoom/harmonização/CTA intactos; sem rosto, o alcance é ao menos o de hoje; modelos quadrado/retangular com pan/zoom/slider como antes.
|
||||
3. **Gates + changelog** — quota ~0,2 dia. e2e frontend local (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts`), changelog, `pnpm gate:fast`, `pnpm push`.
|
||||
Prova: S14/S15/S17/S18 verdes sem edição (slider `noface=1`, `ok<0.5` e amostra harmonizada intactos); fixture de console/5xx intacta; card baixável.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Colisão geométrica contra as caixas dos rostos dos candidatos / margens para "não chegar perto" — rejeitada na D2 com números; a barreira do gate é a janela.
|
||||
- Clipar o corpo do visitante na janela — contraria a Cena 3 (o corpo pode avançar sobre ombros).
|
||||
- Ancorar o eixo Y, "centralizar" por controle novo, ou qualquer mudança de gesto/controle.
|
||||
- Re-detectar/rastrear o rosto durante o pan/zoom (já rabbit hole da intenção/S18); persistir a âncora; telemetria.
|
||||
- Mudar o enquadramento inicial, `frameCardPhotoOnBbox`, `renderPhotoCard`, o slider (piso 0.2/teto 4), a arte-mestre, os banners, a janela `{286,439,592,577}` ou a referência 103.
|
||||
- Generalizar a âncora para os 3 modelos de foto (não têm recorte/rosto) e criar tipo `CardPhotoAnchor` só por pureza.
|
||||
- Novo modo de stub ou e2e novo (D7); resolver o débito adiado do "piso duplicado em composer/render" (S18) sem gatilho.
|
||||
- Migration/Consent/access/int; copy nova; estado de erro novo.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Write-back desfaz o alcance / enquadramento inicial mexido** → inicial usa o clamp de hoje sem âncora; pan/zoom/render usam o mesmo contexto; união nunca remove transform de hoje; units de idempotência e de superconjunto; e2e do slider como guarda de integração.
|
||||
- **Corpo saindo da janela (gap de arte atrás)** → a base `team-card-base.png` é 100% opaca e a referência `team-card-example.jpg` já tem sobreposição leve; o gap lê como céu/arte (mesmo look do proporcional S18). Craft nos dois extremos; **gatilho de revisão:** se o craft reprovar a estética da borda, aplicar uma margem pequena medida na âncora (decisão de produto no gate, não silenciosa).
|
||||
- **Rosto de candidato coberto no extremo** → limite registrado: a barreira aprovada é a janela, não colisão por caixa (D2). **Gatilho:** UAT com fotos reais; se um rosto de candidato sumir de forma inaceitável, item sucessor com margem/exclusões de candidato.
|
||||
- **Sem rosto com alcance pior** → propriedade de superconjunto unit-testada num grid; quando a silhueta é maior que a janela, `anchorX` não existe e o clamp de hoje roda literal.
|
||||
- **Render e pan divergirem** → uma função, um contexto; `cardPhotoDrawRect` e o retorno do render com o mesmo `anchorBox`; unit de render com/sem âncora; e2e S18 cobre o caminho real com os dois ramos.
|
||||
- **Zoom:** em zoom alto o rosto pode não caber na janela → `anchorX` vazio → hoje; em zoom baixo a âncora abre ainda mais o alcance; limites do slider intocados. Craft de pan → zoom → pan.
|
||||
- **Colisão de nome (`anchor` pivô × âncora de enquadramento)** → `anchorBox`; comentário no tipo e nos call sites.
|
||||
- **Regressão dos 3 modelos / knip** → contexto omitido = default exato; `renderPhotoCard` intocado; S13/S15 unit e e2e sem edição; exports novos todos consumidos.
|
||||
- **E2E/hermeticidade** → nenhum fetch/log/async novo; stub intocado; guard de console intacto.
|
||||
- **Performance** → aritmética por pointermove; âncora resolvida uma vez por recorte (memo); sem custo perceptível.
|
||||
|
||||
## Triagem de débitos (simplify — 2 revisores paralelos)
|
||||
|
||||
| ID | Resumo | Origem | Score | Tipo | Destino |
|
||||
| --- | ------------------------------------------------------------------------ | ---------------- | ----- | ------------- | ------------- |
|
||||
| S1 | Rosto do MediaPipe pode vir fora da fonte → hull podia abrir gap | simplify/estrut. | 3 | cheap_polish | já_resolvido |
|
||||
| S2 | `x`/`y` NaN na face viraria offset NaN | simplify/qual. | 3 | cheap_polish | já_resolvido |
|
||||
| S3 | `anchorOffsetRange` sem dizer que é o eixo X | simplify/qual. | 1 | cheap_polish | já_resolvido |
|
||||
| S4 | `244.5` mágico e grid "nunca pior" prometido viravam 2 casos | simplify/qual. | 2 | cheap_polish | já_resolvido |
|
||||
| S5 | Asserção tautológica desenho == retorno no teste do render | simplify/qual. | 1 | cheap_polish | já_resolvido |
|
||||
| S6 | Piso do time duplicado composer/render (débito S18) | simplify/ambos | 2 | defer_trigger | descartar |
|
||||
| S7 | `useMemo` do `photoClamp` dispensável | simplify/qual. | 1 | cheap_polish | descartar |
|
||||
| S8 | e2e não mede o curso em pixels (seam novo) | simplify/estrut. | 1 | cheap_polish | descartar |
|
||||
|
||||
- **Já resolvido no simplify (não reabrir):** S1 (guarda de ranges disjuntos em `clampPhotoOffset` + teste), S2 (`isUsableCardFace` exige `x/y/width/height` finitos), S3 (`anchorXOffsetRange` + doc), S4 (curso derivado de `(TEAM_WINDOW.width − REFERENCE) / 2` + grid de zooms/âncoras/offsets), S5 (asserções cross-contexto).
|
||||
- **Explicitamente fora:** S6 — já é débito registrado na S18 (`docs/plans/cards-time-de-voce-escala-cabeca-impl.md`, gatilho "piso duplicado em composer e render"); o render segue dono do piso do time nesta entrega. S7 — memo barato que estabiliza as deps do efeito. S8 — decidido na D7 (unit pina a régua; e2e pina a integração sem seam novo).
|
||||
- **Defer com gatilho:** `readLargestFaceBox` copia `originX/originY` crus do detector; o clamp novo torna a exposição inofensiva (fallback), mas o enquadramento S18 ainda usa a caixa crua. Gatilho: um UAT/craft com rosto cortado na borda mostrando enquadramento ruim, ou o próximo item que tocar `readLargestFaceBox` — aí clampar à fonte antes de enquadrar.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: a caixa do rosto alcança as bordas da janela (curso ≈489px, ≈±244px do centro inicial); sem controle novo; inicial centrado e S18 intocados; 3 modelos, banners, harmonização, troca de foto e download intocados; sem rosto nunca pior que hoje; 100% no aparelho.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: math pura/client-safe em `src/lib` (sem DOM); identificadores em inglês, copy pt-BR; sem migration/Consent/access/int; knip/cycles verdes; arte-mestre intocada; `pnpm gate:fast` + e2e da superfície + `pnpm push`.
|
||||
- [ ] Testes de domínio previstos: unit do clamp com âncora (união, superconjunto, curso ±244,5, Y, idempotência) e do `renderTeamCard` com/sem `anchorBox`; e2e existente reusa `ok`/`noface`; **int não se aplica** (client-side, sem DB/access/escrita multi-collection).
|
||||
|
||||
## Self-score decision-quality: 5/5
|
||||
|
||||
1. **Decisões caras com rejeitadas:** API do contexto (A–D), regra do X (A–D), fallback da silhueta (A–D), Y (A–D), threading/write-back (A–C), state/âncora (A–C) e e2e (A–C) — todas com Opções/Recomendação/Rejeitadas; o barato (nome do campo, literais) fica como fill-in.
|
||||
2. **Cabe no appetite:** ≤1 dia em 3 fases, sem migration/infra; a régua pura vem primeiro (tracer) e o threading depois, com o fallback preservado por construção.
|
||||
3. **Rabbit holes nomeados:** colisão por caixa, clip do corpo, âncora em Y, re-detecção, e2e/seam novo, generalização para os 3 modelos.
|
||||
4. **Depth check:** reusa `clampPhotoOffset`/`coverScale`/`CardAlphaBbox`, o `CardCutoutResult`/hook, o contexto de `minZoom` já existente e o padrão de options do `zoomCardPhotoTransform`; nada de abstração nova além da função pura de âncora e sem tipo redundante.
|
||||
5. **Intenção preservada:** outcome e aceite intactos (curso ≈2×, sem controle novo, inicial/S18 intocados, fallback nunca pior, privacidade); a engenharia só resolveu o que a intenção deixou em aberto (fórmula, fallback, Y, API) e registrou o limite da barreira dos candidatos.
|
||||
|
|
@ -38,7 +38,9 @@ import {
|
|||
cardPhotoTransformsEqual,
|
||||
centerCardPhotoTransform,
|
||||
panCardPhotoTransform,
|
||||
resolveCardPhotoAnchor,
|
||||
zoomCardPhotoTransform,
|
||||
type CardPhotoClamp,
|
||||
type CardPhotoSize,
|
||||
type CardPhotoTransform,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
|
|
@ -205,6 +207,18 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
? (photoTransform ?? teamReady?.transform ?? null)
|
||||
: photoTransform
|
||||
|
||||
/**
|
||||
* S20 — the fine-tuning clamp context: the team's anchor is the detected face
|
||||
* when usable, else the silhouette; the photo models keep the S13 defaults.
|
||||
*/
|
||||
const photoClamp = useMemo<CardPhotoClamp>(
|
||||
() => ({
|
||||
minZoom: photoMinZoom,
|
||||
anchorBox: teamReady ? resolveCardPhotoAnchor(teamReady.bbox, teamReady.face) : null,
|
||||
}),
|
||||
[photoMinZoom, teamReady],
|
||||
)
|
||||
|
||||
// Assets + font load once per model: typing/panning must not flip the preview
|
||||
// back into the loading state (flicker) nor re-announce the live region.
|
||||
useEffect(() => {
|
||||
|
|
@ -268,6 +282,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
photoSize: { width: teamReady.width, height: teamReady.height },
|
||||
transform: effectiveTransform,
|
||||
window: photoWindow,
|
||||
anchorBox: photoClamp.anchorBox,
|
||||
name,
|
||||
fontFamily,
|
||||
measure,
|
||||
|
|
@ -322,6 +337,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
photoSize,
|
||||
effectiveTransform,
|
||||
photoWindow,
|
||||
photoClamp,
|
||||
])
|
||||
|
||||
const handlePhotoFile = async (file: File | undefined) => {
|
||||
|
|
@ -385,7 +401,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const dy = (event.clientY - drag.y) * scale
|
||||
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
||||
withTransform((transform, size, window) =>
|
||||
panCardPhotoTransform(transform, size, window, dx, dy, photoMinZoom),
|
||||
panCardPhotoTransform(transform, size, window, dx, dy, photoClamp),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -395,13 +411,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
|
||||
const panBy = (dx: number, dy: number) => {
|
||||
withTransform((transform, size, window) =>
|
||||
panCardPhotoTransform(transform, size, window, dx, dy, photoMinZoom),
|
||||
panCardPhotoTransform(transform, size, window, dx, dy, photoClamp),
|
||||
)
|
||||
}
|
||||
|
||||
const zoomTo = (zoom: number) => {
|
||||
withTransform((transform, size, window) =>
|
||||
zoomCardPhotoTransform(transform, size, window, zoom, { minZoom: photoMinZoom }),
|
||||
zoomCardPhotoTransform(transform, size, window, zoom, photoClamp),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import { removeCardPhotoBackground } from '@/components/cards/cardCutout'
|
||||
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
|
||||
import { TEAM_CARD_FACE_REFERENCE_SIZE, type CardRect } from '@/lib/cardModels'
|
||||
import { frameCardPhotoOnFace, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
|
||||
import {
|
||||
frameCardPhotoOnFace,
|
||||
type CardAlphaBbox,
|
||||
type CardFaceBox,
|
||||
type CardPhotoTransform,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
|
||||
export type CardCutoutState =
|
||||
| { status: 'idle' }
|
||||
|
|
@ -23,6 +28,9 @@ export type CardCutoutState =
|
|||
height: number
|
||||
/** Initial framing: cutout top on the slot top, face centered (S18). */
|
||||
transform: CardPhotoTransform
|
||||
/** S20 — silhouette and detected face, kept for the fine-tuning anchor. */
|
||||
bbox: CardAlphaBbox
|
||||
face: CardFaceBox | null
|
||||
}
|
||||
| { status: 'error'; reason: 'engine' | 'empty' }
|
||||
|
||||
|
|
@ -92,6 +100,8 @@ export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenc
|
|||
width: result.width,
|
||||
height: result.height,
|
||||
transform,
|
||||
bbox: result.bbox,
|
||||
face: result.face,
|
||||
})
|
||||
},
|
||||
[photoWindow, harmonyReferenceSrc],
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
* (`CARD_TEAM_PHOTO_MIN_ZOOM`), so its clamp keeps the drawn photo *inside* the
|
||||
* window when it is smaller than it instead of pinning it to the edge.
|
||||
*
|
||||
* S20 — the team fine-tuning may also anchor on the measured box (the face,
|
||||
* else the silhouette): the horizontal range becomes the union of the S13/S18
|
||||
* rule with the range that lets the anchor box touch both window edges, so the
|
||||
* visitor is never limited by the transparent canvas around the person. The
|
||||
* vertical axis keeps the S13/S18 rule.
|
||||
*
|
||||
* `centerCardPhotoTransform` is the initial framing (cover on the window, not
|
||||
* on the whole card) so the face lands on the visible area of the frame.
|
||||
*/
|
||||
|
|
@ -66,12 +72,40 @@ export type CardFaceBox = {
|
|||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* S20 — fine-tuning context of the clamp: `minZoom` widens the zoom floor
|
||||
* (S18) and `anchorBox` allows the pan/zoom to keep the measured box (face,
|
||||
* else silhouette) reaching the window edges. Omitted = the S13/S18 rule.
|
||||
*/
|
||||
export type CardPhotoClamp = {
|
||||
minZoom?: number
|
||||
anchorBox?: CardAlphaBbox | null
|
||||
}
|
||||
|
||||
export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean =>
|
||||
a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
const isUsableCardFace = (face: CardFaceBox | null): face is CardFaceBox =>
|
||||
face !== null &&
|
||||
Number.isFinite(face.x) &&
|
||||
Number.isFinite(face.y) &&
|
||||
Number.isFinite(face.width) &&
|
||||
face.width > 0 &&
|
||||
Number.isFinite(face.height) &&
|
||||
face.height > 0
|
||||
|
||||
/**
|
||||
* S20 — the box the fine-tuning anchors on: the detected face when it is
|
||||
* usable, else the visible silhouette (alpha bbox). Source-pixel coords.
|
||||
*/
|
||||
export const resolveCardPhotoAnchor = (
|
||||
bbox: CardAlphaBbox,
|
||||
face: CardFaceBox | null,
|
||||
): CardAlphaBbox => (isUsableCardFace(face) ? face : bbox)
|
||||
|
||||
export const coverScale = (source: CardPhotoSize, window: CardRect): number => {
|
||||
if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1
|
||||
|
||||
|
|
@ -82,9 +116,9 @@ export const cardPhotoDrawRect = (
|
|||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
minZoom: number = CARD_PHOTO_MIN_ZOOM,
|
||||
clampOptions?: CardPhotoClamp,
|
||||
): CardRect => {
|
||||
const clamped = clampCardPhotoTransform(transform, source, window, minZoom)
|
||||
const clamped = clampCardPhotoTransform(transform, source, window, clampOptions)
|
||||
const scale = coverScale(source, window) * clamped.zoom
|
||||
|
||||
return {
|
||||
|
|
@ -100,25 +134,62 @@ export const cardPhotoDrawRect = (
|
|||
* (the S13 rule) and fully inside it when it is smaller (the S18 team framing,
|
||||
* where the visitor no longer fills the slot).
|
||||
*/
|
||||
const clampPhotoOffset = (value: number, start: number, size: number, drawn: number): number =>
|
||||
drawn >= size
|
||||
? clamp(value, start + size - drawn, start)
|
||||
: clamp(value, start, start + size - drawn)
|
||||
const photoOffsetRange = (start: number, size: number, drawn: number): [number, number] =>
|
||||
drawn >= size ? [start + size - drawn, start] : [start, start + size - drawn]
|
||||
|
||||
/**
|
||||
* S20 — the horizontal offset range that lets the drawn anchor box touch both
|
||||
* window edges; `null` when the drawn box is wider than the window (no
|
||||
* placement).
|
||||
*/
|
||||
const anchorXOffsetRange = (
|
||||
box: CardAlphaBbox,
|
||||
scale: number,
|
||||
start: number,
|
||||
size: number,
|
||||
): [number, number] | null =>
|
||||
box.width * scale > size
|
||||
? null
|
||||
: [start - box.x * scale, start + size - (box.x + box.width) * scale]
|
||||
|
||||
/**
|
||||
* S20 — the S13/S18 range, widened by the anchor range when one exists. For a
|
||||
* box inside the source the two always intersect, so the union is a single
|
||||
* interval; a detected face may sit at the image edge, so a disjoint anchor
|
||||
* (outside the photo) falls back to the S13/S18 range instead of hulling a gap.
|
||||
*/
|
||||
const clampPhotoOffset = (
|
||||
value: number,
|
||||
start: number,
|
||||
size: number,
|
||||
drawn: number,
|
||||
anchorRange?: [number, number] | null,
|
||||
): number => {
|
||||
const [min, max] = photoOffsetRange(start, size, drawn)
|
||||
if (!anchorRange || anchorRange[1] < min || anchorRange[0] > max) {
|
||||
return clamp(value, min, max)
|
||||
}
|
||||
|
||||
return clamp(value, Math.min(min, anchorRange[0]), Math.max(max, anchorRange[1]))
|
||||
}
|
||||
|
||||
export const clampCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
minZoom: number = CARD_PHOTO_MIN_ZOOM,
|
||||
{ minZoom = CARD_PHOTO_MIN_ZOOM, anchorBox }: CardPhotoClamp = {},
|
||||
): CardPhotoTransform => {
|
||||
const zoom = clamp(transform.zoom, minZoom, CARD_PHOTO_MAX_ZOOM)
|
||||
const scale = coverScale(source, window) * zoom
|
||||
const width = source.width * scale
|
||||
const height = source.height * scale
|
||||
const anchorRange = anchorBox
|
||||
? anchorXOffsetRange(anchorBox, scale, window.x, window.width)
|
||||
: null
|
||||
|
||||
return {
|
||||
zoom,
|
||||
offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width),
|
||||
offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width, anchorRange),
|
||||
offsetY: clampPhotoOffset(transform.offsetY, window.y, window.height, height),
|
||||
}
|
||||
}
|
||||
|
|
@ -204,7 +275,7 @@ export const frameCardPhotoOnFace = (
|
|||
): CardPhotoTransform | null => {
|
||||
const baseFraming = frameCardPhotoOnBbox(source, window, bbox)
|
||||
if (!baseFraming) return null
|
||||
if (!face || !(face.width > 0) || !(face.height > 0) || !(referenceSize > 0)) return baseFraming
|
||||
if (!isUsableCardFace(face) || !(referenceSize > 0)) return baseFraming
|
||||
|
||||
const scale = coverScale(source, window)
|
||||
const faceSize = Math.max(face.width, face.height)
|
||||
|
|
@ -223,7 +294,7 @@ export const frameCardPhotoOnFace = (
|
|||
},
|
||||
source,
|
||||
window,
|
||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -234,19 +305,20 @@ export const panCardPhotoTransform = (
|
|||
window: CardRect,
|
||||
dx: number,
|
||||
dy: number,
|
||||
minZoom: number = CARD_PHOTO_MIN_ZOOM,
|
||||
clampOptions?: CardPhotoClamp,
|
||||
): CardPhotoTransform =>
|
||||
clampCardPhotoTransform(
|
||||
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
|
||||
source,
|
||||
window,
|
||||
minZoom,
|
||||
clampOptions,
|
||||
)
|
||||
|
||||
/**
|
||||
* Rescales around a window anchor (used by the zoom control): keeps the point
|
||||
* under `anchor` stable from old to new zoom, then clamps. The team model
|
||||
* passes its own `minZoom` so the visitor can fine-tune below the cover floor.
|
||||
* passes its own `minZoom` (and, on S20, the `anchorBox`) so the visitor can
|
||||
* fine-tune below the cover floor.
|
||||
*/
|
||||
export const zoomCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
|
|
@ -256,14 +328,17 @@ export const zoomCardPhotoTransform = (
|
|||
{
|
||||
anchor,
|
||||
minZoom = CARD_PHOTO_MIN_ZOOM,
|
||||
}: { anchor?: { x: number; y: number }; minZoom?: number } = {},
|
||||
anchorBox,
|
||||
}: { anchor?: { x: number; y: number } } & CardPhotoClamp = {},
|
||||
): CardPhotoTransform => {
|
||||
const resolvedAnchor = anchor ?? {
|
||||
x: window.x + window.width / 2,
|
||||
y: window.y + window.height / 2,
|
||||
}
|
||||
const zoom = clamp(nextZoom, minZoom, CARD_PHOTO_MAX_ZOOM)
|
||||
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window, minZoom)
|
||||
if (zoom === transform.zoom) {
|
||||
return clampCardPhotoTransform(transform, source, window, { minZoom, anchorBox })
|
||||
}
|
||||
|
||||
const ratio = zoom / transform.zoom
|
||||
|
||||
|
|
@ -275,6 +350,6 @@ export const zoomCardPhotoTransform = (
|
|||
},
|
||||
source,
|
||||
window,
|
||||
minZoom,
|
||||
{ minZoom, anchorBox },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import {
|
|||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
cardPhotoDrawRect,
|
||||
clampCardPhotoTransform,
|
||||
type CardAlphaBbox,
|
||||
type CardPhotoClamp,
|
||||
type CardPhotoSize,
|
||||
type CardPhotoTransform,
|
||||
} from './cardPhotoTransform'
|
||||
|
|
@ -217,6 +219,8 @@ export type TeamCardRenderArgs = {
|
|||
fontFamily: string
|
||||
measure: CardMeasureText
|
||||
slot?: CardNameBannerSlot
|
||||
/** S20 — measured anchor (face, else silhouette) of the fine-tuning clamp. */
|
||||
anchorBox?: CardAlphaBbox | null
|
||||
}
|
||||
|
||||
export type TeamCardRenderResult = {
|
||||
|
|
@ -237,12 +241,11 @@ export const renderTeamCard = (
|
|||
): TeamCardRenderResult => {
|
||||
ctx.drawImage(args.base, 0, 0, model.width, model.height)
|
||||
|
||||
const rect = cardPhotoDrawRect(
|
||||
args.transform,
|
||||
args.photoSize,
|
||||
args.window,
|
||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
)
|
||||
const clampOptions: CardPhotoClamp = {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: args.anchorBox,
|
||||
}
|
||||
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window, clampOptions)
|
||||
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
|
||||
|
||||
ctx.drawImage(args.overlay, 0, 0, model.width, model.height)
|
||||
|
|
@ -263,11 +266,6 @@ export const renderTeamCard = (
|
|||
|
||||
return {
|
||||
fit,
|
||||
transform: clampCardPhotoTransform(
|
||||
args.transform,
|
||||
args.photoSize,
|
||||
args.window,
|
||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
),
|
||||
transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window, clampOptions),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,11 @@ import {
|
|||
frameCardPhotoOnBbox,
|
||||
frameCardPhotoOnFace,
|
||||
panCardPhotoTransform,
|
||||
resolveCardPhotoAnchor,
|
||||
zoomCardPhotoTransform,
|
||||
type CardAlphaBbox,
|
||||
type CardFaceBox,
|
||||
type CardPhotoClamp,
|
||||
type CardPhotoSize,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
|
||||
|
|
@ -166,7 +168,9 @@ describe('frameCardPhotoOnFace', () => {
|
|||
expect(transform.zoom).toBeLessThan(CARD_PHOTO_MIN_ZOOM)
|
||||
expect(transform.zoom).toBeGreaterThanOrEqual(CARD_TEAM_PHOTO_MIN_ZOOM)
|
||||
|
||||
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM)
|
||||
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
})
|
||||
const scale = rect.width / SOURCE.width
|
||||
// ...and still lands the face exactly on the reference size
|
||||
expect(face.width * scale).toBeCloseTo(REFERENCE, 5)
|
||||
|
|
@ -190,7 +194,9 @@ describe('frameCardPhotoOnFace', () => {
|
|||
|
||||
expect(transform.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM)
|
||||
|
||||
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM)
|
||||
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
})
|
||||
const scale = rect.width / SOURCE.width
|
||||
const achieved = face.width * scale
|
||||
|
||||
|
|
@ -274,11 +280,13 @@ describe('clampCardPhotoTransform', () => {
|
|||
{ zoom: 0.1, offsetX: 9999, offsetY: -9999 },
|
||||
LANDSCAPE,
|
||||
WINDOW,
|
||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM },
|
||||
)
|
||||
|
||||
expect(shrunk.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM)
|
||||
const rect = cardPhotoDrawRect(shrunk, LANDSCAPE, WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM)
|
||||
const rect = cardPhotoDrawRect(shrunk, LANDSCAPE, WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
})
|
||||
expect(rect.x).toBeGreaterThanOrEqual(WINDOW.x)
|
||||
expect(rect.y).toBeGreaterThanOrEqual(WINDOW.y)
|
||||
expect(rect.x + rect.width).toBeLessThanOrEqual(WINDOW.x + WINDOW.width)
|
||||
|
|
@ -305,13 +313,188 @@ describe('panCardPhotoTransform', () => {
|
|||
WINDOW,
|
||||
40,
|
||||
30,
|
||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM },
|
||||
)
|
||||
|
||||
expect(panned).toEqual({ zoom: 0.3, offsetX: 40, offsetY: 30 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCardPhotoAnchor', () => {
|
||||
const BBOX: CardAlphaBbox = { x: 10, y: 20, width: 300, height: 400 }
|
||||
|
||||
it('prefers a usable face over the silhouette', () => {
|
||||
const face: CardFaceBox = { x: 40, y: 50, width: 60, height: 60 }
|
||||
|
||||
expect(resolveCardPhotoAnchor(BBOX, face)).toEqual(face)
|
||||
expect(resolveCardPhotoAnchor(BBOX, { ...face, width: Number.NaN })).toEqual(BBOX)
|
||||
expect(resolveCardPhotoAnchor(BBOX, { ...face, width: 0 })).toEqual(BBOX)
|
||||
expect(resolveCardPhotoAnchor(BBOX, { ...face, height: -1 })).toEqual(BBOX)
|
||||
expect(resolveCardPhotoAnchor(BBOX, null)).toEqual(BBOX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('S20 anchor clamp', () => {
|
||||
const TEAM_WINDOW: CardRect = { x: 286, y: 439, width: 592, height: 577 }
|
||||
const SOURCE: CardPhotoSize = { width: 1000, height: 1250 }
|
||||
const BBOX: CardAlphaBbox = { x: 200, y: 100, width: 600, height: 900 }
|
||||
const REFERENCE = 103
|
||||
// Big enough that the face lands on the reference below the cover floor:
|
||||
// the drawn photo is smaller than the window horizontally (contain regime).
|
||||
const FACE: CardFaceBox = { x: 400, y: 200, width: 200, height: 200 }
|
||||
const anchor = resolveCardPhotoAnchor(BBOX, FACE)
|
||||
|
||||
it('lets the anchor box reach both window edges from the initial framing', () => {
|
||||
const initial = frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, FACE, REFERENCE)
|
||||
expect(initial).not.toBeNull()
|
||||
if (!initial) return
|
||||
|
||||
const clampOptions: CardPhotoClamp = {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: anchor,
|
||||
}
|
||||
const scale = coverScale(SOURCE, TEAM_WINDOW) * initial.zoom
|
||||
const toTheLeft = panCardPhotoTransform(initial, SOURCE, TEAM_WINDOW, -9999, 0, clampOptions)
|
||||
const toTheRight = panCardPhotoTransform(initial, SOURCE, TEAM_WINDOW, 9999, 0, clampOptions)
|
||||
// course = window − face drawn on the reference size, split evenly around
|
||||
// the initial framing: the design's ≈±244px per side
|
||||
const halfCourse = (TEAM_WINDOW.width - REFERENCE) / 2
|
||||
|
||||
expect(initial.offsetX - toTheLeft.offsetX).toBeCloseTo(halfCourse, 5)
|
||||
expect(toTheRight.offsetX - initial.offsetX).toBeCloseTo(halfCourse, 5)
|
||||
// the anchor box touches each edge and never leaves the window
|
||||
expect(toTheLeft.offsetX + anchor.x * scale).toBeCloseTo(TEAM_WINDOW.x, 5)
|
||||
expect(toTheRight.offsetX + (anchor.x + anchor.width) * scale).toBeCloseTo(
|
||||
TEAM_WINDOW.x + TEAM_WINDOW.width,
|
||||
5,
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts on the anchor side what the S18 clamp rejects, without touching Y', () => {
|
||||
const zoom = REFERENCE / (FACE.width * coverScale(SOURCE, TEAM_WINDOW))
|
||||
const scale = coverScale(SOURCE, TEAM_WINDOW) * zoom
|
||||
const clampOptions: CardPhotoClamp = {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: anchor,
|
||||
}
|
||||
|
||||
const todayRight = panCardPhotoTransform(
|
||||
{ zoom, offsetX: 0, offsetY: 0 },
|
||||
SOURCE,
|
||||
TEAM_WINDOW,
|
||||
9999,
|
||||
9999,
|
||||
{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM },
|
||||
)
|
||||
const anchoredRight = panCardPhotoTransform(
|
||||
{ zoom, offsetX: 0, offsetY: 0 },
|
||||
SOURCE,
|
||||
TEAM_WINDOW,
|
||||
9999,
|
||||
9999,
|
||||
clampOptions,
|
||||
)
|
||||
|
||||
expect(anchoredRight.offsetX).toBeGreaterThan(todayRight.offsetX)
|
||||
expect(anchoredRight.offsetY).toBe(todayRight.offsetY)
|
||||
expect(anchoredRight.offsetX + (anchor.x + anchor.width) * scale).toBeCloseTo(
|
||||
TEAM_WINDOW.x + TEAM_WINDOW.width,
|
||||
5,
|
||||
)
|
||||
})
|
||||
|
||||
it('never rejects a transform that the S18 clamp accepts (grid of zooms and anchors)', () => {
|
||||
const anchors: CardAlphaBbox[] = [
|
||||
anchor,
|
||||
{ x: 40, y: 60, width: 300, height: 300 },
|
||||
{ x: 0, y: 0, width: 900, height: 900 },
|
||||
{ x: -400, y: 0, width: 500, height: 500 },
|
||||
]
|
||||
|
||||
for (const zoom of [0.3, 0.87, 1, 1.6, 2.5]) {
|
||||
for (const anchorBox of anchors) {
|
||||
for (const offset of [-2000, -500, 0, 300, 900, 2000]) {
|
||||
const today = clampCardPhotoTransform(
|
||||
{ zoom, offsetX: offset, offsetY: offset },
|
||||
SOURCE,
|
||||
TEAM_WINDOW,
|
||||
{ minZoom: CARD_TEAM_PHOTO_MIN_ZOOM },
|
||||
)
|
||||
const anchored = clampCardPhotoTransform(today, SOURCE, TEAM_WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox,
|
||||
})
|
||||
|
||||
expect(anchored).toEqual(today)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the S18 range when the anchor lies outside the photo', () => {
|
||||
// zoom 1 draws the source exactly on the window width: today's X is pinned,
|
||||
// while a face box at x = -100 would anchor entirely to its right
|
||||
const clampOptions: CardPhotoClamp = {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: { x: -100, y: 0, width: 100, height: 100 },
|
||||
}
|
||||
const today = clampCardPhotoTransform(
|
||||
{ zoom: 1, offsetX: 9999, offsetY: 9999 },
|
||||
SOURCE,
|
||||
TEAM_WINDOW,
|
||||
{
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
},
|
||||
)
|
||||
|
||||
expect(
|
||||
clampCardPhotoTransform(
|
||||
{ zoom: 1, offsetX: 9999, offsetY: 9999 },
|
||||
SOURCE,
|
||||
TEAM_WINDOW,
|
||||
clampOptions,
|
||||
),
|
||||
).toEqual(today)
|
||||
})
|
||||
|
||||
it('falls back to the S18 rule when the drawn anchor is wider than the window', () => {
|
||||
const wideAnchor: CardAlphaBbox = { x: 0, y: 0, width: 600, height: 600 }
|
||||
// zoom 2: the 600px anchor draws 710px wide, wider than the 592px window
|
||||
const transform = { zoom: 2, offsetX: 9999, offsetY: 9999 }
|
||||
const clampOptions: CardPhotoClamp = {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: wideAnchor,
|
||||
}
|
||||
|
||||
expect(clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, clampOptions)).toEqual(
|
||||
clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('is idempotent with an anchor in both regimes', () => {
|
||||
const transforms = [
|
||||
{ zoom: 0.5, offsetX: -9999, offsetY: -9999 },
|
||||
{ zoom: 0.87, offsetX: 9999, offsetY: 9999 },
|
||||
{ zoom: 2.5, offsetX: -400, offsetY: 120 },
|
||||
]
|
||||
|
||||
for (const transform of transforms) {
|
||||
const once = clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: anchor,
|
||||
})
|
||||
const twice = clampCardPhotoTransform(once, SOURCE, TEAM_WINDOW, {
|
||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
||||
anchorBox: anchor,
|
||||
})
|
||||
|
||||
expect(twice).toEqual(once)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoomCardPhotoTransform', () => {
|
||||
it('keeps the anchor point stable while zooming', () => {
|
||||
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { fitCardName } from '@/lib/cardNameFit'
|
|||
import {
|
||||
centerCardPhotoTransform,
|
||||
frameCardPhotoOnBbox,
|
||||
type CardAlphaBbox,
|
||||
type CardPhotoSize,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
import {
|
||||
|
|
@ -244,6 +245,37 @@ describe('renderTeamCard', () => {
|
|||
expect(rectCalls).toHaveLength(1)
|
||||
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE'])
|
||||
})
|
||||
|
||||
it('S20 — draws and returns the anchored reach instead of the S18 bound', () => {
|
||||
const face: CardAlphaBbox = { x: 80, y: 100, width: 60, height: 60 }
|
||||
const pushed = { ...transform, offsetX: 9999 }
|
||||
const draw = (anchorBox?: CardAlphaBbox | null) => {
|
||||
const fake = createFakeContext()
|
||||
const result = renderTeamCard(fake.ctx, teamModel, {
|
||||
base: { id: 'base' } as unknown as CanvasImageSource,
|
||||
overlay: { id: 'overlay' } as unknown as CanvasImageSource,
|
||||
photo: { id: 'photo' } as unknown as CanvasImageSource,
|
||||
photoSize,
|
||||
transform: pushed,
|
||||
window,
|
||||
anchorBox,
|
||||
name: 'Maria',
|
||||
fontFamily: 'Brexter',
|
||||
measure: createCardMeasure(fake.ctx, 'Brexter'),
|
||||
})
|
||||
|
||||
return { dx: fake.drawCalls[1]!.dx, result }
|
||||
}
|
||||
|
||||
const s18 = draw()
|
||||
const anchored = draw(face)
|
||||
|
||||
// the face box reaches further right than the S18 cover bound, in the draw
|
||||
// and in the write-back contract, with Y untouched
|
||||
expect(anchored.dx).toBeGreaterThan(s18.dx)
|
||||
expect(anchored.result.transform.offsetX).toBeGreaterThan(s18.result.transform.offsetX)
|
||||
expect(anchored.result.transform.offsetY).toBe(s18.result.transform.offsetY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCardBannerWidth (S16)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue