teqo-backup/src/lib/cardPhotoTransform.ts
Francisco Solla 5635e6522c feat(site): vira o site público para o mandato pós-eleição (S47)
Home abre no agradecimento pelos 152.049 votos com board 1+3, trajetória, bandeiras do mandato, galeria de fotos e rodapé institucional; chrome novo (header home/interno, lockup de nome, menu mobile acessível) e /artigos//fotos com título compacto.

As superfícies de campanha saem do ar (308 de /conteudos, /jingles e /cards), junto com newsletter, estúdio público de cards e UI da Central/jingles; download de foto e .ics perdem o 1313. Sem migration, sem Consent novo e sem apagar dados do CMS.
2026-10-22 02:16:08 -03:00

216 lines
6.4 KiB
TypeScript

/**
* S13 — pure cover/pan/zoom math for the photo card models. The visitor photo
* is drawn full-bleed under the master overlay and, by default, must always
* cover the transparent window: `clampCardPhotoTransform` keeps the zoom inside
* [1, 4] and pins the offsets so no gap can open at the window edges.
*
* S33 — the two team models carry `photoPosition: 'free'` (human gate of
* 2026-09-24): their adjustment is not clamped in any axis — the photo may
* leave the frame — while the zoom keeps its range. The policy arrives through
* `CardPhotoClampOptions`; the omitted default is the S13 bounded behavior.
*
* `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.
*/
import type { CardPhotoPosition, CardRect } from './cardModels'
export const CARD_PHOTO_MIN_ZOOM = 1
export const CARD_PHOTO_MAX_ZOOM = 4
/**
* S15 — a cutout smaller than this (in source pixels) is treated as an empty
* segmentation: the card would show a speck, so the composer fails closed and
* offers a retry instead.
*/
const CARD_CUTOUT_MIN_BBOX = 24
/**
* Alpha noise floor of a cutout (matches the mask's soft edge): the bbox read
* ignores it and the S17 tone sample ignores it too, so both see the same photo.
*/
export const CARD_CUTOUT_ALPHA_THRESHOLD = 32
export type CardPhotoSize = {
width: number
height: number
}
export type CardPhotoTransform = {
zoom: number
offsetX: number
offsetY: number
}
/**
* S33 — the adjustment policy of the call site: `bounded` (omitted default)
* pins the offsets so the photo covers the window; `free` keeps the offsets
* untouched in both axes, normalizing only the zoom.
*/
export type CardPhotoClampOptions = {
position?: CardPhotoPosition
}
/** Alpha bounding box of a cutout photo, in source pixels. */
export type CardAlphaBbox = {
x: number
y: number
width: number
height: number
}
const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max)
export const coverScale = (source: CardPhotoSize, window: CardRect): number => {
if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1
return Math.max(window.width / source.width, window.height / source.height)
}
export const cardPhotoDrawRect = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
options: CardPhotoClampOptions = {},
): CardRect => {
const clamped = clampCardPhotoTransform(transform, source, window, options)
const scale = coverScale(source, window) * clamped.zoom
return {
x: clamped.offsetX,
y: clamped.offsetY,
width: source.width * scale,
height: source.height * scale,
}
}
export const clampCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
options: CardPhotoClampOptions = {},
): CardPhotoTransform => {
const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
if (options.position === 'free') {
return { zoom, offsetX: transform.offsetX, offsetY: transform.offsetY }
}
const scale = coverScale(source, window) * zoom
const width = source.width * scale
const height = source.height * scale
return {
zoom,
offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x),
offsetY: clamp(transform.offsetY, window.y + window.height - height, window.y),
}
}
export const centerCardPhotoTransform = (
source: CardPhotoSize,
window: CardRect,
): CardPhotoTransform => {
const scale = coverScale(source, window)
const width = source.width * scale
const height = source.height * scale
return {
zoom: CARD_PHOTO_MIN_ZOOM,
offsetX: window.x + (window.width - width) / 2,
offsetY: window.y + (window.height - height) / 2,
}
}
/**
* S15 — initial framing for a cutout photo: the alpha bbox is scaled to cover
* the window, anchored at the bbox top (head against the top of the slot) and
* centered horizontally on it. Returns `null` when the bbox is degenerate
* (empty segmentation) or would demand more than the max zoom — the composer
* fails closed instead of drawing an invisible or speck-sized photo.
*/
export const frameCardPhotoOnBbox = (
source: CardPhotoSize,
window: CardRect,
bbox: CardAlphaBbox,
): CardPhotoTransform | null => {
if (
source.width <= 0 ||
source.height <= 0 ||
window.width <= 0 ||
window.height <= 0 ||
bbox.width < CARD_CUTOUT_MIN_BBOX ||
bbox.height < CARD_CUTOUT_MIN_BBOX
) {
return null
}
const scale = coverScale(source, window)
const requiredZoom = Math.max(
window.width / bbox.width / scale,
window.height / bbox.height / scale,
)
if (!Number.isFinite(requiredZoom) || requiredZoom > CARD_PHOTO_MAX_ZOOM) return null
const zoom = Math.max(CARD_PHOTO_MIN_ZOOM, requiredZoom)
const applied = scale * zoom
return clampCardPhotoTransform(
{
zoom,
offsetX: window.x + window.width / 2 - (bbox.x + bbox.width / 2) * applied,
offsetY: window.y - bbox.y * applied,
},
source,
window,
)
}
/** Nudges the offset by a step in card pixels; clamped to the window by default. */
export const panCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
dx: number,
dy: number,
options: CardPhotoClampOptions = {},
): CardPhotoTransform =>
clampCardPhotoTransform(
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
source,
window,
options,
)
/**
* Rescales around a window anchor (used by the zoom control): keeps the point
* under `anchor` stable from old to new zoom, then clamps — under the free
* policy only the zoom is normalized.
*/
export const zoomCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
nextZoom: number,
anchor: { x: number; y: number } = {
x: window.x + window.width / 2,
y: window.y + window.height / 2,
},
options: CardPhotoClampOptions = {},
): CardPhotoTransform => {
const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window, options)
const ratio = zoom / transform.zoom
return clampCardPhotoTransform(
{
zoom,
offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio,
offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio,
},
source,
window,
options,
)
}