diff --git a/apps/client/src/common/components/qr-code/QrCode.module.scss b/apps/client/src/common/components/qr-code/QrCode.module.scss index 39a30da51..112abceff 100644 --- a/apps/client/src/common/components/qr-code/QrCode.module.scss +++ b/apps/client/src/common/components/qr-code/QrCode.module.scss @@ -2,7 +2,7 @@ .square { border-radius: $component-border-radius-sm; - background-color: $viewer-color; + background-color: $ui-black; display: flex; align-items: center; justify-content: center; diff --git a/apps/client/src/common/components/qr-code/QrCode.tsx b/apps/client/src/common/components/qr-code/QrCode.tsx index 45813ece8..d8931c28b 100644 --- a/apps/client/src/common/components/qr-code/QrCode.tsx +++ b/apps/client/src/common/components/qr-code/QrCode.tsx @@ -1,4 +1,4 @@ -import { toString } from 'qrcode'; +import { create } from 'qrcode'; import { Suspense, use } from 'react'; import { cx } from '../../utils/styleUtils'; @@ -10,13 +10,21 @@ interface QRCodeProps { size: number; } +// Quiet zone around the code, in modules +const margin = 2; +// Dot diameter as a fraction of a module. Close to 1 keeps the code reliably scannable +// while still reading as dots rather than solid squares +const dotScale = 0.94; +// Light dots on a dark card, to match Ontime's dark theme +const dotColor = '#f6f6f6'; +const backgroundColor = '#101010'; +// Position detection patterns (the three corner "eyes") are drawn as solid squares +// instead of dots, since scanners rely on their crisp edges to find the code +const finderPatternSize = 7; + export default function QRCode({ value, size }: QRCodeProps) { 'use memo'; - const svgPromise = toString(value, { - width: size, - margin: 2, - color: { dark: '#101010', light: '#cfcfcf' }, - }); + const svgPromise = toDottedSvgString(value, size); return ( }> @@ -41,3 +49,56 @@ function QrSvg({ svgPromise, size }: { svgPromise: Promise; size: number /> ); } + +/** + * Encodes the given value and renders it as a dotted QR code (SVG string): + * data modules become circles, while the three finder patterns stay solid for reliable scanning. + */ +async function toDottedSvgString(value: string, size: number): Promise { + const { modules } = create(value); + const moduleCount = modules.size; + const viewBoxSize = moduleCount + margin * 2; + const dotRadius = dotScale / 2; + + let dots = ''; + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (isInFinderPattern(row, col, moduleCount) || !modules.get(row, col)) { + continue; + } + const x = col + margin + 0.5; + const y = row + margin + 0.5; + dots += ``; + } + } + + const finderPatterns = + finderPattern(0, 0) + + finderPattern(0, moduleCount - finderPatternSize) + + finderPattern(moduleCount - finderPatternSize, 0); + + return ( + `` + + `` + + `${dots}${finderPatterns}` + ); +} + +function isInFinderPattern(row: number, col: number, moduleCount: number) { + const inTopRows = row < finderPatternSize; + const inBottomRows = row >= moduleCount - finderPatternSize; + const inLeftCols = col < finderPatternSize; + const inRightCols = col >= moduleCount - finderPatternSize; + return (inTopRows && inLeftCols) || (inTopRows && inRightCols) || (inBottomRows && inLeftCols); +} + +/** Renders one of the three corner "eyes" as nested rounded squares, at (row, col) in module units */ +function finderPattern(row: number, col: number) { + const x = col + margin; + const y = row + margin; + return ( + `` + + `` + + `` + ); +}