Files
bsky-social-app/src/features/inviteFriends/components/qrButterflies.ts
T
vineyardbovines 34bf65958a Use butterfly glyphs in the Invite Friends QR code
Render each QR data module as a Bluesky butterfly instead of a rounded square, using react-native-qrcode-styled's renderCustomPieceItem hook. The finder eyes stay as rounded ring-and-dot shapes and the centered avatar is unchanged.

Butterfly modules cover less of each cell than solid squares, so error correction is raised to H to keep the code scannable. pieceSize is derived from the module count so the QR renders at a fixed size and keeps a consistent white margin inside the card for any handle length.
2026-06-16 10:19:15 -04:00

225 lines
6.9 KiB
TypeScript

/**
* Geometry helpers for rendering the Invite Friends QR code with butterfly data
* modules (APP-2417).
*
* react-native-qrcode-styled draws every "on" module as a rounded square. To
* draw butterflies instead we take over per-module rendering via its
* `renderCustomPieceItem` hook, which bypasses the library's own eye + logo
* handling, so we reproduce all of it here as plain SVG path strings.
*
* All paths are emitted in absolute QR-canvas coordinates (no per-element
* transform) so the single canvas-wide `userSpaceOnUse` gradient maps
* continuously across butterflies and eyes alike. Applying a transform per
* element would instead remap the gradient into each element's local box.
*/
/** Finder ("eye") patterns are 7x7 modules in each non-bottom-right corner. */
export const FINDER_SIZE = 7
/**
* Scale of each butterfly relative to a module. >1 lets neighbours overlap into
* a denser, more legible silhouette. 1.2 matches the approved prototype and,
* with error-correction level H, scans as reliably as solid squares.
*/
export const BUTTERFLY_PIECE_SCALE = 1.2
// Eye corner radii, in module units. Outer ~= 2.3 modules keeps the current
// design's 16px outer-corner look (16 / 7px piece).
const EYE_RADIUS_OUTER = 2.3
const EYE_RADIUS_CUT = 1.4
const EYE_RADIUS_DOT = 1.1
export type FinderCorner = 'topLeft' | 'topRight' | 'bottomLeft'
/**
* The bsky butterfly outline (from the bsky-qr prototype), as a single closed
* path over a 600x530 viewBox.
*/
const BSKY_PATH_DATA =
'm135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z'
const BSKY_VIEW_W = 600
const BSKY_VIEW_H = 530
type BskyCmd =
| {type: 'M'; x: number; y: number}
| {
type: 'C'
x1: number
y1: number
x2: number
y2: number
x: number
y: number
}
| {type: 'Z'}
/**
* Parse the butterfly path once into normalized commands centered on (0,0) and
* scaled to a unit box (uniform scale on the larger viewBox dimension). The path
* only uses M/m, C/c and Z, so the parser handles just those.
*/
function parseBskyPath(): BskyCmd[] {
const cmds: BskyCmd[] = []
const tokens = BSKY_PATH_DATA.match(/[a-zA-Z]|-?(?:\d+\.?\d*|\.\d+)/g) ?? []
const scale = 1 / Math.max(BSKY_VIEW_W, BSKY_VIEW_H)
const offsetX = -(BSKY_VIEW_W * scale) / 2
const offsetY = -(BSKY_VIEW_H * scale) / 2
const norm = (x: number, y: number) => ({
x: x * scale + offsetX,
y: y * scale + offsetY,
})
let i = 0
let cx = 0
let cy = 0
let lastCmd = ''
while (i < tokens.length) {
const t = tokens[i]
if (/[a-zA-Z]/.test(t)) {
lastCmd = t
i++
if (lastCmd === 'Z' || lastCmd === 'z') cmds.push({type: 'Z'})
continue
}
if (lastCmd === 'M' || lastCmd === 'm') {
const x = parseFloat(tokens[i++])
const y = parseFloat(tokens[i++])
if (lastCmd === 'm') {
cx += x
cy += y
} else {
cx = x
cy = y
}
cmds.push({type: 'M', ...norm(cx, cy)})
lastCmd = lastCmd === 'm' ? 'l' : 'L'
} else if (lastCmd === 'C' || lastCmd === 'c') {
const x1 = parseFloat(tokens[i++])
const y1 = parseFloat(tokens[i++])
const x2 = parseFloat(tokens[i++])
const y2 = parseFloat(tokens[i++])
const x = parseFloat(tokens[i++])
const y = parseFloat(tokens[i++])
const rel = lastCmd === 'c'
const ax1 = rel ? cx + x1 : x1
const ay1 = rel ? cy + y1 : y1
const ax2 = rel ? cx + x2 : x2
const ay2 = rel ? cy + y2 : y2
const ax = rel ? cx + x : x
const ay = rel ? cy + y : y
const p1 = norm(ax1, ay1)
const p2 = norm(ax2, ay2)
const p = norm(ax, ay)
cmds.push({
type: 'C',
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
x: p.x,
y: p.y,
})
cx = ax
cy = ay
} else {
i++
}
}
return cmds
}
const BSKY_NORMALIZED = parseBskyPath()
/** A butterfly centered on (centerX, centerY), sized to `scale` canvas units. */
export function butterflyPiecePath(
centerX: number,
centerY: number,
scale: number,
): string {
const tx = (px: number) => centerX + px * scale
const ty = (py: number) => centerY + py * scale
let p = ''
for (const c of BSKY_NORMALIZED) {
if (c.type === 'M') {
p += `M${tx(c.x)} ${ty(c.y)}`
} else if (c.type === 'C') {
p += `C${tx(c.x1)} ${ty(c.y1)} ${tx(c.x2)} ${ty(c.y2)} ${tx(c.x)} ${ty(c.y)}`
} else {
p += 'Z'
}
}
return p
}
function roundedRectPath(
x: number,
y: number,
w: number,
h: number,
r: number,
): string {
const rr = Math.min(r, w / 2, h / 2)
return (
`M${x + rr} ${y} H${x + w - rr} A${rr} ${rr} 0 0 1 ${x + w} ${y + rr} ` +
`V${y + h - rr} A${rr} ${rr} 0 0 1 ${x + w - rr} ${y + h} ` +
`H${x + rr} A${rr} ${rr} 0 0 1 ${x} ${y + h - rr} ` +
`V${y + rr} A${rr} ${rr} 0 0 1 ${x + rr} ${y} Z`
)
}
/**
* Returns the finder corner whose top-left module is (x, y), or null. Used to
* emit each eye exactly once, at its anchor cell.
*/
export function finderCornerAnchor(
x: number,
y: number,
n: number,
): FinderCorner | null {
if (x === 0 && y === 0) return 'topLeft'
if (x === n - FINDER_SIZE && y === 0) return 'topRight'
if (x === 0 && y === n - FINDER_SIZE) return 'bottomLeft'
return null
}
/** Whether (x, y) falls inside any of the three finder boxes. */
export function isInFinderRegion(x: number, y: number, n: number): boolean {
return (
(x < FINDER_SIZE && y < FINDER_SIZE) ||
(x >= n - FINDER_SIZE && y < FINDER_SIZE) ||
(x < FINDER_SIZE && y >= n - FINDER_SIZE)
)
}
/**
* A single even-odd path for one eye: outer rounded ring + inner cut + center
* dot. Even-odd winding fills the ring band and the dot while leaving the gap
* between them empty, reproducing a finder pattern.
*/
export function eyePath(
corner: FinderCorner,
n: number,
pieceSize: number,
): string {
const left = corner === 'topRight' ? (n - FINDER_SIZE) * pieceSize : 0
const top = corner === 'bottomLeft' ? (n - FINDER_SIZE) * pieceSize : 0
const size = FINDER_SIZE * pieceSize
return [
roundedRectPath(left, top, size, size, EYE_RADIUS_OUTER * pieceSize),
roundedRectPath(
left + pieceSize,
top + pieceSize,
size - 2 * pieceSize,
size - 2 * pieceSize,
EYE_RADIUS_CUT * pieceSize,
),
roundedRectPath(
left + 2 * pieceSize,
top + 2 * pieceSize,
3 * pieceSize,
3 * pieceSize,
EYE_RADIUS_DOT * pieceSize,
),
].join(' ')
}