@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -5
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -176
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +82 -119
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { LivenessPhase } from '../liveness/types';
|
|
2
|
+
|
|
3
|
+
// The liveness ring's arithmetic — a MIRROR of the web SDK's
|
|
4
|
+
// components/CaptureRing.tsx and hooks/useLiveness.ts updateLivenessProgress
|
|
5
|
+
// (and of the Flutter SDK's liveness/capture_ring.dart). Change a rule in one
|
|
6
|
+
// and change it in all three in the same commit.
|
|
7
|
+
//
|
|
8
|
+
// Two numbers. The TARGET is where the test has actually got to and only ever
|
|
9
|
+
// moves forward. The DISPLAY eases toward it on real elapsed time, critically
|
|
10
|
+
// damped, so a gesture landing (a whole segment in one go) and the detector's
|
|
11
|
+
// own frame rate both arrive as motion rather than cuts.
|
|
12
|
+
|
|
13
|
+
/** Time constant of the display's approach, seconds. ~95% of a gap in three. */
|
|
14
|
+
export const TAU = 0.09;
|
|
15
|
+
|
|
16
|
+
/** How far a challenge segment may fill on its clock alone: time running out
|
|
17
|
+
* is not progress, so the clock never completes a segment — the gesture
|
|
18
|
+
* landing does. */
|
|
19
|
+
export const CHALLENGE_SEGMENT_CAP = 0.85;
|
|
20
|
+
|
|
21
|
+
/** The native still takes a beat after `capturing` begins; its segment fills
|
|
22
|
+
* across this window. It never CLOSES on it: the clock is capped like a
|
|
23
|
+
* challenge's, and only `complete` (the still in hand) reaches 1. On this
|
|
24
|
+
* SDK the still is settle → native photo → compress, seconds on a slow
|
|
25
|
+
* phone, and the ring used to sit closed for all of it. */
|
|
26
|
+
export const CAPTURE_WINDOW_SEC = 0.4;
|
|
27
|
+
|
|
28
|
+
const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : Number.isFinite(n) ? n : 0);
|
|
29
|
+
|
|
30
|
+
export function advanceTarget(target: number, real: number): number {
|
|
31
|
+
return Math.max(target, clamp01(real));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function easeToward(shown: number, target: number, dt: number): number {
|
|
35
|
+
if (dt <= 0) return shown;
|
|
36
|
+
return shown + (target - shown) * (1 - Math.exp(-dt / TAU));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LivenessProgressInput {
|
|
40
|
+
phase: LivenessPhase;
|
|
41
|
+
/** Steps passed so far. On this SDK the count is bumped at
|
|
42
|
+
* `challenge_passed` — read off the hook, not assumed. */
|
|
43
|
+
completedCount: number;
|
|
44
|
+
/** Every step the user performs: the gestures, plus the flash when it runs. */
|
|
45
|
+
totalCount: number;
|
|
46
|
+
/** Seconds since the current phase began. */
|
|
47
|
+
elapsedInPhase: number;
|
|
48
|
+
/** The running challenge's timeout, seconds. */
|
|
49
|
+
challengeTimeout: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Where the WHOLE test has got to, 0..1, in equal segments: positioning, each
|
|
54
|
+
* step, the capture. Each segment is measured by what actually gates it here —
|
|
55
|
+
* this SDK advances out of positioning the instant the face is framed and lit
|
|
56
|
+
* (no steady-frame counter), so that segment fills on the transition; a
|
|
57
|
+
* challenge fills on its clock; the capture fills across the still's window.
|
|
58
|
+
*/
|
|
59
|
+
export function livenessProgress(s: LivenessProgressInput): number {
|
|
60
|
+
const seg = 1 / (s.totalCount + 2);
|
|
61
|
+
const done = s.completedCount; // steps whose segments are complete
|
|
62
|
+
switch (s.phase) {
|
|
63
|
+
case 'challenge':
|
|
64
|
+
case 'flash':
|
|
65
|
+
return seg * (1 + done + Math.min(CHALLENGE_SEGMENT_CAP, s.elapsedInPhase / Math.max(s.challengeTimeout, 1)));
|
|
66
|
+
case 'challenge_passed':
|
|
67
|
+
return seg * (1 + done + 0);
|
|
68
|
+
case 'capturing':
|
|
69
|
+
return seg * (1 + s.totalCount + Math.min(CHALLENGE_SEGMENT_CAP, s.elapsedInPhase / CAPTURE_WINDOW_SEC));
|
|
70
|
+
case 'complete':
|
|
71
|
+
return 1;
|
|
72
|
+
default:
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Linear mix of two #rrggbb colours, for the close-to-green beat. */
|
|
78
|
+
export function mixHex(a: string, b: string, t: number): string {
|
|
79
|
+
const k = clamp01(t);
|
|
80
|
+
const ch = (h: string, i: number) => parseInt(h.slice(i, i + 2), 16);
|
|
81
|
+
const out = [1, 3, 5].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * k));
|
|
82
|
+
return '#' + out.map((v) => v.toString(16).padStart(2, '0')).join('');
|
|
83
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Recovery from a submit refused over contact proofs. Mirrors the web SDK's
|
|
2
|
+
// steps/contact-recovery.ts — keep the two in lockstep.
|
|
3
|
+
//
|
|
4
|
+
// Contact proof tokens are SINGLE-USE and expire ~30 minutes after the OTP is
|
|
5
|
+
// checked, but they ride session progress and are restored on resume. A
|
|
6
|
+
// resumed attempt therefore carries a proof the server will (rightly)
|
|
7
|
+
// validate-and-drop at submit, while the contact step still shows "verified"
|
|
8
|
+
// — and a plain retry resubmits the same dead token forever.
|
|
9
|
+
//
|
|
10
|
+
// The 422 is recoverable in-flow: clear the stale proofs, walk the person
|
|
11
|
+
// back to the contact step (their data is untouched), and once re-verified
|
|
12
|
+
// the step routes straight back to 'submitted', which auto-submits with the
|
|
13
|
+
// fresh token.
|
|
14
|
+
import { KYCApiError } from '../services/api';
|
|
15
|
+
import type { KYCStep } from '../types/config';
|
|
16
|
+
|
|
17
|
+
export type ContactChannel = 'email' | 'phone';
|
|
18
|
+
|
|
19
|
+
/** The channels a 422 `contact_verification_required` names as missing. */
|
|
20
|
+
export function expiredContactChannels(err: unknown): ContactChannel[] {
|
|
21
|
+
if (!(err instanceof KYCApiError) || err.code !== 'contact_verification_required') return [];
|
|
22
|
+
const missing = Array.isArray(err.body?.missing) ? (err.body.missing as unknown[]) : [];
|
|
23
|
+
return missing.filter((c): c is ContactChannel => c === 'email' || c === 'phone');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function contactStepFor(channel: ContactChannel): KYCStep {
|
|
27
|
+
return channel === 'email' ? 'email-verification' : 'phone-verification';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where the step routes once this channel is verified. Normally the ordinary
|
|
32
|
+
* forward step; in recovery it returns to 'submitted' (which auto-submits
|
|
33
|
+
* with the fresh proof) via any OTHER still-refused channel first — the
|
|
34
|
+
* person never re-walks steps they already completed.
|
|
35
|
+
*/
|
|
36
|
+
export function stepAfterContactVerified(opts: {
|
|
37
|
+
recovery: boolean;
|
|
38
|
+
expired: ContactChannel[];
|
|
39
|
+
channel: ContactChannel;
|
|
40
|
+
}): KYCStep | null {
|
|
41
|
+
if (!opts.recovery) return null;
|
|
42
|
+
const remaining = opts.expired.find((c) => c !== opts.channel);
|
|
43
|
+
return remaining ? contactStepFor(remaining) : 'submitted';
|
|
44
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Declared-country adoption.
|
|
3
|
+
//
|
|
4
|
+
// ONE table decides when evidence about where the applicant IS may change the
|
|
5
|
+
// country they are DECLARING: a reverse-geocoded pin or current-location fix
|
|
6
|
+
// (a geocode), or the address they picked from search (explicit). A mirror of
|
|
7
|
+
// the web SDK's steps/address/country-adoption.ts and Flutter's
|
|
8
|
+
// config/country_adoption.dart; the three run the shared vectors in
|
|
9
|
+
// kyc-sdk-flutter/test/country_adoption_vectors.json, so a drift in any one
|
|
10
|
+
// fails there.
|
|
11
|
+
//
|
|
12
|
+
// The rules, and why each exists:
|
|
13
|
+
// - GEOCODED EVIDENCE outranks every GUESS. The address scope's default is
|
|
14
|
+
// the IP country, and on a dev box that once declared US while the device
|
|
15
|
+
// sat in Calabar (the search returned California), so the fix's own
|
|
16
|
+
// geocode corrects a guess the moment it resolves.
|
|
17
|
+
// - An EXPLICIT declaration is never overridden by a geocode; a PICKED
|
|
18
|
+
// address replaces even that, since the pick is the applicant's newer and
|
|
19
|
+
// more specific statement, and is then itself explicit.
|
|
20
|
+
// - Outside the address scope `selectedCountry` is the ID-VERIFICATION
|
|
21
|
+
// country, and changing it resets the ID choice, so only a guessed value
|
|
22
|
+
// is ever corrected there, picked address or not.
|
|
23
|
+
// - The org's accepted list (proofOfAddress.countries) gates every path: a
|
|
24
|
+
// value the submission gate would refuse never becomes the declaration.
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const ISO2 = /^[A-Z]{2}$/;
|
|
28
|
+
|
|
29
|
+
/** A trimmed, upper-cased ISO-2, or null for anything that is not one. */
|
|
30
|
+
export function normaliseIso2(raw: string | null | undefined): string | null {
|
|
31
|
+
const code = raw?.trim().toUpperCase();
|
|
32
|
+
return code && ISO2.test(code) ? code : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether the org's accepted-country list admits `code`. Null or empty
|
|
36
|
+
* accepts everyone. */
|
|
37
|
+
export function countryAccepted(accepted: readonly string[] | null | undefined, code: string): boolean {
|
|
38
|
+
if (!accepted?.length) return true;
|
|
39
|
+
return accepted.some((c) => c.trim().toUpperCase() === code);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AdoptionInput {
|
|
43
|
+
/** The country the evidence named (a geocode's `parts.country`, a pick's own). */
|
|
44
|
+
country: string | null | undefined;
|
|
45
|
+
selectedCountry: string | null | undefined;
|
|
46
|
+
countryAutoPicked: boolean;
|
|
47
|
+
/** The workflow scope; null is a full verification. */
|
|
48
|
+
scope: string | null | undefined;
|
|
49
|
+
accepted: readonly string[] | null | undefined;
|
|
50
|
+
/** The country came from an address the applicant PICKED. */
|
|
51
|
+
explicit?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CountryAdoption {
|
|
55
|
+
/** The normalised ISO-2 to declare. */
|
|
56
|
+
country: string;
|
|
57
|
+
/** Declare it as a GUESS (later evidence may correct it) rather than as
|
|
58
|
+
* the applicant's own explicit statement. */
|
|
59
|
+
auto: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The adoption rule. See the file header for what each guard is for. */
|
|
63
|
+
export function adoptionDecision(input: AdoptionInput): CountryAdoption | null {
|
|
64
|
+
const code = normaliseIso2(input.country);
|
|
65
|
+
if (!code) return null;
|
|
66
|
+
const onAddressScope = input.scope === 'address';
|
|
67
|
+
const guessed = input.countryAutoPicked || (onAddressScope && input.selectedCountry == null);
|
|
68
|
+
if (!guessed && !(input.explicit && onAddressScope)) return null;
|
|
69
|
+
if (code === normaliseIso2(input.selectedCountry)) return null;
|
|
70
|
+
if (!countryAccepted(input.accepted, code)) return null;
|
|
71
|
+
return { country: code, auto: !input.explicit };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The address scope's default: the visitor's IP country, once, while nothing
|
|
76
|
+
* is declared, and only when the accepted list admits it. A full flow never
|
|
77
|
+
* defaults from the IP (there the country is the ID's).
|
|
78
|
+
*/
|
|
79
|
+
export function geoDefaultCountry(input: {
|
|
80
|
+
geoCountry: string | null | undefined;
|
|
81
|
+
selectedCountry: string | null | undefined;
|
|
82
|
+
scope: string | null | undefined;
|
|
83
|
+
accepted: readonly string[] | null | undefined;
|
|
84
|
+
}): string | null {
|
|
85
|
+
if (input.scope !== 'address' || input.selectedCountry != null) return null;
|
|
86
|
+
const code = normaliseIso2(input.geoCountry);
|
|
87
|
+
if (!code || !countryAccepted(input.accepted, code)) return null;
|
|
88
|
+
return code;
|
|
89
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { tryRequire } from '../services/fingerprint-sources';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// The visitor's most likely country, for DEFAULTS only (never evidence). A
|
|
5
|
+
// mirror of the web SDK's lib/inferred-country.ts — keep the three in
|
|
6
|
+
// lockstep.
|
|
7
|
+
//
|
|
8
|
+
// Two tiers: the server's IP-derived `geoCountry` when it exists, else the
|
|
9
|
+
// device's own locale region (en-NG → NG). The second tier is what makes
|
|
10
|
+
// inference work where the IP cannot answer at all — a dev server on a
|
|
11
|
+
// loopback address, a carrier GeoLite2 cannot place — from a signal the
|
|
12
|
+
// device already carries. Both are guesses a person can correct; nothing
|
|
13
|
+
// recorded branches on them.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const ISO2 = /^[A-Z]{2}$/;
|
|
17
|
+
|
|
18
|
+
interface LocalizationLike {
|
|
19
|
+
getLocales?: () => Array<{ regionCode?: string | null; languageTag?: string | null }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The region subtag of a BCP-47 tag (`en-NG`, `en_NG`, `yo-Latn-NG`). The
|
|
23
|
+
* first subtag is the LANGUAGE ("en"), never a region, so it is skipped. */
|
|
24
|
+
export function regionOfLocaleTag(tag: string | null | undefined): string | null {
|
|
25
|
+
if (!tag) return null;
|
|
26
|
+
const [, ...rest] = tag.split(/[-_]/);
|
|
27
|
+
const region = rest.find((part) => /^[A-Za-z]{2}$/.test(part))?.toUpperCase();
|
|
28
|
+
return region && ISO2.test(region) ? region : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The device's locale tags, best first. expo-localization when the host
|
|
32
|
+
* installed it (it carries an explicit region), else the JS runtime's. */
|
|
33
|
+
export function deviceLocaleTags(): string[] {
|
|
34
|
+
const tags: string[] = [];
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
|
+
const localization = tryRequire<LocalizationLike>(() => require('expo-localization'));
|
|
37
|
+
try {
|
|
38
|
+
for (const locale of localization?.getLocales?.() ?? []) {
|
|
39
|
+
if (locale.regionCode) tags.push(`und-${locale.regionCode}`);
|
|
40
|
+
else if (locale.languageTag) tags.push(locale.languageTag);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// A partial module; fall through to the runtime.
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const runtime = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
47
|
+
if (runtime) tags.push(runtime);
|
|
48
|
+
} catch {
|
|
49
|
+
// No Intl on this runtime.
|
|
50
|
+
}
|
|
51
|
+
return tags;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function inferredCountry(
|
|
55
|
+
geoCountry?: string | null,
|
|
56
|
+
localeTags: string[] = deviceLocaleTags(),
|
|
57
|
+
): string | null {
|
|
58
|
+
const geo = geoCountry?.trim().toUpperCase();
|
|
59
|
+
if (geo && ISO2.test(geo)) return geo;
|
|
60
|
+
for (const tag of localeTags) {
|
|
61
|
+
const region = regionOfLocaleTag(tag);
|
|
62
|
+
if (region) return region;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spacing } from '../config/theme';
|
|
2
|
+
|
|
3
|
+
// ─── How big the selfie circle and the gesture avatar may be ────────────────
|
|
4
|
+
//
|
|
5
|
+
// The circle used to be sized from the window's WIDTH alone (capped at 300),
|
|
6
|
+
// which is the web SDK's rule and is right on a tall phone. On a Samsung S24
|
|
7
|
+
// (360×780 dp, 176 dp shorter than an iPhone 16 Pro Max) the width still gave
|
|
8
|
+
// 296, the circle owned most of the sheet, and the avatar demonstrating the
|
|
9
|
+
// gesture sat below the fold — the one thing the step exists to show. So the
|
|
10
|
+
// HEIGHT bounds it too: whatever the chrome, the instruction, the step dots,
|
|
11
|
+
// the avatar and the footer leave over is what the circle may take, and when
|
|
12
|
+
// it is the height that bound the circle, the avatar shrinks with it.
|
|
13
|
+
//
|
|
14
|
+
// Mirrored in Flutter's liveness/liveness_layout.dart; the real phones below
|
|
15
|
+
// are the shared vectors. Change a number in one and change both.
|
|
16
|
+
|
|
17
|
+
/** The web's phone circle is 256 and its desktop one 320; 300 is where this
|
|
18
|
+
* SDK has always capped, and the tall phones keep it. */
|
|
19
|
+
export const CIRCLE_MAX = 300;
|
|
20
|
+
/** A face is still usable at 200; below that the ring and the oval guide start
|
|
21
|
+
* to crowd it, so a very short screen scrolls a little instead. */
|
|
22
|
+
export const CIRCLE_MIN = 200;
|
|
23
|
+
/** Everything on the sheet that is NOT the circle, measured on device:
|
|
24
|
+
* banner + brand row + title + step bar (~250), the instruction line, the
|
|
25
|
+
* dots, the large avatar, three gaps, the step padding and the footer. */
|
|
26
|
+
export const CIRCLE_HEIGHT_BUDGET = 540;
|
|
27
|
+
/** The circle keeps the step's own side padding on both sides. */
|
|
28
|
+
export const CIRCLE_SIDE_GUTTER = spacing.md * 4;
|
|
29
|
+
|
|
30
|
+
const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n));
|
|
31
|
+
|
|
32
|
+
export interface LivenessLayout {
|
|
33
|
+
/** Diameter of the camera circle, dp. */
|
|
34
|
+
circle: number;
|
|
35
|
+
/** Diameter of the gesture avatar badge, dp. */
|
|
36
|
+
avatar: number;
|
|
37
|
+
/** The fallback icon inside the avatar when the GIF cannot load. */
|
|
38
|
+
avatarIcon: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function livenessLayout(window: { width: number; height: number }): LivenessLayout {
|
|
42
|
+
const byWidth = window.width - CIRCLE_SIDE_GUTTER;
|
|
43
|
+
const byHeight = window.height - CIRCLE_HEIGHT_BUDGET;
|
|
44
|
+
const circle = clamp(Math.min(byWidth, byHeight, CIRCLE_MAX), CIRCLE_MIN, CIRCLE_MAX);
|
|
45
|
+
// Only a SHORT screen shrinks the avatar: a narrow one that is tall enough
|
|
46
|
+
// has the room for it whatever the width did to the circle.
|
|
47
|
+
const large = byHeight >= CIRCLE_MAX;
|
|
48
|
+
return { circle, avatar: large ? 96 : 72, avatarIcon: large ? 40 : 30 };
|
|
49
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { LatLng } from './map-tiles';
|
|
2
|
+
|
|
3
|
+
// The framed Google-map picker's client half, for a WebView (the OkHi model):
|
|
4
|
+
// the SDK loads OUR hosted /embed/map page TOP-LEVEL in a WebView and talks to
|
|
5
|
+
// it over the WebView's own bridge. A mirror of the web SDK's lib/map-frame.ts
|
|
6
|
+
// and the Flutter SDK's config/map_frame.dart — keep the three in lockstep.
|
|
7
|
+
//
|
|
8
|
+
// Message shapes (add-only; the page mirrors them):
|
|
9
|
+
// page -> app: { source: 'myaza-map', type: 'ready' | 'failed' }
|
|
10
|
+
// { source: 'myaza-map', type: 'pin', lat, lng }
|
|
11
|
+
// app -> page: window.__myazaMapCommand(JSON of
|
|
12
|
+
// { source: 'myaza-sdk', type: 'center', lat, lng, zoom? })
|
|
13
|
+
|
|
14
|
+
export const MAP_FRAME_SOURCE = 'myaza-map';
|
|
15
|
+
export const MAP_PARENT_SOURCE = 'myaza-sdk';
|
|
16
|
+
|
|
17
|
+
/** How long to wait for `ready` before falling back to the OSM picker.
|
|
18
|
+
* Generous: the page loads Google's script on a cold cache. */
|
|
19
|
+
export const MAP_FRAME_READY_TIMEOUT_MS = 8000;
|
|
20
|
+
|
|
21
|
+
export interface MapFrameOptions {
|
|
22
|
+
center: LatLng;
|
|
23
|
+
zoom: number;
|
|
24
|
+
/** Recentre-and-zoom-in when the app already holds a pin. */
|
|
25
|
+
hasPin: boolean;
|
|
26
|
+
theme?: 'light' | 'dark';
|
|
27
|
+
primaryColor?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The full page URL: the server-minted frame URL (which already carries the
|
|
31
|
+
* signed APP grant and `mode=app`) plus the render-time parameters. There is
|
|
32
|
+
* no `origin` — an app has none, and the page proves that instead. */
|
|
33
|
+
export function buildMapFrameSrc(frameUrl: string, opts: MapFrameOptions): string {
|
|
34
|
+
const url = new URL(frameUrl);
|
|
35
|
+
url.searchParams.set('lat', String(opts.center.lat));
|
|
36
|
+
url.searchParams.set('lng', String(opts.center.lng));
|
|
37
|
+
url.searchParams.set('zoom', String(opts.hasPin ? 16 : opts.zoom));
|
|
38
|
+
if (opts.theme) url.searchParams.set('theme', opts.theme);
|
|
39
|
+
if (opts.primaryColor) url.searchParams.set('primary', opts.primaryColor);
|
|
40
|
+
return url.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type MapFrameMessage =
|
|
44
|
+
| { type: 'ready' }
|
|
45
|
+
| { type: 'failed' }
|
|
46
|
+
| { type: 'pin'; lat: number; lng: number };
|
|
47
|
+
|
|
48
|
+
const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
49
|
+
|
|
50
|
+
/** Validate-and-drop: the bridge delivers strings, so a JSON string is
|
|
51
|
+
* accepted too; anything not shaped exactly like a frame message is null. */
|
|
52
|
+
export function parseMapFrameMessage(data: unknown): MapFrameMessage | null {
|
|
53
|
+
let msg: unknown = data;
|
|
54
|
+
if (typeof data === 'string') {
|
|
55
|
+
try {
|
|
56
|
+
msg = JSON.parse(data);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!msg || typeof msg !== 'object') return null;
|
|
62
|
+
const m = msg as Record<string, unknown>;
|
|
63
|
+
if (m.source !== MAP_FRAME_SOURCE) return null;
|
|
64
|
+
if (m.type === 'ready') return { type: 'ready' };
|
|
65
|
+
if (m.type === 'failed') return { type: 'failed' };
|
|
66
|
+
if (m.type === 'pin' && finite(m.lat) && finite(m.lng)) {
|
|
67
|
+
if (Math.abs(m.lat) > 90 || Math.abs(m.lng) > 180) return null;
|
|
68
|
+
return { type: 'pin', lat: m.lat, lng: m.lng };
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The recentre command as the script the WebView injects (Use my location,
|
|
74
|
+
* restored progress). Ends in `true` because injected scripts must resolve to
|
|
75
|
+
* something serialisable on iOS. */
|
|
76
|
+
export function centerCommandScript(pin: LatLng): string {
|
|
77
|
+
const command = JSON.stringify({ source: MAP_PARENT_SOURCE, type: 'center', lat: pin.lat, lng: pin.lng, zoom: 16 });
|
|
78
|
+
return `window.__myazaMapCommand && window.__myazaMapCommand(${JSON.stringify(command)}); true;`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function samePin(a: LatLng | null, b: LatLng): boolean {
|
|
82
|
+
return !!a && Math.abs(a.lat - b.lat) < 1e-7 && Math.abs(a.lng - b.lng) < 1e-7;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── The framed STREET VIEW page (the entrance framing) ──────────────────────
|
|
86
|
+
//
|
|
87
|
+
// Same origin, same APP grant, sibling page: /embed/street-view lives beside
|
|
88
|
+
// /embed/map and the grant unlocks the key for either, so the SDK DERIVES its
|
|
89
|
+
// URL from the server-minted mapsFrameUrl by swapping the path — the two
|
|
90
|
+
// pages ship together with this SDK, and the coupling is recorded on both
|
|
91
|
+
// sides. The page is deliberately dumb: it renders the panorama and streams
|
|
92
|
+
// the current view (`sv-pov`) over the same bridge the map uses; the framing
|
|
93
|
+
// chrome, the frame-subtended fov maths and the capture decision stay in the
|
|
94
|
+
// SDK, so hosted, embedded and native applicants meet the identical
|
|
95
|
+
// instrument. Mirror of the web SDK's street-view half; keep in lockstep.
|
|
96
|
+
//
|
|
97
|
+
// page -> app: { source: 'myaza-map', type: 'sv-ready' | 'sv-unavailable' }
|
|
98
|
+
// { source: 'myaza-map', type: 'sv-pov', panoId, heading, pitch, viewFov }
|
|
99
|
+
|
|
100
|
+
/** The street-view page's URL derived from the map frame's, or null when the
|
|
101
|
+
* frame URL is not the page family this SDK knows. */
|
|
102
|
+
export function streetViewFrameUrlOf(mapsFrameUrl: string): string | null {
|
|
103
|
+
try {
|
|
104
|
+
const url = new URL(mapsFrameUrl);
|
|
105
|
+
if (!url.pathname.endsWith('/embed/map')) return null;
|
|
106
|
+
url.pathname = url.pathname.replace(/\/embed\/map$/, '/embed/street-view');
|
|
107
|
+
return url.toString();
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The full page URL: the derived page (carrying the signed APP grant and
|
|
114
|
+
* `mode=app`) plus the pin the panorama should look from. No `origin`: an
|
|
115
|
+
* app has none, and the page proves that instead. */
|
|
116
|
+
export function buildStreetViewFrameSrc(
|
|
117
|
+
frameUrl: string,
|
|
118
|
+
opts: { pin: LatLng; theme?: 'light' | 'dark' },
|
|
119
|
+
): string {
|
|
120
|
+
const url = new URL(frameUrl);
|
|
121
|
+
url.searchParams.set('lat', String(opts.pin.lat));
|
|
122
|
+
url.searchParams.set('lng', String(opts.pin.lng));
|
|
123
|
+
if (opts.theme) url.searchParams.set('theme', opts.theme);
|
|
124
|
+
return url.toString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type StreetViewFrameMessage =
|
|
128
|
+
| { type: 'sv-ready' }
|
|
129
|
+
| { type: 'sv-unavailable' }
|
|
130
|
+
| { type: 'sv-pov'; panoId: string; heading: number; pitch: number; viewFov: number };
|
|
131
|
+
|
|
132
|
+
/** Validate-and-drop for the street-view page's messages; a JSON string is
|
|
133
|
+
* accepted too, since the bridge delivers strings. */
|
|
134
|
+
export function parseStreetViewFrameMessage(data: unknown): StreetViewFrameMessage | null {
|
|
135
|
+
let msg: unknown = data;
|
|
136
|
+
if (typeof data === 'string') {
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(data);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!msg || typeof msg !== 'object') return null;
|
|
144
|
+
const m = msg as Record<string, unknown>;
|
|
145
|
+
if (m.source !== MAP_FRAME_SOURCE) return null;
|
|
146
|
+
if (m.type === 'sv-ready') return { type: 'sv-ready' };
|
|
147
|
+
if (m.type === 'sv-unavailable') return { type: 'sv-unavailable' };
|
|
148
|
+
if (
|
|
149
|
+
m.type === 'sv-pov' &&
|
|
150
|
+
typeof m.panoId === 'string' &&
|
|
151
|
+
m.panoId.length > 0 &&
|
|
152
|
+
finite(m.heading) &&
|
|
153
|
+
finite(m.pitch) &&
|
|
154
|
+
finite(m.viewFov)
|
|
155
|
+
) {
|
|
156
|
+
return { type: 'sv-pov', panoId: m.panoId, heading: m.heading, pitch: m.pitch, viewFov: m.viewFov };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Web Mercator math for the dependency-free map pin picker. Pure and
|
|
2
|
+
// unit-tested — the picker is a thin gesture shell over these.
|
|
3
|
+
//
|
|
4
|
+
// Deliberately NOT react-native-maps: that means native modules, per-platform
|
|
5
|
+
// setup and a Google Maps API key per org for what the picker needs — pan,
|
|
6
|
+
// zoom, one pin. The standard slippy-map maths below is ~60 lines, fully
|
|
7
|
+
// testable, and the tiles are plain <Image>s.
|
|
8
|
+
//
|
|
9
|
+
// A MIRROR of the web SDK's lib/map-tiles.ts — keep the two in lockstep.
|
|
10
|
+
|
|
11
|
+
export const TILE_SIZE = 256;
|
|
12
|
+
export const MIN_ZOOM = 3;
|
|
13
|
+
export const MAX_ZOOM = 19;
|
|
14
|
+
|
|
15
|
+
// Web Mercator's poles — beyond this the projection diverges.
|
|
16
|
+
const MAX_LAT = 85.05112878;
|
|
17
|
+
|
|
18
|
+
export interface LatLng {
|
|
19
|
+
lat: number;
|
|
20
|
+
lng: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clampLat(lat: number): number {
|
|
24
|
+
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function wrapLng(lng: number): number {
|
|
28
|
+
let l = lng;
|
|
29
|
+
while (l > 180) l -= 360;
|
|
30
|
+
while (l < -180) l += 360;
|
|
31
|
+
return l;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** World size in pixels at a zoom level. */
|
|
35
|
+
export function worldSize(zoom: number): number {
|
|
36
|
+
return TILE_SIZE * 2 ** zoom;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Project a coordinate to world pixels at a zoom level. */
|
|
40
|
+
export function latLngToWorld(point: LatLng, zoom: number): { x: number; y: number } {
|
|
41
|
+
const size = worldSize(zoom);
|
|
42
|
+
const lat = clampLat(point.lat);
|
|
43
|
+
const sin = Math.sin((lat * Math.PI) / 180);
|
|
44
|
+
// Clamped into the world: at the pole cap the log term lands a float
|
|
45
|
+
// epsilon outside [0, size], which would render as a phantom tile row.
|
|
46
|
+
const y = (0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI)) * size;
|
|
47
|
+
return {
|
|
48
|
+
x: ((wrapLng(point.lng) + 180) / 360) * size,
|
|
49
|
+
y: Math.min(size, Math.max(0, y)),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Unproject world pixels back to a coordinate. */
|
|
54
|
+
export function worldToLatLng(x: number, y: number, zoom: number): LatLng {
|
|
55
|
+
const size = worldSize(zoom);
|
|
56
|
+
const n = Math.PI - (2 * Math.PI * y) / size;
|
|
57
|
+
return {
|
|
58
|
+
lat: (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))),
|
|
59
|
+
lng: wrapLng((x / size) * 360 - 180),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TilePlacement {
|
|
64
|
+
key: string;
|
|
65
|
+
url: string;
|
|
66
|
+
/** CSS position relative to the viewport's top-left corner. */
|
|
67
|
+
left: number;
|
|
68
|
+
top: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The OSM tiles covering a viewport centred on `center`. Tiles outside the
|
|
73
|
+
* world (above/below the poles) are skipped; longitude wraps.
|
|
74
|
+
*/
|
|
75
|
+
export function visibleTiles(
|
|
76
|
+
center: LatLng,
|
|
77
|
+
zoom: number,
|
|
78
|
+
width: number,
|
|
79
|
+
height: number,
|
|
80
|
+
): TilePlacement[] {
|
|
81
|
+
const world = latLngToWorld(center, zoom);
|
|
82
|
+
const tiles: TilePlacement[] = [];
|
|
83
|
+
const tileCount = 2 ** zoom;
|
|
84
|
+
const originX = world.x - width / 2;
|
|
85
|
+
const originY = world.y - height / 2;
|
|
86
|
+
const first = { x: Math.floor(originX / TILE_SIZE), y: Math.floor(originY / TILE_SIZE) };
|
|
87
|
+
const last = {
|
|
88
|
+
x: Math.floor((originX + width) / TILE_SIZE),
|
|
89
|
+
y: Math.floor((originY + height) / TILE_SIZE),
|
|
90
|
+
};
|
|
91
|
+
for (let ty = first.y; ty <= last.y; ty += 1) {
|
|
92
|
+
if (ty < 0 || ty >= tileCount) continue;
|
|
93
|
+
for (let tx = first.x; tx <= last.x; tx += 1) {
|
|
94
|
+
const wrappedX = ((tx % tileCount) + tileCount) % tileCount;
|
|
95
|
+
tiles.push({
|
|
96
|
+
key: `${zoom}/${tx}/${ty}`,
|
|
97
|
+
url: `https://tile.openstreetmap.org/${zoom}/${wrappedX}/${ty}.png`,
|
|
98
|
+
left: tx * TILE_SIZE - originX,
|
|
99
|
+
top: ty * TILE_SIZE - originY,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return tiles;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The centre after a drag of (dx, dy) viewport pixels. */
|
|
107
|
+
export function panCenter(center: LatLng, zoom: number, dx: number, dy: number): LatLng {
|
|
108
|
+
const world = latLngToWorld(center, zoom);
|
|
109
|
+
const next = worldToLatLng(world.x - dx, world.y - dy, zoom);
|
|
110
|
+
return { lat: clampLat(next.lat), lng: next.lng };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Sensible starting views: gov-DB countries at country zoom, else a world view. */
|
|
114
|
+
const COUNTRY_CENTERS: Record<string, LatLng> = {
|
|
115
|
+
NG: { lat: 9.06, lng: 8.68 },
|
|
116
|
+
GH: { lat: 7.95, lng: -1.02 },
|
|
117
|
+
KE: { lat: 0.02, lng: 37.9 },
|
|
118
|
+
ZA: { lat: -28.48, lng: 24.68 },
|
|
119
|
+
CI: { lat: 7.54, lng: -5.55 },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export function defaultMapView(country: string | undefined): { center: LatLng; zoom: number } {
|
|
123
|
+
const center = country ? COUNTRY_CENTERS[country.toUpperCase()] : undefined;
|
|
124
|
+
return center ? { center, zoom: 6 } : { center: { lat: 6.5, lng: 12 }, zoom: 3 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The height of the map surface on the pin step, from the viewport.
|
|
129
|
+
*
|
|
130
|
+
* A phone gets 40% of its height, clamped to 240–360: on a phone the map
|
|
131
|
+
* owns every touch, so the taller it is the less page is left to scroll on,
|
|
132
|
+
* and Continue has to stay reachable. From a tablet-class width (the web
|
|
133
|
+
* SDK's `sm` breakpoint) it is a flat 420. Mirrors the web SDK's
|
|
134
|
+
* `h-[40vh] min-h-[240px] max-h-[360px] sm:h-[420px]` on AddressCollectionStep
|
|
135
|
+
* and the Flutter `mapSurfaceHeight`; keep the three in lockstep.
|
|
136
|
+
*/
|
|
137
|
+
export const MAP_SURFACE = { phoneFraction: 0.4, phoneMin: 240, phoneMax: 360, wideBreakpoint: 640, wide: 420 } as const;
|
|
138
|
+
|
|
139
|
+
export function mapSurfaceHeight(viewport: { width: number; height: number }): number {
|
|
140
|
+
if (viewport.width >= MAP_SURFACE.wideBreakpoint) return MAP_SURFACE.wide;
|
|
141
|
+
const fraction = Math.round(viewport.height * MAP_SURFACE.phoneFraction);
|
|
142
|
+
return Math.min(MAP_SURFACE.phoneMax, Math.max(MAP_SURFACE.phoneMin, fraction));
|
|
143
|
+
}
|