bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
621 lines
19 KiB
JavaScript
621 lines
19 KiB
JavaScript
/**
|
|
* QUIXZOOM Zoomer Onboarding & Certification System
|
|
*
|
|
* 4-nivå certifiering:
|
|
* 1. Capture Basics (10 min)
|
|
* 2. Quality Test (20 objekt)
|
|
* 3. Guided Missions (AI-hjälp i realtid)
|
|
* 4. Trusted Zoomer (högre ersättning, avancerade uppdrag)
|
|
*
|
|
* Zoomer Quality Score:
|
|
* - GPS-kvalitet
|
|
* - Bildkvalitet
|
|
* - AI-korrigeringar
|
|
* - Godkända observationer
|
|
* - Bekräftelser från andra Zoomers
|
|
* - Svarstid
|
|
* - Fullföljandegrad
|
|
*/
|
|
|
|
class ZoomerOnboarding {
|
|
constructor(config = {}) {
|
|
this.config = {
|
|
levels: [
|
|
{ id: 1, name: 'Capture Basics', duration: 10 }, // 10 minuter
|
|
{ id: 2, name: 'Quality Test', duration: 30 }, // 30 minuter
|
|
{ id: 3, name: 'Guided Missions', duration: 60 }, // 1 timme
|
|
{ id: 4, name: 'Trusted Zoomer', duration: Infinity }, // Kontinuerlig
|
|
],
|
|
qualityThresholds: {
|
|
gpsAccuracy: 5, // meters
|
|
imageBlur: 0.3, // 0-1
|
|
imageExposure: 0.7, // 0-1
|
|
minObservations: 20, // för Quality Test
|
|
minApprovalRate: 0.8, // 80%
|
|
},
|
|
...config,
|
|
};
|
|
|
|
this.zoomers = new Map();
|
|
this.certifications = new Map();
|
|
}
|
|
|
|
/**
|
|
* ============================================================
|
|
* ONBOARDING FLOW
|
|
* ============================================================
|
|
*/
|
|
|
|
async onboardZoomer(zoomerId, profile) {
|
|
console.log(`[Onboarding] Starting onboarding for ${zoomerId}`);
|
|
|
|
const zoomer = {
|
|
id: zoomerId,
|
|
profile,
|
|
level: 0,
|
|
status: 'onboarding',
|
|
startedAt: new Date().toISOString(),
|
|
completedAt: null,
|
|
scores: {
|
|
gps: [],
|
|
image: [],
|
|
aiCorrections: 0,
|
|
approvedObservations: 0,
|
|
totalObservations: 0,
|
|
confirmations: 0,
|
|
responseTime: [],
|
|
completionRate: [],
|
|
},
|
|
};
|
|
|
|
this.zoomers.set(zoomerId, zoomer);
|
|
|
|
// Start Level 1
|
|
await this.startLevel1(zoomerId);
|
|
|
|
return zoomer;
|
|
}
|
|
|
|
async startLevel1(zoomerId) {
|
|
console.log(`[Onboarding] Level 1: Capture Basics for ${zoomerId}`);
|
|
|
|
// Store training data
|
|
const training = {
|
|
title: 'Capture Basics',
|
|
duration: 10, // minutes
|
|
modules: [
|
|
{
|
|
title: 'Håll telefonen rätt',
|
|
content: 'Håll telefonen i brösthöjd, lutad 15° nedåt. Använd båda händerna för stabilitet.',
|
|
video: 'hold_phone.mp4',
|
|
},
|
|
{
|
|
title: 'Gånghastighet',
|
|
content: 'Gå i normal takt (5 km/h). Stanna vid varje objekt i 2-3 sekunder.',
|
|
video: 'walking_pace.mp4',
|
|
},
|
|
{
|
|
title: 'Undvik motljus',
|
|
content: 'Stå med solen bakom dig. Om motljus är oundvikligt, använd HDR.',
|
|
video: 'avoid_backlight.mp4',
|
|
},
|
|
{
|
|
title: 'Stabil video',
|
|
content: 'Andas ut innan du trycker på knappen. Använd telefonens stabilisering.',
|
|
video: 'stable_video.mp4',
|
|
},
|
|
{
|
|
title: 'Flera vinklar',
|
|
content: 'Dokumentera varje objekt från 2-3 vinklar. Gå runt objektet om möjligt.',
|
|
video: 'multiple_angles.mp4',
|
|
},
|
|
],
|
|
quiz: [
|
|
{
|
|
question: 'Vilken är optimal gånghastighet?',
|
|
options: ['3 km/h', '5 km/h', '8 km/h', '10 km/h'],
|
|
correct: 1,
|
|
},
|
|
{
|
|
question: 'Hur länge ska du stanna vid varje objekt?',
|
|
options: ['1 sekund', '2-3 sekunder', '5 sekunder', '10 sekunder'],
|
|
correct: 1,
|
|
},
|
|
{
|
|
question: 'Vad gör du vid motljus?',
|
|
options: ['Tar bilden ändå', 'Står med solen bakom dig', 'Använder blixt', 'Väntar på moln'],
|
|
correct: 1,
|
|
},
|
|
],
|
|
};
|
|
|
|
// Store for retrieval
|
|
if (!this.certifications.has(zoomerId)) {
|
|
this.certifications.set(zoomerId, {});
|
|
}
|
|
this.certifications.get(zoomerId).level1 = training;
|
|
|
|
return training;
|
|
}
|
|
|
|
async completeLevel1(zoomerId, quizResults) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) throw new Error('Zoomer not found');
|
|
|
|
const passed = quizResults.filter(r => r.correct).length / quizResults.length >= 0.8;
|
|
|
|
if (passed) {
|
|
zoomer.level = 1;
|
|
console.log(`[Onboarding] ${zoomerId} passed Level 1`);
|
|
await this.startLevel2(zoomerId);
|
|
} else {
|
|
console.log(`[Onboarding] ${zoomerId} failed Level 1, retry required`);
|
|
}
|
|
|
|
return { passed, level: zoomer.level };
|
|
}
|
|
|
|
async startLevel2(zoomerId) {
|
|
console.log(`[Onboarding] Level 2: Quality Test for ${zoomerId}`);
|
|
|
|
const testMission = {
|
|
title: 'Quality Test',
|
|
description: 'Dokumentera 20 objekt i ditt område. AI:n bedömer kvaliteten.',
|
|
objectives: 20,
|
|
criteria: {
|
|
gpsAccuracy: '< 5m',
|
|
imageBlur: '< 0.3',
|
|
imageExposure: '> 0.7',
|
|
coverage: '2+ vinklar per objekt',
|
|
},
|
|
timeLimit: 30, // minutes
|
|
};
|
|
|
|
return testMission;
|
|
}
|
|
|
|
async evaluateLevel2(zoomerId, observations) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) throw new Error('Zoomer not found');
|
|
|
|
const metrics = this.calculateQualityMetrics(observations);
|
|
|
|
const passed =
|
|
metrics.gpsAccuracy <= this.config.qualityThresholds.gpsAccuracy &&
|
|
metrics.imageBlur <= this.config.qualityThresholds.imageBlur &&
|
|
metrics.imageExposure >= this.config.qualityThresholds.imageExposure &&
|
|
observations.length >= this.config.qualityThresholds.minObservations;
|
|
|
|
if (passed) {
|
|
zoomer.level = 2;
|
|
console.log(`[Onboarding] ${zoomerId} passed Level 2`);
|
|
await this.startLevel3(zoomerId);
|
|
} else {
|
|
console.log(`[Onboarding] ${zoomerId} failed Level 2`);
|
|
console.log(` GPS: ${metrics.gpsAccuracy}m (target: <5m)`);
|
|
console.log(` Blur: ${metrics.imageBlur} (target: <0.3)`);
|
|
console.log(` Exposure: ${metrics.imageExposure} (target: >0.7)`);
|
|
console.log(` Observations: ${observations.length} (target: 20+)`);
|
|
}
|
|
|
|
return { passed, metrics, level: zoomer.level };
|
|
}
|
|
|
|
calculateQualityMetrics(observations) {
|
|
const gpsAccuracies = observations.map(o => o.location?.accuracy || 999);
|
|
const blurs = observations.map(o => o.quality?.blur || 1);
|
|
const exposures = observations.map(o => o.quality?.exposure || 0);
|
|
|
|
return {
|
|
gpsAccuracy: gpsAccuracies.reduce((a, b) => a + b, 0) / gpsAccuracies.length,
|
|
imageBlur: blurs.reduce((a, b) => a + b, 0) / blurs.length,
|
|
imageExposure: exposures.reduce((a, b) => a + b, 0) / exposures.length,
|
|
totalObservations: observations.length,
|
|
};
|
|
}
|
|
|
|
async startLevel3(zoomerId) {
|
|
console.log(`[Onboarding] Level 3: Guided Missions for ${zoomerId}`);
|
|
|
|
const guidedMission = {
|
|
title: 'Guided Mission',
|
|
description: 'AI:n guidar dig i realtid för optimal datainsamling.',
|
|
features: [
|
|
'Real-time feedback',
|
|
'AI suggestions',
|
|
'Quality warnings',
|
|
'Auto-adjust missions',
|
|
],
|
|
examples: [
|
|
{ trigger: 'distance > 5m', instruction: 'Gå två meter närmare' },
|
|
{ trigger: 'occlusion > 0.5', instruction: 'Objektet är delvis skymt, gå runt det' },
|
|
{ trigger: 'blur > 0.3', instruction: 'Stå stilla innan du tar bilden' },
|
|
{ trigger: 'exposure < 0.5', instruction: 'Vänta tills en bil passerat' },
|
|
{ trigger: 'angle < 2', instruction: 'Ta en bild från höger' },
|
|
],
|
|
};
|
|
|
|
return guidedMission;
|
|
}
|
|
|
|
async completeLevel3(zoomerId, missionResults) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) throw new Error('Zoomer not found');
|
|
|
|
const passed = missionResults.completionRate >= 0.8 &&
|
|
missionResults.avgQuality >= 0.8;
|
|
|
|
if (passed) {
|
|
zoomer.level = 3;
|
|
zoomer.status = 'active';
|
|
console.log(`[Onboarding] ${zoomerId} passed Level 3 - Active Zoomer`);
|
|
} else {
|
|
console.log(`[Onboarding] ${zoomerId} needs more guided missions`);
|
|
}
|
|
|
|
return { passed, level: zoomer.level };
|
|
}
|
|
|
|
async promoteToTrusted(zoomerId) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) throw new Error('Zoomer not found');
|
|
|
|
const score = this.calculateZoomerQualityScore(zoomerId);
|
|
|
|
if (score.overall >= 0.8 && zoomer.level >= 3) {
|
|
zoomer.level = 4;
|
|
zoomer.status = 'trusted';
|
|
console.log(`[Onboarding] ${zoomerId} promoted to Trusted Zoomer`);
|
|
|
|
return {
|
|
promoted: true,
|
|
benefits: [
|
|
'Higher compensation (+50%)',
|
|
'Advanced missions',
|
|
'Larger geographic area',
|
|
'Can verify other observations',
|
|
'Regional Coordinator eligibility',
|
|
],
|
|
};
|
|
}
|
|
|
|
return { promoted: false, score: score.overall };
|
|
}
|
|
|
|
/**
|
|
* ============================================================
|
|
* ZOOMER QUALITY SCORE
|
|
* ============================================================
|
|
*/
|
|
|
|
calculateZoomerQualityScore(zoomerId) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) return null;
|
|
|
|
const scores = zoomer.scores;
|
|
|
|
// GPS Quality (20%)
|
|
const avgGps = scores.gps.length > 0
|
|
? scores.gps.reduce((a, b) => a + b, 0) / scores.gps.length
|
|
: 999;
|
|
const gpsScore = Math.max(0, 1 - (avgGps / 10)); // 0m = 1.0, 10m = 0.0
|
|
|
|
// Image Quality (20%)
|
|
const avgImage = scores.image.length > 0
|
|
? scores.image.reduce((a, b) => a + b, 0) / scores.image.length
|
|
: 0;
|
|
const imageScore = avgImage;
|
|
|
|
// AI Corrections (15%) - lower is better
|
|
const totalObs = scores.totalObservations || 1;
|
|
const correctionRate = scores.aiCorrections / totalObs;
|
|
const correctionScore = Math.max(0, 1 - correctionRate);
|
|
|
|
// Approval Rate (20%)
|
|
const approvalRate = scores.totalObservations > 0
|
|
? scores.approvedObservations / scores.totalObservations
|
|
: 0;
|
|
|
|
// Confirmations (10%)
|
|
const confirmationScore = Math.min(1, scores.confirmations / 10);
|
|
|
|
// Response Time (10%)
|
|
const avgResponse = scores.responseTime.length > 0
|
|
? scores.responseTime.reduce((a, b) => a + b, 0) / scores.responseTime.length
|
|
: 3600;
|
|
const responseScore = Math.max(0, 1 - (avgResponse / 3600)); // <1h = 1.0
|
|
|
|
// Completion Rate (5%)
|
|
const avgCompletion = scores.completionRate.length > 0
|
|
? scores.completionRate.reduce((a, b) => a + b, 0) / scores.completionRate.length
|
|
: 0;
|
|
|
|
const overall =
|
|
gpsScore * 0.20 +
|
|
imageScore * 0.20 +
|
|
correctionScore * 0.15 +
|
|
approvalRate * 0.20 +
|
|
confirmationScore * 0.10 +
|
|
responseScore * 0.10 +
|
|
avgCompletion * 0.05;
|
|
|
|
return {
|
|
overall: Math.round(overall * 100) / 100,
|
|
breakdown: {
|
|
gps: Math.round(gpsScore * 100) / 100,
|
|
image: Math.round(imageScore * 100) / 100,
|
|
corrections: Math.round(correctionScore * 100) / 100,
|
|
approval: Math.round(approvalRate * 100) / 100,
|
|
confirmations: Math.round(confirmationScore * 100) / 100,
|
|
response: Math.round(responseScore * 100) / 100,
|
|
completion: Math.round(avgCompletion * 100) / 100,
|
|
},
|
|
level: zoomer.level,
|
|
status: zoomer.status,
|
|
};
|
|
}
|
|
|
|
updateZoomerScore(zoomerId, observation) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) return;
|
|
|
|
const scores = zoomer.scores;
|
|
|
|
// GPS
|
|
if (observation.location?.accuracy) {
|
|
scores.gps.push(observation.location.accuracy);
|
|
}
|
|
|
|
// Image quality
|
|
if (observation.quality?.overall) {
|
|
scores.image.push(observation.quality.overall);
|
|
}
|
|
|
|
// AI corrections
|
|
if (observation.aiCorrections) {
|
|
scores.aiCorrections += observation.aiCorrections;
|
|
}
|
|
|
|
// Approval
|
|
scores.totalObservations++;
|
|
if (observation.approved) {
|
|
scores.approvedObservations++;
|
|
}
|
|
|
|
// Response time
|
|
if (observation.responseTime) {
|
|
scores.responseTime.push(observation.responseTime);
|
|
}
|
|
|
|
// Completion
|
|
if (observation.missionCompleted !== undefined) {
|
|
scores.completionRate.push(observation.missionCompleted ? 1 : 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ============================================================
|
|
* MISSION ASSIGNMENT
|
|
* ============================================================
|
|
*/
|
|
|
|
assignMission(zoomerId, mission) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) return null;
|
|
|
|
const score = this.calculateZoomerQualityScore(zoomerId);
|
|
|
|
// Match zoomer to mission based on quality and level
|
|
const suitability = this.calculateSuitability(score, mission);
|
|
|
|
return {
|
|
zoomerId,
|
|
mission,
|
|
suitability,
|
|
recommended: suitability > 0.8,
|
|
};
|
|
}
|
|
|
|
calculateSuitability(score, mission) {
|
|
if (!score) return 0;
|
|
|
|
let suitability = score.overall;
|
|
|
|
// Adjust based on mission complexity
|
|
if (mission.complexity === 'high' && score.overall < 0.8) {
|
|
suitability *= 0.5;
|
|
}
|
|
|
|
if (mission.timeOfDay === 'night' && score.breakdown.image < 0.7) {
|
|
suitability *= 0.7;
|
|
}
|
|
|
|
if (mission.requiresVerification && score.level < 4) {
|
|
suitability = 0;
|
|
}
|
|
|
|
return Math.round(suitability * 100) / 100;
|
|
}
|
|
|
|
/**
|
|
* ============================================================
|
|
* REGIONAL COORDINATOR
|
|
* ============================================================
|
|
*/
|
|
|
|
async promoteToRegionalCoordinator(zoomerId, area) {
|
|
const zoomer = this.zoomers.get(zoomerId);
|
|
if (!zoomer) throw new Error('Zoomer not found');
|
|
|
|
const score = this.calculateZoomerQualityScore(zoomerId);
|
|
|
|
if (score.overall >= 0.8 && zoomer.level === 4) {
|
|
zoomer.role = 'regional_coordinator';
|
|
zoomer.area = area;
|
|
|
|
console.log(`[Onboarding] ${zoomerId} promoted to Regional Coordinator for ${area}`);
|
|
|
|
return {
|
|
promoted: true,
|
|
responsibilities: [
|
|
'Quality assurance in area',
|
|
'Help train new Zoomers',
|
|
'Answer questions',
|
|
'Spot-check observations',
|
|
'Report issues',
|
|
],
|
|
compensation: {
|
|
base: 1.5, // 50% bonus
|
|
perVerification: 5, // kr per verification
|
|
},
|
|
};
|
|
}
|
|
|
|
return { promoted: false, score: score.overall };
|
|
}
|
|
|
|
/**
|
|
* ============================================================
|
|
* STATS
|
|
* ============================================================
|
|
*/
|
|
|
|
getStats() {
|
|
const zoomers = Array.from(this.zoomers.values());
|
|
|
|
return {
|
|
total: zoomers.length,
|
|
byLevel: {
|
|
0: zoomers.filter(z => z.level === 0).length,
|
|
1: zoomers.filter(z => z.level === 1).length,
|
|
2: zoomers.filter(z => z.level === 2).length,
|
|
3: zoomers.filter(z => z.level === 3).length,
|
|
4: zoomers.filter(z => z.level === 4).length,
|
|
},
|
|
byStatus: {
|
|
onboarding: zoomers.filter(z => z.status === 'onboarding').length,
|
|
active: zoomers.filter(z => z.status === 'active').length,
|
|
trusted: zoomers.filter(z => z.status === 'trusted').length,
|
|
},
|
|
regionalCoordinators: zoomers.filter(z => z.role === 'regional_coordinator').length,
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = ZoomerOnboarding;
|
|
|
|
// Demo
|
|
async function runDemo() {
|
|
const onboarding = new ZoomerOnboarding();
|
|
|
|
console.log('╔════════════════════════════════════════════════════════════╗');
|
|
console.log('║ ZOOMER ONBOARDING & CERTIFICATION SYSTEM ║');
|
|
console.log('╚════════════════════════════════════════════════════════════╝\n');
|
|
|
|
// Onboard a new Zoomer
|
|
const zoomerId = 'zoomer_bangkok_001';
|
|
onboarding.onboardZoomer(zoomerId, {
|
|
name: 'Somchai',
|
|
email: 'somchai@example.com',
|
|
phone: '+66-81-234-5678',
|
|
location: 'Bangkok',
|
|
});
|
|
|
|
console.log('\n=== LEVEL 1: CAPTURE BASICS ===');
|
|
const level1 = await onboarding.startLevel1(zoomerId);
|
|
console.log(`Training: ${level1.title}`);
|
|
console.log(`Modules: ${level1.modules.length}`);
|
|
console.log(`Quiz questions: ${level1.quiz.length}`);
|
|
|
|
// Simulate passing Level 1
|
|
const quizResults = level1.quiz.map((q, i) => ({ question: i, correct: true }));
|
|
await onboarding.completeLevel1(zoomerId, quizResults);
|
|
|
|
console.log('\n=== LEVEL 2: QUALITY TEST ===');
|
|
const level2 = await onboarding.startLevel2(zoomerId);
|
|
console.log(`Mission: ${level2.title}`);
|
|
console.log(`Objectives: ${level2.objectives} objects`);
|
|
|
|
// Simulate observations
|
|
const observations = Array.from({ length: 22 }, (_, i) => ({
|
|
id: `obs_${i}`,
|
|
location: { accuracy: 3 + Math.random() * 2 },
|
|
quality: {
|
|
blur: Math.random() * 0.2,
|
|
exposure: 0.8 + Math.random() * 0.2,
|
|
overall: 0.85,
|
|
},
|
|
}));
|
|
|
|
await onboarding.evaluateLevel2(zoomerId, observations);
|
|
|
|
console.log('\n=== LEVEL 3: GUIDED MISSIONS ===');
|
|
const level3 = await onboarding.startLevel3(zoomerId);
|
|
console.log(`Features: ${level3.features.join(', ')}`);
|
|
|
|
// Simulate completing Level 3
|
|
await onboarding.completeLevel3(zoomerId, {
|
|
completionRate: 0.9,
|
|
avgQuality: 0.85,
|
|
});
|
|
|
|
console.log('\n=== ZOOMER QUALITY SCORE ===');
|
|
|
|
// Add some scores
|
|
const zoomer = onboarding.zoomers.get(zoomerId);
|
|
zoomer.scores.gps = [3, 4, 3, 5, 4, 3, 4, 5, 3, 4];
|
|
zoomer.scores.image = [0.9, 0.85, 0.9, 0.8, 0.9, 0.85, 0.9, 0.9, 0.85, 0.9];
|
|
zoomer.scores.aiCorrections = 2;
|
|
zoomer.scores.approvedObservations = 45;
|
|
zoomer.scores.totalObservations = 50;
|
|
zoomer.scores.confirmations = 8;
|
|
zoomer.scores.responseTime = [300, 450, 200, 600, 350, 400, 250, 500, 300, 350];
|
|
zoomer.scores.completionRate = [1, 1, 1, 1, 0, 1, 1, 1, 1, 1];
|
|
|
|
const score = onboarding.calculateZoomerQualityScore(zoomerId);
|
|
console.log(`Overall: ${score.overall}`);
|
|
console.log(`Breakdown:`);
|
|
for (const [key, value] of Object.entries(score.breakdown)) {
|
|
console.log(` ${key}: ${value}`);
|
|
}
|
|
|
|
console.log('\n=== PROMOTE TO TRUSTED ===');
|
|
const promotion = onboarding.promoteToTrusted(zoomerId);
|
|
console.log(`Promoted: ${promotion.promoted}`);
|
|
if (promotion.promoted) {
|
|
console.log('Benefits:');
|
|
promotion.benefits.forEach(b => console.log(` - ${b}`));
|
|
}
|
|
|
|
console.log('\n=== MISSION ASSIGNMENT ===');
|
|
const mission = {
|
|
id: 'mission_001',
|
|
type: 'night_inventory',
|
|
complexity: 'high',
|
|
timeOfDay: 'night',
|
|
requiresVerification: true,
|
|
};
|
|
|
|
const assignment = onboarding.assignMission(zoomerId, mission);
|
|
console.log(`Mission: ${mission.type}`);
|
|
console.log(`Suitability: ${assignment.suitability}`);
|
|
console.log(`Recommended: ${assignment.recommended}`);
|
|
|
|
console.log('\n=== REGIONAL COORDINATOR ===');
|
|
const rc = onboarding.promoteToRegionalCoordinator(zoomerId, 'Sukhumvit');
|
|
console.log(`Promoted: ${rc.promoted}`);
|
|
if (rc.promoted) {
|
|
console.log('Responsibilities:');
|
|
rc.responsibilities.forEach(r => console.log(` - ${r}`));
|
|
}
|
|
|
|
console.log('\n=== STATS ===');
|
|
console.log(JSON.stringify(onboarding.getStats(), null, 2));
|
|
|
|
console.log('\n✅ Zoomer Onboarding System ready!');
|
|
}
|
|
|
|
if (require.main === module) {
|
|
runDemo().catch(console.error);
|
|
}
|