34bf65958a
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.
225 lines
6.9 KiB
TypeScript
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(' ')
|
|
}
|