Copy overhaul + qr designs
This commit is contained in:
220
src/components/generator/StyledQRCode.tsx
Normal file
220
src/components/generator/StyledQRCode.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import QRCodeLib from 'qrcode';
|
||||
import {
|
||||
ModuleShape,
|
||||
EyeFrameShape,
|
||||
EyeBallShape,
|
||||
moduleShapePath,
|
||||
eyeFramePath,
|
||||
eyeBallPath,
|
||||
isInEye,
|
||||
LOW_COVERAGE_SHAPES,
|
||||
} from '@/lib/qr-shapes';
|
||||
|
||||
export type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
export interface QRGradient {
|
||||
type: 'linear' | 'radial';
|
||||
from: string;
|
||||
to: string;
|
||||
/** Degrees, linear only. */
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
export interface StyledQRCodeProps {
|
||||
value: string;
|
||||
size?: number;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
moduleShape?: ModuleShape;
|
||||
eyeFrameShape?: EyeFrameShape;
|
||||
eyeBallShape?: EyeBallShape;
|
||||
/** Overrides fgColor for the module fill when present. */
|
||||
gradient?: QRGradient | null;
|
||||
eyeColor?: string | null;
|
||||
errorCorrection?: ErrorCorrectionLevel;
|
||||
/** Data URL. Rendered centred, with the modules behind it cleared. */
|
||||
logoUrl?: string;
|
||||
/** Logo edge length as a share of the whole code. Capped at 0.28. */
|
||||
logoScale?: number;
|
||||
/** Quiet zone in modules. Four is the spec minimum. */
|
||||
margin?: number;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective error correction.
|
||||
*
|
||||
* Shapes that fill less of each module, and any logo, both eat into the
|
||||
* redundancy the scanner relies on. Rather than letting someone print a code
|
||||
* that fails in the wild, we quietly raise the level - and the UI says so, so
|
||||
* this is not a hidden override.
|
||||
*/
|
||||
export function resolveErrorCorrection(
|
||||
requested: ErrorCorrectionLevel,
|
||||
moduleShape: ModuleShape,
|
||||
hasLogo: boolean
|
||||
): ErrorCorrectionLevel {
|
||||
if (hasLogo) return 'H';
|
||||
if (LOW_COVERAGE_SHAPES.includes(moduleShape)) {
|
||||
return requested === 'L' || requested === 'M' ? 'Q' : requested;
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
export default function StyledQRCode({
|
||||
value,
|
||||
size = 240,
|
||||
fgColor = '#000000',
|
||||
bgColor = '#FFFFFF',
|
||||
moduleShape = 'square',
|
||||
eyeFrameShape = 'square',
|
||||
eyeBallShape = 'square',
|
||||
gradient = null,
|
||||
eyeColor = null,
|
||||
errorCorrection = 'M',
|
||||
logoUrl,
|
||||
logoScale = 0.22,
|
||||
margin = 4,
|
||||
className,
|
||||
id,
|
||||
}: StyledQRCodeProps) {
|
||||
const gradientId = useMemo(
|
||||
() => `qrgrad-${Math.random().toString(36).slice(2, 9)}`,
|
||||
[]
|
||||
);
|
||||
|
||||
const ec = resolveErrorCorrection(errorCorrection, moduleShape, Boolean(logoUrl));
|
||||
|
||||
const model = useMemo(() => {
|
||||
try {
|
||||
const qr = QRCodeLib.create(value || ' ', { errorCorrectionLevel: ec });
|
||||
return { size: qr.modules.size, data: qr.modules.data };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [value, ec]);
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
const count = model.size;
|
||||
const total = count + margin * 2;
|
||||
const cell = size / total;
|
||||
const offset = margin * cell;
|
||||
|
||||
const at = (r: number, c: number): boolean => {
|
||||
if (r < 0 || c < 0 || r >= count || c >= count) return false;
|
||||
return model.data[r * count + c] === 1;
|
||||
};
|
||||
|
||||
// Modules that would sit under the logo are dropped rather than painted over.
|
||||
// Overpainting leaves half-modules at the edge that some scanners still try
|
||||
// to read; removing them lets the error correction do its job cleanly.
|
||||
const logoSpan = logoUrl ? Math.min(logoScale, 0.28) : 0;
|
||||
const logoFrom = logoUrl ? Math.floor((count * (1 - logoSpan)) / 2) : -1;
|
||||
const logoTo = logoUrl ? Math.ceil((count * (1 + logoSpan)) / 2) : -1;
|
||||
const underLogo = (r: number, c: number) =>
|
||||
Boolean(logoUrl) && r >= logoFrom && r < logoTo && c >= logoFrom && c < logoTo;
|
||||
|
||||
const modulePaths: string[] = [];
|
||||
for (let r = 0; r < count; r++) {
|
||||
for (let c = 0; c < count; c++) {
|
||||
if (!at(r, c)) continue;
|
||||
if (isInEye(r, c, count)) continue;
|
||||
if (underLogo(r, c)) continue;
|
||||
modulePaths.push(
|
||||
moduleShapePath(
|
||||
moduleShape,
|
||||
offset + c * cell,
|
||||
offset + r * cell,
|
||||
cell,
|
||||
{
|
||||
top: at(r - 1, c),
|
||||
bottom: at(r + 1, c),
|
||||
left: at(r, c - 1),
|
||||
right: at(r, c + 1),
|
||||
},
|
||||
r,
|
||||
c
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eyeOrigins: [number, number][] = [
|
||||
[0, 0],
|
||||
[0, count - 7],
|
||||
[count - 7, 0],
|
||||
];
|
||||
|
||||
const framePaths = eyeOrigins.map(([r, c]) =>
|
||||
eyeFramePath(eyeFrameShape, offset + c * cell, offset + r * cell, cell * 7)
|
||||
);
|
||||
const ballPaths = eyeOrigins.map(([r, c]) =>
|
||||
eyeBallPath(eyeBallShape, offset + (c + 2) * cell, offset + (r + 2) * cell, cell * 3)
|
||||
);
|
||||
|
||||
const moduleFill = gradient ? `url(#${gradientId})` : fgColor;
|
||||
const eyeFill = eyeColor || moduleFill;
|
||||
|
||||
const logoPx = size * logoSpan;
|
||||
const logoXY = (size - logoPx) / 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
id={id}
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
shapeRendering="geometricPrecision"
|
||||
role="img"
|
||||
aria-label="QR code"
|
||||
>
|
||||
{gradient && (
|
||||
<defs>
|
||||
{gradient.type === 'linear' ? (
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientTransform={`rotate(${gradient.rotation ?? 45} 0.5 0.5)`}
|
||||
>
|
||||
<stop offset="0%" stopColor={gradient.from} />
|
||||
<stop offset="100%" stopColor={gradient.to} />
|
||||
</linearGradient>
|
||||
) : (
|
||||
<radialGradient id={gradientId}>
|
||||
<stop offset="0%" stopColor={gradient.from} />
|
||||
<stop offset="100%" stopColor={gradient.to} />
|
||||
</radialGradient>
|
||||
)}
|
||||
</defs>
|
||||
)}
|
||||
|
||||
<rect width={size} height={size} fill={bgColor} />
|
||||
|
||||
<path d={modulePaths.join(' ')} fill={moduleFill} fillRule="nonzero" />
|
||||
|
||||
{framePaths.map((d, i) => (
|
||||
<path key={`f${i}`} d={d} fill={eyeFill} fillRule="evenodd" />
|
||||
))}
|
||||
{ballPaths.map((d, i) => (
|
||||
<path key={`b${i}`} d={d} fill={eyeFill} />
|
||||
))}
|
||||
|
||||
{logoUrl && (
|
||||
<image
|
||||
href={logoUrl}
|
||||
x={logoXY}
|
||||
y={logoXY}
|
||||
width={logoPx}
|
||||
height={logoPx}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user