123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
/*
|
|
* QR 자리표시(placeholder) — 실제 스캔 가능한 QR 생성 라이브러리 미설치(GAP).
|
|
* react-native-svg로 시드 문자열 기반 결정론적 모듈 그리드 + 파인더 패턴을 그려
|
|
* "QR처럼 보이는" 미리보기를 제공한다. 스캔 불가 — 상시 "샘플" 라벨 노출.
|
|
* 실제 QR은 M10 티켓 백엔드 + QR 인코더 도입 시 교체(갭: _workspace/port_mobile_m15.md).
|
|
*/
|
|
import React, { useMemo } from 'react';
|
|
import { StyleSheet, Text, View } from 'react-native';
|
|
import Svg, { Rect } from 'react-native-svg';
|
|
import { colors, radius } from '../../theme';
|
|
|
|
interface Props {
|
|
seed: string;
|
|
size?: number;
|
|
quiet?: boolean; // 여백(quiet zone) 포함 여부
|
|
}
|
|
|
|
// 결정론적 해시(문자열 → 32bit) — 시드로 모듈 on/off 결정
|
|
function hashAt(seed: string, i: number): number {
|
|
let h = 2166136261 ^ i;
|
|
for (let k = 0; k < seed.length; k++) {
|
|
h ^= seed.charCodeAt(k);
|
|
h = Math.imul(h, 16777619);
|
|
}
|
|
return (h >>> 0) % 100;
|
|
}
|
|
|
|
const MODULES = 25; // 25x25 격자
|
|
|
|
export function QrPlaceholder({ seed, size = 192, quiet = true }: Props) {
|
|
const cells = useMemo(() => {
|
|
const out: { x: number; y: number }[] = [];
|
|
for (let y = 0; y < MODULES; y++) {
|
|
for (let x = 0; x < MODULES; x++) {
|
|
if (isFinderZone(x, y)) continue; // 파인더 영역은 별도 렌더
|
|
if (hashAt(seed, y * MODULES + x) < 48) out.push({ x, y });
|
|
}
|
|
}
|
|
return out;
|
|
}, [seed]);
|
|
|
|
const pad = quiet ? 2 : 0;
|
|
const total = MODULES + pad * 2;
|
|
const cell = size / total;
|
|
|
|
return (
|
|
<View
|
|
accessibilityRole="image"
|
|
accessibilityLabel="입장 QR 코드 (샘플)"
|
|
style={[styles.wrap, { width: size, height: size }]}
|
|
>
|
|
<Svg width={size} height={size}>
|
|
<Rect x={0} y={0} width={size} height={size} fill={colors.white} />
|
|
{cells.map((c, idx) => (
|
|
<Rect
|
|
key={idx}
|
|
x={(c.x + pad) * cell}
|
|
y={(c.y + pad) * cell}
|
|
width={cell}
|
|
height={cell}
|
|
fill={colors.neutral900}
|
|
/>
|
|
))}
|
|
{/* 3개 파인더 패턴(좌상·우상·좌하) */}
|
|
<Finder x={pad} y={pad} cell={cell} />
|
|
<Finder x={pad + MODULES - 7} y={pad} cell={cell} />
|
|
<Finder x={pad} y={pad + MODULES - 7} cell={cell} />
|
|
</Svg>
|
|
<View style={styles.sampleTag}>
|
|
<Text style={styles.sampleText}>샘플</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function isFinderZone(x: number, y: number): boolean {
|
|
const inTL = x < 8 && y < 8;
|
|
const inTR = x >= MODULES - 8 && y < 8;
|
|
const inBL = x < 8 && y >= MODULES - 8;
|
|
return inTL || inTR || inBL;
|
|
}
|
|
|
|
function Finder({ x, y, cell }: { x: number; y: number; cell: number }) {
|
|
return (
|
|
<>
|
|
<Rect x={x * cell} y={y * cell} width={cell * 7} height={cell * 7} fill={colors.neutral900} />
|
|
<Rect
|
|
x={(x + 1) * cell}
|
|
y={(y + 1) * cell}
|
|
width={cell * 5}
|
|
height={cell * 5}
|
|
fill={colors.white}
|
|
/>
|
|
<Rect
|
|
x={(x + 2) * cell}
|
|
y={(y + 2) * cell}
|
|
width={cell * 3}
|
|
height={cell * 3}
|
|
fill={colors.neutral900}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
wrap: {
|
|
borderRadius: radius.sm,
|
|
overflow: 'hidden',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
sampleTag: {
|
|
position: 'absolute',
|
|
right: 4,
|
|
bottom: 4,
|
|
backgroundColor: colors.aiAccent,
|
|
borderRadius: radius.sm,
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 2,
|
|
},
|
|
sampleText: { color: colors.white, fontSize: 10, fontWeight: '700' },
|
|
});
|